Skip to content

Commit 633c60b

Browse files
committed
feat: Add first hotel facade
1 parent d6bdf34 commit 633c60b

4 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ROOM_HEIGHT, ROOM_WIDTH } from "../constants";
2+
import { computeFacadeCells, type FacadeCell } from "./computeFacadeCells";
3+
import { HotelSprite } from "./HotelSprite";
4+
5+
const BG_ATLAS_KEY = "bgAssets.hd";
6+
const ROOF_X_OFFSET = -197;
7+
const BALCONY_X_OFFSET = -270;
8+
const CEILING_X_OFFSET = -247;
9+
const CEILING_PIPE_Y_OFFSET = 30;
10+
const ROOF_SCALE_Y = 0.65;
11+
12+
/**
13+
* Renders facade elements (walls with windows, roofs, balconies) on empty cells
14+
* adjacent to hotel rooms, reproducing the Parisian building look from the legacy game.
15+
*/
16+
export class FacadeSprite extends Phaser.GameObjects.Container {
17+
constructor(scene: Phaser.Scene) {
18+
super(scene, 0, 0);
19+
}
20+
21+
/**
22+
* Rebuilds all facade sprites based on current room positions.
23+
*/
24+
public rebuild(occupiedCells: Set<string>): void {
25+
this.removeAll(true);
26+
const facadeCells = computeFacadeCells(occupiedCells);
27+
28+
for (const cell of facadeCells) {
29+
this.renderFacadeCell(cell);
30+
}
31+
}
32+
33+
private renderFacadeCell(cell: FacadeCell): void {
34+
const worldPosition = HotelSprite.gridToWorld(cell.gridX, cell.gridY);
35+
const cellLeft = worldPosition.x;
36+
const cellRight = worldPosition.x + ROOM_WIDTH;
37+
const cellTop = worldPosition.y - ROOM_HEIGHT;
38+
const cellBottom = worldPosition.y;
39+
40+
this.renderSideWalls(cell, cellLeft, cellRight, cellBottom);
41+
this.renderCeiling(cell, cellLeft, cellRight, cellTop);
42+
this.renderRoofOrBalcony(cell, cellLeft, cellBottom);
43+
}
44+
45+
private renderSideWalls(
46+
cell: FacadeCell,
47+
cellLeft: number,
48+
cellRight: number,
49+
cellBottom: number,
50+
): void {
51+
if (cell.hasRoomToRight) {
52+
const wall = this.scene.add.image(
53+
cellRight,
54+
cellBottom,
55+
BG_ATLAS_KEY,
56+
"parisTest",
57+
);
58+
wall.setOrigin(1, 1);
59+
this.add(wall);
60+
}
61+
62+
if (cell.hasRoomToLeft) {
63+
const wall = this.scene.add.image(
64+
cellLeft,
65+
cellBottom,
66+
BG_ATLAS_KEY,
67+
"parisWall/parisWall_0001",
68+
);
69+
wall.setOrigin(0, 1);
70+
wall.setFlipX(true);
71+
this.add(wall);
72+
}
73+
}
74+
75+
private renderCeiling(
76+
cell: FacadeCell,
77+
cellLeft: number,
78+
cellRight: number,
79+
cellTop: number,
80+
): void {
81+
if (!cell.hasRoomAbove) return;
82+
83+
const ceiling = this.scene.add.image(
84+
cellLeft + CEILING_X_OFFSET,
85+
cellTop,
86+
BG_ATLAS_KEY,
87+
"parisCeiling",
88+
);
89+
ceiling.setOrigin(0, 0);
90+
this.add(ceiling);
91+
92+
const pipeX = cellLeft + (cellRight - cellLeft) * 0.5;
93+
const pipe = this.scene.add.image(
94+
pipeX,
95+
cellTop + CEILING_PIPE_Y_OFFSET,
96+
BG_ATLAS_KEY,
97+
"parisPipe/parisPipe_0001",
98+
);
99+
pipe.setOrigin(0.5, 0);
100+
this.add(pipe);
101+
}
102+
103+
private renderRoofOrBalcony(
104+
cell: FacadeCell,
105+
cellLeft: number,
106+
cellBottom: number,
107+
): void {
108+
if (!cell.hasRoomBelow) return;
109+
110+
if (cell.hasRoomAbove) {
111+
this.addBalcony(cellLeft, cellBottom);
112+
} else {
113+
this.addRoof(cellLeft, cellBottom);
114+
}
115+
}
116+
117+
private addBalcony(cellLeft: number, cellBottom: number): void {
118+
const balcony = this.scene.add.image(
119+
cellLeft + BALCONY_X_OFFSET,
120+
cellBottom,
121+
BG_ATLAS_KEY,
122+
"parisBalcony",
123+
);
124+
balcony.setOrigin(0, 1);
125+
this.add(balcony);
126+
}
127+
128+
private addRoof(cellLeft: number, cellBottom: number): void {
129+
const roof = this.scene.add.image(
130+
cellLeft + ROOF_X_OFFSET,
131+
cellBottom,
132+
BG_ATLAS_KEY,
133+
"parisRoof",
134+
);
135+
roof.setOrigin(0, 1);
136+
roof.setScale(1, ROOF_SCALE_Y);
137+
this.add(roof);
138+
}
139+
}

