From: Fl_GUI Date: Fri, 21 Mar 2025 19:18:03 +0000 (+0100) Subject: robust train movement X-Git-Url: https://git.openfl.eu/?a=commitdiff_plain;h=refs%2Fheads%2Fmain;p=choochoo.git robust train movement --- diff --git a/assets/background.go b/assets/background.go new file mode 100644 index 0000000..b11e0ce --- /dev/null +++ b/assets/background.go @@ -0,0 +1,17 @@ +package assets + +import ( + _ "image/png" + + "github.com/hajimehoshi/ebiten/v2" +) + +var Bg *ebiten.Image + +func TotalBackgroundSize() (width, height int) { + return Bg.Bounds().Dx(), Bg.Bounds().Dy() +} + +func initBackground() { + Bg = readImageFile("assets/background.png") +} diff --git a/assets/background.png b/assets/background.png new file mode 100644 index 0000000..e7e279d Binary files /dev/null and b/assets/background.png differ diff --git a/assets/button.go b/assets/button.go new file mode 100644 index 0000000..9e53b30 --- /dev/null +++ b/assets/button.go @@ -0,0 +1,33 @@ +package assets + +import ( + "image" + + "github.com/hajimehoshi/ebiten/v2" +) + +type Button struct { + UnPressed, Pressed *ebiten.Image +} + +var ButtonWidth, ButtonHeight float32 + +var ButtonA Button +var ButtonB Button + +func initButton() { + buttons := []*Button{&ButtonA, &ButtonB} + nButtons := len(buttons) + + allSprites := readImageFile("assets/button.png") + buttonWidth := allSprites.Bounds().Dy() / nButtons + buttonHeight := allSprites.Bounds().Dx() / 2 + for i := 0; i < nButtons; i++ { + leftRect := image.Rect(0, i*buttonHeight, buttonWidth, (i+1)*buttonHeight) + rightRect := image.Rect(buttonWidth, i*buttonHeight, 2*buttonWidth, (i+1)*buttonHeight) + buttons[i].UnPressed = ebiten.NewImageFromImage(allSprites.SubImage(leftRect)) + buttons[i].Pressed = ebiten.NewImageFromImage(allSprites.SubImage(rightRect)) + } + ButtonWidth = float32(buttonWidth) + ButtonHeight = float32(buttonHeight) +} diff --git a/assets/button.png b/assets/button.png new file mode 100644 index 0000000..3ddfe04 Binary files /dev/null and b/assets/button.png differ diff --git a/assets/button.png~ b/assets/button.png~ new file mode 100644 index 0000000..fb7dfb5 Binary files /dev/null and b/assets/button.png~ differ diff --git a/assets/cursor.go b/assets/cursor.go new file mode 100644 index 0000000..8a490f6 --- /dev/null +++ b/assets/cursor.go @@ -0,0 +1,9 @@ +package assets + +import "github.com/hajimehoshi/ebiten/v2" + +var Pointer *ebiten.Image + +func initCursor() { + Pointer = readImageFile("assets/pointer.png") +} diff --git a/assets/init.go b/assets/init.go new file mode 100644 index 0000000..c2d7e9c --- /dev/null +++ b/assets/init.go @@ -0,0 +1,13 @@ +package assets + +func loadImages() { + initBackground() +} + +func init() { + initButton() + initCursor() + initLevel() + initTrains() + loadImages() +} diff --git a/assets/level.go b/assets/level.go new file mode 100644 index 0000000..8ef5593 --- /dev/null +++ b/assets/level.go @@ -0,0 +1,33 @@ +package assets + +import ( + "encoding/json" + "fmt" + "os" +) + +var Level *LevelType = new(LevelType) + +type LevelType struct { + Connections [][2]int + Tracks [][2]int +} + +func initLevel() { + fname := "assets/level.json" + file, err := os.Open(fname) + if err != nil { + panic(fmt.Errorf("Could not read %s: %w", fname, err)) + } + defer file.Close() + + err = json.NewDecoder(file).Decode(Level) + if err != nil { + panic(fmt.Errorf("Could not parse %s: %w", fname, err)) + } + + if len(Level.Connections) < 2 || len(Level.Tracks) < 1 { + panic(fmt.Errorf("%s must have at least two connections and one track", fname)) + } + +} diff --git a/assets/level.json b/assets/level.json new file mode 100644 index 0000000..7603adc --- /dev/null +++ b/assets/level.json @@ -0,0 +1,21 @@ +{ + "connections": [ + [170, 80], + [342, 80], + [432, 170], + [432, 342], + [342, 432], + [170, 432], + [80, 342], + [80, 170] + ], + "tracks": [ + [0,1], + [1,2], + [2,3], + [3,4], + [5,6], + [6,7], + [7,0] + ] +} diff --git a/assets/pointer.png b/assets/pointer.png new file mode 100644 index 0000000..90bf8dd Binary files /dev/null and b/assets/pointer.png differ diff --git a/assets/readimage.go b/assets/readimage.go new file mode 100644 index 0000000..e9d560f --- /dev/null +++ b/assets/readimage.go @@ -0,0 +1,26 @@ +package assets + +import ( + "bytes" + "fmt" + "image" + "io/ioutil" + + "github.com/hajimehoshi/ebiten/v2" +) + +func readImageFile(fileName string) *ebiten.Image { + fileBytes, err := ioutil.ReadFile(fileName) + if err != nil { + panic(fmt.Errorf("could not read %s: %v", fileName, err)) + } + img, _, err := image.Decode(bytes.NewReader(fileBytes)) + if err != nil { + panic(fmt.Errorf("could not decode %s: %v", fileName, err)) + } + ebitImg := ebiten.NewImageFromImage(img) + if ebitImg == nil { + panic(fmt.Errorf("could not create an image for %s", fileName)) + } + return ebitImg +} diff --git a/assets/sidebar.json b/assets/sidebar.json new file mode 100644 index 0000000..e2216fe --- /dev/null +++ b/assets/sidebar.json @@ -0,0 +1,4 @@ +{ + "throttle": [560, 220], + "sway": [522,330] +} diff --git a/assets/train.go b/assets/train.go new file mode 100644 index 0000000..dd78fe3 --- /dev/null +++ b/assets/train.go @@ -0,0 +1,54 @@ +package assets + +import ( + "fmt" + "image" + + "github.com/hajimehoshi/ebiten/v2" +) + +var ( + TrainN *ebiten.Image + TrainNE *ebiten.Image + TrainE *ebiten.Image + TrainSE *ebiten.Image + TrainS *ebiten.Image + TrainSW *ebiten.Image + TrainW *ebiten.Image + TrainNW *ebiten.Image + + TrainC *ebiten.Image +) + +var indexToImage = [3][3]**ebiten.Image{ + {&TrainNW, &TrainN, &TrainNE}, + {&TrainW, &TrainC, &TrainE}, + {&TrainSW, &TrainS, &TrainSE}, +} + +func TrainBaseGeoM() ebiten.GeoM { + g := ebiten.GeoM{} + g.Translate(-float64(TrainC.Bounds().Dx())/2, -float64(TrainC.Bounds().Dy())/2) + return g +} + +func initTrains() { + fname := "assets/train.png" + allTrain := readImageFile(fname) + bounds := allTrain.Bounds() + if bounds.Dx() != bounds.Dy() { + panic(fmt.Errorf("%s is not a square image", fname)) + } + + if bounds.Dx()%3 != 0 { + panic(fmt.Errorf("%s has a size that is not a multiple of 3", fname)) + } + chunk := bounds.Dx() / 3 + + for i := 0; i < 3; i++ { + for j := 0; j < 3; j++ { + rect := image.Rect(i*chunk, j*chunk, (i+1)*chunk, (j+1)*chunk) + *indexToImage[j][i] = ebiten.NewImageFromImage(allTrain.SubImage(rect)) + } + } +} diff --git a/assets/train.png b/assets/train.png new file mode 100644 index 0000000..cba9935 Binary files /dev/null and b/assets/train.png differ diff --git a/controls/buttondraw.go b/controls/buttondraw.go new file mode 100644 index 0000000..30d9a7d --- /dev/null +++ b/controls/buttondraw.go @@ -0,0 +1,18 @@ +package controls + +import ( + "github.com/hajimehoshi/ebiten/v2" +) + +func (b *Button) Draw(i *ebiten.Image) { + var buttonImg *ebiten.Image + if b.Pressed { + buttonImg = b.buttn.Pressed + } else { + buttonImg = b.buttn.UnPressed + } + + opts := new(ebiten.DrawImageOptions) + opts.GeoM.Translate(float64(b.at.X), float64(b.at.Y)) + i.DrawImage(buttonImg, opts) +} diff --git a/controls/buttontype.go b/controls/buttontype.go new file mode 100644 index 0000000..9993948 --- /dev/null +++ b/controls/buttontype.go @@ -0,0 +1,30 @@ +package controls + +import ( + "choochoo/assets" + "choochoo/math" +) + +type Button struct { + Pressed bool + buttn assets.Button + at math.Point + onClick func() + onHold func() + onHover func() + clickedFrames int +} + +func NewButton(buttn assets.Button, at math.Point) *Button { + b := new(Button) + b.buttn = buttn + b.at = at + b.onClick = func() {} + b.onHold = func() {} + b.onHover = func() {} + return b +} + +func (b *Button) SetOnClick(f func()) { b.onClick = f } +func (b *Button) SetOnHold(f func()) { b.onHold = f } +func (b *Button) SetOnHover(f func()) { b.onHover = f } diff --git a/controls/buttonupdate.go b/controls/buttonupdate.go new file mode 100644 index 0000000..84f1eea --- /dev/null +++ b/controls/buttonupdate.go @@ -0,0 +1,43 @@ +package controls + +import ( + "choochoo/assets" + + "github.com/hajimehoshi/ebiten/v2" +) + +func (b *Button) Update() { + cx, cy := ebiten.CursorPosition() + pressed := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) + + b.Pressed = false + // releases + if !pressed { + if 0 < b.clickedFrames && b.clickedFrames < 30 { + b.onClick() + } + b.clickedFrames = 0 + return + } + + // inside bounds + if float32(cx) < b.at.X || b.at.X+assets.ButtonWidth < float32(cx) { + return + } + if float32(cy) < b.at.Y || b.at.Y+assets.ButtonHeight < float32(cy) { + return + } + + // holding + if pressed { + b.onHold() + } + + // clicks + b.Pressed = pressed + if !b.Pressed { + b.onHover() + } else { + b.clickedFrames++ + } +} diff --git a/cursor/draw.go b/cursor/draw.go new file mode 100644 index 0000000..8c84295 --- /dev/null +++ b/cursor/draw.go @@ -0,0 +1,15 @@ +package cursor + +import ( + "choochoo/assets" + + "github.com/hajimehoshi/ebiten/v2" +) + +func Draw(i *ebiten.Image) { + ebiten.SetCursorMode(ebiten.CursorModeHidden) + opts := new(ebiten.DrawImageOptions) + x, y := ebiten.CursorPosition() + opts.GeoM.Translate(float64(x), float64(y)) + i.DrawImage(assets.Pointer, opts) +} diff --git a/game/draw.go b/game/draw.go index ac19896..6d2d9b2 100644 --- a/game/draw.go +++ b/game/draw.go @@ -1,6 +1,8 @@ package game import ( + "choochoo/assets" + "choochoo/cursor" "image/color" "github.com/hajimehoshi/ebiten/v2" @@ -12,6 +14,8 @@ import ( func (g *Game) Draw(i *ebiten.Image) { // Oh yeah and draw the background colour. i.Fill(color.RGBA{255, 223, 212, 255}) + bgOptions := new(ebiten.DrawImageOptions) + i.DrawImage(assets.Bg, bgOptions) // tracks on the bottom for _, t := range g.tracks { @@ -24,10 +28,15 @@ func (g *Game) Draw(i *ebiten.Image) { } // trains over the connections - for _, t := range g.trains { + for _, t := range g.trns { t.Draw(i) } + // draw controls + for _, b := range g.buttons { + b.Draw(i) + } + // Do some extra debug drawing if that flag is set if g.DebugDraw { // First draw the tracks now on top of all the @@ -38,7 +47,7 @@ func (g *Game) Draw(i *ebiten.Image) { } // Debug the trains. - for _, t := range g.trains { + for _, t := range g.trns { t.DebugDraw(i) } @@ -47,6 +56,8 @@ func (g *Game) Draw(i *ebiten.Image) { c.DebugDraw(i) } } + + cursor.Draw(i) } func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { diff --git a/game/gametype.go b/game/gametype.go index 0d24de4..9d1eafc 100644 --- a/game/gametype.go +++ b/game/gametype.go @@ -1,8 +1,9 @@ package game import ( + "choochoo/controls" "choochoo/track" - "choochoo/train" + "choochoo/trains" ) // The representation of our entire game! @@ -11,7 +12,8 @@ import ( // adding a track it likely reuses some connections, // and we don't want to double tick/draw connections. type Game struct { - trains []*train.Train + buttons []*controls.Button + trns []*trains.Train tracks []track.Track connections map[track.Connection]struct{} @@ -20,7 +22,8 @@ type Game struct { func NewGame() *Game { return &Game{ - make([]*train.Train, 0), + make([]*controls.Button, 0), + make([]*trains.Train, 0), make([]track.Track, 0), make(map[track.Connection]struct{}), false, @@ -42,6 +45,11 @@ func (g *Game) AddTrack(t track.Track) { // Add a new train to the game. // It just stores the train. -func (g *Game) AddTrain(t *train.Train) { - g.trains = append(g.trains, t) +func (g *Game) AddTrain(t *trains.Train) { + g.trns = append(g.trns, t) + t.PlaceOnTrack(g.tracks[0]) +} + +func (g *Game) AddButton(b *controls.Button) { + g.buttons = append(g.buttons, b) } diff --git a/game/update.go b/game/update.go index de19ae5..2b0a761 100644 --- a/game/update.go +++ b/game/update.go @@ -1,5 +1,7 @@ package game +import "choochoo/input" + // Update the entire game. // For now we just update the trains, but // if this becomes a real game also update @@ -7,8 +9,12 @@ package game // Also interactively building new tracks. // And train collisions, can't forget train collisions. func (g *Game) Update() error { - // but for now just update the trains. - for _, t := range g.trains { + for _, b := range g.buttons { + b.Update() + } + input.UpdateClick() + + for _, t := range g.trns { if err := t.Update(); err != nil { return err } diff --git a/input/drag.go b/input/drag.go new file mode 100644 index 0000000..24f3e43 --- /dev/null +++ b/input/drag.go @@ -0,0 +1,75 @@ +package input + +import ( + "image" + "math" + + "github.com/hajimehoshi/ebiten/v2" +) + +var ClickDrag ClickDragType + +type ClickDragType struct { + CurrentX, CurrentY int + BeginX, BeginY int + EndX, EndY int + IsHeld bool + HeldFrames int + ReleasedFrames int +} + +func UpdateClick() { + ClickDrag.CurrentX, ClickDrag.CurrentY = ebiten.CursorPosition() + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { + // is pressed + if ClickDrag.IsHeld { + // was held + ClickDrag.HeldFrames++ + } else { + // was not held + ClickDrag.HeldFrames = 0 + // start a drag + ClickDrag.BeginX, ClickDrag.BeginY = ebiten.CursorPosition() + } + ClickDrag.IsHeld = true + } else { + // is not pressed + if !ClickDrag.IsHeld { + // was not held + ClickDrag.ReleasedFrames++ + } else { + // was held + ClickDrag.ReleasedFrames = 0 + // end a drag + ClickDrag.EndX, ClickDrag.EndY = ebiten.CursorPosition() + } + ClickDrag.IsHeld = false + } +} + +func In(rect image.Rectangle) bool { + inX := rect.Min.X < ClickDrag.CurrentX && ClickDrag.CurrentX < rect.Max.X + inY := rect.Min.Y < ClickDrag.CurrentY && ClickDrag.CurrentY < rect.Max.Y + return inX && inY +} + +func HeldIn(rect image.Rectangle) bool { + return ClickDrag.IsHeld && In(rect) +} + +func ReleasedIn(rect image.Rectangle) bool { + return IsReleased() && In(rect) +} + +func IsReleased() bool { + return !ClickDrag.IsHeld && ClickDrag.ReleasedFrames == 0 +} + +func IsClickedReleased() bool { + dx := ClickDrag.BeginX - ClickDrag.EndX + dy := ClickDrag.BeginY - ClickDrag.EndY + + dist := math.Pow(float64(dx*dx), float64(dy*dy)) + + return IsReleased() && dist < 5 +} diff --git a/inquiry b/inquiry new file mode 100644 index 0000000..f151aff --- /dev/null +++ b/inquiry @@ -0,0 +1,27 @@ +Dear JessiAndroid. I hope this inquiry finds you well. + +I am working on a little train game, and I am in need of graphics for it. It is a rather small game, as it is also my first. +I was recommended you for being a multi faceted creative who is also a big train nerd, I hope you can agree with this description. + +The game is quite simple. You operate the throttle and brake of a train together with the upcoming rail switch. Your goal is to visit train stations, pick up passengers and drop them off at their desired destination stop. +The game gives you a top down overview of the train map with your train. The train map being deliberately unoptimized and full off twists and turns. +The turns itself take major part into the game. For one they make navigating a challenge, and going too fast around a tight corner will dissatisfy the passenger, reducing your score. +Now, the game is still far from done. I still have to work out the gameplay parts but I have a rough idea. +I'm currently working out all the different graphics I need and am making basic use of them. +I would like to provide you with a build that dynamically loads in the game assets, so that you can evaluate your work in action immediately. +I foresee around 30 sprites in total when counting generously. These do not have to be very detailed as long as the end result looks the part. I will place my trust your creative decision making, and have no intention of interfering. +If needed I can give feedback throughout the project. Conversely, you are free to share your opinion on the gameplay. I would value it very much. + +In terms of the contract, I propose the following compensation. I am able and willing to pay a minimum of 250.00 euro up front, which should equate to about 431.81305 AUD. I also propose a 40% cut of the revenue of game sales which I target to price at 5 euro on the itch.io marketplace, though I do not have any expectation of sales numbers. +For the task I do want to impose the same work ethics I apply to myself: +1) There is no minimum effort. Any bit of work performed is valuable. +2) There is no deadline. If it takes more time that means it will be better. +All the conditions are up for discussion if you take issue with any point. + +While I did not see a commission slot open on your net profiles, please do consider this offer for when you are available for work. + +I hope to hear from you soon. +Kind regards, +Gui Fl + +|| I really hope this is generous enough. I've never (successfully) commissioned anyone before...|| diff --git a/main.go b/main.go index 57b74a0..0fdc37e 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "choochoo/assets" + _ "choochoo/assets" + "choochoo/controls" "choochoo/game" "choochoo/math" - "choochoo/track" - "choochoo/train" + "choochoo/trackbuilder" + "choochoo/trains" "log" "github.com/hajimehoshi/ebiten/v2" @@ -16,34 +19,21 @@ func startingGame() *game.Game { // create a new empty game, as implemented in the game/ folder g := game.NewGame() - // Consecutive points to create the starting track. - // It has a right angle bend which isn't really nice for trains, but at least it works - points := []math.Point{ - {200, 70}, {330, 80}, {400, 170}, {314, 400}, {100, 350}, {100, 140}, - } - - // Make a track piece between the first two points, as implemented in the track/ directory - current := track.NewStraightTrack(points[0], points[1]) - first := current - // Add this track to the game we created at the beginning - g.AddTrack(current) - // Go over the rest of the points that we haven't covered yet - for i := 2; i < len(points); i++ { - // make a track piece from the last connection of the previous track to the new point - next := track.ExtendStraightTrack(current.Connections()[1], points[i]) - g.AddTrack(next) - // update this current for the next loop - current = next - } - - // The last track piece is between the first and last (current) track pieces, and their first and second connections respectively - last := track.ConnectPointsStraight(current.Connections()[1], first.Connections()[0]) - g.AddTrack(last) + trackbuilder.BuildTrack(assets.Level, g) // make a train and place it on the track - choo := train.NewTrainOnTrack(last) + choo := trains.NewTrain(2) g.AddTrain(choo) + // add button to go forwards + fwdbtn := controls.NewButton(assets.ButtonA, math.Point{0, 0}) + g.AddButton(fwdbtn) + fwdbtn.SetOnHold(choo.Throttle) + + revbtn := controls.NewButton(assets.ButtonB, math.Point{32, 0}) + g.AddButton(revbtn) + revbtn.SetOnHold(choo.Reverse) + // draws some extra line if this is true g.DebugDraw = false @@ -55,7 +45,8 @@ func main() { g := startingGame() // Set up the window - ebiten.SetWindowSize(512, 512) + w, h := assets.TotalBackgroundSize() + ebiten.SetWindowSize(w, h) ebiten.SetWindowTitle("choo choo") // Run the game until exit. If there was a problem just print it out diff --git a/math/map.go b/math/map.go new file mode 100644 index 0000000..0089c83 --- /dev/null +++ b/math/map.go @@ -0,0 +1,6 @@ +package math + +func Map(value, minValue, maxValue, minTarget, maxTarget float64) float64 { + ratio := (value - minValue) / (maxValue - minValue) + return ratio*(maxTarget-minTarget) + minTarget +} diff --git a/math/map_test.go b/math/map_test.go new file mode 100644 index 0000000..705b8f4 --- /dev/null +++ b/math/map_test.go @@ -0,0 +1,25 @@ +package math + +import "testing" + +func TestMap(t *testing.T) { + testdata := []struct { + minValue, maxValue, minTarget, maxTarget float64 + }{ + {0, 1, 0, 2}, + {-1, 0, 0, 2}, + } + + for _, td := range testdata { + t.Run("", func(t *testing.T) { + res := Map(td.minValue, td.minValue, td.maxValue, td.minTarget, td.maxTarget) + if res != td.minTarget { + t.Errorf("Map(%f, %f, %f, %f, %f) = %f but expected %f", td.minValue, td.minValue, td.maxValue, td.minTarget, td.maxTarget, res, td.minTarget) + } + res = Map(td.maxValue, td.minValue, td.maxValue, td.minTarget, td.maxTarget) + if res != td.maxTarget { + t.Errorf("Map(%f, %f, %f, %f, %f) = %f but expected %f", td.maxValue, td.minValue, td.maxValue, td.minTarget, td.maxTarget, res, td.maxTarget) + } + }) + } +} diff --git a/track/connectiontype.go b/track/connectiontype.go index 1e55498..c50eeb7 100644 --- a/track/connectiontype.go +++ b/track/connectiontype.go @@ -37,6 +37,12 @@ type StraightConnection struct { at math.Point } +func NewStraightConnection(at math.Point) Connection { + c := new(StraightConnection) + c.at = at + return c +} + // A more complex type of connection that I haven't implemented yet. // It connects multiple tracks and can decide which two to connect. type Switch struct { diff --git a/trackbuilder/build.go b/trackbuilder/build.go new file mode 100644 index 0000000..bc75efb --- /dev/null +++ b/trackbuilder/build.go @@ -0,0 +1,32 @@ +package trackbuilder + +import ( + "choochoo/assets" + "choochoo/game" + "choochoo/math" + "choochoo/track" + "fmt" +) + +func BuildTrack(level *assets.LevelType, g *game.Game) { + conns := make([]track.Connection, len(level.Connections)) + for i, c := range level.Connections { + conns[i] = track.NewStraightConnection(math.Point{float32(c[0]), float32(c[1])}) + } + + for i, t := range level.Tracks { + if t[0] < 0 || len(conns) <= t[0] { + panic(fmt.Errorf("There is no %d-th connection to make track %d", t[0], i)) + } + if t[1] < 0 || len(conns) <= t[1] { + panic(fmt.Errorf("There is no %d-th connection to make track %d", t[1], i)) + } + if t[0] == t[1] { + panic(fmt.Errorf("Track %d cannot start and end on the same connection", i)) + } + from := conns[t[0]] + to := conns[t[1]] + newTrack := track.ConnectPointsStraight(from, to) + g.AddTrack(newTrack) + } +} diff --git a/train/draw.go b/train/draw.go deleted file mode 100644 index 4eca9af..0000000 --- a/train/draw.go +++ /dev/null @@ -1,103 +0,0 @@ -package train - -import ( - "choochoo/math" - "image/color" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" -) - -// Draw function of a train which is called once every frame. -// It doesn't block game ticks in this engine, which is nice. -// -// A train is a series of bogies, and pairs of them are connected -// by a carriage. Bogies are circles, carriages are thick lines -func (t *Train) Draw(i *ebiten.Image) { - bogieColor := color.RGBA{101, 105, 92, 255} - cartColor := color.RGBA{105, 255, 215, 255} - - // Draw all the bogies - for _, b := range t.Bogies { - // Ask the bogie where it is - at := b.at() - // Draw a circle there - vector.DrawFilledCircle(i, at.X, at.Y, 6, bogieColor, true) - } - - // Draw all the carriages - // This is a funky loop header. Basically - // it goes over all the bogies but skips one - // This way the current and next bogie form a - // pair for our carriage. - // If there are an uneven amount of bogies... uhhh don't - for j := 0; j < len(t.Bogies)-1; j += 2 { - // These are our two bogies. The one at the index and one after it - one := t.Bogies[j].at() - two := t.Bogies[j+1].at() - // This is the center point between the two bogies, on which - // we'll draw the carriage. I think this hides some jitteriness - // of the bogies themselves since it averages the two. - center := math.WeightedAverage(one, 0.5, two, 0.5) - // Front and back points for our carriage ends - front := calculateCartEnd(center, one, t.cartLength/2) - back := calculateCartEnd(center, two, t.cartLength/2) - // Draw a rectangle there - vector.StrokeLine(i, front.X, front.Y, back.X, back.Y, 8, cartColor, true) - } -} - -// Debug drawing on top of the previous draw. -// It adds some lines to know where things are looking. -func (t *Train) DebugDraw(i *ebiten.Image) { - bogieColor := color.RGBA{112, 201, 56, 255} - debugColor := color.RGBA{255, 255, 0, 255} - for _, b := range t.Bogies { - at := b.at() - vector.DrawFilledCircle(i, at.X, at.Y, 6, bogieColor, false) - } - - b := t.Bogies[0] - at := b.at() - targetAt := b.target.At() - vector.StrokeLine(i, at.X, at.Y, targetAt.X, targetAt.Y, 1, debugColor, false) -} - -// Very fun function! It calculates half a carriage. -// The distance between a pair of bogies isn't fixed, even if we want it to be. -// And neither is the distance between a bogie and the center of two. -// So to not grow and shrink our carriages, the line between the center -// and a carriage is scaled so that it matches the target distance. -func calculateCartEnd(center, bogie math.Point, targetDistance float32) math.Point { - // The distance between the center and dist. - // This is only useful as a direction. - centerToBogie := center.Dist(bogie) - // Now! - // First move the bogie so that the center would be at the origin. You do this - // by just subtracting the center point. And subtraction is adding the negative value. - // Then multiply this point around center with a distance correcting factor. - // It was centerToBogie long but it needs to be tragetDistance long. So divide by - // centerToBogie and multipy by targetDistance. So x * (y/x). - // Lastly put it back in place by adding the center point again. - // You can also look at the diagram below. Left to right, top to bottom - return bogie.Add(center.Mult(-1)).Mult(targetDistance / centerToBogie).Add(center) -} - -// ^ ^ -// | C | -// | `. | -// | B | -// | | -// +-------> C-------> -// `. -// B -// -// ^ ^ -// | | C -// | | \ -// | | `. -// | | T -// C-------> -------> -// \ -// `. -// T diff --git a/train/update.go b/train/update.go deleted file mode 100644 index f49d3ff..0000000 --- a/train/update.go +++ /dev/null @@ -1,128 +0,0 @@ -package train - -// Update the train position. Called once per tick so don't take too long. -// The train, which is just a sequence of bogies, will step the first -// bogie by the train's speed, and step all other bogies forward -// so that they maintain the same fixed distance to the one in front. -// This is not an exact solution and will fail in impossible track designs. -// -// Well... it's one of the two approaches I can think off, and this is -// much more robust/elegant than the other I guess. -// The second approach would be to calculate the intersection point of a -// circle around the bogie in front and the entire track. -// This produces 2 or more points and one of them is correct. -// Lot's of math, and not better, or even worse, in impossible tracks. -func (t *Train) Update() error { - // The first bogie can just step forward with the train's speed. - // This is the leading bogie and is not constrained by anything. - b := t.Bogies[0] - // I say forward but if the speed is negative it will go backwards instead. - b.step(t.speed) - - nextBogie := b - // Go through all the other bogies one after the other - for i := 1; i < len(t.Bogies); i++ { - b := t.Bogies[i] - // This is the current distance to the bogie in front, - // subtracted with our target distance. We want the - // result to be 0, but this isn't always possible. - d := b.dist(nextBogie) - b.distanceToNext - // This is a factor we'll multiply the step with, - // and then decrease. We want to take big steps - // at the initial iterations, but fine tune it - // later. I'll also use this to limit the amount - // of max iterations - var falloff float32 = 1.0 - // If the subtracted distance is too small, or too big, move - // the bogie until it is small enough. - // You could also compare abs(d) < 0.1 but technicalities blah blah. - // ALSO check that the falloff is becoming too small so that - // our update doesn't take too long. - for (d < -0.1 || 0.1 < d) && falloff > 0.1 { - // Step the bogie according to the error in distance, but a - // little smaller than that. Taking the exact error as - // step only works on perfectly straight track, and we don't - // want to get stuck - b.step(d * falloff) - // Decrease the falloff by multiplying it with 0.9 - // This basically calculates 0.9^n with n the amount - // of iterations. With the limit at 0.1 the max - // amount of iterations is 22. - falloff *= 0.9 - // After making a step, calculate the error again. - // This is the distance to the next bogie, subtracted - // with our target distance. - d = b.dist(nextBogie) - b.distanceToNext - } - // The bogie we just put in the right place is the target - // for the next bogie - nextBogie = b - } - - return nil -} - -// Move a bogie along the track that it is on. -// It is moved by a distance in the game, but -// a bogie's position is kept as a progress on its track -func (b *bogie) step(stepSize float32) { - // First of all turn that distance into a progress increment. - // This is like a percentage increase. - incr := stepSize / b.currentTrack.Length() - b.trackCompletion += incr - - // If we overshoot our track (completion is bigger than 1.0 or 100%) - // we'll go to the beginning of next track. - // It's not a complete implementation actually. - // If the completion would be 1.2, that 0.2 overshoot - // should be turned back into a step size, and this - // step should be made on the next track. - // In reality this overshoot is going to be small - // so I don't bother with it. - if b.trackCompletion > 1.0 { - // Start on the new track on the beginning - b.trackCompletion = 0 - // Ask our target connection, that we're moving towards, - // what the next track is. Very important to ask because - // a connection could be a switch, and it needs to decide - // for us where we're going. - b.currentTrack = b.target.NextTrack(b.currentTrack) - // Ask our new track what its connections are. - // One of the connections will be our current target, - // because that's the one we just crossed, and the - // other is our next target. - nextConnections := b.currentTrack.Connections() - // Is our target the first connection? - if nextConnections[0] == b.target { - // so our new target becomes the other of the track's connections - b.target = nextConnections[1] - // Nah its the second one - } else { - // so our new target becomes the other of the track's connections - b.target = nextConnections[0] - } - } - - // This does the same as the previous block, except for under - // shoot. This happens when our completion goes below 0 when - // we're moving backwards. - // It might look simpler than going forward, but the same - // code is hidden behind the frontAndBack() function of a bogie. - if b.trackCompletion < 0.0 { - // Our bogie is on a track. A track has two connections - // and one of those is the bogie's target that the bogie - // looks at. That's the one in front of the bogie, and the - // other is in back. When undershooting a track - // this back bogie becomes our new target. - _, back := b.frontAndBack() - // Start the new track at the end, we're going backwards - // after all - b.trackCompletion = 1 - // The bogie, going backwards, is now looking at the - // connection it just crossed, which used to be behind it - b.target = back - // Ask this connection what the next track is, given - // that we came from the previous track. - b.currentTrack = b.target.NextTrack(b.currentTrack) - } -} diff --git a/trains/draw.go b/trains/draw.go new file mode 100644 index 0000000..ca70407 --- /dev/null +++ b/trains/draw.go @@ -0,0 +1,106 @@ +package trains + +import ( + "choochoo/assets" + "choochoo/math" + "image/color" + maths "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// Draw function of a train which is called once every frame. +// It doesn't block game ticks in this engine, which is nice. +// +// A train is a series of bogies, and pairs of them are connected +// by a carriage. Bogies are circles, carriages are thick lines +func (t *Train) Draw(i *ebiten.Image) { + bogieColor := color.RGBA{101, 105, 92, 255} + + // Draw all the bogies + for _, b := range t.Bogies { + // Ask the bogie where it is + at := b.at() + // Draw a circle there + vector.DrawFilledCircle(i, at.X, at.Y, 6, bogieColor, true) + } + + // Draw all the carriages + // This is a funky loop header. Basically + // it goes over all the bogies but skips one + // This way the current and next bogie form a + // pair for our carriage. + // If there are an uneven amount of bogies... uhhh don't + for j := 0; j < len(t.Bogies)-1; j += 2 { + // These are our two bogies. The one at the index and one after it + front := t.Bogies[j].at() + back := t.Bogies[j+1].at() + // This is the center point between the two bogies, on which + // we'll draw the carriage. I think this hides some jitteriness + // of the bogies themselves since it averages the two. + center := math.WeightedAverage(front, 0.5, back, 0.5) + img, _ := getTrainImage(front, back) + opts := new(ebiten.DrawImageOptions) + opts.GeoM = assets.TrainBaseGeoM() + + opts.GeoM.Translate(float64(center.X), float64(center.Y)) + i.DrawImage(img, opts) + } +} + +// Debug drawing on top of the previous draw. +// It adds some lines to know where things are looking. +func (t *Train) DebugDraw(i *ebiten.Image) { + bogieColor := color.RGBA{112, 201, 56, 255} + debugColor := color.RGBA{255, 255, 0, 255} + for _, b := range t.Bogies { + at := b.at() + vector.DrawFilledCircle(i, at.X, at.Y, 6, bogieColor, false) + } + + b := t.Bogies[0] + at := b.at() + targetAt := b.target.At() + vector.StrokeLine(i, at.X, at.Y, targetAt.X, targetAt.Y, 1, debugColor, false) +} + +func getTrainImage(front, back math.Point) (*ebiten.Image, float64) { + x := front.X - back.X + // flip y axis to y up + y := -(front.Y - back.Y) + + // y/x > 2 && y/x > -2 + if y > 2*x && y > -2*x { + return assets.TrainN, maths.Pi / 2 + } + // y/x < 2 && y/x > 1/2 + if y < 2*x && 2*y > x { + return assets.TrainNE, -maths.Pi / 4 + } + // y/x < 1/2 && > -1/2 + if 2*y < x && 2*y > -x { + return assets.TrainE, 0 + } + // y/x < -1/2 && y/x > -2 + if 2*y < -x && y > -2*x { + return assets.TrainSE, maths.Pi / 4 + } + // y/x < -2 && y/x < 2 + if y < -2*x && y < 2*x { + return assets.TrainS, -maths.Pi / 2 + } + // y/x > 2 && y/x < 1/2 + if y > 2*x && 2*y < x { + return assets.TrainSW, maths.Pi * 3 / 4 + } + // y/x > 1/2 && y/x < -1/2 + if 2*y > x && 2*y < -x { + return assets.TrainW, maths.Pi + } + // y/x > -1/2 && y/x < -2 + if 2*y > -x && y < -2*x { + return assets.TrainNW, -maths.Pi * 3 / 4 + } + return assets.TrainC, 0 +} diff --git a/train/traintype.go b/trains/traintype.go similarity index 76% rename from train/traintype.go rename to trains/traintype.go index d6e1989..076eaf8 100644 --- a/train/traintype.go +++ b/trains/traintype.go @@ -1,8 +1,10 @@ -package train +package trains import ( + "choochoo/assets" "choochoo/math" "choochoo/track" + "errors" ) // A train, choo choo. @@ -13,6 +15,9 @@ type Train struct { Bogies []*bogie speed float32 cartLength float32 + maxSpeed float32 + minSpeed float32 + friction float32 } // A bogie, roll roll? @@ -34,26 +39,24 @@ type bogie struct { } // Make a new train, and put it on a track immediately -func NewTrainOnTrack(t track.Track) *Train { +func NewTrain(carriages int) *Train { + maxBogieDistance := float32(assets.TrainC.Bounds().Dx()) tr := new(Train) // Prepare the number of bogies of the train. // These will be empty but we'll fill them in. // The number of bogies must be even! Because two bogies form a cart. // No high speed trains with bogies in between carriages sorry. - tr.Bogies = make([]*bogie, 8) + tr.Bogies = make([]*bogie, carriages*2) // This is a fun variable. It is the distance between the current // bogie and the one in front of it. But this distance alternates! // Two bogies under the same cart have a smaller distance // than two bogies across a cart coupling. - var d float32 = 20 + var d float32 = 0.3 * maxBogieDistance // Go over all the bogies positions for i := range tr.Bogies { // make a bogie b := new(bogie) - // Put it on the track - b.currentTrack = t - // Orient the bogie towards the next connection - b.target = t.Connections()[1] + b.trackCompletion = 0.5 // Set it's distance to the next bogie b.distanceToNext = d // Give this bogie to the train @@ -61,15 +64,46 @@ func NewTrainOnTrack(t track.Track) *Train { // Change the bogie distance for the next one. By always // subtracting it from 50 the value d will be 20, 30, 20, 30 // repeating. - d = 50 - d + d = maxBogieDistance - d } // set the train speed - tr.speed = 3 + tr.speed = 0 + tr.maxSpeed = 10 + tr.minSpeed = -5 + tr.friction = -0.3 // and the train cart's length tr.cartLength = 48 return tr } +func (t *Train) PlaceOnTrack(trck track.Track) { + towards := trck.Connections()[0] + if towards == nil { + towards = trck.Connections()[1] + } + if towards == nil { + panic(errors.New("can't place a train on a track without connection")) + } + + cumDistance := float32(0) + for _, b := range t.Bogies { + b.currentTrack = trck + b.target = towards + b.step(-cumDistance) + cumDistance += b.distanceToNext + } +} + +func (t *Train) Throttle() { + var accel float32 = 0.5 + t.speed += accel +} + +func (t *Train) Reverse() { + var accel float32 = 0.5 + t.speed -= accel +} + // Give back the connections that a bogie is facing, and the other one // The front connection will always be the target connection func (b *bogie) frontAndBack() (front, back track.Connection) { diff --git a/trains/update.go b/trains/update.go new file mode 100644 index 0000000..a2bbc27 --- /dev/null +++ b/trains/update.go @@ -0,0 +1,134 @@ +package trains + +import ( + "iter" + "math" +) + +// Update the train position. Called once per tick so don't take too long. +// The train, which is just a sequence of bogies, will step the first +// bogie by the train's speed, and step all other bogies forward +// so that they maintain the same fixed distance to the one in front. +// This is not an exact solution and will fail in impossible track designs. +// +// Well... it's one of the two approaches I can think off, and this is +// much more robust/elegant than the other I guess. +// The second approach would be to calculate the intersection point of a +// circle around the bogie in front and the entire track. +// This produces 2 or more points and one of them is correct. +// Lots of math, and not better, or even worse, in impossible tracks. +func (t *Train) Update() error { + t.speed += t.friction * float32(math.Atan(float64(t.speed))) + if t.speed > t.maxSpeed { + t.speed = t.maxSpeed + } + if t.speed < t.minSpeed { + t.speed = t.minSpeed + } + + if t.speed >= 0 { + errCalc := func(current, prev *bogie) float32 { + return current.dist(prev) - current.distanceToNext + } + t.moveBogies(t.forwardOrder(), errCalc) + } else if t.speed < 0 { + errCalc := func(current, prev *bogie) float32 { + // going backwards, so step is negative + return -1 * (current.dist(prev) - prev.distanceToNext) + } + t.moveBogies(t.reverseOrder(), errCalc) + } + + return nil +} + +func (t *Train) moveBogies(bogies iter.Seq[*bogie], distErr func(current, prev *bogie) float32) { + var prevBogie *bogie + for b := range bogies { + if prevBogie == nil { + b.step(t.speed) + } else { + dd := distErr(b, prevBogie) + var falloff float32 = 1.0 + for (dd < -0.1 || 0.1 < dd) && falloff > 0.1 { + b.step(dd * falloff) + falloff *= 0.9 + dd = distErr(b, prevBogie) + } + } + prevBogie = b + } +} + +func (t *Train) forwardOrder() iter.Seq[*bogie] { + return func(yield func(*bogie) bool) { + for _, b := range t.Bogies { + if !yield(b) { + return + } + } + } +} + +func (t *Train) reverseOrder() iter.Seq[*bogie] { + return func(yield func(*bogie) bool) { + for i := len(t.Bogies) - 1; i >= 0; i-- { + if !yield(t.Bogies[i]) { + return + } + } + } +} + +// Move a bogie along the track that it is on. +// It is moved by a distance in the game, but +// a bogie's position is kept as a progress on its track +func (b *bogie) step(stepSize float32) { + // First of all turn that distance into a progress increment. + // This is like a percentage increase. + incr := stepSize / b.currentTrack.Length() + b.trackCompletion += incr + + // If we overshoot our track (completion is bigger than 1.0 or 100%) + // we'll go to the beginning of next track. + // It's not a complete implementation actually. + // If the completion would be 1.2, that 0.2 overshoot + // should be turned back into a step size, and this + // step should be made on the next track. + // In reality this overshoot is going to be small + // so I don't bother with it. + if b.trackCompletion > 1.0 { + if b.target.NextTrack(b.currentTrack) == nil { + b.trackCompletion = 1.0 + } else { + b.trackCompletion = 0 + b.currentTrack = b.target.NextTrack(b.currentTrack) + nextConnections := b.currentTrack.Connections() + if nextConnections[0] == b.target { + b.target = nextConnections[1] + } else { + b.target = nextConnections[0] + } + } + } + + // This does the same as the previous block, except for under + // shoot. This happens when our completion goes below 0 when + // we're moving backwards. + // It might look simpler than going forward, but the same + // code is hidden behind the frontAndBack() function of a bogie. + if b.trackCompletion < 0.0 { + _, back := b.frontAndBack() + if back.NextTrack(b.currentTrack) == nil { + b.trackCompletion = 0 + } else { + b.trackCompletion = 1 + b.target = back + b.currentTrack = b.target.NextTrack(b.currentTrack) + } + } + + if b.currentTrack == nil { + panic("bogie ran off the track") + } +}