diff --git a/apps/game/assets/maps/meadows.map b/apps/game/assets/maps/meadows.map index d754243..20ebdcb 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,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 +{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7,9,10],"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,1],[10,1],[0,2],[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 79760fd..686e0ba 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,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 +{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7,9,10],"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 index d02bba1..c6fd8a5 100644 --- a/apps/game/assets/maps/zvenets.map +++ b/apps/game/assets/maps/zvenets.map @@ -1 +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 +{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7,9,10],"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],[10,1],[1,10],[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/floor.png b/apps/game/assets/tiles/floor.png new file mode 100644 index 0000000..6a1de4b --- /dev/null +++ b/apps/game/assets/tiles/floor.png Binary files differ diff --git a/apps/game/assets/tiles/wall.png b/apps/game/assets/tiles/wall.png new file mode 100644 index 0000000..9f52e1c --- /dev/null +++ b/apps/game/assets/tiles/wall.png Binary files differ diff --git a/apps/game/assets/tiles/well.png b/apps/game/assets/tiles/well.png new file mode 100644 index 0000000..3e84997 --- /dev/null +++ b/apps/game/assets/tiles/well.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index acbb51b..d7f6e6d 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -44,6 +44,9 @@ 'tiles/tree', 'tiles/house', 'tiles/tower', + 'tiles/floor', + 'tiles/wall', + 'tiles/well', 'chars/elder_irwin', 'chars/trader_mila' ]; diff --git a/apps/game/src/data/__tests__/transitions.test.ts b/apps/game/src/data/__tests__/transitions.test.ts new file mode 100644 index 0000000..4e75cab --- /dev/null +++ b/apps/game/src/data/__tests__/transitions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTransition, type TransitionCtx } from '../transitions'; +import type { TransitionDef } from '../locations'; + +const ctx: TransitionCtx = { + hasFlag: (f) => f === 'door_open', + hasItem: (i) => i === 'salt' +}; + +const at = (x: number, y: number) => ({ x, y }); + +describe('resolveTransition — выбор по тайлу/триггеру', () => { + const defs: TransitionDef[] = [ + { tile: at(2, 2), target: { kind: 'area', area: 'ponds', entry: at(3, 3) } }, + { tile: at(5, 5), label: 'Дверь', target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, trigger: 'click' }, + { tile: at(9, 6), target: { kind: 'return' }, trigger: 'click' } + ]; + + it('step-переход: наступил на тайл — сработал', () => { + const pick = resolveTransition(defs, at(2, 2), 'step', ctx); + expect(pick?.ok).toBe(true); + }); + + it('step не срабатывает на click-переходе того же тайла', () => { + expect(resolveTransition(defs, at(5, 5), 'step', ctx)).toBeNull(); + expect(resolveTransition(defs, at(5, 5), 'click', ctx)?.ok).toBe(true); + }); + + it('клик мимо переходов — null', () => { + expect(resolveTransition(defs, at(20, 20), 'click', ctx)).toBeNull(); + }); + + it('return-переход выбирается по kind цели, не по тайлу входа', () => { + const pick = resolveTransition(defs, at(9, 6), 'click', ctx); + expect(pick?.ok && pick.def.target.kind === 'return').toBe(true); + }); +}); + +describe('resolveTransition — условия и отказ', () => { + it('requiresFlag не выполнен — отказ с lockedText', () => { + const defs: TransitionDef[] = [ + { + tile: at(4, 4), + target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, + requiresFlag: 'gate_open', + lockedText: 'Заперто.' + } + ]; + const pick = resolveTransition(defs, at(4, 4), 'step', ctx); + expect(pick).toEqual({ ok: false, lockedText: 'Заперто.' }); + // Флаг поставлен — пускает. + expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => true, hasItem: () => false })?.ok).toBe(true); + }); + + it('requiresItem не выполнен — отказ; с предметом пускает', () => { + const defs: TransitionDef[] = [ + { + tile: at(4, 4), + target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, + requiresItem: 'salt', + lockedText: 'Нужна соль.' + } + ]; + expect(resolveTransition(defs, at(4, 4), 'step', { hasFlag: () => false, hasItem: () => false })).toEqual({ + ok: false, + lockedText: 'Нужна соль.' + }); + expect(resolveTransition(defs, at(4, 4), 'step', ctx)?.ok).toBe(true); + }); + + it('без lockedText — текст отказа по умолчанию', () => { + const pick = resolveTransition( + [{ tile: at(4, 4), target: { kind: 'area', area: 'zvenets', entry: at(1, 1) }, requiresFlag: 'x' }], + at(4, 4), + 'step', + ctx + ); + expect(pick?.ok).toBe(false); + expect(pick && !pick.ok && pick.lockedText.length > 0).toBe(true); + }); +}); + +describe('resolveTransition — дисарм входа', () => { + const defs: TransitionDef[] = [ + { tile: at(3, 3), target: { kind: 'area', area: 'meadows', entry: at(9, 9) } } + ]; + + it('step-переход на тайле входа игнорируется, пока герой не покинул его', () => { + expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(3, 3))).toBeNull(); + }); + + it('после ухода с тайла входа переход снова работает', () => { + expect(resolveTransition(defs, at(3, 3), 'step', ctx, at(10, 10))?.ok).toBe(true); + expect(resolveTransition(defs, at(3, 3), 'step', ctx, null)?.ok).toBe(true); + }); + + it('дисарм не глушит click-переходы на том же тайле', () => { + const clickDefs: TransitionDef[] = [ + { tile: at(3, 3), target: { kind: 'return' }, trigger: 'click' } + ]; + expect(resolveTransition(clickDefs, at(3, 3), 'click', ctx, at(3, 3))?.ok).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index e8c2e16..de3984c 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { DialogueGraph, TileMapData } from '@rpg/engine'; import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map'; -import { LOCATIONS } from '../locations'; +import { AREAS } from '../locations'; import { validateContent, validateDialogue, validateLocations, validateNpcs } from '../validate'; /** Карта целиком из проходимой травы (или с одиночной стеной). */ @@ -54,7 +54,7 @@ const maps = new Map([['zvenets', flatMap(28, 20)]]); expect(validateNpcs(maps)).toEqual([]); // NPCS статичен — подменяем карту: стена на тайле Ирвина даст 'in-wall'. - const elder = LOCATIONS.zvenets.npcs[0]!; + const elder = AREAS.zvenets.npcs[0]!; const walled = new Map([['zvenets', flatMap(28, 20, { x: elder.tile.x, y: elder.tile.y })]]); const inv = validateNpcs(walled); expect(inv.some((i) => i.id === 'in-wall' && i.message.includes(elder.id))).toBe(true); @@ -62,19 +62,17 @@ }); describe('validateLocations', () => { - it('exit в несуществующую локацию — ошибка exit-target', () => { + it('переход в несуществующую область — ошибки target нет, guard на карту', () => { const maps = new Map([['meadows', flatMap(28, 28)]]); - // LOCATIONS статичен; проверяем чистую функцию через его реальные данные, - // а битую цель эмулируем картой без пары: meadows -> ponds существует, - // но ponds в maps нет — exit-target не сработает, сработает отсутствие. + // AREAS статичен; проверяем чистую функцию через реальные данные: + // meadows -> ponds/zvenets существуют, но их карт в maps нет — + // guard на отсутствующую карту не роняет проверку. const inv = validateLocations(maps); - // meadows ссылается на ponds/zvenets — их карт нет: ошибок target нет, - // но и падений нет (guard на отсутствующую карту). expect(inv.every((i) => i.severity === 'warn' || i.where?.includes('meadows'))).toBe(true); }); it('спавн в стене ловится', () => { - const spawn = LOCATIONS.meadows.spawn; + const spawn = AREAS.meadows.spawn; const maps = new Map([['meadows', flatMap(28, 28, spawn)]]); const inv = validateLocations(maps); expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts index f952ecf..d7310b2 100644 --- a/apps/game/src/data/enemies.ts +++ b/apps/game/src/data/enemies.ts @@ -74,4 +74,4 @@ } }; -/** Точки спавна врагов задаются в data/locations.ts (LOCATIONS[*].enemies). */ +/** Точки спавна врагов задаются в data/locations.ts (AREAS[*].enemies). */ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 600d661..919465b 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -4,17 +4,35 @@ import type { ItemId } from './items'; /** - * Локации демо-среза. Карты хранятся файлами assets/maps/.map - * (формат rpg-map, RLE) и загружаются через parseMap в BootScene. + * Области демо-среза (улица и интерьер — общий контекст). Карты хранятся + * файлами assets/maps/.map (формат rpg-map, RLE) и загружаются через + * parseMap в BootScene. */ -export type LocationId = 'meadows' | 'ponds' | 'zvenets'; +export type AreaId = 'meadows' | 'ponds' | 'zvenets'; -export interface LocationExit { - /** Тайл-триггер (наступил — переход). */ +/** Куда ведёт переход: в другую область (с точкой входа) или назад, откуда вошли. */ +export type TransitionTarget = + | { kind: 'area'; area: AreaId; entry: { x: number; y: number } } + | { kind: 'return' }; + +/** + * Единый механизм переходов: выходы между локациями (step), двери/порталы/ + * колодцы (click). Первая подходящая по тайлу и триггеру — срабатывает. + */ +export interface TransitionDef { + /** Тайл-триггер. */ tile: { x: number; y: number }; - to: LocationId; - /** Куда поставить героя в новой локации. */ - entry: { x: number; y: number }; + /** Подпись для подсказки при близости (позже) и снапшота. */ + label?: string; + target: TransitionTarget; + /** step — наступил (по умолчанию), click — клик по тайлу (двери, порталы). */ + trigger?: 'step' | 'click'; + /** Условия: не выполнены — переход не срабатывает, герой получает lockedText. */ + requiresFlag?: string; + requiresItem?: ItemId; + lockedText?: string; + /** Длительность fade (сек), по умолчанию 0.4. */ + duration?: number; } /** Зона опасности (накат): герой без нужного предмета еле идёт и рискует голосом. */ @@ -26,25 +44,25 @@ requiresItem?: ItemId; } -export interface LocationDef { - id: LocationId; +export interface AreaDef { + id: AreaId; name: string; - /** Музыка локации (кроссфейд при переходе). */ + /** Музыка области (кроссфейд при переходе). */ music: string; - /** Стартовый тайл (новая игра / респаун). */ + /** Стартовый тайл (новая игра / респаун / фолбэк return). */ spawn: { x: number; y: number }; npcs: NpcDef[]; enemies: { kind: EnemyKindId; tile: { x: number; y: number } }[]; - exits: LocationExit[]; - /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов. */ - ambience: 'ash' | 'fog'; + transitions: TransitionDef[]; + /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ + ambience: 'ash' | 'fog' | 'none'; /** Зоны наката (необязательно). */ hazards?: HazardDef[]; /** Спавны пейзажных оленей (тайлы). */ fauna?: { x: number; y: number }[]; } -export const LOCATIONS: Record = { +export const AREAS: Record = { meadows: { id: 'meadows', name: 'Выжженные луга', @@ -58,11 +76,19 @@ { kind: 'crawler', tile: { x: 22, y: 21 } }, { kind: 'heavy', tile: { x: 6, y: 22 } } ], - exits: [ + transitions: [ // Северо-западный угол: тропа на Серые пруды. - { tile: { x: 2, y: 2 }, to: 'ponds', entry: { x: 3, y: 3 } }, + { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'ponds', entry: { x: 3, y: 3 } } }, // Тропа в Звенец (деревня-мастерская). - { tile: { x: 26, y: 14 }, to: 'zvenets', entry: { x: 3, y: 10 } } + { tile: { x: 26, y: 14 }, target: { kind: 'area', area: 'zvenets', entry: { x: 3, y: 10 } } }, + // Встречный колодец: звон в Звенец (клик по тайлу колодца). + { + tile: { x: 22, y: 20 }, + label: 'Старый колодец', + target: { kind: 'area', area: 'zvenets', entry: { x: 9, y: 7 } }, + trigger: 'click', + duration: 0.2 + } ], ambience: 'ash', // Безгласные олени у берегов прудов — подходят к звону. @@ -84,9 +110,9 @@ { kind: 'spitter', tile: { x: 18, y: 18 } }, { kind: 'heavy', tile: { x: 13, y: 17 } } ], - exits: [ + transitions: [ // Обратно на луга. - { tile: { x: 2, y: 2 }, to: 'meadows', entry: { x: 3, y: 3 } } + { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } ], ambience: 'fog', // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. @@ -123,15 +149,23 @@ spawn: { x: 14, y: 10 }, npcs: NPCS, enemies: [], - exits: [ + transitions: [ // Тропа обратно на луга. - { tile: { x: 2, y: 10 }, to: 'meadows', entry: { x: 25, y: 14 } } + { tile: { x: 2, y: 10 }, target: { kind: 'area', area: 'meadows', entry: { x: 25, y: 14 } } }, + // Колодец у дома Ирвина: телепорт на луга (клик; встречный лугам). + { + tile: { x: 9, y: 6 }, + label: 'Старый колодец', + target: { kind: 'area', area: 'meadows', entry: { x: 22, y: 21 } }, + trigger: 'click', + duration: 0.2 + } ], ambience: 'ash' } }; -/** Определение локации по id (с фолбэком на луга для старых сейвов). */ -export function locationOf(id: string | null | undefined): LocationDef { - return LOCATIONS[(id as LocationId) ?? 'meadows'] ?? LOCATIONS.meadows; +/** Область по id (фолбэк 'meadows' — для старых сейвов и пустых id). */ +export function areaOf(id: string | null | undefined): AreaDef { + return AREAS[(id ?? 'meadows') as AreaId] ?? AREAS.meadows; } \ No newline at end of file diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index cd9a92b..bb848b1 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -9,10 +9,13 @@ ASH: 4, BELLFLOWER: 5, TOWER: 6, - HOUSE: 7 + HOUSE: 7, + FLOOR: 8, + WALL: 9, + WELL: 10 } as const; -const BLOCKED = [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE]; +const BLOCKED = [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE, TILES.WALL, TILES.WELL]; /** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */ const TALL_OBJECTS: Record = { @@ -136,6 +139,9 @@ } tiles[at(26, 14)] = TILES.PATH; + // Встречный колодец на лугах: телепорт обратно в Звенец (клик по тайлу). + tiles[at(22, 20)] = TILES.WELL; + return { width, height, @@ -288,6 +294,9 @@ tiles[at(18, 5)] = TILES.HOUSE; tiles[at(21, 9)] = TILES.HOUSE; + // Старый колодец у дома Ирвина: телепорт на луга (см. AREAS, transitions). + tiles[at(9, 6)] = TILES.WELL; + // Стартовая поляна и тайл перехода всегда свободны. tiles[at(14, 10)] = TILES.GRASS; tiles[at(2, 10)] = TILES.PATH; diff --git a/apps/game/src/data/transitions.ts b/apps/game/src/data/transitions.ts new file mode 100644 index 0000000..720e612 --- /dev/null +++ b/apps/game/src/data/transitions.ts @@ -0,0 +1,47 @@ +import type { TransitionDef } from './locations'; +import type { ItemId } from './items'; + +/** + * Чистый выбор перехода: по тайлу и триггеру, с условиями и дисармом. + * Вынесен из сцены — тестируется в Vitest без браузера. + */ + +/** Что резолверу нужно знать о герое (подставляется из GameState/Inventory). */ +export interface TransitionCtx { + hasFlag(flag: string): boolean; + hasItem(id: ItemId): boolean; +} + +/** Результат выбора: переход доступен / отказ (заперт) / ничего на этом тайле. */ +export type TransitionPick = + | { ok: true; def: TransitionDef } + | { ok: false; lockedText: string } + | null; + +/** + * Первый переход на тайле с этим триггером. step-переходы на disarmTile + * игнорируются (герой только что вошёл через него — иначе немедленный вылет). + */ +export function resolveTransition( + defs: TransitionDef[], + tile: { x: number; y: number }, + trigger: 'step' | 'click', + ctx: TransitionCtx, + disarmTile?: { x: number; y: number } | null +): TransitionPick { + const def = defs.find( + (t) => + t.tile.x === tile.x && + t.tile.y === tile.y && + (t.trigger ?? 'step') === trigger && + !(trigger === 'step' && disarmTile && t.tile.x === disarmTile.x && t.tile.y === disarmTile.y) + ); + if (!def) return null; + if (def.requiresFlag && !ctx.hasFlag(def.requiresFlag)) { + return { ok: false, lockedText: def.lockedText ?? 'Не пускает.' }; + } + if (def.requiresItem && !ctx.hasItem(def.requiresItem)) { + return { ok: false, lockedText: def.lockedText ?? 'Нужен предмет.' }; + } + return { ok: true, def }; +} \ No newline at end of file diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 6567f88..207eb1d 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -7,7 +7,7 @@ type TileMapData, type DialogueGraph } from '@rpg/engine'; -import { LOCATIONS } from './locations'; +import { AREAS } from './locations'; import { NPCS } from './npcs'; import { DIALOGUES } from './dialogues'; import { ENEMY_KINDS } from './enemies'; @@ -61,13 +61,13 @@ return out; } -/** NPC: не в стенах, в границах своей локации. */ +/** NPC: не в стенах, в границах своей области. */ export function validateNpcs(maps: Map): Invariant[] { const out: Invariant[] = []; for (const npc of NPCS) { - for (const loc of Object.values(LOCATIONS)) { - if (!loc.npcs.some((n) => n.id === npc.id)) continue; - const map = maps.get(loc.id); + for (const area of Object.values(AREAS)) { + if (!area.npcs.some((n) => n.id === npc.id)) continue; + const map = maps.get(area.id); if (!map) continue; const grid = gridOf(map); const check = mergeInvariants( @@ -80,14 +80,14 @@ return out; } -/** Локации: спавн/враги проходимы, exits ведут в существующие области и проходимые entry. */ +/** Области: спавн/враги проходимы, переходы ведут в существующие области и проходимые entry. */ export function validateLocations(maps: Map): Invariant[] { const out: Invariant[] = []; - for (const loc of Object.values(LOCATIONS)) { + for (const loc of Object.values(AREAS)) { const map = maps.get(loc.id); if (!map) continue; const grid = gridOf(map); - const where = `${WHERE}/location/${loc.id}`; + const where = `${WHERE}/area/${loc.id}`; out.push(...mergeInvariants( checkBounds('spawn', loc.spawn, map.width, map.height, where), checkWalkable('spawn', loc.spawn, grid, where) @@ -98,16 +98,20 @@ checkWalkable(`враг#${i}`, e.tile, grid, where) )); }); - for (const exit of loc.exits) { - if (!LOCATIONS[exit.to]) { - out.push({ id: 'exit-target', severity: 'error', message: `exit -> «${exit.to}»: локация не существует`, where }); + for (const [i, t] of loc.transitions.entries()) { + // Тайл-триггер в границах своей карты (может быть и непроходим — колодец). + out.push(...mergeInvariants(checkBounds(`переход#${i}`, t.tile, map.width, map.height, where))); + if (t.target.kind !== 'area') continue; + const to: string = t.target.area; + if (!(to in AREAS)) { + out.push({ id: 'transition-target', severity: 'error', message: `переход#${i} -> «${to}»: область не существует`, where }); continue; } - const targetMap = maps.get(exit.to); + const targetMap = maps.get(to); if (targetMap) { out.push(...mergeInvariants( - checkBounds(`exit entry (${exit.to})`, exit.entry, targetMap.width, targetMap.height, where), - checkWalkable(`exit entry (${exit.to})`, exit.entry, gridOf(targetMap), where) + checkBounds(`переход#${i} entry (${to})`, t.target.entry, targetMap.width, targetMap.height, where), + checkWalkable(`переход#${i} entry (${to})`, t.target.entry, gridOf(targetMap), where) )); } } diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index 876ce99..776e653 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,6 +1,6 @@ import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; -import { LOCATIONS } from '../data/locations'; +import { AREAS } from '../data/locations'; import { MenuScene } from './MenuScene'; /** @@ -42,8 +42,8 @@ .then(async () => await this.game.assets.loadAtlas('chars/clumps_sheet.json')) .then(async () => await this.game.assets.loadAtlas('chars/fauna_sheet.json')) .then(async () => { - // Карты локаций: файлы .map (rpg-map, RLE) -> parseMap. - for (const id of Object.keys(LOCATIONS)) { + // Карты областей: файлы .map (rpg-map, RLE) -> parseMap. + for (const id of Object.keys(AREAS)) { await this.game.loadMap(id); } }) diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 9cd3ab4..c0c2d3b 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -35,7 +35,14 @@ import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene'; import { InventoryScene } from './InventoryScene'; import { TILES } from '../data/map'; -import { locationOf, type LocationDef, type HazardDef } from '../data/locations'; +import { + areaOf, + type AreaDef, + type AreaId, + type HazardDef, + type TransitionDef +} from '../data/locations'; +import { resolveTransition, type TransitionCtx } from '../data/transitions'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; @@ -86,6 +93,7 @@ private pendingInteraction: | { kind: 'talk'; def: NpcDef } | { kind: 'flower'; x: number; y: number } + | { kind: 'transition'; def: TransitionDef; tile: { x: number; y: number } } | null = null; /** Таймер мигания при неуязвимости (свой, не боевой). */ private blinkT = 0; @@ -103,21 +111,31 @@ private charDebug: SpriteDebugView; /** Последний тост (текст + тик) — канал текста для агентного моста. */ private lastToast: { text: string; tick: number } | null = null; + /** + * Тайл входа в область: step-переходы на нём глушатся, пока герой + * с него не ушёл (иначе вошёл в дверь — и немедленно вылетел обратно). + */ + private disarmTile: { x: number; y: number } | null = null; + /** Предыдущий тайл героя (чтобы тост «заперто» не спамил каждый тик). */ + private prevStepTile: { x: number; y: number } | null = null; constructor( private game: Game, save: SaveData | null, - private location: LocationDef, - /** Точка входа при переходе из другой локации (приоритетнее save). */ - entry?: { x: number; y: number } + private area: AreaDef, + /** Точка входа при переходе из другой области (приоритетнее save). */ + entry?: { x: number; y: number }, + /** Откуда вошли (для target kind 'return'): область + тайл триггера. */ + private returnTo?: { area: AreaId; entry: { x: number; y: number } } ) { this.camera = game.engine.camera; - const data = this.game.mapFiles.get(location.id)!; + const data = this.game.mapFiles.get(area.id)!; this.map = new IsometricTileMap(data, this.tileTextures()); this.world.addChild(this.map.view, this.actors); this.game.renderer.worldRoot.addChild(this.world); - const startTile = entry ?? save?.pos ?? location.spawn; + const startTile = entry ?? save?.pos ?? area.spawn; + this.disarmTile = entry ? { ...startTile } : null; if (save?.state) this.game.state.load(save.state); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { void this.game.audio.play('sfx/step', 0.35); @@ -125,7 +143,7 @@ // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). this.actors.add(this.player.view, startTile.x, startTile.y); - for (const def of location.npcs) { + for (const def of area.npcs) { const view = this.makeNpcView(def); this.actors.add(view, def.tile.x, def.tile.y); this.npcs.push({ def, view }); @@ -146,7 +164,7 @@ this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes); } ); - for (const s of location.enemies) { + for (const s of area.enemies) { this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y)); } this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); @@ -166,7 +184,7 @@ this.actors, this.game.engine.events, data, - (location.fauna ?? []).map((t) => tileToWorld(t.x, t.y)) + (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)) ); this.fauna.setBlocked(data.blocked); this.fauna.setFrames( @@ -206,7 +224,7 @@ // Атмосфера локации: пепел на лугах, туман над прудами. this.ash = - location.ambience === 'fog' + area.ambience === 'fog' ? new ParticleEmitter({ color: 0x7a7a88, rate: 6, @@ -234,7 +252,7 @@ this.game.renderer.uiRoot.addChildAt(this.fog, 0); // Название локации в правом верхнем углу. - const name = new PixelText({ text: location.name, size: 11, color: 0x999988 }); + const name = new PixelText({ text: area.name, size: 11, color: 0x999988 }); name.anchor.set(1, 0); name.position.set(474, 4); this.game.renderer.uiRoot.addChild(name); @@ -274,9 +292,9 @@ } enter(): void { - console.log('[location]', this.location.id); // маяк для смоук-тестов + console.log('[location]', this.area.id); // маяк для смоук-тестов // Амбиент локации (кроссфейд; тот же трек не перезапускается). - this.game.playMusic(this.location.music); + this.game.playMusic(this.area.music); } exit(): void { @@ -374,7 +392,7 @@ this.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); - this.checkExits(tile); + this.checkTransitions(tile); this.debug.setLines([ `tile ${tile.x},${tile.y}`, @@ -391,7 +409,7 @@ */ private updateHazard(tile: { x: number; y: number }): void { const hazard = - (this.location.hazards ?? []).find((h) => + (this.area.hazards ?? []).find((h) => h.tiles.some((t) => t.x === tile.x && t.y === tile.y) ) ?? null; const masked = hazard !== null && this.game.inventory.has(hazard.requiresItem ?? 'cloth'); @@ -410,16 +428,70 @@ this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; } - /** Переход между локациями: герой наступил на тайл-триггер. */ - private checkExits(tile: { x: number; y: number }): void { - const exit = this.location.exits.find((e) => e.tile.x === tile.x && e.tile.y === tile.y); - if (!exit) return; + /** Контекст условий перехода: флаги GameState, сумка героя. */ + private transitionCtx(): TransitionCtx { + return { + hasFlag: (f) => this.game.state.hasFlag(f), + hasItem: (id) => this.game.inventory.has(id) + }; + } + + /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ + private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { + const t = def.target; + if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry }; + return { + area: areaOf(this.returnTo?.area), + entry: this.returnTo?.entry ?? this.area.spawn + }; + } + + /** Выполнить переход: новая сцена с fade (длительность — из определения). */ + private useTransition(def: TransitionDef): void { + const next = this.targetEntry(def); void this.game.scenes.replace( - new LocationScene(this.game, null, locationOf(exit.to), exit.entry), - { duration: 0.4 } + new LocationScene(this.game, null, next.area, next.entry, { + area: this.area.id, + entry: this.player.currentTile() + }), + { duration: def.duration ?? 0.4 } ); } + /** + * Единый механизм переходов (step-триггер): герой наступил на тайл. + * На тайле входа step-переходы дисармованы, пока герой с него не ушёл. + */ + private checkTransitions(tile: { x: number; y: number }): void { + const fresh = + this.prevStepTile === null || + this.prevStepTile.x !== tile.x || + this.prevStepTile.y !== tile.y; + this.prevStepTile = tile; + // Ушёл с тайла входа — дисарм снят, переходы снова работают. + if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) { + this.disarmTile = null; + } + + const pick = resolveTransition( + this.area.transitions, + tile, + 'step', + this.transitionCtx(), + this.disarmTile + ); + if (!pick) return; + if (!pick.ok) { + // «Заперто» — не спамим: только при приходе на тайл. + if (fresh) { + this.showToast(pick.lockedText); + void this.game.audio.play('sfx/ui_click', 0.4); + } + return; + } + this.useTransition(pick.def); + } + render(): void {} // ---------- бой ---------- @@ -501,7 +573,7 @@ this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1); this.playerCombat.revive(); // Респаун на стартовом тайле локации - this.player.teleportTo(this.location.spawn); + this.player.teleportTo(this.area.spawn); this.updateCameraFollow(true); this.healthBar.setHp(this.playerCombat.hp); } @@ -538,15 +610,16 @@ tile: def.tile, met: this.game.state.hasFlag(def.flagKey) })); - const transitions: TransitionSnapshot[] = this.location.exits.map((e) => ({ - tile: e.tile, - to: e.to, - label: null + const transitions: TransitionSnapshot[] = this.area.transitions.map((t) => ({ + tile: t.tile, + to: t.target.kind === 'area' ? t.target.area : '', + trigger: t.trigger ?? 'step', + label: t.label ?? null })); const layer: LocationSnapshot = { scene: 'location', - area: this.location.id, - areaName: this.location.name, + area: this.area.id, + areaName: this.area.name, hero, enemies, npcs, @@ -652,7 +725,10 @@ [TILES.BELLFLOWER, a.texture('tiles/bellflower')], [TILES.TREE, a.texture('tiles/tree')], [TILES.TOWER, a.texture('tiles/tower')], - [TILES.HOUSE, a.texture('tiles/house')] + [TILES.HOUSE, a.texture('tiles/house')], + [TILES.FLOOR, a.texture('tiles/floor')], + [TILES.WALL, a.texture('tiles/wall')], + [TILES.WELL, a.texture('tiles/well')] ]); } @@ -705,8 +781,27 @@ return; } + // Клик-переходы (колодцы, двери) — до маршрутизации движения; + // издалека герой сначала подходит к тайлу-триггеру. + const pick = resolveTransition( + this.area.transitions, + clicked, + 'click', + this.transitionCtx(), + null + ); + if (pick) { + if (!pick.ok) { + this.showToast(pick.lockedText); + void this.game.audio.play('sfx/ui_click', 0.4); + } else { + this.requestTransition(pick.def); + } + return; + } + // Клик по лунному колокольчику (пруды) — собрать цветок. - if (this.location.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { + if (this.area.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { this.requestCollect(clicked.x, clicked.y); return; } @@ -766,6 +861,20 @@ } } + /** Клик-переход: в радиусе — сразу; издалека — подойти и сработать. */ + private requestTransition(def: TransitionDef): void { + this.pendingInteraction = null; + if (this.inInteractRange(def.tile.x, def.tile.y)) { + this.useTransition(def); + return; + } + const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); + if (path) { + this.pendingInteraction = { kind: 'transition', def, tile: def.tile }; + this.player.followPath(path); + } + } + /** Сработать отложенным взаимодействием, когда герой остановился. */ private resolvePendingInteraction(): void { if (!this.pendingInteraction || this.player.moving) return; @@ -773,8 +882,18 @@ this.pendingInteraction = null; if (p.kind === 'talk') { if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def); - } else { + } else if (p.kind === 'flower') { if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y); + } else { + // Условие могли не выполнить, пока герой шёл — перепроверяем. + const pick = resolveTransition( + this.area.transitions, + p.tile, + 'click', + this.transitionCtx(), + null + ); + if (pick?.ok && this.inInteractRange(p.tile.x, p.tile.y)) this.useTransition(pick.def); } } @@ -920,10 +1039,11 @@ this.game.state.setVar('hp', this.playerCombat.hp); this.game.saves.save('autosave', { version: SAVE_VERSION, - location: this.location.id, + area: this.area.id, pos, state: this.game.state.serialize(), items: this.game.inventory.serialize().items, + returnTo: this.returnTo ?? null, savedAt: Date.now() } satisfies SaveData); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index e2b391e..f353d00 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -9,7 +9,7 @@ type SnapshotLayer } from '@rpg/engine'; import type { Game } from '../Game'; -import { locationOf } from '../data/locations'; +import { areaOf, type AreaId } from '../data/locations'; import { LocationScene } from './LocationScene'; import { SaveSlotsScene } from './SaveSlotsScene'; import { SettingsScene } from './SettingsScene'; @@ -135,12 +135,12 @@ if (save) { const norm = normalizeSave(save); this.game.inventory.load({ items: norm.items }); - void this.game.scenes.replace(new LocationScene(this.game, norm, locationOf(norm.location)), { + void this.game.scenes.replace(new LocationScene(this.game, norm, areaOf(norm.area)), { duration: 0.4 }); } else { this.game.inventory.clear(); - void this.game.scenes.replace(new LocationScene(this.game, null, locationOf(undefined)), { + void this.game.scenes.replace(new LocationScene(this.game, null, areaOf(undefined)), { duration: 0.4 }); } @@ -162,25 +162,40 @@ } /** - * Формат сейва: локация, позиция героя, сериализованное состояние прохождения + * Формат сейва: область, позиция героя, сериализованное состояние прохождения * и сумка. У старых сейвов нет `version`/`items` — normalizeSave дорастает их. */ -export const SAVE_VERSION = 2; +export const SAVE_VERSION = 3; + +/** Откуда герой вошёл в область (для переходов kind 'return'). */ +export interface ReturnToData { + area: AreaId; + entry: { x: number; y: number }; +} export interface SaveData { version: number; - /** В какой локации герой (фолбэк 'meadows' для старых сейвов). */ - location: string; + /** В какой области герой (фолбэк 'meadows' для старых сейвов). */ + area: string; pos: { x: number; y: number }; state: GameStateData; /** Содержимое сумки: itemId -> количество. */ items: Record; + /** Откуда вошли в область (v3; null/undefined — вход не через переход). */ + returnTo?: ReturnToData | null; savedAt: number; } /** Дополнить сейв старого формата до текущего (без записи). */ export function normalizeSave(save: SaveData): SaveData { if (save.version === SAVE_VERSION) return save; - // v1 (до сумки): location/pos/state/savedAt без version и items. - return { ...save, version: SAVE_VERSION, items: save.items ?? {} }; + // v1/v2: area зовётся location, returnTo ещё нет. + const legacy = save as SaveData & { location?: string }; + return { + ...save, + version: SAVE_VERSION, + area: save.area ?? legacy.location ?? 'meadows', + returnTo: save.returnTo ?? null, + items: save.items ?? {} + }; } \ No newline at end of file diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index fc7d4eb..0ec420b 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -65,7 +65,8 @@ continue; } const save = this.game.saves.load(slot)!; - const loc = save.location === 'ponds' ? 'пруды' : 'луга'; + const areaId = save.area ?? (save as { location?: string }).location; + const loc = areaId === 'ponds' ? 'пруды' : areaId === 'zvenets' ? 'Звенец' : 'луга'; const date = new Date(save.savedAt); const when = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; items.push({ diff --git a/docs/demo.md b/docs/demo.md index a75e916..e79385e 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -108,8 +108,9 @@ - ✅ **Батч 0 — агентный набор инструментов** (готово): мост `window.__agent`, валидация контента, `tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + `docs/engine/agent.md` + `docs/engine/practices.md`. -- **Батч 1**: единый механизм переходов (step/click), дисарм, `returnTo`, - сейвы v3, телепорт-колодец. +- ✅ **Батч 1 — единый механизм переходов** (готово): `TransitionDef` + (step/click, условия, lockedText), дисарм тайла входа, `returnTo`, сейвы v3, + телепорт-колодцы (Звенец ↔ луга), сценарий `tools/checks/transitions.mjs`. - **Батч 2**: интерьеры (дом Ирвина, лавка Милы) + интерактивные объекты. - **Батч 3**: взаимодействие с миром шире (знаки/подбор/рычаг, мот-песок). - **Батч 4**: ИИ врагов (LOS/шум/патруль/wary/flee/return). diff --git a/docs/engine/practices.md b/docs/engine/practices.md index b237eea..6826be4 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -10,18 +10,33 @@ 1. `npm run typecheck` — быстрый отсев. 2. `npx vitest run <затронутый тест>` — точечный прогон. 3. `node tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + - карты + карты-файлы равны генераторам + браузерная проверка инвариантов). + карты + карты-файлы равны генераторам + браузерная проверка инвариантов + + сценарий переходов `tools/checks/transitions.mjs`). 4. Смоук акта 1 (`node tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. ## Ситуация: добавляю локацию 1. Генератор в `tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. -2. `LocationId` + `LocationDef` в `data/locations.ts`: spawn/npcs/enemies/exits. +2. `AreaId` + `AreaDef` в `data/locations.ts`: spawn/npcs/enemies/transitions. 3. Экспорт через `AREAS`/`areaOf` и загрузка в `BootScene` по `Object.keys(AREAS)`. -4. Валидатор проверит спавн/врагов/exits автоматически (`agent:invariants`): +4. Валидатор проверит спавн/врагов/переходы автоматически (`agent:invariants`): **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен на воду/дерево). Чинить данные, а не валидатор. -5. Сценарий в `tools/checks/`: переход туда и обратно через `walkTo` + `waitFor`. +5. Сценарий в `tools/checks/`: переход туда и обратно через `walkTo` + `waitFor` + (готовый образец — `tools/checks/transitions.mjs`). + +## Ситуация: добавляю переход (дверь, портал, колодец) + +1. Один механизм на всё — `TransitionDef` в `data/locations.ts`: `trigger: 'step'` + (наступил, выходы между областями) или `'click'` (двери/колодцы; герой подходит + к тайлу сам). Условия — `requiresFlag`/`requiresItem` + `lockedText` (тост). +2. Карту грузит `npm run maps`; тайл-триггер может быть непроходимым (колодец) — + клик-переход обрабатывается до маршрутизации движения. +3. `npm run maps` при провале «fresh»-теста чинит файлы, **но сначала прочти + провал**: расхождение «тайлов N, ожидалось W×H» = индекс за пределами карты + в генераторе (разреженный массив дорос, см. граблю ниже). +4. Проверка: `tools/checks/transitions.mjs` — шаг до перехода, клик-переход + туда и обратно, снапшот `s.transitions` (триггеры и цели). ## Ситуация: добавляю NPC/диалог @@ -75,4 +90,9 @@ - Маяки консоли: `[boot] ассеты загружены` (BootScene), `[location] ` (вход в локацию) — на них строится `openGame`. - `window.__agent` только в DEV-сборке; для прод-сборки — `VITE_AGENT=1` (когда понадобится). -- Vite-алиас `@rpg/engine` — только regex; строковый перехватывает под-пути. \ No newline at end of file +- Vite-алиас `@rpg/engine` — только regex; строковый перехватывает под-пути. +- **Запись мимо карты в генераторе не падает**: `tiles[at(x,y)]` с индексом за + W×H молча дорастит разреженный массив — ловится только тестом «fresh» + («тайлов 583, ожидалось 560»). После правки генератора всегда `npm run maps`. +- **Асинхронный шаг в каркасе Checks**: `stepResult` обязан `await fn()` — + без await промис считается успехом (ok:true, ms:1, details:{}). \ No newline at end of file diff --git a/docs/world.md b/docs/world.md index dbfa0e8..c8fdffa 100644 --- a/docs/world.md +++ b/docs/world.md @@ -61,6 +61,10 @@ Люди здесь «сварщики тишины»: чинят резонаторы, плавят бронзу, ведут звонную книгу. Звенец не крепость и не лагерь — это мастерская. Люди здесь чинят мир, а не воюют с ним. +*Старые колодцы*: у дома Ирвина и на лугах стоят два бронзообручных колодца — парные. +Прохожий, позвонивший колокольчиком над жерлом, слышит отклик издалека, и звон +переносит его к парному колодцу (игровая механика телепорта, клик по тайлу колодца). + ## 5. Персонажи - **Звонарь** (герой, безымянный) — молодой звонарь из Звенца. Носит плащ, на поясе — diff --git a/tools/agent.mjs b/tools/agent.mjs index 60a6cf2..4639188 100644 --- a/tools/agent.mjs +++ b/tools/agent.mjs @@ -10,17 +10,18 @@ */ import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs'; const args = parseArgs(); const cmd = args._[0] ?? 'check'; -/** Один «серверный» шаг проверки: результат + ms + детали. */ -function stepResult(name, fn) { +/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */ +async function stepResult(name, fn) { const t0 = Date.now(); try { - const details = fn(); + const details = await fn(); return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; } catch (err) { return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; @@ -60,6 +61,16 @@ return c.finish({ pretty: args.pretty }); } +/** Сценарий из tools/checks/ как шаг check: exit code -> результат. */ +async function runScenario(name, file) { + return stepResult(name, async () => { + const mod = await import(pathToFileURL(resolve(new URL('..', import.meta.url).pathname, file))); + const code = await mod.default({ args, pretty: args.pretty }); + if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`); + return null; + }); +} + const commands = { /** Полный прогон проверок. */ async check() { @@ -80,7 +91,8 @@ return null; }) }, { name: 'maps:fresh', run: mapsFresh }, - { name: 'agent:invariants', run: agentInvariants } + { name: 'agent:invariants', run: agentInvariants }, + { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/tools/checks/transitions.mjs b/tools/checks/transitions.mjs new file mode 100644 index 0000000..47b74a4 --- /dev/null +++ b/tools/checks/transitions.mjs @@ -0,0 +1,64 @@ +/** + * Сценарий transitions — единый механизм переходов. + * 1) step-переход: пешком в Звенец -> area==='zvenets'. + * 2) клик-переход: клик по колодцу у дома Ирвина -> телепорт на луга (entry (22,21)). + * 3) обратный колодец: клик по луговому колодцу -> возврат в Звенец. + * 4) снапшот перечисляет переходы области с триггерами. + * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('transitions'); + const server = await startDevServer(); + let ctx; + try { + await c.run('step-переход: луга -> Звенец пешком', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + // Спим врагов, чтобы путь был безопасным; идём к тропе (26,14). + await ctx.agent.command('scene:sleepAll'); + const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(walked, 'walkTo(26,14) не дошёл'); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + c.expect(w.ok, 'после тропы не в Звенце', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('клик-переход: колодец Звенца -> луга', async () => { + // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(9, 6); + const w = await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 22 && s.hero.tile.y === 21, 'герой не на entry (22,21)', s.hero.tile); + return null; + }); + await c.run('обратный колодец: луга -> Звенец', async () => { + // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. + await ctx.agent.tapTile(22, 20); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 9 && s.hero.tile.y === 7, 'герой не на entry (9,7) Звенца', s.hero.tile); + return null; + }); + await c.run('снапшот: переходы области с триггерами', async () => { + const s = await ctx.agent.snapshot(); + const wells = (s.transitions ?? []).filter((t) => t.trigger === 'click'); + c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); + c.expect( + (s.transitions ?? []).every((t) => typeof t.to === 'string' && t.to.length > 0), + 'переход без цели', + s.transitions + ); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 2eeca0d..5b068f4 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -239,6 +239,74 @@ } /** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ +/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ +function floorTile() { + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, 'T1'); + const r = rng(808); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 32; x++) { + if (!c.get(x, y)) continue; + // Доски «полосами» по экранному Y + случайная щепка. + const seam = y % 5 === 0; + c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1'); + } + } + return c; +} + +/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ +function wallTile() { + const c = new Canvas(32, 32); + const r = rng(909); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0; + c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + } + // Нижний ряд темнее (тень у земли). + for (let x = 0; x < 32; x++) { + c.set(x, 30, 'C0'); + c.set(x, 31, 'C0'); + } + return c; +} + +/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ +function wellTile() { + const c = new Canvas(32, 40); + const r = rng(1010); + + // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. + for (let y = 20; y <= 26; y++) { + const half = Math.floor((26 - y) * 1.8) + 2; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0'); + } + // Каменная кладка вокруг жерла (эллипс). + for (let y = 16; y <= 28; y++) { + const half = Math.floor((24 - y) * 1.35) + 3; + for (let dx = -half; dx <= half; dx++) { + const rim = Math.abs(dx) > half - 2; + c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y)); + } + } + // Бронзовый обруч по кромке — тёплый: колодец «звонный». + for (let x = 6; x <= 25; x++) { + c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1'); + c.set(x, 16, 'B0'); + } + // Ворото с бронзовой рукоятью. + for (let y = 4; y <= 15; y++) { + c.set(7, y, 'T1'); + c.set(24, y, 'T1'); + } + for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1'); + c.set(12, 4, 'B1'); + c.set(13, 4, 'B2'); // блик бронзы + return c; +} + function towerTile() { const c = new Canvas(32, 80); const r = rng(1414); @@ -525,6 +593,9 @@ save(treeTile(), 'tiles/tree.png'); save(houseTile(), 'tiles/house.png'); save(towerTile(), 'tiles/tower.png'); +save(floorTile(), 'tiles/floor.png'); +save(wallTile(), 'tiles/wall.png'); +save(wellTile(), 'tiles/well.png'); console.log('Генерация персонажей:'); save(heroDown1, 'chars/hero_down_1.png');