package main import ( "image/color" "math/rand" "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" ) // Game holds the top-level state for the Ebitengine run loop. type Game struct { ship Ship walk map[[2]int]bool crew []Crew selectedCrew int lastTime time.Time systems []System combat Combat enemy Ship rng *rand.Rand } // Update advances simulation by dt seconds and handles input. func (g *Game) Update() error { if g.lastTime.IsZero() { g.lastTime = time.Now() return nil } dt := time.Since(g.lastTime).Seconds() g.lastTime = time.Now() if dt > 0.1 { dt = 0.1 } for i := range g.crew { updateCrew(&g.crew[i], dt) } // Advance combat each tick when ongoing. if g.combat.Result == GameOngoing { updateCombat(&g.combat, g.systems, dt, g.rng) } // Restart on R when the fight is decided. if inpututil.IsKeyJustPressed(ebiten.KeyR) && g.combat.Result != GameOngoing { g.combat = NewCombat() g.systems = NewStartingSystems() g.crew = NewStartingCrew() g.selectedCrew = -1 } if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { cx, cy := ebiten.CursorPosition() if cy >= HudY { // HUD strip: route to power management. // No-op after combat ends (PC1). if g.combat.Result != GameOngoing { return nil } if role, ok := hudHitTest(cx, cy); ok { addPower(g.systems, role, ReactorCap) } // HUD-band miss (gutter / out-of-columns) is a no-op; do not fall through. return nil } // Below HudY: crew-select / move logic. tx, ty := cx/TilePx, cy/TilePx for i := range g.crew { if g.crew[i].TileX == tx && g.crew[i].TileY == ty { g.selectedCrew = i return nil } } if g.selectedCrew >= 0 && g.walk[[2]int{tx, ty}] { from := [2]int{g.crew[g.selectedCrew].TileX, g.crew[g.selectedCrew].TileY} g.crew[g.selectedCrew].Path = bfsPath(g.walk, from, [2]int{tx, ty}) g.crew[g.selectedCrew].MoveT = 0 } } if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) { cx, cy := ebiten.CursorPosition() if cy >= HudY { // No-op after combat ends (PC1). if g.combat.Result != GameOngoing { return nil } if role, ok := hudHitTest(cx, cy); ok { removePower(g.systems, role) } // HUD-band miss is a no-op. } } return nil } // Draw renders the whole frame. func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(color.RGBA{10, 15, 25, 255}) drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT) drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT) for i, c := range g.crew { drawCrew(screen, c, i == g.selectedCrew) } drawHud(screen, g.systems) drawHullBars(screen, g.combat) drawWeaponCharge(screen, g.combat) drawShieldsIndicator(screen, g.ship, g.combat, g.systems) drawEvasionReadout(screen, g.ship, g.systems) drawResultOverlay(screen, g.combat.Result) } // Layout fixes the virtual resolution; the window scales to fit. func (g *Game) Layout(_, _ int) (int, int) { return VirtualW, VirtualH } func main() { ship := NewPlayerShip() walk := walkableTiles(ship) crew := NewStartingCrew() systems := NewStartingSystems() combat := NewCombat() enemy := NewEnemyShip() rng := rand.New(rand.NewSource(time.Now().UnixNano())) ebiten.SetWindowSize(1280, 720) ebiten.SetWindowTitle("ftl-shape") if err := ebiten.RunGame(&Game{ ship: ship, walk: walk, crew: crew, selectedCrew: -1, systems: systems, combat: combat, enemy: enemy, rng: rng, }); err != nil { panic(err) } }