~bigbes/game-prototype-ftl

1b674aa54513febeb3d317027977e0fd80c992ff — Eugene Blikh 6 days ago a1f0ec5
feat(crew): restrict movement to room cell slots

Add per-room cell slots and limit crew movement to unoccupied cells.

- Add Cells field to Room and populate two slots per player room.
- Shift enemy-ship cells by the same x-offset as rooms.
- Build a cellSet lookup and gate moves on it, skipping occupied cells.
- Add cellOccupied check so two crew cannot share a slot.
- Render dim cell markers in render.go.
- Cover cellSet and occupancy with tests; tighten hit-flash test timing.
6 files changed, 98 insertions(+), 8 deletions(-)

M combat_test.go
M main.go
M render.go
M ship.go
M tiles.go
M tiles_test.go
M combat_test.go => combat_test.go +1 -1
@@ 145,7 145,7 @@ func TestUpdateCombat_hitFlashTicksDown(t *testing.T) {
	rng := rand.New(rand.NewSource(42))
	sys := NewStartingSystems() // all power 0 — no weapons, no enemy fire crosses interval
	c := NewCombat()
	c.Player.HitFlashT = 0.05
	c.Player.HitFlashT = 0.01
	c.Enemy.HitFlashT = 0.15
	updateCombat(&c, sys, 1.0/60.0, rng)
	if c.Player.HitFlashT != 0 {

M main.go => main.go +21 -2
@@ 13,6 13,7 @@ import (
type Game struct {
	ship         Ship
	walk         map[[2]int]bool
	cells        map[[2]int]bool
	crew         []Crew
	selectedCrew int
	lastTime     time.Time


@@ 75,9 76,13 @@ func (g *Game) Update() error {
			}
		}

		if g.selectedCrew >= 0 && g.walk[[2]int{tx, ty}] {
		if g.selectedCrew >= 0 && g.cells[[2]int{tx, ty}] {
			dest := [2]int{tx, ty}
			if g.cellOccupied(dest, g.selectedCrew) {
				return nil
			}
			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].Path = bfsPath(g.walk, from, dest)
			g.crew[g.selectedCrew].MoveT = 0
		}
	}


@@ 99,6 104,18 @@ func (g *Game) Update() error {
	return nil
}

func (g *Game) cellOccupied(cell [2]int, excludeCrew int) bool {
	for i, c := range g.crew {
		if i == excludeCrew {
			continue
		}
		if len(c.Path) == 0 && c.TileX == cell[0] && c.TileY == cell[1] {
			return true
		}
	}
	return false
}

// Draw renders the whole frame.
func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{10, 15, 25, 255})


@@ 123,6 140,7 @@ func (g *Game) Layout(_, _ int) (int, int) {
func main() {
	ship := NewPlayerShip()
	walk := walkableTiles(ship)
	cells := cellSet(ship)
	crew := NewStartingCrew()
	systems := NewStartingSystems()
	combat := NewCombat()


@@ 133,6 151,7 @@ func main() {
	if err := ebiten.RunGame(&Game{
		ship:         ship,
		walk:         walk,
		cells:        cells,
		crew:         crew,
		selectedCrew: -1,
		systems:      systems,

M render.go => render.go +7 -0
@@ 111,6 111,13 @@ func drawRoom(screen *ebiten.Image, r Room, powered bool) {

	// Label: a few pixels inside the top-left corner.
	ebitenutil.DebugPrintAt(screen, r.Name, int(x)+3, int(y)+2)

	// Cell markers: small dim squares at cell positions.
	for _, c := range r.Cells {
		cx := float32(c[0]*TilePx + TilePx/2 - 2)
		cy := float32(c[1]*TilePx + TilePx/2 - 2)
		vector.DrawFilledRect(screen, cx, cy, 4, 4, color.RGBA{255, 255, 255, 60}, false)
	}
}

// drawHullBars renders hull readouts for both sides.

M ship.go => ship.go +16 -5
@@ 24,6 24,7 @@ type Room struct {
	GridW int
	GridH int
	Role  RoomRole
	Cells [][2]int
}

// Ship is a pure-data description of a ship layout.


@@ 39,6 40,11 @@ func NewEnemyShip() Ship {
	rooms := make([]Room, len(player.Rooms))
	for i, r := range player.Rooms {
		r.GridX += 24
		shifted := make([][2]int, len(r.Cells))
		for j, c := range r.Cells {
			shifted[j] = [2]int{c[0] + 24, c[1]}
		}
		r.Cells = shifted
		rooms[i] = r
	}
	return Ship{Rooms: rooms}


@@ 50,11 56,16 @@ func NewEnemyShip() Ship {
func NewPlayerShip() Ship {
	return Ship{
		Rooms: []Room{
			{ID: 1, Name: "Pilot", GridX: 3, GridY: 9, GridW: 3, GridH: 3, Role: RolePilot},
			{ID: 2, Name: "Weapons", GridX: 6, GridY: 6, GridW: 4, GridH: 3, Role: RoleWeapons},
			{ID: 3, Name: "Shields", GridX: 6, GridY: 9, GridW: 4, GridH: 3, Role: RoleShields},
			{ID: 4, Name: "MedBay", GridX: 6, GridY: 12, GridW: 4, GridH: 3, Role: RoleMedBay},
			{ID: 5, Name: "Engines", GridX: 10, GridY: 9, GridW: 3, GridH: 3, Role: RoleEngines},
			{ID: 1, Name: "Pilot", GridX: 3, GridY: 9, GridW: 3, GridH: 3, Role: RolePilot,
				Cells: [][2]int{{4, 10}, {5, 10}}},
			{ID: 2, Name: "Weapons", GridX: 6, GridY: 6, GridW: 4, GridH: 3, Role: RoleWeapons,
				Cells: [][2]int{{7, 7}, {8, 7}}},
			{ID: 3, Name: "Shields", GridX: 6, GridY: 9, GridW: 4, GridH: 3, Role: RoleShields,
				Cells: [][2]int{{7, 10}, {8, 10}}},
			{ID: 4, Name: "MedBay", GridX: 6, GridY: 12, GridW: 4, GridH: 3, Role: RoleMedBay,
				Cells: [][2]int{{7, 13}, {8, 13}}},
			{ID: 5, Name: "Engines", GridX: 10, GridY: 9, GridW: 3, GridH: 3, Role: RoleEngines,
				Cells: [][2]int{{11, 10}, {12, 10}}},
		},
	}
}

M tiles.go => tiles.go +10 -0
@@ 12,6 12,16 @@ func walkableTiles(s Ship) map[[2]int]bool {
	return walk
}

func cellSet(s Ship) map[[2]int]bool {
	cells := make(map[[2]int]bool)
	for _, r := range s.Rooms {
		for _, c := range r.Cells {
			cells[c] = true
		}
	}
	return cells
}

func bfsPath(walk map[[2]int]bool, from, to [2]int) [][2]int {
	if from == to {
		return nil

M tiles_test.go => tiles_test.go +43 -0
@@ 41,6 41,49 @@ func TestWalkableTiles_ignoresOutside(t *testing.T) {
	}
}

func TestCellSet_returnsAllCells(t *testing.T) {
	s := NewPlayerShip()
	cells := cellSet(s)
	if len(cells) != 10 {
		t.Fatalf("expected 10 cells (2 per room × 5 rooms), got %d", len(cells))
	}
	expected := [][2]int{
		{4, 10}, {5, 10},
		{7, 7}, {8, 7},
		{7, 10}, {8, 10},
		{7, 13}, {8, 13},
		{11, 10}, {12, 10},
	}
	for _, c := range expected {
		if !cells[c] {
			t.Errorf("expected cell %v to be present", c)
		}
	}
}

func TestCellSet_allWalkable(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	cells := cellSet(s)
	for c := range cells {
		if !walk[c] {
			t.Errorf("cell %v is not walkable", c)
		}
	}
}

func TestCellSet_startingCrewOnCells(t *testing.T) {
	s := NewPlayerShip()
	cells := cellSet(s)
	crew := NewStartingCrew()
	for _, c := range crew {
		pos := [2]int{c.TileX, c.TileY}
		if !cells[pos] {
			t.Errorf("crew %s at %v is not on a cell", c.Name, pos)
		}
	}
}

func TestBFSPath_sameTile(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)