--- /dev/null
+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")
+}
--- /dev/null
+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)
+}
--- /dev/null
+package assets
+
+import "github.com/hajimehoshi/ebiten/v2"
+
+var Pointer *ebiten.Image
+
+func initCursor() {
+ Pointer = readImageFile("assets/pointer.png")
+}
--- /dev/null
+package assets
+
+func loadImages() {
+ initBackground()
+}
+
+func init() {
+ initButton()
+ initCursor()
+ initLevel()
+ initTrains()
+ loadImages()
+}
--- /dev/null
+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))
+ }
+
+}
--- /dev/null
+{
+ "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]
+ ]
+}
--- /dev/null
+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
+}
--- /dev/null
+{
+ "throttle": [560, 220],
+ "sway": [522,330]
+}
--- /dev/null
+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))
+ }
+ }
+}
--- /dev/null
+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)
+}
--- /dev/null
+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 }
--- /dev/null
+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++
+ }
+}
--- /dev/null
+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)
+}
package game
import (
+ "choochoo/assets"
+ "choochoo/cursor"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
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 {
}
// 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
}
// Debug the trains.
- for _, t := range g.trains {
+ for _, t := range g.trns {
t.DebugDraw(i)
}
c.DebugDraw(i)
}
}
+
+ cursor.Draw(i)
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
package game
import (
+ "choochoo/controls"
"choochoo/track"
- "choochoo/train"
+ "choochoo/trains"
)
// The representation of our entire game!
// 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{}
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,
// 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)
}
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
// 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
}
--- /dev/null
+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
+}
--- /dev/null
+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...||
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"
// 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
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
--- /dev/null
+package math
+
+func Map(value, minValue, maxValue, minTarget, maxTarget float64) float64 {
+ ratio := (value - minValue) / (maxValue - minValue)
+ return ratio*(maxTarget-minTarget) + minTarget
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
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 {
--- /dev/null
+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)
+ }
+}
+++ /dev/null
-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
+++ /dev/null
-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)
- }
-}
--- /dev/null
+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
+}
-package train
+package trains
import (
+ "choochoo/assets"
"choochoo/math"
"choochoo/track"
+ "errors"
)
// A train, choo choo.
Bogies []*bogie
speed float32
cartLength float32
+ maxSpeed float32
+ minSpeed float32
+ friction float32
}
// A bogie, roll roll?
}
// 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
// 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) {
--- /dev/null
+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")
+ }
+}