package game import ( "fmt" "image" "image/color" "math" "path/filepath" "strings" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) // Hur länge tillfälliga meddelanden visas. const ( deniedShowSec = 2.0 learnedShowSec = 3.0 progressedShowSec = 2.5 ) // 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 run *sim.Run cam *sim.Camera animT float64 hideMap bool // M döljer/visar minikartan } // NewLevelScene laddar banan och ställer spelaren på startrutan. know är // spelarens japanska, som delas mellan banor; seed styr omgångens slump. func NewLevelScene(a *Assets, id string, know *lang.Knowledge, seed rng.Seed) (*LevelScene, error) { lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ id: id, run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Seed: seed}), 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.run.Player.Tile } // PlayerMoving rapporterar om spelaren är mellan två rutor (skriptläget). func (s *LevelScene) PlayerMoving() bool { return s.run.Player.Moving() } // 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.run.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 } if helpJustPressed() { s.run.AskHelp() } if mapJustPressed() { s.hideMap = !s.hideMap } s.run.Step(dt, dirInput(), confirmJustPressed()) if s.run.Player.Moving() { s.animT += dt } else { s.animT = 0 } 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(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[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) } } // Följarna ritas bakifrån så att ledaren hamnar överst. leader := s.run.Player followers := s.run.Party.Followers() for i := len(followers); i >= 1; i-- { from, to := s.run.Party.FollowerTiles(i) fx := float64(from.X*TileSize) + float64((to.X-from.X)*TileSize)*leader.Progress fy := float64(from.Y*TileSize) + float64((to.Y-from.Y)*TileSize)*leader.Progress facing := sim.Down switch { case to.X > from.X: facing = sim.Right case to.X < from.X: facing = sim.Left case to.Y < from.Y: facing = sim.Up } op.GeoM.Reset() op.GeoM.Translate(ox+math.Round(fx), oy+math.Round(fy)) op.ColorScale.Reset() t := memberTints[i%len(memberTints)] op.ColorScale.Scale(t[0], t[1], t[2], 1) screen.DrawImage(g.Assets.PlayerFrame(facing, leader.Moving() && from != to, s.animT), &op) op.ColorScale.Reset() s.drawUrge(g, screen, followers[i-1], int(ox+math.Round(fx)), int(oy+math.Round(fy))) } px, py := leader.WorldPos(TileSize) op.GeoM.Reset() op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op) s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py))) s.drawHUD(g, screen) } // memberTints färgar följarnas sprites tills de har egna. var memberTints = [][3]float32{{1, 1, 1}, {1, 0.75, 0.55}, {0.7, 0.95, 0.75}, {0.8, 0.7, 1}, {1, 0.95, 0.55}} // urgeMark är tecknet som visas över huvudet på någon med ett behov. func urgeMark(u sim.Urge) (string, color.Color) { switch u { case sim.NeedToilet: return "WC", colBad case sim.Thirsty: return "飲", colEnglish case sim.Hungry: return "食", colWarn case sim.Distracted: return "?", colDim } return "", nil } func (s *LevelScene) drawUrge(g *Game, screen *ebiten.Image, m *sim.Member, x, y int) { mark, col := urgeMark(m.Urge) if mark == "" { if m.Meters.Min() <= 0 { mark, col = "!!", colBad } else { return } } w := g.Assets.Width(mark) vector.DrawFilledRect(screen, float32(x+8-w/2-1), float32(y-11), float32(w+2), 11, colPanel, false) g.Assets.Draw(screen, mark, x+8-w/2, y-12, col) } // drawHUD ritar den övre raden (bana, resmål, tid, kort, japanska-nivå) och // inforutan (perrong, skylt, nekad spärr, nytt ord). 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) dest := r.Level.Destination.Get("romaji") if r.Know.ShowsDestinationJP() { // hint från nivå 1: resmålet på japanska, att matcha mot skyltarna dest += " " + r.Level.Destination.Get("ja") } a.Draw(screen, r.Level.Name.Get(UILang), 4, 0, colText) a.Draw(screen, a.T("hud.destination")+": "+dest, 4+a.Width(r.Level.Name.Get(UILang))+12, 0, colKnown) a.Draw(screen, fmt.Sprintf("%s %3.0fs %s %d %s %d¥ %s Lv%d·%d", a.T("hud.time"), r.Time, a.T("hud.steps"), r.Steps, a.T("hud.card"), r.Card.Balance, a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), 4, LineH, colDim) s.drawMeters(g, screen) if r.Outcome != sim.Ongoing { return // resultatpanelen tar över; ingen inforuta eller minikarta } lines, accent, bar := s.infoLines(a) if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned, entry.Translation(UILang), entry.Romaji), colKnown)) } if len(r.Progressed) > 0 && r.Time-r.ProgressedAt < progressedShowSec { lines = append(lines, a.wrapLine(plainLine(a.T("hud.progress")+": "+strings.Join(r.Progressed, ", "), colWarn), ScreenW-8)...) } if urges := r.Party.Urges(); len(urges) > 0 { var parts []string for _, m := range urges { parts = append(parts, m.Name+": "+a.T("urge."+m.Urge.String())) } lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...) } // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren // (eller tåget under hen) annars skulle hamna bakom den. var box image.Rectangle hasBox := len(lines) > 0 if hasBox { h := 8 + LineH*len(lines) if bar { h += 8 } _, py := r.Player.WorldPos(TileSize) y := ScreenH - h if int(py-s.cam.Y)+2*TileSize > y { y = 24 } box = image.Rect(0, y, ScreenW, y+h) } if !s.hideMap { s.drawMinimap(g, screen, box, hasBox) } if !hasBox { return } y, h := box.Min.Y, box.Dy() vector.DrawFilledRect(screen, 0, float32(y), ScreenW, float32(h), colPanel, false) vector.DrawFilledRect(screen, 0, float32(y), ScreenW, 2, accent, false) a.drawLines(screen, lines, 4, y+4) if bar { p := r.HelpProgress() if r.Facility.Active { p = r.FacilityProgress() } by := float32(y + h - 7) vector.DrawFilledRect(screen, 4, by, ScreenW-8, 4, colBar, false) vector.DrawFilledRect(screen, 4, by, float32((ScreenW-8)*p), 4, colKnown, false) } } // infoLines bygger inforutans rader: perronginfo, skylt eller nekad spärr. func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar bool) { r := s.run lvl := r.Level tile := r.Player.Tile accent = colBar maxW := ScreenW - 8 english := lang.ShowEnglish(lvl.ID, tile.X, tile.Y, lvl.Difficulty) if tr, at, secs := r.PlatformHere(); tr != nil { // Avgångstavlan är en skylt som alla andra: japanska, sedd genom kunskapen. rd := lang.Render(a.Dict, r.Know, tr.BoardText(), UILang) lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...) lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...) if english { lines = append(lines, a.wrapLine(plainLine(fmt.Sprintf("%s: Track %s %s for %s", a.T("hud.english"), tr.Track, tr.Line.Get("en"), tr.Destination.Get("en")), colEnglish), maxW)...) } if at { accent = colGood lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s %s", a.T("hud.departs_in"), math.Ceil(secs), a.T("hud.board")), colGood)) } else { accent = colWarn lines = append(lines, plainLine(fmt.Sprintf("%s %.0f s", a.T("hud.next_train_in"), math.Ceil(secs)), colWarn)) } return lines, accent, false } if sg := r.SignHere(); sg != nil { know := r.Know revealed := r.SignRevealed(tile) if revealed { know = nil // allt känt accent = colKnown } rd := lang.Render(a.Dict, know, sg.Text["ja"], UILang) lines = append(lines, a.wrapLine(shownLine(rd.Original, colText, r.Know.ShowsReadings()), maxW)...) lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...) if english { lines = append(lines, a.wrapLine(plainLine(a.T("hud.english")+": "+sg.Text.Get("en"), colEnglish), maxW)...) } switch { case r.Help.Active: lines = append(lines, plainLine(a.T("hud.help_progress"), colDim)) bar = true case !revealed: lines = append(lines, plainLine(fmt.Sprintf(a.T("hud.help_prompt"), lang.HelpSeconds(r.Know.Level())), colDim)) } return lines, accent, bar } switch lvl.At(tile.X, tile.Y) { case level.Toilet: if r.Facility.Active { return []line{plainLine(a.T("hud.toilet_busy"), colDim)}, colGood, true } if n := len(r.Party.NeedsToilet()); n > 0 { return []line{plainLine(fmt.Sprintf(a.T("hud.toilet_prompt"), n, sim.ToiletSecPerMember*float64(n)), colGood)}, colGood, false } return []line{plainLine(a.T("hud.toilet_none"), colDim)}, accent, false case level.Kiosk: if n := len(r.Party.NeedsKiosk()); n > 0 { return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n), colGood)}, colGood, false } return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false } if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec { return []line{plainLine(a.T("hud.gate_denied"), colBad)}, colBad, false } return nil, accent, false } // drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger. func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) { members := s.run.Party.Members const w, gap = 14, 4 x0 := ScreenW - 4 - len(members)*(w+gap) for i, m := range members { x := float32(x0 + i*(w+gap)) g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+3, 0, colDim) for j, v := range []struct { val float64 col color.Color }{{m.Meters.Food, colWarn}, {m.Meters.Drink, colEnglish}, {m.Meters.Toilet, colGood}} { y := float32(13 + j*3) vector.DrawFilledRect(screen, x, y, w, 2, colBar, false) c := v.col if v.val < sim.UrgeThreshold { c = colBad } vector.DrawFilledRect(screen, x, y, float32(w*v.val/100), 2, c, false) } } }