M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
All checks were successful
release / build-release (push) Successful in 1m35s
All checks were successful
release / build-release (push) Successful in 1m35s
- level: Schedule (ankomst/intervall/uppehåll) med Status(t), spårnummer, avgift, skylttexter per i-ruta, Path (BFS) och UngatedPlatforms – regeln att perronger bara nås via spärrar testas för alla banor - sim.Run: klocka, tågkort (saldo dras vid spärr, tomt kort stoppas), ombordstigning med Enter när tåget är inne, vinst/förlust; headless bot-test klarar bana 001 och förlorar på fel tåg - game: perronginfo (avgår om / nästa tåg om), skyltruta, nekad spärr, tågvagn som ritas när tåget är inne, inforutan flyttas upp när spelaren står nära nederkanten; resultat med spärrpassager och saldo - bana 001 ombyggd så spärrarna är flaskhalsar; bana 002 omgenererad med spärrar runt perrongerna och skylttexter - tools/shots räknar ut vägar med BFS; skriptkommandot waittrain - docs/README/wiki uppdaterade med nya bilder Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ type Assets struct {
|
||||
ResDir string
|
||||
LevelsDir string
|
||||
Tiles map[level.Tile]*ebiten.Image
|
||||
Train *ebiten.Image // tågvagn, ritas vid perrongen när tåget är inne
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
}
|
||||
@@ -38,13 +39,17 @@ func LoadAssets() (*Assets, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w := tiles.Bounds().Dx(); w < TileSize*len(tileOrder) {
|
||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, TileSize*len(tileOrder))
|
||||
sub := func(i int) *ebiten.Image {
|
||||
return tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||
}
|
||||
if w, need := tiles.Bounds().Dx(), TileSize*(len(tileOrder)+1); w < need {
|
||||
return nil, fmt.Errorf("tiles.png är %d px bred, behöver minst %d", w, need)
|
||||
}
|
||||
for i, t := range tileOrder {
|
||||
a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||
a.Tiles[t] = sub(i)
|
||||
}
|
||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
a.Train = sub(len(tileOrder))
|
||||
|
||||
player, err := loadImage("sprites", "player.png")
|
||||
if err != nil {
|
||||
|
||||
@@ -17,15 +17,16 @@ import (
|
||||
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
|
||||
const playerSpeed = 6.0
|
||||
|
||||
// LevelScene är själva spelandet av en bana.
|
||||
// Så länge "spärren nekade" visas efter försöket.
|
||||
const deniedShowSec = 2.0
|
||||
|
||||
// LevelScene är själva spelandet av en bana. All spellogik ligger i sim.Run;
|
||||
// scenen översätter input och ritar.
|
||||
type LevelScene struct {
|
||||
id string
|
||||
lvl *level.Level
|
||||
player *sim.Mover
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
elapsed float64
|
||||
steps int
|
||||
id string
|
||||
run *sim.Run
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
}
|
||||
|
||||
// NewLevelScene laddar banan och ställer spelaren på startrutan.
|
||||
@@ -35,20 +36,25 @@ func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
|
||||
return nil, err
|
||||
}
|
||||
s := &LevelScene{
|
||||
id: id,
|
||||
lvl: lvl,
|
||||
player: sim.NewMover(lvl.SpawnPoint(), playerSpeed),
|
||||
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
||||
id: id,
|
||||
run: sim.NewRun(lvl, playerSpeed),
|
||||
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
||||
}
|
||||
s.followPlayer()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PlayerTile returnerar rutan spelaren står på/lämnar (används av skriptläget).
|
||||
func (s *LevelScene) PlayerTile() level.Point { return s.player.Tile }
|
||||
func (s *LevelScene) PlayerTile() level.Point { return s.run.Player.Tile }
|
||||
|
||||
// TrainHere rapporterar om spelaren står på en perrong vars tåg är inne (skriptläget).
|
||||
func (s *LevelScene) TrainHere() bool {
|
||||
tr, at, _ := s.run.PlatformHere()
|
||||
return tr != nil && at
|
||||
}
|
||||
|
||||
func (s *LevelScene) followPlayer() {
|
||||
x, y := s.player.WorldPos(TileSize)
|
||||
x, y := s.run.Player.WorldPos(TileSize)
|
||||
s.cam.Follow(x+TileSize/2, y+TileSize/2)
|
||||
}
|
||||
|
||||
@@ -61,50 +67,116 @@ func (s *LevelScene) Update(g *Game) error {
|
||||
g.SetScene(m)
|
||||
return nil
|
||||
}
|
||||
s.elapsed += dt
|
||||
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter)
|
||||
if s.player.Moving() {
|
||||
s.run.Step(dt, dirInput(), confirmJustPressed())
|
||||
if s.run.Player.Moving() {
|
||||
s.animT += dt
|
||||
} else {
|
||||
s.animT = 0
|
||||
// Står vi på en perrong är banan slut (M2 ger tåg, tidtabell och spärrar).
|
||||
if t := s.lvl.At(s.player.Tile.X, s.player.Tile.Y); t == level.Platform || t == level.WrongPlatform {
|
||||
g.SetScene(NewResultScene(s, s.lvl.TrainAt(t)))
|
||||
}
|
||||
}
|
||||
s.followPlayer()
|
||||
if s.run.Outcome != sim.Ongoing {
|
||||
g.SetScene(NewResultScene(s))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första
|
||||
// väggruta nedanför, till höger, till vänster eller ovanför perrongen.
|
||||
func (s *LevelScene) trainTile(p level.Point) level.Point {
|
||||
for _, d := range [...]level.Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
|
||||
n := level.Point{X: p.X + d.X, Y: p.Y + d.Y}
|
||||
if s.run.Level.At(n.X, n.Y) == level.Wall {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
lvl := s.run.Level
|
||||
ox, oy := s.cam.Offset()
|
||||
ox, oy = math.Round(ox), math.Round(oy)
|
||||
|
||||
// Rita bara rutor som syns.
|
||||
x0 := max(0, int(s.cam.X)/TileSize)
|
||||
y0 := max(0, int(s.cam.Y)/TileSize)
|
||||
x1 := min(s.lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
|
||||
y1 := min(s.lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
|
||||
x1 := min(lvl.Width(), int(s.cam.X+ScreenW)/TileSize+2)
|
||||
y1 := min(lvl.Height(), int(s.cam.Y+ScreenH)/TileSize+2)
|
||||
var op ebiten.DrawImageOptions
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := x0; x < x1; x++ {
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+float64(x*TileSize), oy+float64(y*TileSize))
|
||||
screen.DrawImage(g.Assets.Tiles[s.lvl.At(x, y)], &op)
|
||||
screen.DrawImage(g.Assets.Tiles[lvl.At(x, y)], &op)
|
||||
}
|
||||
}
|
||||
// Tåg som står inne.
|
||||
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
|
||||
if !s.run.TrainVisible(t) {
|
||||
continue
|
||||
}
|
||||
for _, p := range lvl.Find(t) {
|
||||
tt := s.trainTile(p)
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+float64(tt.X*TileSize), oy+float64(tt.Y*TileSize))
|
||||
screen.DrawImage(g.Assets.Train, &op)
|
||||
}
|
||||
}
|
||||
|
||||
px, py := s.player.WorldPos(TileSize)
|
||||
px, py := s.run.Player.WorldPos(TileSize)
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py))
|
||||
screen.DrawImage(g.Assets.PlayerFrame(s.player.Facing, s.player.Moving(), s.animT), &op)
|
||||
screen.DrawImage(g.Assets.PlayerFrame(s.run.Player.Facing, s.run.Player.Moving(), s.animT), &op)
|
||||
|
||||
// HUD
|
||||
s.drawHUD(g, screen)
|
||||
}
|
||||
|
||||
func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
r := s.run
|
||||
a := g.Assets
|
||||
vector.DrawFilledRect(screen, 0, 0, ScreenW, 24, color.RGBA{24, 20, 37, 200}, false)
|
||||
// TODO(M3): visa japanska när en font med kana/kanji finns – debugfonten saknar dem.
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", s.lvl.Name.Get("sv"), g.Assets.T("hud.destination"),
|
||||
s.lvl.Destination.Get("romaji")), 4, 1)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s", g.Assets.T("hud.time"), s.elapsed,
|
||||
g.Assets.T("hud.steps"), s.steps, g.Assets.T("hud.controls")), 4, 12)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s: %s", r.Level.Name.Get("sv"), a.T("hud.destination"),
|
||||
r.Level.Destination.Get("romaji")), 4, 1)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s", a.T("hud.time"), r.Time,
|
||||
a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.controls")), 4, 12)
|
||||
|
||||
// Inforutan: perronginfo, skylt eller nekad spärr. Visas inte när banan är avgjord.
|
||||
if r.Outcome != sim.Ongoing {
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
accent := color.RGBA{58, 68, 102, 255}
|
||||
if tr, at, secs := r.PlatformHere(); tr != nil {
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji")))
|
||||
if at {
|
||||
accent = color.RGBA{99, 199, 77, 255}
|
||||
lines = append(lines, fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")))
|
||||
} else {
|
||||
accent = color.RGBA{254, 174, 52, 255}
|
||||
lines = append(lines, fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)))
|
||||
}
|
||||
} else if sg := r.SignHere(); sg != nil {
|
||||
lines = wrap(a.T("hud.sign")+": "+sg.Text.Get("sv"), textCols)
|
||||
} else if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
|
||||
accent = color.RGBA{228, 59, 68, 255}
|
||||
lines = []string{a.T("hud.gate_denied")}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
h := float32(8 + 12*len(lines))
|
||||
// Rutan ligger i nederkant, men flyttas upp under HUD:en om spelaren
|
||||
// (eller tåget under hen) annars skulle hamna bakom den.
|
||||
_, py := r.Player.WorldPos(TileSize)
|
||||
y := float32(ScreenH) - h
|
||||
if float32(py-s.cam.Y)+2*TileSize > y {
|
||||
y = 24
|
||||
}
|
||||
vector.DrawFilledRect(screen, 0, y, ScreenW, h, color.RGBA{24, 20, 37, 230}, false)
|
||||
vector.DrawFilledRect(screen, 0, y, ScreenW, 2, accent, false)
|
||||
for i, l := range lines {
|
||||
ebitenutil.DebugPrintAt(screen, l, 4, int(y)+4+12*i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,16 @@ import (
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// ResultScene visas när spelaren nått en perrong.
|
||||
// ResultScene visas när banan är avgjord (rätt eller fel tåg).
|
||||
type ResultScene struct {
|
||||
from *LevelScene
|
||||
train *level.Train
|
||||
from *LevelScene
|
||||
}
|
||||
|
||||
// NewResultScene bygger resultatet för banan som just spelades.
|
||||
func NewResultScene(from *LevelScene, train *level.Train) *ResultScene {
|
||||
return &ResultScene{from: from, train: train}
|
||||
}
|
||||
|
||||
func (r *ResultScene) correct() bool { return r.train != nil && r.train.Correct }
|
||||
func NewResultScene(from *LevelScene) *ResultScene { return &ResultScene{from: from} }
|
||||
|
||||
func (r *ResultScene) Update(g *Game) error {
|
||||
switch {
|
||||
@@ -44,22 +39,33 @@ func (r *ResultScene) Update(g *Game) error {
|
||||
|
||||
func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
r.from.Draw(g, screen) // banan i bakgrunden
|
||||
bg := color.RGBA{24, 20, 37, 230}
|
||||
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 100, bg, false)
|
||||
accent := color.RGBA{228, 59, 68, 255}
|
||||
headline := g.Assets.T("level.wrong_train")
|
||||
if r.correct() {
|
||||
accent = color.RGBA{99, 199, 77, 255}
|
||||
headline = g.Assets.T("level.cleared")
|
||||
}
|
||||
vector.DrawFilledRect(screen, 20, 40, ScreenW-40, 3, accent, false)
|
||||
run := r.from.run
|
||||
a := g.Assets
|
||||
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 112, color.RGBA{24, 20, 37, 235}, false)
|
||||
|
||||
ebitenutil.DebugPrintAt(screen, headline, 28, 50)
|
||||
if r.train != nil {
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", r.train.Line.Get("romaji"),
|
||||
r.train.Destination.Get("romaji")), 28, 66) // TODO(M3): japanska när fonten finns
|
||||
accent, headline := color.RGBA{228, 59, 68, 255}, a.T("level.wrong_train")
|
||||
if run.Outcome == sim.Won {
|
||||
accent, headline = color.RGBA{99, 199, 77, 255}, a.T("level.cleared")
|
||||
}
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d", g.Assets.T("hud.time"), r.from.elapsed,
|
||||
g.Assets.T("hud.steps"), r.from.steps), 28, 86)
|
||||
ebitenutil.DebugPrintAt(screen, g.Assets.T("result.hint"), 28, 118)
|
||||
vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false)
|
||||
ebitenutil.DebugPrintAt(screen, headline, 28, 46)
|
||||
|
||||
y := 62
|
||||
if tr := run.Boarded; tr != nil {
|
||||
// TODO(M3): japanska när fonten finns
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %s %s -> %s", a.T("hud.track"), tr.Track,
|
||||
tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y)
|
||||
y += 12
|
||||
if run.Outcome == sim.Lost {
|
||||
for _, l := range wrap(fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"),
|
||||
run.Level.Destination.Get("romaji")), 44) {
|
||||
ebitenutil.DebugPrintAt(screen, l, 28, y)
|
||||
y += 12
|
||||
}
|
||||
}
|
||||
}
|
||||
y += 6
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s %.1fs %s %d %s %d %s %d¥", a.T("hud.time"), run.Time,
|
||||
a.T("hud.steps"), run.Steps, a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Card.Balance), 28, y)
|
||||
ebitenutil.DebugPrintAt(screen, a.T("result.hint"), 28, 128)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// Stegen skiljs med ';':
|
||||
//
|
||||
// wait N vänta N bildrutor
|
||||
// waittrain vänta tills tåget vid perrongen spelaren står på är inne
|
||||
// press KEY tryck enter | esc | r | up | down en bildruta
|
||||
// move DIR N gå N rutor åt up | down | left | right (släpper när rutorna är avklarade)
|
||||
// shot PATH spara aktuell bild som PNG (intern upplösning × shotScale)
|
||||
@@ -83,7 +84,7 @@ func parseScript(src string) (*script, error) {
|
||||
return nil, fmt.Errorf("script: 'shot PATH'")
|
||||
}
|
||||
st.arg = f[1]
|
||||
case "quit":
|
||||
case "quit", "waittrain":
|
||||
default:
|
||||
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
|
||||
}
|
||||
@@ -109,6 +110,9 @@ func parseDir(s string) sim.Dir {
|
||||
// playerTiler implementeras av scener som har en spelare i rutnätet.
|
||||
type playerTiler interface{ PlayerTile() level.Point }
|
||||
|
||||
// trainWaiter implementeras av scener där man kan vänta på ett tåg.
|
||||
type trainWaiter interface{ TrainHere() bool }
|
||||
|
||||
// tick körs i början av varje Update. frame är senast ritade bild.
|
||||
// Returnerar ebiten.Termination när skriptet vill avsluta.
|
||||
func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
||||
@@ -151,6 +155,10 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
|
||||
return err
|
||||
}
|
||||
s.advance()
|
||||
case "waittrain":
|
||||
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrainHere() {
|
||||
s.advance()
|
||||
}
|
||||
case "quit":
|
||||
return ebiten.Termination
|
||||
}
|
||||
|
||||
27
internal/game/text.go
Normal file
27
internal/game/text.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
|
||||
// Debugfonten är 6 px bred per tecken; 320 px ger drygt 50 tecken.
|
||||
const textCols = 50
|
||||
|
||||
// wrap radbryter en text på ordgränser till högst width tecken per rad.
|
||||
func wrap(s string, width int) []string {
|
||||
var lines []string
|
||||
cur := ""
|
||||
for _, w := range strings.Fields(s) {
|
||||
switch {
|
||||
case cur == "":
|
||||
cur = w
|
||||
case len([]rune(cur))+1+len([]rune(w)) > width:
|
||||
lines = append(lines, cur)
|
||||
cur = w
|
||||
default:
|
||||
cur += " " + w
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user