phaser/src/game/components/HotelSprite.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import type { Hotel } from "#phaser/models";
22
import { ROOM_HEIGHT, ROOM_WIDTH } from "../constants";
33
import type { Position } from "../types";
44
import { createRoomSprite } from "./createRoomSprite";
5+
import { FacadeSprite } from "./FacadeSprite";
56
import type { RoomSprite } from "./RoomSprite";
67

78
export class HotelSprite extends Phaser.GameObjects.Container {
89
private roomsById = new Map<string, RoomSprite>();
910
private roomsByPosition = new Map<string, string>();
11+
private facadeSprite: FacadeSprite;
1012

1113
constructor(scene: Phaser.Scene) {
1214
super(scene, 0, 0);
15+
this.facadeSprite = new FacadeSprite(scene);
16+
this.add(this.facadeSprite);
1317
scene.add.existing(this);
1418
}
1519

@@ -52,6 +56,14 @@ export class HotelSprite extends Phaser.GameObjects.Container {
5256
this.roomsById.delete(id);
5357
}
5458
}
59+
60+
this.rebuildFacade();
61+
}
62+
63+
private rebuildFacade(): void {
64+
const occupiedCells = new Set(this.roomsByPosition.keys());
65+
this.facadeSprite.rebuild(occupiedCells);
66+
this.facadeSprite.setDepth(-1);
5567
}
5668

