diff --git a/apps/game/assets/chars/fauna_deer_1.png b/apps/game/assets/chars/fauna_deer_1.png new file mode 100644 index 0000000..c2b54be --- /dev/null +++ b/apps/game/assets/chars/fauna_deer_1.png Binary files differ diff --git a/apps/game/assets/chars/fauna_deer_2.png b/apps/game/assets/chars/fauna_deer_2.png new file mode 100644 index 0000000..8abf5ec --- /dev/null +++ b/apps/game/assets/chars/fauna_deer_2.png Binary files differ diff --git a/apps/game/assets/chars/fauna_sheet.json b/apps/game/assets/chars/fauna_sheet.json new file mode 100644 index 0000000..1cf12da --- /dev/null +++ b/apps/game/assets/chars/fauna_sheet.json @@ -0,0 +1,40 @@ +{ + "frames": { + "fauna_deer_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "fauna_deer_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "fauna_sheet.png", + "size": { + "w": 32, + "h": 24 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/apps/game/assets/chars/fauna_sheet.png b/apps/game/assets/chars/fauna_sheet.png new file mode 100644 index 0000000..e6f9f9f --- /dev/null +++ b/apps/game/assets/chars/fauna_sheet.png Binary files differ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index b57c63e..ebf2bef 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -40,6 +40,8 @@ ambience: 'ash' | 'fog'; /** Зоны наката (необязательно). */ hazards?: HazardDef[]; + /** Спавны пейзажных оленей (тайлы). */ + fauna?: { x: number; y: number }[]; } export const LOCATIONS: Record = { @@ -62,7 +64,12 @@ // Тропа в Звенец (деревня-мастерская). { tile: { x: 26, y: 14 }, to: 'zvenets', entry: { x: 3, y: 10 } } ], - ambience: 'ash' + ambience: 'ash', + // Безгласные олени у берегов прудов — подходят к звону. + fauna: [ + { x: 9, y: 18 }, + { x: 12, y: 20 } + ] }, ponds: { id: 'ponds', @@ -105,7 +112,9 @@ { x: 16, y: 4 } ] } - ] + ], + // Один олень на северо-западном берегу. + fauna: [{ x: 4, y: 6 }] }, zvenets: { id: 'zvenets', diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index 38b3624..876ce99 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -40,6 +40,7 @@ }) .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) .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)) { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 8ee9fd2..b1bf055 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -33,6 +33,7 @@ import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; +import { FaunaSystem } from '../systems/fauna/FaunaSystem'; import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; import { CombatViews } from '../systems/combat/CombatViews'; @@ -74,6 +75,8 @@ private blinkT = 0; /** Оверлей дымки (зоны наката). Виден только в низинах. */ private fog: Graphics; + /** Пейзажная фауна (безгласные олени). */ + private fauna: FaunaSystem; /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ private inHazard: HazardDef | null = null; /** Тач-джойстик (активен только для касаний). */ @@ -138,6 +141,32 @@ else this.combatViews.hitBurst(s); }); + // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). + this.fauna = new FaunaSystem( + this.actors, + this.game.engine.events, + data, + (location.fauna ?? []).map((t) => tileToWorld(t.x, t.y)) + ); + this.fauna.setBlocked(data.blocked); + this.fauna.setFrames( + this.game.assets.frames('chars/fauna_sheet.json', 'fauna_deer') as [Texture, Texture] + ); + // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона. + this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { + const s = worldToScreen(origin.x, origin.y); + const fx = ParticleEmitter.oneShot(6, { + color: 0x8a8a96, + rate: 0, + lifetime: [1.2, 2.2], + velocity: { x: [-6, 6], y: [-14, -8] }, + size: 1, + seed: ((origin.x * 13 + origin.y) | 0) || 1 + }); + fx.position.set(s.x, s.y - 8); + this.world.addChild(fx); + }); + const savedHp = this.game.state.getNumber('hp') || PLAYER_COMBAT.maxHp; this.playerCombat = new PlayerCombat(savedHp); this.healthBar = new HealthBar(); @@ -231,6 +260,7 @@ } exit(): void { + this.fauna.exit(); this.combatViews.exit(); this.ash.clear(); this.world.destroy({ children: true }); @@ -246,6 +276,7 @@ update(dt: number): void { this.ash.update(dt); + this.fauna.update(dt); const input = this.game.engine.input; if (this.dialogue.active) { // Во время диалога клик/пробел только листают реплики. diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts new file mode 100644 index 0000000..61635e3 --- /dev/null +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -0,0 +1,199 @@ +import { + Container, + Sprite, + StateMachine, + Texture, + moveTowardsW, + worldDist, + worldToScreen, + type EventBus, + type IsoDepthLayer, + type TileMapData, + type Vec2 +} from '@rpg/engine'; + +/** + * Пейзажная фауна (витрина StateMachine вне боя): безгласный олень. + * Стоит, медленно бродит по соседним тайлам; на громкий звон рядом + * подходит к источнику и стоит у колокольчиков. Не боевой. + */ + +/** Радиус слуха на звон (мировые юниты). */ +const HEAR_RADIUS = 7; +/** Скорость брожения / подхода (юниты/сек). */ +const WANDER_SPEED = 0.35; +const APPROACH_SPEED = 0.7; + +interface FaunaEntity { + /** Позиция (ноги) в мировых юнитах. */ + pos: Vec2; + machine: StateMachine; + /** Куда идём (если идём). */ + dest: Vec2 | null; + view: Container; + sprite: Sprite; + frames: [Texture, Texture]; + animT: number; + animI: number; + /** Пауза перед следующим шагом (idle). */ + pause: number; +} + +export class FaunaSystem { + private entities: FaunaEntity[] = []; + private rng: () => number; + private off: () => void; + + constructor( + private actors: IsoDepthLayer, + events: EventBus, + private data: TileMapData, + spawns: Vec2[], + seed = 20260908 + ) { + // Детерминированный ГПСЧ — брожение одинаково между запусками. + let s = seed; + this.rng = () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (const p of spawns) this.spawn(p); + // Громкий звон рядом — олень подходит к источнику и стоит у колокольчиков. + this.off = events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { + for (const e of this.entities) { + if (worldDist(e.pos, origin) <= HEAR_RADIUS) { + e.dest = { ...origin }; + e.machine.change('approach'); + } + } + }); + } + + /** Привязать кадры (вызывается сценой после конструктора). */ + setFrames(frames: [Texture, Texture]): void { + for (const e of this.entities) { + e.frames = frames; + e.sprite.texture = frames[0]; + } + } + + /** Заспавнить оленя в точке (юниты). */ + private spawn(pos: Vec2): void { + const view = new Container(); + const sprite = new Sprite(); + sprite.anchor.set(0.5, 1); + view.addChild(sprite); + const sp = worldToScreen(pos.x, pos.y); + view.position.set(sp.x, sp.y); + this.actors.add(view, Math.floor(pos.x), Math.floor(pos.y)); + + const e: FaunaEntity = { + pos: { ...pos }, + machine: new StateMachine(), + dest: null, + view, + sprite, + frames: [sprite.texture, sprite.texture], + animT: 0, + animI: 0, + pause: 0 + }; + + // Стоит: через паузу делает шаг к соседнему тайлу. + e.machine.add('idle', { + update: () => { + if (e.machine.time >= e.pause) this.startWander(e); + } + }); + // Бродит: медленно к случайной соседней точке. + e.machine.add('wander', { + update: (dt) => this.stepTo(e, dt, WANDER_SPEED, 'idle') + }); + // Подходит на звон: быстрее, к источнику звука. + e.machine.add('approach', { + update: (dt) => this.stepTo(e, dt, APPROACH_SPEED, 'idle') + }); + e.machine.change('idle'); + e.pause = 1.5 + this.rng() * 3; + this.entities.push(e); + } + + /** Начать шаг к случайной соседней проходимой точке. */ + private startWander(e: FaunaEntity): void { + const dirs = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ]; + const options = dirs + .map(([dx, dy]) => ({ x: e.pos.x + dx, y: e.pos.y + dy })) + .filter((p) => this.walkable(p)); + if (options.length === 0) { + e.pause = 2; + return; + } + e.dest = options[Math.floor(this.rng() * options.length)]!; + e.pause = 1.5 + this.rng() * 3; + e.machine.change('wander'); + } + + /** Движение к dest; по приходе — переход в idle. */ + private stepTo(e: FaunaEntity, dt: number, speed: number, back: string): void { + if (!e.dest) { + e.machine.change(back); + return; + } + e.pos = moveTowardsW(e.pos, e.dest, speed * dt); + if (e.pos.x === e.dest.x && e.pos.y === e.dest.y) { + e.dest = null; + e.machine.change(back); + } + } + + private walkable(p: Vec2): boolean { + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + if (tx < 0 || ty < 0 || tx >= this.data.width || ty >= this.data.height) return false; + return !this.blocked.includes(this.data.tiles[ty * this.data.width + tx]!); + } + + private blocked: number[] = []; + + /** Задать непроходимые id тайлов (сцена синхронизирует со своей картой). */ + setBlocked(ids: number[]): void { + this.blocked = ids; + } + + update(dt: number): void { + for (const e of this.entities) { + e.machine.update(dt); + // Покадровая анимация: 2 кадра, 3 Гц, только на ходу. + if (e.dest) { + e.animT += dt; + if (e.animT >= 0.33) { + e.animT -= 0.33; + e.animI = 1 - e.animI; + e.sprite.texture = e.frames[e.animI]!; + } + } else { + e.animI = 0; + e.sprite.texture = e.frames[0]!; + } + const sp = worldToScreen(e.pos.x, e.pos.y); + e.view.position.set(sp.x, sp.y); + this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y)); + } + } + + exit(): void { + this.off?.(); + for (const e of this.entities) { + this.actors.removeChild(e.view); + e.view.destroy({ children: true }); + } + this.entities = []; + } +} \ No newline at end of file diff --git a/docs/art-style.md b/docs/art-style.md index a7a71c9..2056221 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -118,6 +118,7 @@ | `chars/hero_side_1.png`, `hero_side_2.png` | ходьба влево | вправо — горизонтальный флип | | `chars/elder_irwin.png` | idle | длинный плащ T0/T1, борода S0, стилос | | `chars/trader_mila.png` | idle | платок W3, тележка-ручка, ручной колокольчик B1 | +| `chars/fauna_deer_1.png`, `fauna_deer_2.png` | стоит/шаг | безгласный олень: серо-оливковый G1/G2, рога M2, без тёплых пикселей | ### Интерьер/UI (позже) diff --git a/docs/demo.md b/docs/demo.md index 1882bf2..077b70d 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -82,8 +82,7 @@ ## Дорожная карта (что осталось за срезом) -- Акт 1 до конца: финальная кат-сцена с Tween-камерой к башне (зоны наката у прудов готовы: дымка, замедление, полотно-маска). -- Пейзажная фауна: безгласный олень у полян колокольчиков (мини-StateMachine вне боя), мотыли над потревоженным пеплом. +- Акт 1 до конца: финальная кат-сцена с Tween-камерой к башне (зоны наката у прудов готовы: дымка, замедление, полотно-маска; фауна готова: безгласные олени на мини-StateMachine, мотыли над потревоженным пеплом). - Экономика Милы: торговля (соль/моты), мот-песок как валюта. - Звонная книга Ирвина: журнал заданий расширяется за пределы одного квеста. - Tiled-импорт: альтернативный путь производства карт (для больших локаций). diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 1632cf2..2eeca0d 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -536,6 +536,107 @@ save(elderIrwin, 'chars/elder_irwin.png'); save(traderMila, 'chars/trader_mila.png'); +// ---------- безгласный олень (пейзажная фауна) ---------- +// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. + +const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' }; + +// Кадр 1: стоит (ноги вместе). +const deerStand = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...ooo..ooo.....', + '................', + '................', + '................' +], DEER_MAP); + +// Кадр 2: шаг (ноги врозь). +const deerWalk = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '..oGo....oGo....', + '..oGo....oGo....', + '.oGo......oGo...', + '.oGo......oGo...', + '.ooo......ooo...', + '................', + '................', + '................' +], DEER_MAP); + +save(deerStand, 'chars/fauna_deer_1.png'); +save(deerWalk, 'chars/fauna_deer_2.png'); + +// атлас фауны (кадры 16x24 — та же сетка, что у героя) +const FRAME_W_FAUNA = 16; +const FRAME_H_FAUNA = 24; +const faunaFrames = [deerStand, deerWalk]; +const faunaNames = ['fauna_deer_1', 'fauna_deer_2']; +const faunaSheetW = FRAME_W_FAUNA * faunaFrames.length; +const faunaSheetCanvas = new Canvas(faunaSheetW, FRAME_H_FAUNA); +const faunaJson = {}; +for (let i = 0; i < faunaFrames.length; i++) { + const src = faunaFrames[i]; + for (let y = 0; y < FRAME_H_FAUNA; y++) { + for (let x = 0; x < FRAME_W_FAUNA; x++) { + const rgba = src.get(x, y); + if (rgba) faunaSheetCanvas.set(i * FRAME_W_FAUNA + x, y, rgba); + } + } + faunaJson[faunaNames[i]] = { + frame: { x: i * FRAME_W_FAUNA, y: 0, w: FRAME_W_FAUNA, h: FRAME_H_FAUNA }, + rotated: false, + trimmed: false, + sourceSize: { w: FRAME_W_FAUNA, h: FRAME_H_FAUNA } + }; +} +writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheetCanvas.toPng()); +writeFileSync( + OUT + 'chars/fauna_sheet.json', + JSON.stringify( + { + frames: faunaJson, + meta: { image: 'fauna_sheet.png', size: { w: faunaSheetW, h: FRAME_H_FAUNA }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/fauna_sheet.png (+ .json): ${faunaSheetW}x${FRAME_H_FAUNA}`); + // ---------- пепельные сгустки (враги демо-боя) ---------- // Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. diff --git a/tools/probe-scene.tmp.mjs b/tools/probe-scene.tmp.mjs deleted file mode 100644 index 09ecc99..0000000 --- a/tools/probe-scene.tmp.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import puppeteer from 'puppeteer-core'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', headless: true, - 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('pageerror', (e) => console.log('[err]', e.message)); -const booted = new Promise((r) => page.on('console', (m) => m.text().includes('[boot]') && r())); -await page.goto('http://localhost:5199/', { waitUntil: 'networkidle2' }); -await booted; -await new Promise((r) => setTimeout(r, 1200)); -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); - -// Клик в тайл на 2 шага к цели по диагонали (всегда на экране), у цели — точно в неё. -async function stepToward(tx, ty) { - const p = await page.evaluate((tx, ty) => { - const g = window.__game; - const sc = g.scenes.current; - const t = sc.player.currentTile(); - const dx = Math.sign(tx - t.x); - const dy = Math.sign(ty - t.y); - const target = Math.abs(tx - t.x) <= 2 && Math.abs(ty - t.y) <= 2 ? { x: tx, y: ty } - : { x: t.x + dx * 2, y: t.y + dy * 2 }; - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - const wx = (target.x - target.y) * 16; - const wy = (target.x + target.y) * 8 + 8; - return { x: rect.left + ((wx + wr.x) / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height }; - }, tx, ty); - await page.mouse.click(p.x, p.y); -} -async function walkTo(tx, ty) { - for (let i = 0; i < 40; i++) { - const at = await page.evaluate((tx, ty) => { - const t = window.__game.scenes.current?.player?.currentTile(); - return t && t.x === tx && t.y === ty; - }, tx, ty); - if (at) return true; - await stepToward(tx, ty); - await new Promise((r) => setTimeout(r, 700)); - } - return false; -} -await walkTo(2, 2); -await new Promise((r) => setTimeout(r, 1500)); -const ok = await walkTo(4, 9); -const state = await page.evaluate(() => { - const g = window.__game; - return { loc: g.scenes.current.location.id, tile: g.scenes.current.player.currentTile(), - fog: g.scenes.current.fog?.alpha, mul: g.scenes.current.player.speedMul }; -}); -console.log(JSON.stringify(state), 'arrived:', ok); -await page.screenshot({ path: '/tmp/probe_hazard.png' }); -await browser.close();