diff --git a/apps/game/assets/chars/clint.png b/apps/game/assets/chars/clint.png new file mode 100644 index 0000000..e540b1a --- /dev/null +++ b/apps/game/assets/chars/clint.png Binary files differ diff --git a/apps/game/assets/chars/npcs_sheet.json b/apps/game/assets/chars/npcs_sheet.json index ac671e2..bf57c26 100644 --- a/apps/game/assets/chars/npcs_sheet.json +++ b/apps/game/assets/chars/npcs_sheet.json @@ -55,12 +55,40 @@ "w": 16, "h": 24 } + }, + "clint_1": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "clint_2": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } } }, "meta": { "image": "npcs_sheet.png", "size": { - "w": 64, + "w": 96, "h": 24 }, "scale": 1 @@ -73,6 +101,10 @@ "trader_mila_idle": [ "trader_mila_1", "trader_mila_2" + ], + "clint_idle": [ + "clint_1", + "clint_2" ] } } \ No newline at end of file diff --git a/apps/game/assets/chars/npcs_sheet.png b/apps/game/assets/chars/npcs_sheet.png index 8c86936..dbaa542 100644 --- a/apps/game/assets/chars/npcs_sheet.png +++ b/apps/game/assets/chars/npcs_sheet.png Binary files differ diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index b83d003..fd6d35a 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -5,13 +5,15 @@ import traderFirst from './dialogues/trader_first.json'; import traderRepeat from './dialogues/trader_repeat.json'; import traderAfter from './dialogues/trader_after.json'; +import clintFirst from './dialogues/clint_first.json'; +import clintRepeat from './dialogues/clint_repeat.json'; /** - * Диалоги NPC как графы (по docs/world.md, акт 1). Источник истины — JSON + * Диалоги NPC как графы (по docs/world.md, акты 1–2). Источник истины — JSON * в data/dialogues/ (формат 4 пробела + \n): их правит и визуальный редактор, * и человек руками. Флаги/вары/предметы — строками, сверяются с реестрами * validate.ts (checkGraph). Мила — безгласная: шёпотом, коротко. - * Ирвин — экономит дыхание. + * Ирвин — экономит дыхание. Клинт — ворчит и чинит. */ export const DIALOGUES: Record = { elder_first: elderFirst as unknown as DialogueGraph, @@ -19,5 +21,7 @@ elder_hand_in: elderHandIn as unknown as DialogueGraph, trader_first: traderFirst as unknown as DialogueGraph, trader_repeat: traderRepeat as unknown as DialogueGraph, - trader_after: traderAfter as unknown as DialogueGraph + trader_after: traderAfter as unknown as DialogueGraph, + clint_first: clintFirst as unknown as DialogueGraph, + clint_repeat: clintRepeat as unknown as DialogueGraph }; \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_first.json b/apps/game/src/data/dialogues/clint_first.json new file mode 100644 index 0000000..b57dba0 --- /dev/null +++ b/apps/game/src/data/dialogues/clint_first.json @@ -0,0 +1,35 @@ +{ + "start": "grunt", + "nodes": { + "grunt": { + "speaker": "Клинт", + "text": "*не поднимая головы* Не звенит. Ржавый весь. Уходи.", + "next": "squint" + }, + "squint": { + "speaker": "Клинт", + "text": "*прищуривается* Звонарь?... От Ирвина? Того, что книгу ведёт?", + "next": "half_tone" + }, + "half_tone": { + "speaker": "Клинт", + "text": "Скажи ему: колокол башни — на пол-тона ниже, чем в книге. Книге веры нет, если уши молчат.", + "next": "work" + }, + "work": { + "speaker": "Клинт", + "text": "А у меня тут своя беда: резонатор ржавый стоит. Молчит, скотина, третий месяц.", + "setFlags": ["met_clint"], + "next": "offer" + }, + "offer": { + "speaker": "Клинт", + "text": "Постучишь, если доживу до починки? Звон любит терпеливых.", + "next": "reply" + }, + "reply": { + "speaker": "Звонарь", + "text": "*кивает*" + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_repeat.json b/apps/game/src/data/dialogues/clint_repeat.json new file mode 100644 index 0000000..da52a55 --- /dev/null +++ b/apps/game/src/data/dialogues/clint_repeat.json @@ -0,0 +1,20 @@ +{ + "start": "grumble", + "nodes": { + "grumble": { + "speaker": "Клинт", + "text": "Опять ты. Хорошо. Слушай: у Машины есть голос — низкий, через пепел. Все её боятся, а зря.", + "next": "hint" + }, + "hint": { + "speaker": "Клинт", + "text": "Дышит она в такт. Кто найдёт верный звон — уложит спать, как ребёнка.", + "next": "half_tone" + }, + "half_tone": { + "speaker": "Клинт", + "text": "И про пол-тона не забудь. Книга врёт, уши — нет.", + "end": true + } + } +} \ No newline at end of file diff --git a/apps/game/src/data/dialogues/trader_after.json b/apps/game/src/data/dialogues/trader_after.json index 62d291e..9943f91 100644 --- a/apps/game/src/data/dialogues/trader_after.json +++ b/apps/game/src/data/dialogues/trader_after.json @@ -4,6 +4,7 @@ "listen": { "speaker": "Торговка Мила", "text": "*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.", + "setFlags": ["act2_hook"], "end": true } } diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 72f22f0..209ff7d 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -27,7 +27,11 @@ /** Прочитана записка в доме Ирвина (интерактив note_elder). */ read_note: 'read_note', /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ - hint_bells: 'hint_bells' + hint_bells: 'hint_bells', + /** Акт 2 начался: Мила указала на разъезд («Машина дышит», trader_after). */ + act2_hook: 'act2_hook', + /** Познакомились с Клинтом (диалог clint_first). */ + met_clint: 'met_clint' } as const; export type FlagId = keyof typeof FLAGS; diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index 5bbd626..726f313 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -49,4 +49,44 @@ export const NPCS: NpcDef[] = NPCS_RAW.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } -})); \ No newline at end of file +})); + +/** + * Размещения NPC по флагу: персонаж появляется в области, когда флаг сюжета + * поднят (и пропадает, если сценарий его снимет). Так акт 2 заселяет рощу + * только после крючка Милы — контент не живёт вне своей главы. + */ +export interface NpcPlacement { + /** id области (из locations.ts — строкой, чтобы не зациклить импорт). */ + area: string; + /** Флаг, при котором NPC стоит на карте. */ + flag: FlagId; + def: NpcDef; +} + +/** Клинт из raw-определения (общий с NPCS тайловый мост). */ +function npcFrom(d: Omit): NpcDef { + return { ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } }; +} + +export const NPC_PLACEMENTS: NpcPlacement[] = [ + { + // Кузнец-починщик стоит у своего резонатора в роще после крючка Милы. + area: 'rust_grove', + flag: FLAGS.act2_hook, + def: npcFrom({ + id: 'clint', + name: 'Клинт', + pos: { x: 11.5, y: 9.5 }, + sprite: 'clint', + dialogueFirst: 'clint_first', + dialogueRepeat: 'clint_repeat', + flagKey: FLAGS.met_clint + }) + } +]; + +/** NPC области: статичные (area.npcs) + размещения по поднятым флагам. */ +export function npcsForArea(area: string, hasFlag: (f: FlagId) => boolean): NpcDef[] { + return NPC_PLACEMENTS.filter((p) => p.area === area && hasFlag(p.flag)).map((p) => p.def); +} \ No newline at end of file diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index dde283b..51dab4e 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -8,7 +8,7 @@ type DialogueGraph } from '@rpg/engine'; import { AREAS } from './locations'; -import { NPCS } from './npcs'; +import { NPCS, NPC_PLACEMENTS } from './npcs'; import { DIALOGUES } from './dialogues'; import { ENEMY_KINDS } from './enemies'; import { QUESTS } from './quests'; @@ -49,7 +49,7 @@ return checkGraph(id, graph, graphRefs()); } -/** NPC: не в стенах, в границах своей области. */ +/** NPC: не в стенах, в границах своей области (включая размещения по флагам). */ export function validateNpcs(maps: Map): Invariant[] { const out: Invariant[] = []; for (const npc of NPCS) { @@ -65,6 +65,17 @@ out.push(...check); } } + // Размещения: тайл своей области должен быть проходим всегда — флаг может + // подняться в любой момент, NPC не должен появиться в стене. + for (const p of NPC_PLACEMENTS) { + const map = maps.get(p.area); + if (!map) continue; + const grid = gridOf(map); + out.push(...mergeInvariants( + checkBounds(`NPC ${p.def.id} (placement)`, p.def.tile, map.width, map.height, WHERE), + checkWalkable(`NPC ${p.def.id} (placement)`, p.def.tile, grid, WHERE) + )); + } return out; } @@ -249,7 +260,7 @@ } } } - for (const npc of NPCS) { + for (const npc of [...NPCS, ...NPC_PLACEMENTS.map((p) => p.def)]) { checkFlag(npc.flagKey, `${WHERE}/npc/${npc.id}`, 'flagKey'); if (!(npc.dialogueFirst in DIALOGUES)) { out.push({ id: 'dialogue-ref', severity: 'error', message: `NPC ${npc.id}: dialogueFirst «${npc.dialogueFirst}» не существует`, where: `${WHERE}/npc/${npc.id}` }); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index d914a1c..9425ed4 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -36,6 +36,7 @@ import { InteractionRouter, resolvePixelTile } from '../systems/ClickRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; +import { npcsForArea } from '../data/npcs'; import { ActorWalker } from '../systems/cutscene/CutsceneActors'; import { DIALOGUES } from '../data/dialogues'; import { questDialogueFor } from '../data/quests'; @@ -209,10 +210,14 @@ addFx: (u) => this.game.engine.fx.add(u), fx: this.fx }); - for (const def of area.npcs) { + // NPC области: статичные из контента + размещения по флагам (акт 2). + // Снапшот/клики читают this.npcs — размещения подхватываются сами. + const placedNpcs = [...area.npcs, ...npcsForArea(area.id, (f) => this.game.state.hasFlag(f))]; + for (const def of placedNpcs) { const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty)); this.actors.add(made.view, def.tile.x, def.tile.y); this.npcs.push({ def, view: made.view, body: made.body }); + if (!area.npcs.includes(def)) this.objects.addNpc(def); } // Интерактивные объекты: те же вьюхи в actors-слое, реакции — через Interactables. diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts index 767cf1c..ca8b3f5 100644 --- a/apps/game/src/systems/SceneObjects.ts +++ b/apps/game/src/systems/SceneObjects.ts @@ -52,6 +52,21 @@ return (this.registry.at(x, y, 'npc')?.ref as NpcDef | undefined) ?? null; } + /** + * Дополнительный NPC после создания сцены — размещения по флагам (акт 2): + * попадает и в клик-роутинг, и в кэш занятых тайлов (если тот уже построен). + */ + addNpc(def: NpcDef): void { + this.registry.add({ + id: `npc:${def.id}`, + kind: 'npc', + pos: tileToWorld(def.tile.x, def.tile.y), + radius: NPC_RADIUS, + ref: def + }); + this.npcTiles?.add(def.tile.y * 4096 + def.tile.x); + } + /** Интерактивный объект на тайле (клик-роутинг). */ interactableAt(x: number, y: number): InteractableDef | null { return (this.registry.at(x, y, 'interactable')?.ref as InteractableDef | undefined) ?? null; diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs index d9fb1df..747cbbc 100644 --- a/apps/game/tools/checks/interact.mjs +++ b/apps/game/tools/checks/interact.mjs @@ -5,7 +5,9 @@ * 3) очаг: реакция зависит от флага quest_bells_done; * 4) записка (once): флаг read_note; * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; - * 6) лавка Милы: прилавок меняет реакцию с флагом. + * 6) лавка Милы: прилавок меняет реакцию с флагом; + * 7) размещение NPC по флагу: act2_hook -> Клинт появляется в Ржавой роще, + * клик открывает его диалог. * Запуск: node tools/agent.mjs run tools/checks/interact.mjs */ import { withChecks } from '../lib.mjs'; @@ -122,6 +124,30 @@ c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); return null; }); + await c.run('размещение NPC по флагу: Клинт в роще', async () => { + // Выходим из лавки обратно в Звенец (проём (5,8)). + await t.ctx.agent.tapTile(5, 8); + const back = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(back.ok, 'не вернулись из лавки в Звенец', { area: back.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // Флаг-крючок акта 2: после него Клинт стоит в Ржавой роще. + await t.ctx.agent.command('scene:setFlag', { flag: 'act2_hook' }); + // Роща: Звенец (26,10) -> роща (2,10). + await t.goToArea('rust_grove', { x: 26, y: 10 }); + await t.sleepEnemies(); + const s = await t.ctx.agent.snapshot(); + const clint = (s.npcs ?? []).find((n) => n.id === 'clint'); + c.expect(clint !== undefined, 'Клинта нет в роще после act2_hook', s.npcs); + if (!clint) return null; + // Клик по Клинту — сцена подводит героя и открывает диалог + // (путь через рощу небыстрый — таймаут с запасом). + await t.ctx.agent.tapTile(clint.tile.x, clint.tile.y); + const d = await t.ctx.agent.runDialogue(2400); + c.expect(d, 'диалог Клинта не открылся'); + const s2 = await t.ctx.agent.snapshot(); + c.expect((s2.flags ?? []).includes('met_clint'), 'диалог не поднял met_clint', s2.flags); + return null; + }); }, { pretty } ); diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index 1bf4905..0d3a240 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -858,6 +858,35 @@ '................' ], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); +// Клинт, кузнец-починщик (акт 2): кожаный фартук T0/T1, ржавые пятна R1, +// руки мастера S0/S1 — ворчун с молотком. +const clint = fromAscii([ + '................', + '.....oooooo.....', + '....oPPPPPPo....', + '...oPpPPPpPo....', + '...oPPPPPPPo....', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oTTTTo.....', + '....oTtTTtTo....', + '...oTTTTTTTTo...', + '...oTtTTTTtTo...', + '...oTTTtTTTTo...', + '...oTTTTTTTToB..', + '...oTTTTTTTTo...', + '...oTtTTTTtTo...', + '....oTo..oTo....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +], { o: 'P0', P: 'P2', p: 'P1', S: 'S1', s: 'S0', T: 'T0', t: 'T1', B: 'B0' }); + // ---------- вывод ---------- mkdirSync(OUT + 'tiles', { recursive: true }); @@ -904,6 +933,7 @@ save(heroSide2, 'chars/hero_side_2.png'); save(elderIrwin, 'chars/elder_irwin.png'); save(traderMila, 'chars/trader_mila.png'); +save(clint, 'chars/clint.png'); // ---------- безгласный олень (пейзажная фауна) ---------- // Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. @@ -1164,16 +1194,18 @@ new Uint8Array(elderIrwin.data), bobFrame(new Uint8Array(elderIrwin.data), FRAME_W, FRAME_H), new Uint8Array(traderMila.data), - bobFrame(new Uint8Array(traderMila.data), FRAME_W, FRAME_H) + bobFrame(new Uint8Array(traderMila.data), FRAME_W, FRAME_H), + new Uint8Array(clint.data), + bobFrame(new Uint8Array(clint.data), FRAME_W, FRAME_H) ], - ['elder_irwin_1', 'elder_irwin_2', 'trader_mila_1', 'trader_mila_2'], + ['elder_irwin_1', 'elder_irwin_2', 'trader_mila_1', 'trader_mila_2', 'clint_1', 'clint_2'], { frameW: FRAME_W, frameH: FRAME_H, image: 'npcs_sheet.png', - animations: { elder_irwin_idle: [0, 1], trader_mila_idle: [2, 3] } + animations: { elder_irwin_idle: [0, 1], trader_mila_idle: [2, 3], clint_idle: [4, 5] } } ); writeFileSync(OUT + 'chars/npcs_sheet.png', npcSheet.png); writeFileSync(OUT + 'chars/npcs_sheet.json', JSON.stringify(npcSheet.json, null, 2)); -console.log(` chars/npcs_sheet.png (+ .json): ${FRAME_W * 4}x${FRAME_H}`); +console.log(` chars/npcs_sheet.png (+ .json): ${FRAME_W * 6}x${FRAME_H}`); console.log('Готово: ' + OUT); \ No newline at end of file