5769
public getRoomAt(gridX: number, gridY: number): RoomSprite | undefined {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Describes a grid cell where a facade element should be rendered,
3+
* along with which neighboring directions contain rooms.
4+
*/
5+
export type FacadeCell = {
6+
gridX: number;
7+
gridY: number;
8+
hasRoomToRight: boolean;
9+
hasRoomToLeft: boolean;
10+
hasRoomAbove: boolean;
11+
hasRoomBelow: boolean;
12+
};
13+
14+
const NEIGHBOR_OFFSETS = [
15+
{ dx: 1, dy: 0 },
16+
{ dx: -1, dy: 0 },
17+
{ dx: 0, dy: 1 },
18+
{ dx: 0, dy: -1 },
19+
];
20+
21+
/**
22+
* Computes empty grid cells adjacent to occupied rooms where facade elements should be placed.
23+
*
24+
* For each empty cell, flags indicate which neighboring directions contain a room,
25+
* so the renderer knows which facade sprites to place.
26+
*/
27+
export function computeFacadeCells(occupiedCells: Set<string>): FacadeCell[] {
28+
const facadeCellMap = new Map<string, FacadeCell>();
29+
30+
for (const cellKey of occupiedCells) {
31+
const parts = cellKey.split(",");
32+
const gridX = Number(parts[0]);
33+
const gridY = Number(parts[1]);
34+
addNeighborFacadeCells(facadeCellMap, occupiedCells, gridX, gridY);
35+
}
36+
37+
return Array.from(facadeCellMap.values());
38+
}
39+
40+
function addNeighborFacadeCells(
41+
facadeCellMap: Map<string, FacadeCell>,
42+
occupiedCells: Set<string>,
43+
roomGridX: number,
44+
roomGridY: number,
45+
): void {
46+
for (const { dx, dy } of NEIGHBOR_OFFSETS) {
47+
const neighborX = roomGridX + dx;
48+
const neighborY = roomGridY + dy;
49+
const neighborKey = `${neighborX},${neighborY}`;
50+
51+
if (occupiedCells.has(neighborKey)) continue;
52+
53+
if (!facadeCellMap.has(neighborKey)) {
54+
facadeCellMap.set(neighborKey, {
55+
gridX: neighborX,
56+
gridY: neighborY,
57+
hasRoomToRight: false,
58+
hasRoomToLeft: false,
59+
hasRoomAbove: false,
60+
hasRoomBelow: false,
61+
});
62+
}
63+
64+
const cell = facadeCellMap.get(neighborKey);
65+
if (!cell) continue;
66+
67+
if (dx === -1) cell.hasRoomToRight = true;
68+
if (dx === 1) cell.hasRoomToLeft = true;
69+
if (dy === -1) cell.hasRoomAbove = true;
70+
if (dy === 1) cell.hasRoomBelow = true;
71+
}
72+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from "vitest";
2+
import { computeFacadeCells } from "#phaser/game/components/computeFacadeCells";
3+
4+
describe("computeFacadeCells", () => {
5+
it("should return no facade cells when hotel has no rooms", () => {
6+
const occupiedCells = new Set<string>();
7+
8+
const facadeCells = computeFacadeCells(occupiedCells);
9+
10+
expect(facadeCells).toEqual([]);
11+
});
12+
13+
it("should return facade cells adjacent to a single room", () => {
14+
const occupiedCells = new Set(["0,1"]);
15+
16+
const facadeCells = computeFacadeCells(occupiedCells);
17+
18+
expect(facadeCells).toContainEqual(
19+
expect.objectContaining({ gridX: 1, gridY: 1 }),
20+
);
21+
expect(facadeCells).toContainEqual(
22+
expect.objectContaining({ gridX: -1, gridY: 1 }),
23+
);
24+
expect(facadeCells).toContainEqual(
25+
expect.objectContaining({ gridX: 0, gridY: 2 }),
26+
);
27+
expect(facadeCells).toContainEqual(
28+
expect.objectContaining({ gridX: 0, gridY: 0 }),
29+
);
30+
});
31+
32+
it("should not return facade cells that are occupied by rooms", () => {
33+
const occupiedCells = new Set(["0,1", "1,1"]);
34+
35+
const facadeCells = computeFacadeCells(occupiedCells);
36+
37+
const facadePositions = facadeCells.map(
38+
(cell) => `${cell.gridX},${cell.gridY}`,
39+
);
40+
expect(facadePositions).not.toContain("0,1");
41+
expect(facadePositions).not.toContain("1,1");
42+
});
43+
44+
it("should set hasRoomToRight when right neighbor is occupied", () => {
45+
const occupiedCells = new Set(["1,1"]);
46+
47+
const facadeCells = computeFacadeCells(occupiedCells);
48+
49+
const leftCell = facadeCells.find(
50+
(cell) => cell.gridX === 0 && cell.gridY === 1,
51+
);
52+
expect(leftCell).toBeDefined();
53+
expect(leftCell?.hasRoomToRight).toBe(true);
54+
expect(leftCell?.hasRoomToLeft).toBe(false);
55+
});
56+
57+
it("should set hasRoomToLeft when left neighbor is occupied", () => {
58+
const occupiedCells = new Set(["0,1"]);
59+
60+
const facadeCells = computeFacadeCells(occupiedCells);
61+
62+
const rightCell = facadeCells.find(
63+
(cell) => cell.gridX === 1 && cell.gridY === 1,
64+
);
65+
expect(rightCell).toBeDefined();
66+
expect(rightCell?.hasRoomToLeft).toBe(true);
67+
expect(rightCell?.hasRoomToRight).toBe(false);
68+
});
69+
70+
it("should set hasRoomAbove when top neighbor is occupied", () => {
71+
const occupiedCells = new Set(["0,2"]);
72+
73+
const facadeCells = computeFacadeCells(occupiedCells);
74+
75+
const belowCell = facadeCells.find(
76+
(cell) => cell.gridX === 0 && cell.gridY === 1,
77+
);
78+
expect(belowCell).toBeDefined();
79+
expect(belowCell?.hasRoomAbove).toBe(true);
80+
});
81+
82+
it("should set hasRoomBelow when bottom neighbor is occupied", () => {
83+
const occupiedCells = new Set(["0,1"]);
84+
85+
const facadeCells = computeFacadeCells(occupiedCells);
86+
87+
const aboveCell = facadeCells.find(
88+
(cell) => cell.gridX === 0 && cell.gridY === 2,
89+
);
90+
expect(aboveCell).toBeDefined();
91+
expect(aboveCell?.hasRoomBelow).toBe(true);
92+
});
93+
94+
it("should not duplicate facade cells shared between two rooms", () => {
95+
const occupiedCells = new Set(["0,1", "2,1"]);
96+
97+
const facadeCells = computeFacadeCells(occupiedCells);
98+
99+
const cellAt1_1 = facadeCells.filter(
100+
(cell) => cell.gridX === 1 && cell.gridY === 1,
101+
);
102+
expect(cellAt1_1).toHaveLength(1);
103+
expect(cellAt1_1[0]?.hasRoomToLeft).toBe(true);
104+
expect(cellAt1_1[0]?.hasRoomToRight).toBe(true);
105+
});
106+
});

0 commit comments

Comments
 (0)