diff --git a/apps/game/assets/chars/hero_sheet.png b/apps/game/assets/chars/hero_sheet.png index 780f7ea..b85284e 100644 --- a/apps/game/assets/chars/hero_sheet.png +++ b/apps/game/assets/chars/hero_sheet.png Binary files differ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 1b23753..b57c63e 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -1,6 +1,7 @@ import type { NpcDef } from './npcs'; import type { EnemyKindId } from './enemies'; import { NPCS } from './npcs'; +import type { ItemId } from './items'; /** * Локации демо-среза. Карты хранятся файлами assets/maps/.map @@ -16,6 +17,15 @@ entry: { x: number; y: number }; } +/** Зона опасности (накат): герой без нужного предмета еле идёт и рискует голосом. */ +export interface HazardDef { + name: string; + /** Тайлы низины (свежий пласт пепла). */ + tiles: { x: number; y: number }[]; + /** Без чего нельзя дышать здесь (по умолчанию — полотно-маска). */ + requiresItem?: ItemId; +} + export interface LocationDef { id: LocationId; name: string; @@ -28,6 +38,8 @@ exits: LocationExit[]; /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов. */ ambience: 'ash' | 'fog'; + /** Зоны наката (необязательно). */ + hazards?: HazardDef[]; } export const LOCATIONS: Record = { @@ -69,7 +81,31 @@ // Обратно на луга. { tile: { x: 2, y: 2 }, to: 'meadows', entry: { x: 3, y: 3 } } ], - ambience: 'fog' + ambience: 'fog', + // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. + hazards: [ + { + name: 'Свежий накат', + requiresItem: 'cloth', + tiles: [ + { x: 5, y: 16 }, + { x: 6, y: 17 }, + { x: 7, y: 17 }, + { x: 8, y: 18 }, + { x: 9, y: 18 }, + { x: 10, y: 18 }, + { x: 11, y: 19 }, + { x: 12, y: 19 }, + { x: 13, y: 18 }, + { x: 14, y: 18 }, + { x: 15, y: 17 }, + { x: 4, y: 9 }, + { x: 3, y: 8 }, + { x: 15, y: 6 }, + { x: 16, y: 4 } + ] + } + ] }, zvenets: { id: 'zvenets', diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 385831e..8ee9fd2 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -27,7 +27,7 @@ import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene'; import { InventoryScene } from './InventoryScene'; import { TILES } from '../data/map'; -import { locationOf, type LocationDef } from '../data/locations'; +import { locationOf, type LocationDef, type HazardDef } from '../data/locations'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; @@ -72,6 +72,10 @@ | null = null; /** Таймер мигания при неуязвимости (свой, не боевой). */ private blinkT = 0; + /** Оверлей дымки (зоны наката). Виден только в низинах. */ + private fog: Graphics; + /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ + private inHazard: HazardDef | null = null; /** Тач-джойстик (активен только для касаний). */ private joystick: VirtualJoystick; private debug: DebugOverlay; @@ -175,6 +179,11 @@ this.ash.position.set(240, 120); this.game.renderer.worldRoot.addChild(this.ash); + // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). + this.fog = new Graphics().rect(0, 0, 480, 270).fill(0x5a6a78); + this.fog.alpha = 0; + this.game.renderer.uiRoot.addChildAt(this.fog, 0); + // Название локации в правом верхнем углу. const name = new PixelText({ text: location.name, size: 11, color: 0x999988 }); name.anchor.set(1, 0); @@ -226,6 +235,7 @@ this.ash.clear(); this.world.destroy({ children: true }); this.ash.destroy(); + this.fog.destroy(); this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); this.locationLabel.destroy({ children: true }); @@ -305,6 +315,7 @@ } this.resolvePendingInteraction(); const tile = this.player.currentTile(); + this.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); this.checkExits(tile); @@ -318,6 +329,31 @@ if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); } + /** + * Зона наката под ногами: без маски герой еле идёт и рискует голосом, + * звон здесь «проверяет воздух» — пепел на секунду видно волной. + */ + private updateHazard(tile: { x: number; y: number }): void { + const hazard = + (this.location.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'); + this.player.speedMul = hazard !== null && !masked ? 0.5 : 1; + + // Вход в низину: предупреждение один раз за вход. + if (hazard !== null && this.inHazard === null) { + this.showToast( + masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.` + ); + void this.game.audio.play('sfx/ash_hiss', 0.5); + } + this.inHazard = hazard; + + // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. + 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); @@ -342,6 +378,12 @@ this.combatViews.resonanceRing(s); this.game.camera.addShake(1.5, 0.25); this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0)); + // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. + if (this.inHazard !== null) { + this.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); + this.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); + void this.game.audio.play('sfx/ash_hiss', 0.7); + } } else if (action === 'attack') { this.combat.playerConeAttack(from, this.player.dirVector); } diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 4e7315b..45783c7 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -42,6 +42,8 @@ private waypoint: Vec2 | null = null; /** Скорость в мировых юнитах/сек. */ private speed = 1.75; + /** Множитель скорости (зоны наката без маски — 0.5). Меняется сценой каждый кадр. */ + speedMul = 1; constructor( private map: IsometricTileMap, @@ -105,7 +107,7 @@ update(dt: number): void { if (!this.waypoint) return; - this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * dt); + this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt); if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { this.onStep?.(); // ступили на новый тайл this.advanceWaypoint(); @@ -137,7 +139,7 @@ // Экранная ось джойстика -> мировое направление (экранные оси — мировые диагонали). const w = screenToWorld(dir.x, dir.y); const n = worldNorm(w.x, w.y); - const step = this.speed * dt; + const step = this.speed * this.speedMul * dt; const nx = this.pos.x + n.x * step; const ny = this.pos.y + n.y * step; // Двигаемся только если новая точка внутри карты и не в стене diff --git a/docs/demo.md b/docs/demo.md index ba1ae52..1882bf2 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -82,7 +82,7 @@ ## Дорожная карта (что осталось за срезом) -- Акт 1 до конца: зоны наката у прудов (замедление, маска-полотно), финальная кат-сцена с Tween-камерой к башне. +- Акт 1 до конца: финальная кат-сцена с Tween-камерой к башне (зоны наката у прудов готовы: дымка, замедление, полотно-маска). - Пейзажная фауна: безгласный олень у полян колокольчиков (мини-StateMachine вне боя), мотыли над потревоженным пеплом. - Экономика Милы: торговля (соль/моты), мот-песок как валюта. - Звонная книга Ирвина: журнал заданий расширяется за пределы одного квеста. diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index a8fff4f..1632cf2 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -712,8 +712,13 @@ const framesJson = {}; for (let i = 0; i < heroFrames.length; i++) { const src = heroFrames[i]; - // Buffer.set: offset в байтах — кадр целиком (16*24 пикселя). - sheetCanvas.data.set(src.data, i * FRAME_W * FRAME_H * 4); + // Построчное копирование: строка атласа шире кадра, цельный set ломает шаг. + for (let y = 0; y < FRAME_H; y++) { + for (let x = 0; x < FRAME_W; x++) { + const rgba = src.get(x, y); + if (rgba) sheetCanvas.set(i * FRAME_W + x, y, rgba); + } + } framesJson[heroNames[i]] = { frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H }, rotated: false, diff --git a/tools/probe-scene.tmp.mjs b/tools/probe-scene.tmp.mjs new file mode 100644 index 0000000..09ecc99 --- /dev/null +++ b/tools/probe-scene.tmp.mjs @@ -0,0 +1,58 @@ +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();