M1: rörelse, kollision, kamera, scener, tileset, skärmdumpar, CI
Some checks failed
release / build-release (push) Failing after 27s
Some checks failed
release / build-release (push) Failing after 27s
- internal/sim: rutbaserad Mover (4 riktningar, riktningsbyte vid rutgräns, bildrute-exakt ankomst) och Camera (följer, klampar, centrerar små kartor), med tester - internal/game: scener meny -> bana -> resultat, tangentbord + gamepad, tileset och spelaranimation, HUD, skriptläge (-script) för deterministiska skärmdumpar och tester - tools/genart genererar placeholder-grafik (tiles.png, player.png); tools/shots tar doc/img/*.png som README och wiki visar - bana 002-shinjuku (33x21) för kamerascroll; level.List/LoadFile/TrainAt - .gitea/workflows/release.yaml: vet + test + linux/windows-zippar till rullande latest på brasse-linux01-runnern (linux-amd64) - docs: roadmap M1 klar, tester, plan, README med bilder och styrning Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
97
internal/game/assets.go
Normal file
97
internal/game/assets.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// Ordningen på rutorna i resources/sprites/tiles.png (måste matcha tools/genart).
|
||||
var tileOrder = []level.Tile{
|
||||
level.Wall, level.Floor, level.Spawn, level.Platform, level.Gate, level.TicketMachine, level.Sign,
|
||||
}
|
||||
|
||||
// Assets är allt som laddats från resources/.
|
||||
type Assets struct {
|
||||
ResDir string
|
||||
LevelsDir string
|
||||
Tiles map[level.Tile]*ebiten.Image
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
}
|
||||
|
||||
// LoadAssets läser grafik och texter.
|
||||
func LoadAssets() (*Assets, error) {
|
||||
dir, err := res.Dir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &Assets{ResDir: dir, Tiles: map[level.Tile]*ebiten.Image{}}
|
||||
a.LevelsDir, _ = res.Path("data", "levels")
|
||||
|
||||
tiles, err := loadImage("sprites", "tiles.png")
|
||||
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))
|
||||
}
|
||||
for i, t := range tileOrder {
|
||||
a.Tiles[t] = tiles.SubImage(image.Rect(i*TileSize, 0, (i+1)*TileSize, TileSize)).(*ebiten.Image)
|
||||
}
|
||||
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
|
||||
|
||||
player, err := loadImage("sprites", "player.png")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for row := 0; row < 4; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
a.Player[row][col] = player.SubImage(image.Rect(col*TileSize, row*TileSize, (col+1)*TileSize, (row+1)*TileSize)).(*ebiten.Image)
|
||||
}
|
||||
}
|
||||
|
||||
if err := res.LoadJSON(&a.Text, "text", "sv.json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// T slår upp en UI-text; saknas nyckeln visas nyckeln själv så att det syns.
|
||||
func (a *Assets) T(key string) string {
|
||||
if s, ok := a.Text[key]; ok {
|
||||
return s
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// PlayerFrame returnerar rätt bildruta för riktning och gångfas.
|
||||
func (a *Assets) PlayerFrame(facing sim.Dir, moving bool, animT float64) *ebiten.Image {
|
||||
row := 0
|
||||
switch facing {
|
||||
case sim.Up:
|
||||
row = 1
|
||||
case sim.Left:
|
||||
row = 2
|
||||
case sim.Right:
|
||||
row = 3
|
||||
}
|
||||
col := 0
|
||||
if moving {
|
||||
col = 1 + int(animT/0.12)%2
|
||||
}
|
||||
return a.Player[row][col]
|
||||
}
|
||||
|
||||
func loadImage(parts ...string) (*ebiten.Image, error) {
|
||||
img, err := res.LoadImage(parts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ebiten.NewImageFromImage(img), nil
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
// Package game innehåller Ebitengine-spelloopen. Detta är det enda paketet
|
||||
// (tillsammans med cmd/) som får känna till Ebitengine; all spellogik ska
|
||||
// ligga i paket som går att testa utan fönster.
|
||||
// Package game innehåller Ebitengine-spelloopen och scenerna. Detta är det
|
||||
// enda paketet (tillsammans med cmd/) som får känna till Ebitengine; all
|
||||
// spellogik ligger i paket som går att testa utan fönster (internal/sim,
|
||||
// internal/level …).
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
|
||||
@@ -21,115 +13,87 @@ import (
|
||||
const (
|
||||
ScreenW = 320
|
||||
ScreenH = 180
|
||||
TileSize = 8
|
||||
TileSize = 16
|
||||
dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS
|
||||
)
|
||||
|
||||
// Scene är ett spelläge: meny, bana, resultat …
|
||||
type Scene interface {
|
||||
Update(g *Game) error
|
||||
Draw(g *Game, screen *ebiten.Image)
|
||||
}
|
||||
|
||||
// Game är Ebitengine-tillståndet för hela programmet.
|
||||
type Game struct {
|
||||
lvl *level.Level
|
||||
player *ebiten.Image
|
||||
tiles map[level.Tile]*ebiten.Image
|
||||
resDir string
|
||||
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar (för smoke-test)
|
||||
frames int
|
||||
levelError error
|
||||
Assets *Assets
|
||||
scene Scene
|
||||
next Scene
|
||||
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar
|
||||
script *script
|
||||
frame *ebiten.Image // allt ritas hit och blittas sedan till skärmen (gör skärmdumpar möjliga)
|
||||
}
|
||||
|
||||
// Options styr uppstarten.
|
||||
type Options struct {
|
||||
LevelID string // bana att visa, t.ex. "001-flygplatsen"
|
||||
SmokeFrames int // >0 avslutar efter N uppdateringar
|
||||
LevelID string // hoppa direkt in i en bana (tom = meny)
|
||||
SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test)
|
||||
Script string // skriptade steg för skärmdumpar/tester, se script.go
|
||||
}
|
||||
|
||||
// New laddar resurser och bygger spelet.
|
||||
func New(opt Options) (*Game, error) {
|
||||
dir, err := res.Dir()
|
||||
a, err := LoadAssets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &Game{resDir: dir, smokeLeft: opt.SmokeFrames}
|
||||
|
||||
img, err := res.LoadImage("sprites", "placeholder.png")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)}
|
||||
if opt.Script != "" {
|
||||
if g.script, err = parseScript(opt.Script); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
g.player = ebiten.NewImageFromImage(img)
|
||||
|
||||
var raw []byte
|
||||
if raw, err = res.ReadFile("data", "levels", opt.LevelID+".json"); err == nil {
|
||||
g.lvl, err = level.Parse(raw)
|
||||
}
|
||||
if err != nil {
|
||||
g.levelError = err
|
||||
}
|
||||
|
||||
g.tiles = map[level.Tile]*ebiten.Image{}
|
||||
for t, c := range map[level.Tile]color.RGBA{
|
||||
level.Wall: {38, 43, 68, 255},
|
||||
level.Floor: {139, 155, 180, 255},
|
||||
level.Spawn: {99, 199, 77, 255},
|
||||
level.Platform: {254, 174, 52, 255},
|
||||
level.WrongPlatform: {228, 59, 68, 255},
|
||||
level.Gate: {44, 232, 245, 255},
|
||||
level.TicketMachine: {247, 118, 34, 255},
|
||||
level.Sign: {255, 255, 255, 255},
|
||||
} {
|
||||
im := ebiten.NewImage(TileSize, TileSize)
|
||||
im.Fill(c)
|
||||
g.tiles[t] = im
|
||||
if opt.LevelID != "" {
|
||||
s, err := NewLevelScene(a, opt.LevelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.scene = s
|
||||
} else {
|
||||
g.scene, err = NewMenuScene(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// SetScene byter scen från och med nästa uppdatering.
|
||||
func (g *Game) SetScene(s Scene) { g.next = s }
|
||||
|
||||
// Update körs 60 ggr/s.
|
||||
func (g *Game) Update() error {
|
||||
g.frames++
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
||||
return ebiten.Termination
|
||||
}
|
||||
if g.smokeLeft > 0 {
|
||||
g.smokeLeft--
|
||||
if g.smokeLeft == 0 {
|
||||
return ebiten.Termination
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if g.next != nil {
|
||||
g.scene, g.next = g.next, nil
|
||||
}
|
||||
if g.script != nil {
|
||||
if err := g.script.tick(g, g.frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return g.scene.Update(g)
|
||||
}
|
||||
|
||||
// Draw ritar aktuell bild.
|
||||
// Draw ritar aktuell scen till den interna bilden och sedan till skärmen.
|
||||
func (g *Game) Draw(screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
|
||||
if g.lvl != nil {
|
||||
// Centrera banan på skärmen (riktiga banor får kamera senare).
|
||||
ox := (ScreenW - g.lvl.Width()*TileSize) / 2
|
||||
oy := (ScreenH - g.lvl.Height()*TileSize) / 2
|
||||
var op ebiten.DrawImageOptions
|
||||
for y := 0; y < g.lvl.Height(); y++ {
|
||||
for x := 0; x < g.lvl.Width(); x++ {
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(float64(ox+x*TileSize), float64(oy+y*TileSize))
|
||||
screen.DrawImage(g.tiles[g.lvl.At(x, y)], &op)
|
||||
}
|
||||
}
|
||||
s := g.lvl.SpawnPoint()
|
||||
op.GeoM.Reset()
|
||||
op.GeoM.Translate(float64(ox+s.X*TileSize)-4, float64(oy+s.Y*TileSize)-8)
|
||||
screen.DrawImage(g.player, &op)
|
||||
}
|
||||
|
||||
name := "(ingen bana)"
|
||||
if g.lvl != nil {
|
||||
name = g.lvl.Name.Get("sv") + " -> " + g.lvl.Destination.Get("ja")
|
||||
} else if g.levelError != nil {
|
||||
name = "FEL: " + g.levelError.Error()
|
||||
}
|
||||
ebitenutil.DebugPrint(screen, fmt.Sprintf(
|
||||
"Metro Puzzle JP %s (%s) %.0f fps\nBana: %s\nResurser: %s\nESC avslutar",
|
||||
buildinfo.Version, buildinfo.Mode, ebiten.ActualFPS(), name, g.resDir))
|
||||
g.scene.Draw(g, g.frame)
|
||||
screen.DrawImage(g.frame, nil)
|
||||
}
|
||||
|
||||
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
|
||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||
return ScreenW, ScreenH
|
||||
}
|
||||
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH }
|
||||
|
||||
99
internal/game/input.go
Normal file
99
internal/game/input.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
const stickDeadzone = 0.5
|
||||
|
||||
// dirInput returnerar hållen riktning från tangentbord (pilar/WASD) eller
|
||||
// gamepad (d-pad / vänster spak). None om inget hålls.
|
||||
func dirInput() sim.Dir {
|
||||
if virtual.held != sim.None {
|
||||
return virtual.held
|
||||
}
|
||||
switch {
|
||||
case ebiten.IsKeyPressed(ebiten.KeyArrowUp) || ebiten.IsKeyPressed(ebiten.KeyW):
|
||||
return sim.Up
|
||||
case ebiten.IsKeyPressed(ebiten.KeyArrowDown) || ebiten.IsKeyPressed(ebiten.KeyS):
|
||||
return sim.Down
|
||||
case ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA):
|
||||
return sim.Left
|
||||
case ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD):
|
||||
return sim.Right
|
||||
}
|
||||
for _, id := range ebiten.AppendGamepadIDs(nil) {
|
||||
if !ebiten.IsStandardGamepadLayoutAvailable(id) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftTop):
|
||||
return sim.Up
|
||||
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftBottom):
|
||||
return sim.Down
|
||||
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftLeft):
|
||||
return sim.Left
|
||||
case ebiten.IsStandardGamepadButtonPressed(id, ebiten.StandardGamepadButtonLeftRight):
|
||||
return sim.Right
|
||||
}
|
||||
x := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickHorizontal)
|
||||
y := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickVertical)
|
||||
switch {
|
||||
case y < -stickDeadzone:
|
||||
return sim.Up
|
||||
case y > stickDeadzone:
|
||||
return sim.Down
|
||||
case x < -stickDeadzone:
|
||||
return sim.Left
|
||||
case x > stickDeadzone:
|
||||
return sim.Right
|
||||
}
|
||||
}
|
||||
return sim.None
|
||||
}
|
||||
|
||||
// confirmJustPressed: Enter/mellanslag eller gamepadens nedre knapp (A/kryss).
|
||||
func confirmJustPressed() bool {
|
||||
if virtual.pressed["enter"] {
|
||||
return true
|
||||
}
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) {
|
||||
return true
|
||||
}
|
||||
return gamepadJustPressed(ebiten.StandardGamepadButtonRightBottom)
|
||||
}
|
||||
|
||||
// backJustPressed: Escape eller gamepadens högra knapp (B/cirkel).
|
||||
func backJustPressed() bool {
|
||||
return virtual.pressed["esc"] || inpututil.IsKeyJustPressed(ebiten.KeyEscape) || gamepadJustPressed(ebiten.StandardGamepadButtonRightRight)
|
||||
}
|
||||
|
||||
// menuMove returnerar -1/+1 när användaren just tryckte upp/ner i en meny.
|
||||
func menuMove() int {
|
||||
switch {
|
||||
case virtual.pressed["up"]:
|
||||
return -1
|
||||
case virtual.pressed["down"]:
|
||||
return 1
|
||||
case inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop):
|
||||
return -1
|
||||
case inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func gamepadJustPressed(b ebiten.StandardGamepadButton) bool {
|
||||
for _, id := range ebiten.AppendGamepadIDs(nil) {
|
||||
if ebiten.IsStandardGamepadLayoutAvailable(id) && inpututil.IsStandardGamepadButtonJustPressed(id, b) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// inpututil_R: R = spela om banan.
|
||||
func inpututil_R() bool { return virtual.pressed["r"] || inpututil.IsKeyJustPressed(ebiten.KeyR) }
|
||||
110
internal/game/scene_level.go
Normal file
110
internal/game/scene_level.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Spelarens grundfart i rutor per sekund (blir en stat i M7).
|
||||
const playerSpeed = 6.0
|
||||
|
||||
// LevelScene är själva spelandet av en bana.
|
||||
type LevelScene struct {
|
||||
id string
|
||||
lvl *level.Level
|
||||
player *sim.Mover
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
elapsed float64
|
||||
steps int
|
||||
}
|
||||
|
||||
// NewLevelScene laddar banan och ställer spelaren på startrutan.
|
||||
func NewLevelScene(a *Assets, id string) (*LevelScene, error) {
|
||||
lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
|
||||
if err != nil {
|
||||
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)),
|
||||
}
|
||||
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) followPlayer() {
|
||||
x, y := s.player.WorldPos(TileSize)
|
||||
s.cam.Follow(x+TileSize/2, y+TileSize/2)
|
||||
}
|
||||
|
||||
func (s *LevelScene) Update(g *Game) error {
|
||||
if backJustPressed() {
|
||||
m, err := NewMenuScene(g.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.SetScene(m)
|
||||
return nil
|
||||
}
|
||||
s.elapsed += dt
|
||||
s.steps += s.player.Step(dt, dirInput(), s.lvl.CanEnter)
|
||||
if s.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()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
px, py := s.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)
|
||||
|
||||
// HUD
|
||||
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)
|
||||
}
|
||||
83
internal/game/scene_menu.go
Normal file
83
internal/game/scene_menu.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
)
|
||||
|
||||
type menuEntry struct {
|
||||
id string
|
||||
name string
|
||||
dest string
|
||||
}
|
||||
|
||||
// MenuScene listar banorna och låter spelaren välja.
|
||||
type MenuScene struct {
|
||||
entries []menuEntry
|
||||
sel int
|
||||
err error
|
||||
}
|
||||
|
||||
// NewMenuScene läser in banlistan.
|
||||
func NewMenuScene(a *Assets) (*MenuScene, error) {
|
||||
ids, err := level.List(a.LevelsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &MenuScene{}
|
||||
for _, id := range ids {
|
||||
l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json"))
|
||||
if err != nil {
|
||||
m.err = err
|
||||
continue
|
||||
}
|
||||
m.entries = append(m.entries, menuEntry{id: id, name: l.Name.Get("sv"), dest: l.Destination.Get("romaji")}) // TODO(M3): japanska när fonten finns
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *MenuScene) Update(g *Game) error {
|
||||
if backJustPressed() {
|
||||
return ebiten.Termination
|
||||
}
|
||||
if n := len(m.entries); n > 0 {
|
||||
m.sel = (m.sel + menuMove() + n) % n
|
||||
if confirmJustPressed() {
|
||||
s, err := NewLevelScene(g.Assets, m.entries[m.sel].id)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
return nil
|
||||
}
|
||||
g.SetScene(s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{24, 20, 37, 255})
|
||||
vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false)
|
||||
ebitenutil.DebugPrintAt(screen, g.Assets.T("game.title"), 8, 4)
|
||||
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.choose_level"), 8, 30)
|
||||
|
||||
for i, e := range m.entries {
|
||||
y := 46 + i*14
|
||||
if i == m.sel {
|
||||
vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, color.RGBA{58, 68, 102, 255}, false)
|
||||
}
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s -> %s", e.name, e.dest), 12, y)
|
||||
}
|
||||
if m.err != nil {
|
||||
ebitenutil.DebugPrintAt(screen, "FEL: "+m.err.Error(), 8, ScreenH-40)
|
||||
}
|
||||
ebitenutil.DebugPrintAt(screen, g.Assets.T("menu.hint"), 8, ScreenH-26)
|
||||
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13)
|
||||
}
|
||||
65
internal/game/scene_result.go
Normal file
65
internal/game/scene_result.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
)
|
||||
|
||||
// ResultScene visas när spelaren nått en perrong.
|
||||
type ResultScene struct {
|
||||
from *LevelScene
|
||||
train *level.Train
|
||||
}
|
||||
|
||||
// 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 (r *ResultScene) Update(g *Game) error {
|
||||
switch {
|
||||
case inpututil_R():
|
||||
s, err := NewLevelScene(g.Assets, r.from.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.SetScene(s)
|
||||
case confirmJustPressed() || backJustPressed():
|
||||
m, err := NewMenuScene(g.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.SetScene(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
189
internal/game/script.go
Normal file
189
internal/game/script.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// Skriptläge (-script) för automatiserade skärmdumpar och smoke-tester.
|
||||
// Stegen skiljs med ';':
|
||||
//
|
||||
// wait N vänta N bildrutor
|
||||
// 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)
|
||||
// quit avsluta spelet
|
||||
//
|
||||
// Exempel: -level 001-flygplatsen -script "move down 2; move right 2; shot doc/img/level.png; quit"
|
||||
|
||||
const shotScale = 3
|
||||
|
||||
type scriptStep struct {
|
||||
cmd string
|
||||
arg string
|
||||
n int
|
||||
dir sim.Dir
|
||||
done bool
|
||||
}
|
||||
|
||||
type script struct {
|
||||
steps []scriptStep
|
||||
i int
|
||||
counter int
|
||||
lastPos level.Point
|
||||
havePos bool
|
||||
}
|
||||
|
||||
// virtual är den simulerade inputen som input.go läser före riktig input.
|
||||
var virtual struct {
|
||||
held sim.Dir
|
||||
pressed map[string]bool
|
||||
}
|
||||
|
||||
func parseScript(src string) (*script, error) {
|
||||
s := &script{}
|
||||
for _, raw := range strings.Split(src, ";") {
|
||||
f := strings.Fields(raw)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
st := scriptStep{cmd: f[0]}
|
||||
switch f[0] {
|
||||
case "wait":
|
||||
if len(f) != 2 {
|
||||
return nil, fmt.Errorf("script: 'wait N'")
|
||||
}
|
||||
st.n, _ = strconv.Atoi(f[1])
|
||||
case "press":
|
||||
if len(f) != 2 {
|
||||
return nil, fmt.Errorf("script: 'press KEY'")
|
||||
}
|
||||
st.arg = f[1]
|
||||
case "move":
|
||||
if len(f) != 3 {
|
||||
return nil, fmt.Errorf("script: 'move DIR N'")
|
||||
}
|
||||
st.dir = parseDir(f[1])
|
||||
if st.dir == sim.None {
|
||||
return nil, fmt.Errorf("script: okänd riktning %q", f[1])
|
||||
}
|
||||
st.n, _ = strconv.Atoi(f[2])
|
||||
case "shot":
|
||||
if len(f) != 2 {
|
||||
return nil, fmt.Errorf("script: 'shot PATH'")
|
||||
}
|
||||
st.arg = f[1]
|
||||
case "quit":
|
||||
default:
|
||||
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
|
||||
}
|
||||
s.steps = append(s.steps, st)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseDir(s string) sim.Dir {
|
||||
switch s {
|
||||
case "up":
|
||||
return sim.Up
|
||||
case "down":
|
||||
return sim.Down
|
||||
case "left":
|
||||
return sim.Left
|
||||
case "right":
|
||||
return sim.Right
|
||||
}
|
||||
return sim.None
|
||||
}
|
||||
|
||||
// playerTiler implementeras av scener som har en spelare i rutnätet.
|
||||
type playerTiler interface{ PlayerTile() level.Point }
|
||||
|
||||
// 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 {
|
||||
virtual.held = sim.None
|
||||
virtual.pressed = map[string]bool{}
|
||||
if s.i >= len(s.steps) {
|
||||
return nil
|
||||
}
|
||||
st := &s.steps[s.i]
|
||||
switch st.cmd {
|
||||
case "wait":
|
||||
s.counter++
|
||||
if s.counter >= st.n {
|
||||
s.advance()
|
||||
}
|
||||
case "press":
|
||||
virtual.pressed[st.arg] = true
|
||||
s.advance()
|
||||
case "move":
|
||||
pt, ok := g.scene.(playerTiler)
|
||||
if !ok {
|
||||
s.advance() // ingen spelare att flytta i den här scenen
|
||||
return nil
|
||||
}
|
||||
cur := pt.PlayerTile()
|
||||
if !s.havePos {
|
||||
s.lastPos, s.havePos = cur, true
|
||||
}
|
||||
if cur != s.lastPos {
|
||||
s.lastPos = cur
|
||||
s.counter++
|
||||
}
|
||||
if s.counter >= st.n {
|
||||
s.advance()
|
||||
return nil
|
||||
}
|
||||
virtual.held = st.dir
|
||||
case "shot":
|
||||
if err := saveShot(frame, st.arg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.advance()
|
||||
case "quit":
|
||||
return ebiten.Termination
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *script) advance() {
|
||||
s.i++
|
||||
s.counter = 0
|
||||
s.havePos = false
|
||||
}
|
||||
|
||||
func saveShot(frame *ebiten.Image, path string) error {
|
||||
w, h := frame.Bounds().Dx(), frame.Bounds().Dy()
|
||||
buf := make([]byte, 4*w*h)
|
||||
frame.ReadPixels(buf)
|
||||
out := image.NewNRGBA(image.Rect(0, 0, w*shotScale, h*shotScale))
|
||||
for y := 0; y < h*shotScale; y++ {
|
||||
for x := 0; x < w*shotScale; x++ {
|
||||
src := 4 * ((y/shotScale)*w + x/shotScale)
|
||||
dst := out.PixOffset(x, y)
|
||||
copy(out.Pix[dst:dst+4], buf[src:src+4])
|
||||
out.Pix[dst+3] = 255
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Println("skärmdump:", path)
|
||||
return png.Encode(f, out)
|
||||
}
|
||||
Reference in New Issue
Block a user