diff --git a/CLAUDE.md b/CLAUDE.md index f19dc45..9c01773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ npm run maps # перегенерация карт-файлов (tools/maps + encodeMap) node tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) node tools/smoke-ponds.mjs # смоук перехода луга -> Серые пруды +node tools/smoke-zvenets.mjs # смоук перехода луга -> Звенец node tools/smoke-quest.mjs # смоук диалога с Ирвином и сумки/журнала ``` diff --git a/apps/game/assets/audio/music/zvenets.wav b/apps/game/assets/audio/music/zvenets.wav new file mode 100644 index 0000000..00ab46b --- /dev/null +++ b/apps/game/assets/audio/music/zvenets.wav Binary files differ diff --git a/apps/game/assets/maps/meadows.map b/apps/game/assets/maps/meadows.map index 9fa8ed3..d754243 100644 --- a/apps/game/assets/maps/meadows.map +++ b/apps/game/assets/maps/meadows.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3],"tall":{"3":{"height":1.5,"ground":0}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,7],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,6],[3,1],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,7],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,4],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,4],[0,3],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,4],[0,1],[1,2],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,4],[0,3],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,4],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/ponds.map b/apps/game/assets/maps/ponds.map index 83c780c..79760fd 100644 --- a/apps/game/assets/maps/ponds.map +++ b/apps/game/assets/maps/ponds.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3],"tall":{"3":{"height":1.5,"ground":0}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file diff --git a/apps/game/assets/maps/zvenets.map b/apps/game/assets/maps/zvenets.map new file mode 100644 index 0000000..d02bba1 --- /dev/null +++ b/apps/game/assets/maps/zvenets.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4}},"encoding":"rle","tiles":[[4,30],[3,1],[4,6],[0,4],[4,13],[3,1],[4,9],[0,2],[4,2],[0,1],[4,23],[0,3],[4,1],[0,1],[4,12],[3,1],[4,2],[3,1],[4,1],[3,1],[4,5],[0,5],[4,12],[3,1],[4,2],[0,2],[4,6],[0,5],[4,1],[0,1],[4,3],[7,1],[4,6],[3,1],[4,3],[3,1],[4,5],[7,1],[4,1],[1,11],[4,8],[3,1],[0,2],[4,6],[1,5],[6,1],[4,1],[1,1],[4,1],[1,2],[4,6],[3,3],[4,1],[3,1],[4,6],[1,8],[4,1],[1,1],[4,6],[3,1],[4,3],[0,1],[4,7],[1,1],[4,1],[1,9],[4,1],[7,1],[4,8],[1,12],[0,1],[1,1],[4,1],[1,3],[4,1],[0,3],[4,4],[3,1],[0,1],[4,7],[1,2],[4,1],[1,8],[0,4],[4,5],[3,1],[0,5],[4,2],[1,11],[4,1],[0,3],[4,6],[3,1],[0,6],[1,8],[4,1],[1,2],[4,1],[0,3],[4,4],[3,1],[4,3],[0,3],[4,1],[0,5],[4,17],[0,1],[4,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,5],[0,4],[4,7],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,2],[0,2],[4,4],[0,5],[4,5],[3,2],[4,2],[0,1],[3,1],[4,5],[0,12],[4,8],[0,3],[4,6],[0,5],[4,1],[0,5],[4,4],[0,1],[3,1],[0,1],[4,1],[3,1],[0,1],[4,7],[0,7],[4,1],[0,3],[4,4],[0,3],[4,1]]} \ No newline at end of file diff --git a/apps/game/assets/tiles/house.png b/apps/game/assets/tiles/house.png new file mode 100644 index 0000000..fceaf88 --- /dev/null +++ b/apps/game/assets/tiles/house.png Binary files differ diff --git a/apps/game/assets/tiles/tower.png b/apps/game/assets/tiles/tower.png new file mode 100644 index 0000000..f550331 --- /dev/null +++ b/apps/game/assets/tiles/tower.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 661dc9a..c70d946 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -42,6 +42,8 @@ 'tiles/ash', 'tiles/bellflower', 'tiles/tree', + 'tiles/house', + 'tiles/tower', 'chars/elder_irwin', 'chars/trader_mila' ]; @@ -58,7 +60,8 @@ 'sfx/ui_click', 'sfx/chime', 'music/meadows', - 'music/ponds' + 'music/ponds', + 'music/zvenets' ]; constructor(readonly engine: Engine) { diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index e4b42fe..1b23753 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -6,7 +6,7 @@ * Локации демо-среза. Карты хранятся файлами assets/maps/.map * (формат rpg-map, RLE) и загружаются через parseMap в BootScene. */ -export type LocationId = 'meadows' | 'ponds'; +export type LocationId = 'meadows' | 'ponds' | 'zvenets'; export interface LocationExit { /** Тайл-триггер (наступил — переход). */ @@ -36,7 +36,7 @@ name: 'Выжженные луга', music: 'music/meadows', spawn: { x: 14, y: 14 }, - npcs: NPCS, + npcs: [], enemies: [ { kind: 'crawler', tile: { x: 8, y: 9 } }, { kind: 'crawler', tile: { x: 10, y: 7 } }, @@ -46,7 +46,9 @@ ], exits: [ // Северо-западный угол: тропа на Серые пруды. - { tile: { x: 2, y: 2 }, to: 'ponds', entry: { x: 3, y: 3 } } + { tile: { x: 2, y: 2 }, to: 'ponds', entry: { x: 3, y: 3 } }, + // Тропа в Звенец (деревня-мастерская). + { tile: { x: 26, y: 14 }, to: 'zvenets', entry: { x: 3, y: 10 } } ], ambience: 'ash' }, @@ -68,6 +70,19 @@ { tile: { x: 2, y: 2 }, to: 'meadows', entry: { x: 3, y: 3 } } ], ambience: 'fog' + }, + zvenets: { + id: 'zvenets', + name: 'Звенец', + music: 'music/zvenets', + spawn: { x: 14, y: 10 }, + npcs: NPCS, + enemies: [], + exits: [ + // Тропа обратно на луга. + { tile: { x: 2, y: 10 }, to: 'meadows', entry: { x: 25, y: 14 } } + ], + ambience: 'ash' } }; diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index 8a9c879..cd9a92b 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -7,14 +7,18 @@ WATER: 2, TREE: 3, ASH: 4, - BELLFLOWER: 5 + BELLFLOWER: 5, + TOWER: 6, + HOUSE: 7 } as const; -const BLOCKED = [TILES.WATER, TILES.TREE]; +const BLOCKED = [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE]; /** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */ const TALL_OBJECTS: Record = { - [TILES.TREE]: { height: 1.5, ground: TILES.GRASS } + [TILES.TREE]: { height: 1.5, ground: TILES.GRASS }, + [TILES.TOWER]: { height: 2.5, ground: TILES.PATH }, + [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH } }; /** Детерминированный ГПСЧ, чтобы локация выглядела одинаково между запусками. */ @@ -120,6 +124,18 @@ tiles[at(2, 2)] = TILES.PATH; tiles[at(3, 3)] = TILES.PATH; + // Тропа в Звенец: восточный край, вокруг тайлов перехода тоже расчищаем. + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -2; dx <= 0; dx++) { + const nx = 26 + dx; + const ny = 14 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) { + tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.GRASS; + } + } + } + tiles[at(26, 14)] = TILES.PATH; + return { width, height, @@ -210,4 +226,78 @@ blocked: [...BLOCKED], tall: TALL_OBJECTS }; -} \ No newline at end of file +} +/** + * Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной + * водонапорной башни с колоколом. Дома из вагонных щитов, крыши из + * платформ, посреди площади — звонное место. Безопасный хаб (без врагов). + */ +export function buildZvenetsMap(width = 28, height = 20): TileMapData { + const rng = mulberry32(20260907); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Пятна чахлой травы по краям. + for (let i = 0; i < 18; i++) { + const x = Math.floor(rng() * width); + const y = Math.floor(rng() * height); + const r = 1 + Math.floor(rng() * 2); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng() < 0.6) { + tiles[at(nx, ny)] = TILES.GRASS; + } + } + } + } + + // Площадь: утоптанный пепел в центре карты. + for (let y = 6; y <= 13; y++) { + for (let x = 9; x <= 19; x++) { + if (rng() < 0.9) tiles[at(x, y)] = TILES.PATH; + } + } + + // Тропа: от западного края (переход на луга) к площади. + let px = 2; + let py = 10; + while (px < 9 || py !== 10) { + tiles[at(px, py)] = TILES.PATH; + if (px < 9 && (py === 10 || rng() < 0.6)) px++; + else if (py !== 10) py += py < 10 ? 1 : -1; + else px++; + } + + // Сухие деревья по краям. + for (let i = 0; i < 30; i++) { + const edge = rng() < 0.5; + const x = edge ? Math.floor(rng() * 3) : width - 1 - Math.floor(rng() * 3); + const y = Math.floor(rng() * height); + if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) { + tiles[at(x, y)] = TILES.TREE; + } + } + + // Башня с колоколом — север площади. + tiles[at(14, 7)] = TILES.TOWER; + + // Дома: два вдоль тропы, лавка Милы — восток площади. + tiles[at(7, 6)] = TILES.HOUSE; + tiles[at(18, 5)] = TILES.HOUSE; + tiles[at(21, 9)] = TILES.HOUSE; + + // Стартовая поляна и тайл перехода всегда свободны. + tiles[at(14, 10)] = TILES.GRASS; + tiles[at(2, 10)] = TILES.PATH; + tiles[at(3, 10)] = TILES.PATH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS + }; +} diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index b0e6020..70e5bea 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -17,7 +17,7 @@ { id: 'elder', name: 'Старейшина Ирвин', - tile: { x: 13, y: 15 }, + tile: { x: 12, y: 9 }, sprite: 'elder_irwin', dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', @@ -26,7 +26,7 @@ { id: 'trader', name: 'Торговка Мила', - tile: { x: 16, y: 12 }, + tile: { x: 17, y: 11 }, sprite: 'trader_mila', dialogueFirst: 'trader_first', dialogueRepeat: 'trader_first', diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 3543b5b..385831e 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -427,7 +427,9 @@ [TILES.WATER, a.texture('tiles/water')], [TILES.ASH, a.texture('tiles/ash')], [TILES.BELLFLOWER, a.texture('tiles/bellflower')], - [TILES.TREE, a.texture('tiles/tree')] + [TILES.TREE, a.texture('tiles/tree')], + [TILES.TOWER, a.texture('tiles/tower')], + [TILES.HOUSE, a.texture('tiles/house')] ]); } diff --git a/docs/art-style.md b/docs/art-style.md index e2d1a0b..a7a71c9 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -106,6 +106,8 @@ | 3 | `tiles/tree.png` | дерево Ржавой рощи — 32×48, tall-объект | | 4 | `tiles/ash.png` | пепельный бугор: P4/P6 с тёмным ободом | | 5 | `tiles/bellflower.png` | трава + цветок B1/B2 (квестовый тайл) | +| 6 | `tiles/tower.png` | башня Звенца — 32×80, tall-объект 2.5 юнита: бак на ногах-рельсах, колокол B0/B2 под баком | +| 7 | `tiles/house.png` | дом Звенца — 32×56, tall-объект 1.75 юнита: стены из вагонных щитов, крыша из шпал | ### Персонажи (16×24) diff --git a/docs/demo.md b/docs/demo.md index e708ac5..ba1ae52 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -82,7 +82,8 @@ ## Дорожная карта (что осталось за срезом) -- Акт 1 до конца: посадка меняет пейзаж шире (поляна), финальная кат-сцена с Tween-камерой. +- Акт 1 до конца: зоны наката у прудов (замедление, маска-полотно), финальная кат-сцена с Tween-камерой к башне. +- Пейзажная фауна: безгласный олень у полян колокольчиков (мини-StateMachine вне боя), мотыли над потревоженным пеплом. - Экономика Милы: торговля (соль/моты), мот-песок как валюта. - Звонная книга Ирвина: журнал заданий расширяется за пределы одного квеста. - Tiled-импорт: альтернативный путь производства карт (для больших локаций). diff --git a/tools/audio/gen.mjs b/tools/audio/gen.mjs index b12ee5a..8cdaf5e 100644 --- a/tools/audio/gen.mjs +++ b/tools/audio/gen.mjs @@ -244,6 +244,34 @@ return loopify(normalize(s, 0.5)); } +/** + * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим + * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. + */ +function musicZvenets() { + const dur = 8; + const wind = bandNoise(81, 80, 500, dur); + const strikes = [ + { at: 1.0, f: 148 }, + { at: 5.0, f: 111 } + ]; + const s = synth(dur, (t) => { + let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5)); + for (const s of strikes) { + const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары + if (dt >= 0 && dt < 2.5) { + v += + (Math.sin(2 * Math.PI * s.f * dt) * 0.22 + + Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 + + Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) * + decay(dt, 2.5, 3); + } + } + return v; + }); + return loopify(normalize(s, 0.55)); +} + // ---------- запись ---------- const SFX = { @@ -257,7 +285,8 @@ 'sfx/ui_click': uiClick, 'sfx/chime': chime, 'music/meadows': musicMeadows, - 'music/ponds': musicPonds + 'music/ponds': musicPonds, + 'music/zvenets': musicZvenets }; mkdirSync(`${OUT}sfx`, { recursive: true }); diff --git a/tools/maps/gen.test.ts b/tools/maps/gen.test.ts index 69f17db..1747767 100644 --- a/tools/maps/gen.test.ts +++ b/tools/maps/gen.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { encodeMap, parseMap } from '../../packages/engine/src/map/mapFormat'; -import { buildMeadowsMap, buildPondsMap } from '../../apps/game/src/data/map'; +import { buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../../apps/game/src/data/map'; /** * Генератор карт-файлов: `npm run maps` (vitest run tools/maps/gen.test.ts). @@ -18,7 +18,8 @@ const MAPS: Record> = { meadows: buildMeadowsMap(), - ponds: buildPondsMap() + ponds: buildPondsMap(), + zvenets: buildZvenetsMap() }; describe('генерация карт-файлов (rpg-map, RLE)', () => { diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 8598997..a8fff4f 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -173,6 +173,136 @@ return c; } +/** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */ +function houseTile() { + const c = new Canvas(32, 56); + const r = rng(777); + + // Стены: вагонные щиты — горизонтальные доски C1 со швами C0. + for (let y = 17; y <= 48; y++) { + const seam = (y - 17) % 6 === 0; + for (let x = 6; x <= 25; x++) { + c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + } + // Вертикальные стойки по краям стен. + for (let y = 17; y <= 48; y++) { + c.set(6, y, 'M1'); + c.set(25, y, 'M1'); + } + + // Крыша-платформа: треугольник из шпал, расширяется книзу. + for (let i = 0; i <= 15; i++) { + const y = 1 + i; + const half = Math.min(13, 1 + Math.floor(i * 0.85)); + for (let dx = -half; dx <= half; dx++) { + c.set(16 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); + } + } + // Свес крыши. + for (let x = 2; x <= 29; x++) { + c.set(x, 18, 'T0'); + } + + // Дверь: тёмный проём с бронзовой ручкой. + for (let y = 32; y <= 48; y++) { + for (let x = 13; x <= 19; x++) c.set(x, y, 'P1'); + } + c.set(18, 40, 'B0'); + + // Окно: тёмный квадрат с металлической рамой. + for (let y = 22; y <= 27; y++) { + for (let x = 9; x <= 13; x++) c.set(x, y, 'P2'); + } + for (let x = 8; x <= 14; x++) { + c.set(x, 21, 'M0'); + c.set(x, 28, 'M0'); + } + for (let y = 21; y <= 28; y++) { + c.set(7, y, 'M0'); + c.set(15, y, 'M0'); + } + c.set(11, 24, 'M1'); // переплёт + + // Фундамент из шпал. + for (let x = 4; x <= 27; x++) { + c.set(x, 49, 'R1'); + c.set(x, 50, 'R0'); + } + + // Пепел у стен. + c.set(3, 52, 'P5'); + c.set(28, 52, 'P5'); + c.set(9, 53, 'P4'); + + return c; +} + +/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ +function towerTile() { + const c = new Canvas(32, 80); + const r = rng(1414); + + // Бак: цилиндр из полос металла. + for (let y = 12; y <= 40; y++) { + for (let x = 7; x <= 24; x++) { + c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); + } + // Боковые тени. + c.set(7, y, 'M0'); + c.set(8, y, 'M0'); + c.set(24, y, 'M0'); + } + // Пояса обруча. + for (const y of [14, 22, 30, 38]) { + for (let x = 7; x <= 24; x++) c.set(x, y, 'M2'); + } + // Коническая крыша бака. + for (let i = 0; i <= 6; i++) { + const half = 9 - i; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2'); + } + // Флюгер-шпиль. + for (let y = 2; y <= 4; y++) c.set(16, y, 'M2'); + c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос + + // Ноги-рельсы. + for (const [lx, rx] of [ + [10, 12], + [19, 21] + ]) { + for (let y = 41; y <= 77; y++) { + c.set(lx, y, 'M2'); + c.set(lx + 1, y, 'M0'); + c.set(rx, y, 'M2'); + c.set(rx - 1, y, 'M0'); + } + } + // Раскосы между ногами. + for (let i = 0; i < 4; i++) { + const y = 46 + i * 8; + for (let dx = 0; dx < 8; dx++) { + c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + } + } + + // Колокол из рельсов под баком, между ног. + for (let y = 44; y <= 54; y++) { + const half = y < 50 ? 2 : 3; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0'); + } + c.set(16, 45, 'B2'); // блик + c.set(15, 48, 'B1'); + c.set(16, 55, 'M0'); // язык + + // Пепел у ног. + c.set(8, 79, 'P5'); + c.set(23, 79, 'P5'); + + return c; +} + // ---------- персонажи 16x24 ---------- // Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, @@ -393,6 +523,8 @@ save(ashTile(), 'tiles/ash.png'); save(bellflowerTile(), 'tiles/bellflower.png'); save(treeTile(), 'tiles/tree.png'); +save(houseTile(), 'tiles/house.png'); +save(towerTile(), 'tiles/tower.png'); console.log('Генерация персонажей:'); save(heroDown1, 'chars/hero_down_1.png'); diff --git a/tools/smoke-zvenets.mjs b/tools/smoke-zvenets.mjs new file mode 100644 index 0000000..ce03562 --- /dev/null +++ b/tools/smoke-zvenets.mjs @@ -0,0 +1,65 @@ +/** + * Смоук перехода луга -> Звенец: герой идёт кликами на восток карты, + * где триггер (26,14). Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] + */ +import puppeteer from 'puppeteer-core'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_zvenets.png'; + +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', + headless: true, + // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, + // и навигация, завязанная на звук, зависает. + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); + +page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); + +// Ждём маяк BootScene: без него клики уходят «в загрузку». +const booted = new Promise((resolve) => { + page.on('console', (msg) => { + if (msg.text().includes('[boot] ассеты загружены')) resolve(); + }); +}); + +await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); +await booted; +await new Promise((r) => setTimeout(r, 1500)); // fade перехода в меню + +// «Новая игра» (первый пункт в свежем профиле; канвас 480x270 растянут x2) +await page.mouse.click(480, 283); +await new Promise((r) => setTimeout(r, 2500)); + +// Клик в тайл на 2 шага восточнее ног героя (но не дальше триггера): +// центр ромба (tx,ty) — ((tx-ty)*16, (tx+ty)*8+8) виртуальных px. +for (let i = 0; i < 20; i++) { + const p = await page.evaluate(() => { + const g = window.__game; + const sc = g.scenes.current; + const tile = sc.player.currentTile(); + const target = { x: Math.min(26, tile.x + 2), y: tile.y }; + const wx = (target.x - target.y) * 16; + const wy = (target.x + target.y) * 8 + 8; + const rect = document.querySelector('canvas').getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + return { + x: rect.left + ((wx + wr.x) / 480) * rect.width, + y: rect.top + ((wy + wr.y) / 270) * rect.height + }; + }); + await page.mouse.click(p.x, p.y); + await new Promise((r) => setTimeout(r, 700)); +} + +// Даём fade-переходу (0.4 с) и enter() новой сцены завершиться до скриншота. +await new Promise((r) => setTimeout(r, 1500)); + +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close(); \ No newline at end of file