M10: byten, hemresan och levelcheck
All checks were successful
release / build-release (push) Successful in 1m37s
All checks were successful
release / build-release (push) Successful in 1m37s
Resor med byten: en bana kan ha next som pekar på en delbana (kind leg, syns inte på bankartan). När man klivit av rätt tåg fortsätter samma omgång på nästa station med klocka, plånbok, sällskap och räknare; par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg gäller hela resan. level.ValidateChains kräver sammanhängande kedjor. Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan klaras första gången: planet lyfter, resan summeras, eftertexter; Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats; poängraden radbryts. Skärmdumpar och docs uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ type Assets struct {
|
||||
Train *ebiten.Image // tågvagn, ritas längs rälsen när tåget syns
|
||||
Rails *ebiten.Image // rälsruta (horisontell), roteras för vertikala spår
|
||||
Posters []*ebiten.Image // affischer för visuell röra (ritas över väggrutor)
|
||||
Plane *ebiten.Image // flygplanet i slutscenen
|
||||
Player [4][3]*ebiten.Image // [riktning: ner, upp, vänster, höger][bildruta: stilla, gå1, gå2]
|
||||
Text map[string]string
|
||||
Dict *lang.Dictionary
|
||||
@@ -93,6 +94,9 @@ func LoadAssets() (*Assets, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Plane, err = loadImage("sprites", "plane.png"); 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)
|
||||
|
||||
@@ -71,6 +71,7 @@ type Options struct {
|
||||
Mute bool // inget ljud
|
||||
SavePath string // sparfil ("" = standardplatsen)
|
||||
NoSave bool // läs sparfilen men skriv aldrig
|
||||
Ending bool // öppna slutscenen direkt (tester/skärmdumpar)
|
||||
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
|
||||
}
|
||||
|
||||
@@ -137,18 +138,40 @@ func New(opt Options) (*Game, error) {
|
||||
g.Wallet.Card = opt.CardYen
|
||||
}
|
||||
|
||||
if opt.LevelID != "" {
|
||||
switch {
|
||||
case opt.Ending:
|
||||
g.scene = NewEndingScene(g)
|
||||
case opt.LevelID != "":
|
||||
s, err := g.startLevel(opt.LevelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.scene = s
|
||||
} else {
|
||||
default:
|
||||
g.scene = NewMapScene(g)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// chainAfter är resans fortsättning efter banan id (delbanorna via next).
|
||||
func (g *Game) chainAfter(id string) []*level.Level {
|
||||
if c := level.ChainOf(g.Levels, id); len(c) > 1 {
|
||||
return c[1:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isLastMain rapporterar om banan är storyns sista huvudbana.
|
||||
func (g *Game) isLastMain(id string) bool {
|
||||
var last *level.Level
|
||||
for _, l := range g.Levels {
|
||||
if !l.IsSide() && !l.IsLeg() && (last == nil || l.Order > last.Order) {
|
||||
last = l
|
||||
}
|
||||
}
|
||||
return last != nil && last.ID == id
|
||||
}
|
||||
|
||||
func loadCatalog() (*progress.Catalog, error) {
|
||||
sk, err := res.ReadFile("data", "skills.json")
|
||||
if err != nil {
|
||||
@@ -240,7 +263,7 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
|
||||
return sim.RunOptions{
|
||||
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
|
||||
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
|
||||
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
|
||||
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode, Chain: g.chainAfter(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,12 +304,13 @@ func (g *Game) startLevel(id string) (*LevelScene, error) {
|
||||
|
||||
// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen).
|
||||
type LevelOutcome struct {
|
||||
XP int
|
||||
LevelUps int
|
||||
FirstClear bool
|
||||
NewItems []string
|
||||
NewPoints int
|
||||
Unlocked []string // banor som öppnades
|
||||
XP int
|
||||
LevelUps int
|
||||
FirstClear bool
|
||||
NewItems []string
|
||||
NewPoints int
|
||||
Unlocked []string // banor som öppnades
|
||||
StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet
|
||||
}
|
||||
|
||||
// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar,
|
||||
@@ -323,6 +347,10 @@ func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome {
|
||||
out.Unlocked = append(out.Unlocked, l.ID)
|
||||
}
|
||||
}
|
||||
if g.isLastMain(id) && !g.Progress.StoryCleared {
|
||||
g.Progress.StoryCleared = true
|
||||
out.StoryCleared = true
|
||||
}
|
||||
}
|
||||
g.State.Current = nil
|
||||
g.Save()
|
||||
|
||||
178
internal/game/scene_ending.go
Normal file
178
internal/game/scene_ending.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
||||
)
|
||||
|
||||
// EndingScene är storyns slut: sällskapet flyger hem, resan summeras och
|
||||
// eftertexterna rullar. Enter går vidare mellan delarna, Esc hoppar till
|
||||
// bankartan.
|
||||
type EndingScene struct {
|
||||
t float64 // sekunder i aktuell del
|
||||
phase int // 0 flyget, 1 summeringen, 2 eftertexterna
|
||||
lines []string
|
||||
}
|
||||
|
||||
const (
|
||||
endingFlightSec = 7.0 // flyget innan summeringen kommer av sig själv
|
||||
endingCreditsPxS = 14.0 // eftertexternas rullhastighet
|
||||
)
|
||||
|
||||
// NewEndingScene startar hemresan.
|
||||
func NewEndingScene(g *Game) *EndingScene {
|
||||
return &EndingScene{lines: strings.Split(g.Assets.T("ending.credits"), "\n")}
|
||||
}
|
||||
|
||||
func (e *EndingScene) Update(g *Game) error {
|
||||
e.t += dt
|
||||
switch {
|
||||
case backJustPressed():
|
||||
g.SetScene(NewMapScene(g))
|
||||
return nil
|
||||
case confirmJustPressed():
|
||||
e.phase++
|
||||
e.t = 0
|
||||
}
|
||||
if e.phase == 0 && e.t > endingFlightSec {
|
||||
e.phase, e.t = 1, 0
|
||||
}
|
||||
if e.phase == 2 && e.t*endingCreditsPxS > float64(len(e.lines)*LineH+ScreenH) {
|
||||
e.phase++ // eftertexterna har rullat förbi
|
||||
}
|
||||
if e.phase > 2 {
|
||||
g.SetScene(NewMapScene(g))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EndingScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
e.drawSky(screen)
|
||||
switch e.phase {
|
||||
case 0:
|
||||
e.drawFlight(g, screen)
|
||||
a.DrawBig(screen, a.T("ending.title"), 24, 20, colText)
|
||||
a.Draw(screen, a.T("ending.sub"), 24, 44, colDim)
|
||||
case 1:
|
||||
e.drawSummary(g, screen)
|
||||
default:
|
||||
e.drawCredits(g, screen)
|
||||
}
|
||||
a.Draw(screen, a.T("ending.hint"), 8, ScreenH-12, colDim)
|
||||
}
|
||||
|
||||
// drawSky är en skymningshimmel i band, från mörkblått upptill till orange
|
||||
// vid horisonten, med långsamt drivande moln.
|
||||
func (e *EndingScene) drawSky(screen *ebiten.Image) {
|
||||
top, bottom := [3]float64{30, 32, 80}, [3]float64{230, 120, 70}
|
||||
const bands = 12
|
||||
for i := 0; i < bands; i++ {
|
||||
f := float64(i) / float64(bands-1)
|
||||
col := color.RGBA{uint8(top[0] + (bottom[0]-top[0])*f), uint8(top[1] + (bottom[1]-top[1])*f), uint8(top[2] + (bottom[2]-top[2])*f), 255}
|
||||
vector.DrawFilledRect(screen, 0, float32(i*ScreenH/bands), ScreenW, float32(ScreenH/bands+1), col, false)
|
||||
}
|
||||
cloud := color.RGBA{250, 220, 200, 120}
|
||||
for i := 0; i < 5; i++ {
|
||||
x := math.Mod(float64(i)*73+e.t*(4+float64(i)), ScreenW+60) - 30
|
||||
y := float64(40 + i*22)
|
||||
vector.DrawFilledRect(screen, float32(x), float32(y), 28, 6, cloud, false)
|
||||
vector.DrawFilledRect(screen, float32(x+6), float32(y-4), 14, 5, cloud, false)
|
||||
}
|
||||
vector.DrawFilledRect(screen, 0, ScreenH-20, ScreenW, 20, color.RGBA{24, 20, 37, 255}, false) // marken
|
||||
}
|
||||
|
||||
// drawFlight: planet lyfter åt höger medan sällskapet vinkar på marken.
|
||||
func (e *EndingScene) drawFlight(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
f := min(1, e.t/endingFlightSec)
|
||||
px := -40 + f*(ScreenW+80)
|
||||
py := 120 - f*80 + 3*math.Sin(e.t*2)
|
||||
var op ebiten.DrawImageOptions
|
||||
op.GeoM.Scale(2, 2)
|
||||
op.GeoM.Translate(px, py)
|
||||
screen.DrawImage(a.Plane, &op)
|
||||
for i := range memberTints {
|
||||
var o ebiten.DrawImageOptions
|
||||
t := memberTints[i]
|
||||
o.ColorScale.Scale(t[0], t[1], t[2], 1)
|
||||
o.GeoM.Translate(float64(ScreenW/2-50+i*20), ScreenH-20-TileSize+math.Abs(2*math.Sin(e.t*3+float64(i))))
|
||||
screen.DrawImage(a.PlayerFrame(sim.Up, true, e.t+float64(i)*0.3), &o)
|
||||
}
|
||||
}
|
||||
|
||||
// drawSummary är resan i siffror: banor, stjärnor, poäng, ord och souvenirer.
|
||||
func (e *EndingScene) drawSummary(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
p := g.Progress
|
||||
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, ScreenH-40, colPanel, false)
|
||||
vector.DrawFilledRect(screen, 16, 14, ScreenW-32, 2, colGood, false)
|
||||
a.DrawBig(screen, a.T("ending.summary"), 24, 20, colGood)
|
||||
levels, cleared, score := 0, 0, 0
|
||||
for _, l := range g.Levels {
|
||||
if l.IsLeg() {
|
||||
continue
|
||||
}
|
||||
levels++
|
||||
if r := p.Levels[l.ID]; r != nil && r.Cleared {
|
||||
cleared++
|
||||
score += r.Score
|
||||
}
|
||||
}
|
||||
y := 44
|
||||
rows := []string{
|
||||
fmt.Sprintf("%s: %d/%d", a.T("ending.levels"), cleared, levels),
|
||||
fmt.Sprintf("%s: %d/%d", a.T("ending.stars"), p.TotalStars(), 3*levels),
|
||||
fmt.Sprintf("%s: %d", a.T("ending.score"), score),
|
||||
fmt.Sprintf("%s: %d", a.T("ending.words"), g.Know.Count()),
|
||||
}
|
||||
for _, r := range rows {
|
||||
a.Draw(screen, r, 24, y, colText)
|
||||
y += LineH
|
||||
}
|
||||
var items []string
|
||||
for id := range p.Items {
|
||||
if it := g.Catalog.Item(id); it != nil {
|
||||
items = append(items, it.Name.Get(UILang))
|
||||
}
|
||||
}
|
||||
sort.Strings(items)
|
||||
souvenirs := a.T("ending.none")
|
||||
if len(items) > 0 {
|
||||
souvenirs = strings.Join(items, ", ")
|
||||
}
|
||||
for _, l := range a.wrapLine(plainLine(a.T("ending.items")+": "+souvenirs, colKnown), ScreenW-48) {
|
||||
a.drawLines(screen, []line{l}, 24, y)
|
||||
y += LineH
|
||||
}
|
||||
y += 4
|
||||
for _, l := range a.wrapLine(plainLine(a.T("ending.story_done"), colWarn), ScreenW-48) {
|
||||
a.drawLines(screen, []line{l}, 24, y)
|
||||
y += LineH
|
||||
}
|
||||
}
|
||||
|
||||
// drawCredits rullar eftertexterna nerifrån och upp.
|
||||
func (e *EndingScene) drawCredits(g *Game, screen *ebiten.Image) {
|
||||
a := g.Assets
|
||||
y0 := ScreenH - int(e.t*endingCreditsPxS)
|
||||
for i, l := range e.lines {
|
||||
y := y0 + i*LineH
|
||||
if y < -LineH || y > ScreenH {
|
||||
continue
|
||||
}
|
||||
col := colDim
|
||||
if i == 0 || (i > 0 && e.lines[i-1] == "" && i+1 < len(e.lines) && e.lines[i+1] != "") {
|
||||
col = colText // rubriker
|
||||
}
|
||||
a.Draw(screen, l, (ScreenW-a.Width(l))/2, y, col)
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,20 @@ const (
|
||||
learnedShowSec = 3.0
|
||||
progressedShowSec = 2.5
|
||||
flashShowSec = 2.5
|
||||
transferBannerSec = 3.0
|
||||
)
|
||||
|
||||
// Mätarnas bredd och mellanrum uppe till höger (per medlem).
|
||||
const (
|
||||
meterW = 11
|
||||
meterGap = 3
|
||||
)
|
||||
|
||||
// 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
|
||||
level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station)
|
||||
run *sim.Run
|
||||
cam *sim.Camera
|
||||
animT float64
|
||||
@@ -58,14 +66,21 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &LevelScene{
|
||||
id: id,
|
||||
run: sim.NewRun(lvl, g.runOptions(id, seed)),
|
||||
cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)),
|
||||
}
|
||||
s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))}
|
||||
s.rng = rand.New(rand.NewPCG(1, 2))
|
||||
s.rebind(g)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// rebind bygger om allt som hör till den aktuella delbanan: kamera, räls,
|
||||
// affischer, melodifaser och bakgrundsprat. Körs vid start och vid byte.
|
||||
func (s *LevelScene) rebind(g *Game) {
|
||||
lvl := s.run.Level
|
||||
s.level = lvl
|
||||
s.cam = sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize))
|
||||
s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan
|
||||
s.lastPhase = map[level.Tile]level.Phase{}
|
||||
s.rng = rand.New(rand.NewPCG(1, 2))
|
||||
s.particles = nil
|
||||
s.buildTracks()
|
||||
s.buildPosters()
|
||||
g.startChatter(lvl.Noise)
|
||||
@@ -73,7 +88,6 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) {
|
||||
s.lastPhase[tr.tile], _ = s.run.PlatformPhase(tr.tile)
|
||||
}
|
||||
s.followPlayer()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// TrackPhaseName är fasen för närmaste perrong inom indikatoravståndet
|
||||
@@ -145,6 +159,9 @@ func (s *LevelScene) Update(g *Game) error {
|
||||
// Minispel: all input går dit; Esc avbryter minispelet, inte banan.
|
||||
mx, my := miniMoveXY()
|
||||
s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()})
|
||||
if s.run.Level != s.level {
|
||||
s.rebind(g) // bytet: resan fortsätter på nästa station
|
||||
}
|
||||
if s.run.Outcome != sim.Ongoing {
|
||||
s.outcome = g.levelEnded(s.run, s.id)
|
||||
g.SetScene(NewResultScene(s))
|
||||
@@ -299,6 +316,14 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
s.drawIndicator(screen, ox, oy)
|
||||
|
||||
s.drawHUD(g, screen)
|
||||
if r := s.run; r.TransferAt >= 0 && r.Time-r.TransferAt < transferBannerSec && r.Mini == nil && r.Outcome == sim.Ongoing {
|
||||
txt := g.Assets.T("hud.transfer_done")
|
||||
w := g.Assets.Width(txt) + 16
|
||||
x := (ScreenW - w) / 2
|
||||
vector.DrawFilledRect(screen, float32(x), 60, float32(w), LineH+8, colPanel, false)
|
||||
vector.DrawFilledRect(screen, float32(x), 60, float32(w), 2, colWarn, false)
|
||||
g.Assets.Draw(screen, txt, x+8, 64, colWarn)
|
||||
}
|
||||
if s.run.Mini != nil {
|
||||
s.drawMinigame(g, screen)
|
||||
}
|
||||
@@ -346,27 +371,41 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
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.FinalDestination().Get("romaji")
|
||||
if !r.IsLastLeg() { // resa med byte: den här delbanan slutar vid bytesstationen
|
||||
dest += " " + a.T("hud.via") + " " + r.Level.Destination.Get("romaji")
|
||||
}
|
||||
if r.Know.ShowsDestinationJP() { // hint från nivå 1: delbanans mål på japanska, att matcha mot skyltarna
|
||||
dest += " " + r.Level.Destination.Get("ja")
|
||||
}
|
||||
name := r.Level.Name.Get(UILang)
|
||||
if r.Level.NumFloors() > 1 {
|
||||
name += " " + r.Level.FloorName(r.Player.Tile.Z)
|
||||
}
|
||||
if r.Legs() > 1 {
|
||||
name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs())
|
||||
}
|
||||
a.Draw(screen, name, 4, 0, colText)
|
||||
destX := 4 + a.Width(name) + 12
|
||||
a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown)
|
||||
a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()),
|
||||
destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim)
|
||||
// Japanska-nivån får plats till vänster om mätarna; annars kortas den.
|
||||
jpX := destX + a.Width(a.T("hud.destination")+": "+dest) + 12
|
||||
metersX := ScreenW - 4 - len(r.Party.Members)*(meterW+meterGap)
|
||||
jp := fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count())
|
||||
if jpX+a.Width(jp) > metersX-4 {
|
||||
jp = fmt.Sprintf("Lv%d", r.Know.Level())
|
||||
}
|
||||
if jpX+a.Width(jp) <= metersX-4 {
|
||||
a.Draw(screen, jp, jpX, 0, colDim)
|
||||
}
|
||||
timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time)
|
||||
if r.Level.TimeLimitSec > 0 {
|
||||
if r.TimeLimit() > 0 {
|
||||
left := r.TimeLeft()
|
||||
timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60)
|
||||
}
|
||||
wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash)
|
||||
if r.Level.MaxMissedTrains > 0 {
|
||||
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains)
|
||||
if r.MaxMissed() > 0 {
|
||||
wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.MaxMissed())
|
||||
}
|
||||
if r.Wallet.Phone {
|
||||
wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery)
|
||||
@@ -375,7 +414,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket
|
||||
}
|
||||
walletCol := colDim
|
||||
if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 {
|
||||
if r.TimeLimit() > 0 && r.TimeLeft() < 30 {
|
||||
walletCol = colBad
|
||||
}
|
||||
a.Draw(screen, wallet, 4, LineH, walletCol)
|
||||
@@ -385,9 +424,6 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
|
||||
}
|
||||
lines, accent, bar := s.infoLines(a)
|
||||
if len(lines) == 0 {
|
||||
s.drawModeHint(a, screen)
|
||||
}
|
||||
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))
|
||||
@@ -402,7 +438,7 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
}
|
||||
lines = append(lines, a.wrapLine(plainLine(strings.Join(parts, " "), colWarn), ScreenW-8)...)
|
||||
}
|
||||
if r.Flashing(flashShowSec) {
|
||||
if r.Flashing(flashShowSec) && r.FlashKey != sim.FlashTransfer { // bytet har sin egen banner
|
||||
col := colBad
|
||||
if r.FlashKey == sim.FlashRescan {
|
||||
col = colWarn
|
||||
@@ -426,6 +462,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
|
||||
// (eller tåget under hen) annars skulle hamna bakom den.
|
||||
var box image.Rectangle
|
||||
hasBox := len(lines) > 0
|
||||
if !hasBox {
|
||||
s.drawModeHint(a, screen) // lägesrutan bara när ingen inforuta visas
|
||||
}
|
||||
if hasBox {
|
||||
h := 8 + LineH*len(lines)
|
||||
if bar {
|
||||
@@ -579,7 +618,7 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
|
||||
// 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 = 11, 3
|
||||
const w, gap = meterW, meterGap
|
||||
x0 := ScreenW - 4 - len(members)*(w+gap)
|
||||
for i, m := range members {
|
||||
x := float32(x0 + i*(w+gap))
|
||||
|
||||
@@ -33,9 +33,11 @@ type mapNode struct {
|
||||
func NewMapScene(g *Game) *MapScene {
|
||||
m := &MapScene{sides: map[string][]*level.Level{}}
|
||||
for _, l := range g.Levels {
|
||||
if l.IsSide() {
|
||||
switch {
|
||||
case l.IsLeg(): // delbanor nås via sin resa
|
||||
case l.IsSide():
|
||||
m.sides[l.Parent] = append(m.sides[l.Parent], l)
|
||||
} else {
|
||||
default:
|
||||
m.mains = append(m.mains, l)
|
||||
}
|
||||
}
|
||||
@@ -192,8 +194,12 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
|
||||
name = a.T("map.side") + ": " + name
|
||||
}
|
||||
a.Draw(screen, name, 8, 134, colText)
|
||||
info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"),
|
||||
fd := level.FinalOf(g.Levels, cur).Destination
|
||||
info := fmt.Sprintf("→ %s %s %s %s %d", fd.Get("ja"), fd.Get("romaji"),
|
||||
strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party)
|
||||
if n := len(level.ChainOf(g.Levels, cur.ID)); n > 1 {
|
||||
info += fmt.Sprintf(" %s %d", a.T("map.transfers"), n-1)
|
||||
}
|
||||
if g.State.Current != nil && g.State.Current.LevelID == cur.ID {
|
||||
info += " " + a.T("map.resume")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ func (r *ResultScene) Update(g *Game) error {
|
||||
}
|
||||
g.SetScene(s)
|
||||
case confirmJustPressed() || backJustPressed():
|
||||
if r.from.outcome.StoryCleared {
|
||||
g.SetScene(NewEndingScene(g)) // storyn klar: hemresan
|
||||
return nil
|
||||
}
|
||||
g.SetScene(NewMapScene(g))
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -248,6 +248,7 @@ type Level struct {
|
||||
Kind string `json:"kind"` // "main" (standard) eller "side"
|
||||
Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns
|
||||
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
|
||||
Next string `json:"next"` // nästa delbana (kind "leg") i en resa med byten: när man klivit av här fortsätter banan där
|
||||
Rewards Rewards `json:"rewards"`
|
||||
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
|
||||
Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er)
|
||||
@@ -381,9 +382,9 @@ func (l *Level) Validate() error {
|
||||
switch l.Kind {
|
||||
case "":
|
||||
l.Kind = "main"
|
||||
case "main", "side":
|
||||
case "main", "side", "leg":
|
||||
default:
|
||||
return fmt.Errorf("level %s: kind måste vara main eller side", l.ID)
|
||||
return fmt.Errorf("level %s: kind måste vara main, side eller leg", l.ID)
|
||||
}
|
||||
if l.Kind == "side" && l.Parent == "" {
|
||||
return fmt.Errorf("level %s: sidobana utan parent", l.ID)
|
||||
@@ -782,6 +783,81 @@ func (l *Level) Annotate(net *nav.Network) error {
|
||||
// IsSide rapporterar om banan är en sidobana.
|
||||
func (l *Level) IsSide() bool { return l.Kind == "side" }
|
||||
|
||||
// IsLeg rapporterar om banan är en delbana i en resa med byten (nås bara
|
||||
// via en annan banas next, visas inte på bankartan).
|
||||
func (l *Level) IsLeg() bool { return l.Kind == "leg" }
|
||||
|
||||
// ChainOf ger resan som börjar med banan id: banan själv följd av dess
|
||||
// delbanor i ordning. nil om id är okänt.
|
||||
func ChainOf(all []*Level, id string) []*Level {
|
||||
byID := map[string]*Level{}
|
||||
for _, l := range all {
|
||||
byID[l.ID] = l
|
||||
}
|
||||
var out []*Level
|
||||
for l := byID[id]; l != nil && len(out) <= len(all); l = byID[l.Next] {
|
||||
out = append(out, l)
|
||||
if l.Next == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FinalOf är resans sista delbana (banan själv utan byten) – dess
|
||||
// destination är resans resmål.
|
||||
func FinalOf(all []*Level, l *Level) *Level {
|
||||
if c := ChainOf(all, l.ID); len(c) > 0 {
|
||||
return c[len(c)-1]
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// ValidateChains kontrollerar resorna med byten: next finns och är en
|
||||
// delbana, varje delbana används exakt en gång, inga cykler, och på
|
||||
// nätkopplade banor börjar nästa delbana där den förra slutade.
|
||||
func ValidateChains(all []*Level) error {
|
||||
byID := map[string]*Level{}
|
||||
for _, l := range all {
|
||||
byID[l.ID] = l
|
||||
}
|
||||
refs := map[string]int{}
|
||||
for _, l := range all {
|
||||
if l.Next == "" {
|
||||
continue
|
||||
}
|
||||
n := byID[l.Next]
|
||||
if n == nil {
|
||||
return fmt.Errorf("level %s: next %q finns inte", l.ID, l.Next)
|
||||
}
|
||||
if !n.IsLeg() {
|
||||
return fmt.Errorf("level %s: next %q måste ha kind leg", l.ID, l.Next)
|
||||
}
|
||||
refs[n.ID]++
|
||||
if l.Networked() && n.Networked() && n.Station != l.To {
|
||||
return fmt.Errorf("level %s: bytet hänger inte ihop – slutar i %q men %s börjar i %q", l.ID, l.To, n.ID, n.Station)
|
||||
}
|
||||
}
|
||||
for _, l := range all {
|
||||
if l.IsLeg() && refs[l.ID] != 1 {
|
||||
return fmt.Errorf("level %s: delbanan refereras av %d banor, ska vara exakt 1", l.ID, refs[l.ID])
|
||||
}
|
||||
}
|
||||
for _, l := range all {
|
||||
if l.IsLeg() {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for c := l; c != nil && c.Next != ""; c = byID[c.Next] {
|
||||
if seen[c.ID] {
|
||||
return fmt.Errorf("level %s: resan går i cirkel", l.ID)
|
||||
}
|
||||
seen[c.ID] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll läser alla banor i en mapp, sorterade på id, och kopplar dem som
|
||||
// pekar på linjenätet till ../network.json (läses en gång).
|
||||
func LoadAll(dir string) ([]*Level, error) {
|
||||
@@ -810,6 +886,9 @@ func LoadAll(dir string) ([]*Level, error) {
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
if err := ValidateChains(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -665,3 +665,70 @@ func TestCarsWomenOnlyAndCrowdFields(t *testing.T) {
|
||||
t.Fatal("filer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoryChains: resorna med byten hänger ihop, storyn börjar och slutar på
|
||||
// flygplatsen, och felaktiga kedjor ger fel.
|
||||
func TestStoryChains(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := LoadAll(dir) // kör ValidateChains
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var first, last *Level
|
||||
legs, chains := 0, 0
|
||||
for _, l := range all {
|
||||
switch {
|
||||
case l.IsLeg():
|
||||
legs++
|
||||
if l.Order != 0 {
|
||||
t.Errorf("%s: delbanor har ingen order", l.ID)
|
||||
}
|
||||
case !l.IsSide():
|
||||
if first == nil || l.Order < first.Order {
|
||||
first = l
|
||||
}
|
||||
if last == nil || l.Order > last.Order {
|
||||
last = l
|
||||
}
|
||||
}
|
||||
if l.Next != "" {
|
||||
chains++
|
||||
c := ChainOf(all, l.ID)
|
||||
if len(c) < 2 || c[0] != l || FinalOf(all, l) != c[len(c)-1] {
|
||||
t.Errorf("%s: ChainOf/FinalOf = %v", l.ID, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if legs == 0 || chains == 0 {
|
||||
t.Fatal("storyn ska ha resor med byten")
|
||||
}
|
||||
if first == nil || first.Station != "narita-airport" {
|
||||
t.Errorf("storyn ska börja på flygplatsen, första banan är %v", first)
|
||||
}
|
||||
if last == nil || FinalOf(all, last).To != "narita-airport" {
|
||||
t.Errorf("storyn ska sluta på flygplatsen, sista banan slutar i %q", FinalOf(all, last).To)
|
||||
}
|
||||
|
||||
mk := func(id, kind, next, station, to string) *Level {
|
||||
return &Level{ID: id, Kind: kind, Next: next, Station: station, To: to}
|
||||
}
|
||||
bad := map[string][]*Level{
|
||||
"saknad next": {mk("a", "main", "zzz", "", "")},
|
||||
"next inte leg": {mk("a", "main", "b", "", ""), mk("b", "main", "", "", "")},
|
||||
"oanvänd delbana": {mk("a", "main", "", "", ""), mk("b", "leg", "", "", "")},
|
||||
"delbana två gånger": {mk("a", "main", "b", "", ""), mk("c", "main", "b", "", ""), mk("b", "leg", "", "", "")},
|
||||
"cirkel": {mk("a", "main", "b", "", ""), mk("b", "leg", "c", "", ""), mk("c", "leg", "b", "", "")},
|
||||
"byte hänger inte": {mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "z", "w")},
|
||||
}
|
||||
for name, lv := range bad {
|
||||
if err := ValidateChains(lv); err == nil {
|
||||
t.Errorf("%s: skulle ha gett fel", name)
|
||||
}
|
||||
}
|
||||
if err := ValidateChains([]*Level{mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "y", "w")}); err != nil {
|
||||
t.Errorf("giltig kedja: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ type LevelResult struct {
|
||||
|
||||
// Progress är det som växer mellan banor.
|
||||
type Progress struct {
|
||||
XP int `json:"xp"`
|
||||
SkillPoints int `json:"skillPoints"`
|
||||
Skills map[string]bool `json:"skills"`
|
||||
Items map[string]bool `json:"items"`
|
||||
Equipped map[string]string `json:"equipped"` // slot -> item-id
|
||||
Levels map[string]*LevelResult `json:"levels"`
|
||||
XP int `json:"xp"`
|
||||
SkillPoints int `json:"skillPoints"`
|
||||
Skills map[string]bool `json:"skills"`
|
||||
Items map[string]bool `json:"items"`
|
||||
Equipped map[string]string `json:"equipped"` // slot -> item-id
|
||||
Levels map[string]*LevelResult `json:"levels"`
|
||||
StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ (M11) låses upp
|
||||
}
|
||||
|
||||
// New börjar från noll.
|
||||
@@ -235,8 +236,8 @@ func (p *Progress) Unlocked(all []*level.Level, id string) bool {
|
||||
me = l
|
||||
}
|
||||
}
|
||||
if me == nil {
|
||||
return false
|
||||
if me == nil || me.IsLeg() {
|
||||
return false // delbanor nås bara via sin resa
|
||||
}
|
||||
if me.IsSide() {
|
||||
return p.Cleared(me.Parent)
|
||||
|
||||
101
internal/sim/legs_test.go
Normal file
101
internal/sim/legs_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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/rng"
|
||||
)
|
||||
|
||||
// loadChain laddar alla banor och ger resan som börjar med id.
|
||||
func loadChain(t *testing.T, id string) []*level.Level {
|
||||
t.Helper()
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := level.LoadAll(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := level.ChainOf(all, id)
|
||||
if len(c) < 2 {
|
||||
t.Fatalf("%s är ingen resa med byten: %v", id, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestJourneyWithTransfer: bana 007 fortsätter på Ueno efter första tåget –
|
||||
// klockan, plånboken och sällskapet följer med, par-tiden adderas, och
|
||||
// resan vinns först när andra tåget nått Asakusa.
|
||||
func TestJourneyWithTransfer(t *testing.T) {
|
||||
c := loadChain(t, "007-akihabara")
|
||||
head, leg := c[0], c[1]
|
||||
r := NewRun(head, RunOptions{Seed: rng.FromInt(7), Chain: c[1:]})
|
||||
if r.Legs() != 2 || r.LegNo() != 1 || r.IsLastLeg() || r.FinalDestination().Get("ja") != "浅草" || r.TimeLimit() != head.TimeLimitSec {
|
||||
t.Fatalf("resans start: legs=%d no=%d final=%s limit=%d", r.Legs(), r.LegNo(), r.FinalDestination().Get("ja"), r.TimeLimit())
|
||||
}
|
||||
par1 := r.ParSec()
|
||||
for _, m := range r.Party.Members {
|
||||
m.Card = 100000 // egna kort med gott om saldo: testet handlar om bytet
|
||||
}
|
||||
r.Wallet.Card = 100000
|
||||
tr := boardAt(t, r, level.Platform)
|
||||
ride(t, r)
|
||||
if r.Outcome != Ongoing || r.Level != leg || r.LegNo() != 2 || !r.IsLastLeg() {
|
||||
t.Fatalf("efter första tåget: outcome=%v level=%s legNo=%d", r.Outcome, r.Level.ID, r.LegNo())
|
||||
}
|
||||
if r.TransferAt < 0 || r.FlashKey != FlashTransfer || r.Player.Tile != leg.SpawnPoint() || len(r.Rides) != 1 || r.Rides[0] != tr {
|
||||
t.Fatalf("bytet: transferAt=%.1f flash=%q tile=%v rides=%d", r.TransferAt, r.FlashKey, r.Player.Tile, len(r.Rides))
|
||||
}
|
||||
if r.Time < 10 || r.ParSec() <= par1 || r.Fog.Explored(leg) >= 0.5 {
|
||||
t.Fatalf("klockan ska fortsätta (%.1f), par adderas (%.0f > %.0f) och dimman är ny (%.2f)", r.Time, r.ParSec(), par1, r.Fog.Explored(leg))
|
||||
}
|
||||
if from, to := r.Party.FollowerTiles(1); from != leg.SpawnPoint() || to != leg.SpawnPoint() {
|
||||
t.Fatalf("sällskapet ska stå på nya starten: %v %v", from, to)
|
||||
}
|
||||
boardAt(t, r, level.Platform)
|
||||
ride(t, r)
|
||||
if r.Outcome != Won || len(r.Rides) != 2 || r.Score.Total <= 0 {
|
||||
t.Fatalf("resan ska vinnas efter andra tåget: outcome=%v (%s) rides=%d", r.Outcome, r.LostReason, len(r.Rides))
|
||||
}
|
||||
if r.Score.Explore <= 0 || r.Score.Explore > ScoreExploreMax {
|
||||
t.Fatalf("utforskat är snittet över delbanorna: %d", r.Score.Explore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourneyWrongTrainOnSecondLeg: fel tåg på bytesstationen förlorar resan.
|
||||
func TestJourneyWrongTrainOnSecondLeg(t *testing.T) {
|
||||
c := loadChain(t, "007-akihabara")
|
||||
r := NewRun(c[0], RunOptions{Seed: rng.FromInt(8), Chain: c[1:]})
|
||||
for _, m := range r.Party.Members {
|
||||
m.Card = 100000
|
||||
}
|
||||
r.Wallet.Card = 100000
|
||||
boardAt(t, r, level.Platform2) // Yamanote mot Ueno – också rätt
|
||||
ride(t, r)
|
||||
if r.Outcome != Ongoing || r.LegNo() != 2 {
|
||||
t.Fatalf("första tåget: %v", r.Outcome)
|
||||
}
|
||||
boardAt(t, r, level.WrongPlatform) // Ginza mot Shibuya: fel håll
|
||||
ride(t, r)
|
||||
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
|
||||
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainLevelsLoad: alla delbanor går att läsa en och en också.
|
||||
func TestChainLevelsLoad(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"007b-ueno", "008b-nippori"} {
|
||||
l, err := level.LoadFile(filepath.Join(dir, id+".json"))
|
||||
if err != nil || !l.IsLeg() {
|
||||
t.Fatalf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,9 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party {
|
||||
return p
|
||||
}
|
||||
|
||||
// Reset ställer hela sällskapet på en ny ruta (byte till nästa delbana).
|
||||
func (p *Party) Reset(at level.Point) { p.trail = []level.Point{at} }
|
||||
|
||||
// Size är antalet i sällskapet inkl. spelaren.
|
||||
func (p *Party) Size() int { return len(p.Members) }
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ type Run struct {
|
||||
Boarded *level.Train
|
||||
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
|
||||
|
||||
// Resor med byten: Level är aktuell delbana; tiden, plånboken och
|
||||
// sällskapet följer med mellan delbanorna.
|
||||
LegIndex int // 0 = första delbanan
|
||||
TransferAt float64 // när senaste bytet skedde (< 0 = inget)
|
||||
Rides []*level.Train // tågen man åkt med hittills
|
||||
calmCars int // resor i en lugn vagn (bonus)
|
||||
crowdedRide bool // någon resa i en full vagn (svettig-risk)
|
||||
exploreSum float64 // utforskad andel i avklarade delbanor
|
||||
legsDone int
|
||||
timeLimit int // resans tidsgräns (första delbanans), 0 = ingen
|
||||
maxMissed int // resans gräns för missade tåg (första delbanans)
|
||||
|
||||
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
|
||||
Dict *lang.Dictionary
|
||||
Know *lang.Knowledge
|
||||
@@ -116,6 +128,7 @@ const (
|
||||
// Fler flash-nycklar.
|
||||
const (
|
||||
FlashWomenOnly = "hud.women_only"
|
||||
FlashTransfer = "hud.transfer_done"
|
||||
FlashEarly = "hud.early_board"
|
||||
FlashBump = "hud.bump"
|
||||
)
|
||||
@@ -213,6 +226,8 @@ type RunOptions struct {
|
||||
InfiniteBattery bool // telefonen laddar inte ur
|
||||
|
||||
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
|
||||
|
||||
Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -261,7 +276,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
Visits: map[level.Point]int{},
|
||||
lastTile: l.SpawnPoint(),
|
||||
ProgressedAt: -1,
|
||||
parSec: float64(l.ParSec),
|
||||
parSec: parFor(l),
|
||||
timeLimit: l.TimeLimitSec,
|
||||
maxMissed: l.MaxMissedTrains,
|
||||
TransferAt: -1,
|
||||
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
|
||||
baseSpeed: o.Speed,
|
||||
NoticeAt: -1,
|
||||
@@ -288,14 +306,91 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
r.Fog.RevealAll(l)
|
||||
}
|
||||
r.Fog.Reveal(l, l.SpawnPoint())
|
||||
if r.parSec <= 0 {
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||||
}
|
||||
r.Mode = o.Mode
|
||||
return r
|
||||
}
|
||||
|
||||
// parFor är "bra tid" för en delbana: parSec i bandatan, annars kortaste
|
||||
// vägen i rutor × ParSecPerTile + ParSlackSec.
|
||||
func parFor(l *level.Level) float64 {
|
||||
if l.ParSec > 0 {
|
||||
return float64(l.ParSec)
|
||||
}
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
return float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||||
}
|
||||
|
||||
// Legs är antalet delbanor i resan, LegNo den aktuella (1-baserad).
|
||||
func (r *Run) Legs() int { return 1 + len(r.opts.Chain) }
|
||||
func (r *Run) LegNo() int { return r.LegIndex + 1 }
|
||||
|
||||
// IsLastLeg rapporterar om det inte blir fler byten.
|
||||
func (r *Run) IsLastLeg() bool { return r.LegIndex >= len(r.opts.Chain) }
|
||||
|
||||
// FinalDestination är resans slutmål (sista delbanans destination).
|
||||
func (r *Run) FinalDestination() level.Text {
|
||||
if n := len(r.opts.Chain); n > 0 {
|
||||
return r.opts.Chain[n-1].Destination
|
||||
}
|
||||
return r.Level.Destination
|
||||
}
|
||||
|
||||
// TimeLimit och MaxMissed är resans gränser (0 = ingen).
|
||||
func (r *Run) TimeLimit() int { return r.timeLimit }
|
||||
func (r *Run) MaxMissed() int { return r.maxMissed }
|
||||
|
||||
// nextLeg är nästa delbana, eller nil på den sista.
|
||||
func (r *Run) nextLeg() *level.Level {
|
||||
if r.IsLastLeg() {
|
||||
return nil
|
||||
}
|
||||
return r.opts.Chain[r.LegIndex]
|
||||
}
|
||||
|
||||
// startLeg byter till nästa delbana: ny station, spelaren och sällskapet på
|
||||
// dess start, ny dimma och nytt folk – men klockan, plånboken, mätarna och
|
||||
// räknarna fortsätter. Par-tiden adderas.
|
||||
func (r *Run) startLeg(l *level.Level) {
|
||||
r.exploreSum += r.Fog.Explored(r.Level)
|
||||
r.legsDone++
|
||||
r.LegIndex++
|
||||
r.Level = l
|
||||
spawn := l.SpawnPoint()
|
||||
r.Player = NewMover(spawn, r.baseSpeed)
|
||||
r.Party.Reset(spawn)
|
||||
r.Fog = NewFog(l, FogRadius+r.opts.FogRadius)
|
||||
r.Fog.ExtraLandmarks = r.opts.Landmarks
|
||||
if r.opts.RevealAll {
|
||||
r.Fog.RevealAll(l)
|
||||
}
|
||||
r.Fog.Reveal(l, spawn)
|
||||
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
|
||||
r.platformPhase = map[level.Tile]level.Phase{}
|
||||
for _, t := range level.PlatformTiles {
|
||||
r.platformPhase[t], _ = r.PlatformPhase(t)
|
||||
}
|
||||
r.Revealed = map[level.Point]bool{}
|
||||
r.Visits = map[level.Point]int{}
|
||||
r.lastTile = spawn
|
||||
r.Mini, r.MiniKind, r.PendingTrain, r.CarChosen = nil, MiniNone, nil, nil
|
||||
r.Help, r.Facility = HelpState{}, FacilityState{}
|
||||
r.DeniedAt, r.BumpedAt, r.WrongLane = -1, -1, false
|
||||
r.parSec += parFor(l)
|
||||
r.TransferAt = r.Time
|
||||
r.flash(FlashTransfer)
|
||||
}
|
||||
|
||||
// rideDone bokför en avslutad tågresa (vagnbonus, svettig-risk).
|
||||
func (r *Run) rideDone(tr *level.Train) {
|
||||
r.Rides = append(r.Rides, tr)
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||||
r.calmCars++
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 2 {
|
||||
r.crowdedRide = true
|
||||
}
|
||||
}
|
||||
|
||||
// ParSec är tiden för tre stjärnor.
|
||||
func (r *Run) ParSec() float64 { return r.parSec }
|
||||
|
||||
@@ -735,6 +830,11 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
r.Blocked += g.Blocked
|
||||
switch {
|
||||
case g.Success():
|
||||
if next := r.nextLeg(); next != nil {
|
||||
r.rideDone(tr)
|
||||
r.startLeg(next) // bytet: resan fortsätter på nästa station
|
||||
return
|
||||
}
|
||||
r.finish(tr, true, "")
|
||||
case !tr.Correct:
|
||||
reason := LostWrongTrain
|
||||
@@ -801,22 +901,22 @@ func (r *Run) checkLimits() {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
|
||||
if r.timeLimit > 0 && r.Time > float64(r.timeLimit) {
|
||||
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
|
||||
r.finish(nil, false, LostTimeUp)
|
||||
return
|
||||
}
|
||||
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
|
||||
if r.maxMissed > 0 && r.MissedTrains > r.maxMissed {
|
||||
r.finish(nil, false, LostMissedTrains)
|
||||
}
|
||||
}
|
||||
|
||||
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
|
||||
func (r *Run) TimeLeft() float64 {
|
||||
if r.Level.TimeLimitSec == 0 {
|
||||
if r.timeLimit == 0 {
|
||||
return 0
|
||||
}
|
||||
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
|
||||
return max(0, float64(r.timeLimit)-r.Time)
|
||||
}
|
||||
|
||||
// computeScore fyller i poängen för en vunnen bana.
|
||||
@@ -825,15 +925,13 @@ func (r *Run) computeScore() {
|
||||
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
|
||||
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
|
||||
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
|
||||
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
|
||||
sc.Explore = int(ScoreExploreMax * (r.exploreSum + r.Fog.Explored(r.Level)) / float64(r.legsDone+1))
|
||||
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
|
||||
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
|
||||
if r.WomenOnlyViolation {
|
||||
sc.Penalty += PenaltyWomenOnly
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||||
sc.Car = ScoreCarBonus
|
||||
}
|
||||
sc.Car = ScoreCarBonus * r.calmCars
|
||||
base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
|
||||
if r.Mode == nav.Signs {
|
||||
sc.Bonus = base * ScoreSignsBonusPct / 100
|
||||
@@ -860,7 +958,7 @@ func (r *Run) earnedDebuffs() []Debuff {
|
||||
if r.poisoned {
|
||||
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
|
||||
if r.crowdedRide && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
|
||||
out = append(out, Debuff{Sweaty, 1})
|
||||
}
|
||||
return out
|
||||
@@ -968,6 +1066,7 @@ func (r *Run) SignHere() *level.SignText {
|
||||
// tillräckligt bra resultat lär man sig resmålets ord.
|
||||
func (r *Run) finish(tr *level.Train, won bool, reason string) {
|
||||
r.Boarded = tr
|
||||
r.rideDone(tr)
|
||||
r.NewDebuffs = r.earnedDebuffs()
|
||||
if !won {
|
||||
r.Outcome = Lost
|
||||
|
||||
Reference in New Issue
Block a user