diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index addfc57..be34ebf 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -11,7 +11,7 @@ constructor(private game: Game, private onBack: () => void) { const panel = new Panel({ width: 280, height: 180 }); - panel.position.set((480 - 280) / 2, (270 - 180) / 2); + panel.position.set((Game.VIRTUAL_W - 280) / 2, (Game.VIRTUAL_H - 180) / 2); const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a }); title.anchor.set(0.5); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b2e95e5..b3e37ff 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -233,8 +233,8 @@ this.game.renderer.worldRoot.addChild(this.chargeRing); this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { - width: 480, - height: 270, + width: Game.VIRTUAL_W, + height: Game.VIRTUAL_H, margin: 8 }); this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); @@ -330,7 +330,7 @@ } // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). - this.fog = new Graphics().rect(0, 0, 480, 270).fill(0x5a6a78); + this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78); this.fog.alpha = 0; this.game.renderer.uiRoot.addChildAt(this.fog, 0); @@ -360,7 +360,7 @@ this.game.renderer.uiRoot.addChild(this.hint); // Тач-джойстик: перехватывает касания (мышь проходит насквозь). - this.joystick = new VirtualJoystick({ screen: { width: 480, height: 270 } }); + this.joystick = new VirtualJoystick({ screen: { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H } }); this.joystick.eventMode = 'none'; // включается при первом касании this.game.renderer.uiRoot.addChild(this.joystick); @@ -860,10 +860,12 @@ this.game.engine.fx.add(animator); view.addChild(sprite); - // Маркер «с ним можно говорить»: восклицательный штрих. + // Маркер «с ним можно говорить»: восклицательный штрих над головой + // (высота — от реального кадра атласа, не от захардкоженной). + const frameH = idle[0]!.height; const marker = new Graphics(); - marker.rect(-1, -27, 2, 4).fill(0xf0d878); - marker.rect(-1, -21, 2, 2).fill(0xf0d878); + marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878); + marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878); view.addChild(marker); return view; } diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index 0ec420b..074978c 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -1,5 +1,5 @@ import { Container, MenuList, Panel, PixelText, type Scene } from '@rpg/engine'; -import type { Game } from '../Game'; +import { Game } from '../Game'; import type { SaveData } from './MenuScene'; /** @@ -16,7 +16,7 @@ private onBack: () => void ) { const panel = new Panel({ width: 240, height: 150 }); - panel.position.set((480 - 240) / 2, (270 - 150) / 2); + panel.position.set((Game.VIRTUAL_W - 240) / 2, (Game.VIRTUAL_H - 150) / 2); const title = new PixelText({ text: 'СЕЙВЫ', size: 14, color: 0xd8c79a }); title.anchor.set(0.5); diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts index 04a1a3d..5d944f9 100644 --- a/apps/game/src/scenes/SettingsScene.ts +++ b/apps/game/src/scenes/SettingsScene.ts @@ -6,7 +6,7 @@ type Scene, type SettingsData } from '@rpg/engine'; -import type { Game } from '../Game'; +import { Game } from '../Game'; /** * Настройки: громкости шин аудио. Панель поверх вызывающей сцены (push/pop), @@ -22,7 +22,7 @@ private onBack: () => void ) { const panel = new Panel({ width: 220, height: 130 }); - panel.position.set((480 - 220) / 2, (270 - 130) / 2); + panel.position.set((Game.VIRTUAL_W - 220) / 2, (Game.VIRTUAL_H - 130) / 2); const title = new PixelText({ text: 'НАСТРОЙКИ', size: 14, color: 0xd8c79a }); title.anchor.set(0.5); diff --git a/apps/game/tools/aiart/lint-art.mjs b/apps/game/tools/aiart/lint-art.mjs index 80c0cd1..b835554 100644 --- a/apps/game/tools/aiart/lint-art.mjs +++ b/apps/game/tools/aiart/lint-art.mjs @@ -11,6 +11,7 @@ import { readFileSync } from 'node:fs'; import { decodePng } from '@rpg/engine/tools/png.mjs'; import { PALETTE } from '../pixelart/palette.mjs'; +import { SLOTS } from '../pixelart/sizes.mjs'; /** Палитра по имени: "game" — 32 цвета игры; иначе JSON-файл с массивом hex. */ function loadPalette(name) { @@ -25,9 +26,6 @@ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } -/** Известные слоты спрайтов (арт-библия + tall-объекты и комочки gen.mjs). */ -const SLOTS = new Set(['14x12', '14x14', '16x16', '16x24', '20x20', '32x16', '32x32', '32x40', '32x48', '32x56', '32x80', '48x48', '64x80', '96x96', '128x112']); - /** Все непрозрачные пиксели — из палитры? Возвращает список нарушений. */ function checkPalette(img, pal) { const set = new Set(pal.map((c) => c.join(','))); diff --git a/apps/game/tools/pixelart/__tests__/sizes.test.ts b/apps/game/tools/pixelart/__tests__/sizes.test.ts new file mode 100644 index 0000000..2c180e7 --- /dev/null +++ b/apps/game/tools/pixelart/__tests__/sizes.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { SIZES, SLOTS } from '../sizes.mjs'; + +/** + * Реестр размеров (sizes.mjs) — единственный источник размеров спрайтов + * для gen.mjs и lint-art.mjs. Здесь проверяется сам реестр и его + * согласованность с фактическими ассетами (манифест AI-атласов). + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const dim = (s: { w: number } | number[]) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); + +describe('реестр размеров спрайтов', () => { + it('все группы дают валидные слоты «ширина x высота»', () => { + for (const group of Object.values(SIZES)) { + const sizes = 'w' in group ? [group] : Object.values(group); + for (const s of sizes) { + expect(dim(s as never)).toMatch(/^\d+x\d+$/); + } + } + }); + + it('базовые слоты арт-библии на месте', () => { + for (const slot of ['32x16', '16x24', '20x20', '32x48', '32x56', '32x80', '64x80']) { + expect(SLOTS.has(slot), slot).toBe(true); + } + }); + + it('frameSize манифеста AI-атласов входит в слоты', () => { + const manifest = JSON.parse( + readFileSync(resolve(here, '../../aiart/atlas-manifest.json'), 'utf8') + ) as Record; + for (const [id, entry] of Object.entries(manifest)) { + const fs = entry.frameSize; + if (!fs) continue; + expect(SLOTS.has(`${fs[0]}x${fs[1]}`), id).toBe(true); + } + }); +}); \ No newline at end of file diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index f70abbd..1ef1aaf 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -9,6 +9,7 @@ import { buildAtlas } from '@rpg/engine/tools/atlas.mjs'; import { bobFrame } from '@rpg/engine/tools/frames.mjs'; import { RGBA } from './palette.mjs'; +import { SIZES } from './sizes.mjs'; /** Канвас с палитрой игры (ключи цветов — из palette.mjs). */ class Canvas extends EngineCanvas { @@ -24,8 +25,8 @@ /** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ function tileBase(base, light, dark, seed, specks) { - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, base); + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, base); const r = rng(seed); for (let i = 0; i < specks; i++) { const x = Math.floor(r() * 32); @@ -85,8 +86,8 @@ function ashTile() { // Пепельный бугор: чуть светлее травы, тоже без обводки. - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, 'P3'); + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'P3'); const r = rng(9); for (let i = 0; i < 6; i++) { const x = 3 + Math.floor(r() * 26); @@ -116,7 +117,7 @@ /** Дерево Ржавой рощи 32x48: ржавый ствол, редкая серо-оливковая крона, кабель. */ function treeTile() { - const c = new Canvas(32, 48); + const c = new Canvas(...SIZES.tall.tree); const r = rng(2026); const ellipse = (cx, cy, rx, ry, color) => { @@ -187,7 +188,7 @@ /** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */ function houseTile() { - const c = new Canvas(32, 56); + const c = new Canvas(...SIZES.tall.house); const r = rng(777); // Стены: вагонные щиты — горизонтальные доски C1 со швами C0. @@ -256,7 +257,7 @@ * фундамент из рельсов. Якорь — низ-центр footprint'а (ставит движок, prop). */ function bigHouseTile() { - const c = new Canvas(64, 80); + const c = new Canvas(...SIZES.prop.houseBig); const r = rng(4242); const planks = (x0, x1, y0, y1) => { for (let y = y0; y <= y1; y++) { @@ -304,8 +305,8 @@ /** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ /** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ function floorTile() { - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, 'T1'); + const c = new Canvas(SIZES.tile.w, SIZES.tile.h); + fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'T1'); const r = rng(808); for (let y = 0; y < 16; y++) { for (let x = 0; x < 32; x++) { @@ -320,7 +321,7 @@ /** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ function wallTile() { - const c = new Canvas(32, 32); + const c = new Canvas(...SIZES.tall.wall); const r = rng(909); for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { @@ -338,7 +339,7 @@ /** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ function wellTile() { - const c = new Canvas(32, 40); + const c = new Canvas(...SIZES.tall.well); const r = rng(1010); // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. @@ -371,7 +372,7 @@ } function towerTile() { - const c = new Canvas(32, 80); + const c = new Canvas(...SIZES.tall.tower); const r = rng(1414); // Бак: цилиндр из полос металла. @@ -736,9 +737,9 @@ 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 FRAME_W_FAUNA = SIZES.fauna.w; +const FRAME_H_FAUNA = SIZES.fauna.h; const faunaSheet = buildAtlas( [deerStand, deerWalk].map((c) => new Uint8Array(c.data)), ['fauna_deer_1', 'fauna_deer_2'], @@ -876,9 +877,9 @@ save(heavy1, 'chars/clump_heavy_1.png'); save(heavy2, 'chars/clump_heavy_2.png'); -// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку 20x20) -const CLUMP_W = 20; -const CLUMP_H = 20; +// атлас сгустков (кадры разной ширины — вписаны по низу-центру в ячейку) +const CLUMP_W = SIZES.clump.w; +const CLUMP_H = SIZES.clump.h; /** Canvas → кадр w×h×4 с выравниванием по низу-центру. */ const toClumpFrame = (c) => { const out = new Uint8Array(CLUMP_W * CLUMP_H * 4); @@ -906,8 +907,8 @@ // ---------- атлас героя (Spritesheet: один PNG + JSON + animations) ---------- -const FRAME_W = 16; -const FRAME_H = 24; +const FRAME_W = SIZES.char.w; +const FRAME_H = SIZES.char.h; const heroSheet = buildAtlas( [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2].map((c) => new Uint8Array(c.data)), ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2'], diff --git a/apps/game/tools/pixelart/sizes.mjs b/apps/game/tools/pixelart/sizes.mjs new file mode 100644 index 0000000..6bcc911 --- /dev/null +++ b/apps/game/tools/pixelart/sizes.mjs @@ -0,0 +1,47 @@ +/** + * Реестр размеров спрайтов игры — единственный источник истины для + * генераторов (gen.mjs), линтера (aiart/lint-art.mjs) и aiart-тулз. + * Таблица размеров в docs/art-style.md ссылается сюда (§2). + * + * Рантайм размеры не отсюда: движок читает их из атласного JSON + * (`AssetLoader.frameSize`) — тулзовый реестр движок не знает. + */ +export const SIZES = { + /** Тайл-ромб (изометрия 2:1). */ + tile: { w: 32, h: 16 }, + /** Высокие тайлы: спрайт якорится низом в центр ромба. */ + tall: { + wall: [32, 32], + well: [32, 40], + tree: [32, 48], + house: [32, 56], + tower: [32, 80] + }, + /** Крупные пропы. */ + prop: { + houseBig: [64, 80] + }, + /** Кадры героя и NPC (общая сетка атласов chars/*_sheet). */ + char: { w: 16, h: 24 }, + /** Кадры фауны (та же сетка, что у героя). */ + fauna: { w: 16, h: 24 }, + /** Кадры сгустков (враги демо-боя; рисунки вписаны по низу-центру). */ + clump: { w: 20, h: 20 }, + /** Спрайты декора поверх тайлов (микро-кусты, камни, проплешины). */ + decor: { w: 16, h: 16 } +}; + +/** Слоты вне реестра: рукодельные кадры сгустков и крупные AI-ассеты. */ +const EXTRA_SLOTS = ['14x12', '14x14', '48x48', '96x96', '128x112']; + +const dim = (s) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); + +/** Все размеры группы: группа — либо один размер {w,h}, либо карта имён → размер. */ +const groupDims = (group) => + 'w' in group ? [group] : Object.values(group); + +/** Все допустимые слоты линтера: реестр + исключения. */ +export const SLOTS = new Set([ + ...Object.values(SIZES).flatMap((group) => groupDims(group).map(dim)), + ...EXTRA_SLOTS +]); \ No newline at end of file diff --git a/docs/art-style.md b/docs/art-style.md index d336aa8..4a674e6 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -77,6 +77,9 @@ ## 2. Формат и размеры +Источник номиналов — реестр `apps/game/tools/pixelart/sizes.mjs` (его читают +генераторы и линтер; рантайм берёт размер кадра из атласа — `AssetLoader.frameSize`). + - Тайл: ромб **32×16** (движок, `DEFAULT_ISO`). - Персонаж: **16×24**, якорь — центр между ступней (низ спрайта = центр ромба тайла). - Дерево (высокий объект): **32×48**, якорь — центр ствола у земли. diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index 83a24a7..a2a9262 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -31,6 +31,9 @@ - 32 цвета, тёмная холодная база; **тёплые цвета — только живое** (герой, NPC, колокольчики, огонь). Это правило сеттинга «Пепельных лугов» — см. `docs/art-style.md`. - Размеры: тайл 32×16 (изометрия 2:1), персонаж ~16×24, высокое дерево 32×48. + Номиналы — в реестре `apps/game/tools/pixelart/sizes.mjs` (его импортируют + `gen.mjs` и линтер `aiart/lint-art.mjs` — новые размеры добавляются в одном месте); + рантайм размер кадра читает из атласного JSON (`AssetLoader.frameSize`). - Пиксель перо = 1 виртуальный пиксель; никаких сглаженных краёв — контуры ступенчатые. ## ASCII-спрайты diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index ddc22f8..6f4ca6d 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -32,6 +32,17 @@ см. [art-pipeline.md](art-pipeline.md)); готовые списки удобно отдавать в `SpriteAnimator` (`anim.md`). Пример генерации атласа — в [art-pipeline.md](art-pipeline.md). +Размер кадра в рантайме — из атласа, не из констант: + +```ts +assets.frameSize('chars/hero_sheet.json'); // { w: 16, h: 24 } — первый кадр +assets.frameSize('chars/npcs_sheet.json', 'trader_mila_1'); // конкретный кадр (sourceSize) +``` + +Смещения над головой персонажа (маркер, полоска HP) считайте от `frameSize`, +а не от захардкоженной высоты. Источник номиналов для генераторов — +`apps/game/tools/pixelart/sizes.mjs` (см. [art-pipeline.md](art-pipeline.md)). + ## FrameAnimation ```ts diff --git a/docs/plan.md b/docs/plan.md index eedaf22..d81abca 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,6 +24,10 @@ | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | +Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся +единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт +пользы демо и добавляет второй способ рассинхрона изометрии. + Приоритет между работами A и B: **B (изображения) выше** — визуальный стиль блокирует восприятие акта 2; аудио сейчас закрывается процедурным генератором (`tools/audio/gen.mjs`) приемлемо. diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts index 8c29476..3b8ff8e 100644 --- a/packages/engine/src/assets/AssetLoader.ts +++ b/packages/engine/src/assets/AssetLoader.ts @@ -89,6 +89,19 @@ return frames; } + /** + * Размер кадра атласа из JSON (sourceSize: номинал ячейки до обрезки). + * Незнание имени кадра — размер первого кадра атласа. + */ + frameSize(key: string, frameName?: string): { w: number; h: number } { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const name = frameName ?? Object.keys(sheet.data.frames)[0]; + const frame = sheet.data.frames[name]; + if (!frame?.sourceSize) throw new Error(`В атласе ${key} нет кадра ${name} (или без sourceSize)`); + return { w: frame.sourceSize.w, h: frame.sourceSize.h }; + } + texture(key: string): Texture { const t = this.textures.get(key); if (!t) throw new Error(`Текстура не загружена: ${key}`);