diff --git a/.gitignore b/.gitignore index 6cb573a..0643ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,7 @@ apps/game/tools/aiart/sheets/ # Сырые AI-клипы аудио (перегенерируются gen_audioldm2.py по seed) apps/game/tools/aiaudio/review/ +# B4: сырьё и кандидаты AI-арта (в assets/ попадают вручную после апрува) +apps/game/tools/aiart/review-b4/ __pycache__/ diff --git a/apps/game/assets/tiles/bell_rope.png b/apps/game/assets/tiles/bell_rope.png new file mode 100644 index 0000000..3583885 --- /dev/null +++ b/apps/game/assets/tiles/bell_rope.png Binary files differ diff --git a/apps/game/assets/tiles/counter.png b/apps/game/assets/tiles/counter.png new file mode 100644 index 0000000..6d85c49 --- /dev/null +++ b/apps/game/assets/tiles/counter.png Binary files differ diff --git a/apps/game/assets/tiles/hearth.png b/apps/game/assets/tiles/hearth.png new file mode 100644 index 0000000..1850270 --- /dev/null +++ b/apps/game/assets/tiles/hearth.png Binary files differ diff --git a/apps/game/assets/tiles/mote.png b/apps/game/assets/tiles/mote.png new file mode 100644 index 0000000..3f7d3cf --- /dev/null +++ b/apps/game/assets/tiles/mote.png Binary files differ diff --git a/apps/game/assets/tiles/note.png b/apps/game/assets/tiles/note.png new file mode 100644 index 0000000..ea6ba26 --- /dev/null +++ b/apps/game/assets/tiles/note.png Binary files differ diff --git a/apps/game/assets/tiles/resonator.png b/apps/game/assets/tiles/resonator.png new file mode 100644 index 0000000..ff132bf --- /dev/null +++ b/apps/game/assets/tiles/resonator.png Binary files differ diff --git a/apps/game/assets/tiles/signpost.png b/apps/game/assets/tiles/signpost.png new file mode 100644 index 0000000..c6f20d8 --- /dev/null +++ b/apps/game/assets/tiles/signpost.png Binary files differ diff --git a/apps/game/assets/tiles/spit.png b/apps/game/assets/tiles/spit.png new file mode 100644 index 0000000..c571f94 --- /dev/null +++ b/apps/game/assets/tiles/spit.png Binary files differ diff --git a/apps/game/assets/tiles/tone_tree.png b/apps/game/assets/tiles/tone_tree.png new file mode 100644 index 0000000..50006e9 --- /dev/null +++ b/apps/game/assets/tiles/tone_tree.png Binary files differ diff --git a/apps/game/assets/ui/icon_bellflower.png b/apps/game/assets/ui/icon_bellflower.png new file mode 100644 index 0000000..cdad729 --- /dev/null +++ b/apps/game/assets/ui/icon_bellflower.png Binary files differ diff --git a/apps/game/assets/ui/icon_clock.png b/apps/game/assets/ui/icon_clock.png new file mode 100644 index 0000000..df5fca8 --- /dev/null +++ b/apps/game/assets/ui/icon_clock.png Binary files differ diff --git a/apps/game/assets/ui/icon_cloth.png b/apps/game/assets/ui/icon_cloth.png new file mode 100644 index 0000000..c2089d8 --- /dev/null +++ b/apps/game/assets/ui/icon_cloth.png Binary files differ diff --git a/apps/game/assets/ui/icon_flask.png b/apps/game/assets/ui/icon_flask.png new file mode 100644 index 0000000..161bc59 --- /dev/null +++ b/apps/game/assets/ui/icon_flask.png Binary files differ diff --git a/apps/game/assets/ui/icon_map.png b/apps/game/assets/ui/icon_map.png new file mode 100644 index 0000000..e1e5f23 --- /dev/null +++ b/apps/game/assets/ui/icon_map.png Binary files differ diff --git a/apps/game/assets/ui/icon_mote.png b/apps/game/assets/ui/icon_mote.png new file mode 100644 index 0000000..99aba24 --- /dev/null +++ b/apps/game/assets/ui/icon_mote.png Binary files differ diff --git a/apps/game/assets/ui/icon_salt.png b/apps/game/assets/ui/icon_salt.png new file mode 100644 index 0000000..83bb73e --- /dev/null +++ b/apps/game/assets/ui/icon_salt.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index cc7858c..6c3d598 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -98,6 +98,24 @@ 'tiles/rail', 'tiles/stairs', 'tiles/machine', + // AI-арт B4 (задача 5): интерактивы и снаряд плевка. + 'tiles/signpost', + 'tiles/mote', + 'tiles/note', + 'tiles/spit', + 'tiles/bell_rope', + 'tiles/counter', + 'tiles/resonator', + 'tiles/tone_tree', + 'tiles/hearth', + // Иконки предметов сумки 8×8 (арт-библия §4). + 'ui/icon_clock', + 'ui/icon_cloth', + 'ui/icon_bellflower', + 'ui/icon_salt', + 'ui/icon_flask', + 'ui/icon_mote', + 'ui/icon_map', 'chars/hero_lamp' ]; diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index ff7faa3..2275b2b 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -116,6 +116,7 @@ id: 'sign_meadows_east', pos: { x: 48.5, y: 27.5 }, kind: 'signpost', + view: { tex: 'tiles/signpost' }, label: 'Столб у тропы', responses: [ { @@ -128,6 +129,7 @@ id: 'sign_meadows_west', pos: { x: 2.5, y: 3.5 }, kind: 'signpost', + view: { tex: 'tiles/signpost' }, label: 'Столб у тропы', responses: [ { @@ -140,6 +142,7 @@ id: 'mote_1', pos: { x: 13.5, y: 28.5 }, kind: 'pickup', + view: { tex: 'tiles/mote' }, label: 'Пепельный мот', once: true, responses: [ @@ -155,6 +158,7 @@ id: 'mote_2', pos: { x: 16.5, y: 38.5 }, kind: 'pickup', + view: { tex: 'tiles/mote' }, label: 'Пепельный мот', once: true, responses: [ @@ -170,6 +174,7 @@ id: 'mote_3', pos: { x: 16.5, y: 20.5 }, kind: 'pickup', + view: { tex: 'tiles/mote' }, label: 'Пепельный мот', once: true, responses: [ @@ -192,6 +197,7 @@ id, pos: { x: tx + 0.5, y: ty + 0.5 }, kind: 'examine', + view: { tex: 'tiles/tone_tree' }, label: 'Дерево-механизм', responses: [ { @@ -220,6 +226,7 @@ id: 'clint_resonator', pos: { x: 24.5, y: 16.5 }, kind: 'lever', + view: { tex: 'tiles/resonator' }, label: 'Резонатор Клинта', responses: [ { @@ -239,6 +246,7 @@ id: 'sign_ponds', pos: { x: 5.5, y: 3.5 }, kind: 'signpost', + view: { tex: 'tiles/signpost' }, label: 'Столб у берега', responses: [ { @@ -253,6 +261,7 @@ id: 'sign_zvenets', pos: { x: 3.5, y: 19.5 }, kind: 'signpost', + view: { tex: 'tiles/signpost' }, label: 'Столб у входа', responses: [ { @@ -265,6 +274,7 @@ id: 'bell_rope', pos: { x: 27.5, y: 13.5 }, kind: 'lever', + view: { tex: 'tiles/bell_rope' }, label: 'Верёвка колокола', responses: [ { @@ -300,6 +310,7 @@ id: 'hearth_elder', pos: { x: 8.5, y: 0.5 }, kind: 'examine', + view: { tex: 'tiles/hearth' }, label: 'Очаг', responses: [ { @@ -314,6 +325,7 @@ id: 'note_elder', pos: { x: 10.5, y: 4.5 }, kind: 'examine', + view: { tex: 'tiles/note' }, label: 'Записка', once: true, responses: [ @@ -330,6 +342,7 @@ id: 'counter_shop', pos: { x: 5.5, y: 3.5 }, kind: 'examine', + view: { tex: 'tiles/counter' }, label: 'Прилавок', responses: [ { @@ -350,6 +363,7 @@ id: 'old_machine', pos: { x: 13.5, y: 1.5 }, kind: 'lever', + view: { tex: 'tiles/machine' }, label: 'Старая Машина', responses: [ { diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index ad8e033..fa29265 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -1,8 +1,8 @@ import { playSfx } from '../data/sfxSpecs'; -import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; +import { Container, MenuList, MenuSceneBase, Panel, PixelText, Sprite } from '@rpg/engine'; import { Game } from '../Game'; import { questLog } from '../data/quests'; -import { itemName, useItemLine } from '../data/items'; +import { itemName, useItemLine, type ItemId } from '../data/items'; import { MapScene } from './MapScene'; /** @@ -11,7 +11,21 @@ * Список сумки с курсором: Enter — применить предмет (часы показывают время, * время в сумке заморожено — локация не тикается). Журнал — текст без фокуса. */ + +/** Иконка предмета 8×8 (арт-библия §4): ui/icon_.png. */ +const ITEM_ICONS: Record = { + clock: 'ui/icon_clock', + cloth: 'ui/icon_cloth', + bellflower: 'ui/icon_bellflower', + salt: 'ui/icon_salt', + water_flask: 'ui/icon_flask', + mote: 'ui/icon_mote', + map_scroll: 'ui/icon_map' +}; export class InventoryScene extends MenuSceneBase { + /** Иконки строк сумки (синхронизируются с окном скролла). */ + private icons: Container | null = null; + constructor( private game: Game, private onBack: () => void @@ -22,6 +36,21 @@ ); } + override update(dt: number): void { + super.update(dt); + this.syncIcons(); + } + + /** Иконки следуют за окном скролла: видимость и строка — как у кнопок. */ + private syncIcons(): void { + if (!this.icons || !this.menu) return; + const win = this.menu.window ?? { first: 0, last: this.icons.children.length }; + this.icons.children.forEach((icon, i) => { + icon.visible = i >= win.first && i < win.last; + icon.position.y = 7 + (i - win.first) * 15; + }); + } + protected build(): void { const panel = new Panel({ width: 300, height: 222 }); panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); @@ -51,10 +80,21 @@ this.menu.setItems( slots.map((slot) => ({ label: slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id), + value: '', // метка прижата влево — освобождаем место под иконку onSelect: () => this.useItem(slot.id, result) })) ); panel.addChild(this.menu); + // Иконки 8×8 слева от строк: тикаются с окном скролла (syncIcons). + this.icons = new Container(); + this.icons.position.set(20, 52); + slots.forEach((slot, i) => { + const icon = new Sprite(this.game.assets.texture(ITEM_ICONS[slot.id as ItemId])); + icon.anchor.set(0.5, 0.5); + icon.position.set(-6, 7 + i * 15); + this.icons!.addChild(icon); + }); + panel.addChild(this.icons); } else { addLine('— пусто —', 0x777788, 24, 54); } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 0fc4736..5e05f63 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -304,7 +304,8 @@ this.actors, enemyTextures(this.game.assets), this.world, - this.fx + this.fx, + this.game.assets ); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow). diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts index 65bcc3c..d4303da 100644 --- a/apps/game/src/systems/combat/CombatViews.ts +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -1,12 +1,12 @@ import { Container, - Graphics, IsoDepthLayer, Sprite, SpriteAnimator, Texture, worldToTile, worldToScreen, + type AssetLoader, type Entity, type FxLayer, type Vec2 @@ -29,7 +29,7 @@ export class CombatViews { private views = new Map(); - private projectileViews = new Map(); + private projectileViews = new Map(); constructor( private combat: CombatWorld, @@ -37,7 +37,9 @@ private enemyTextures: Map, private fxRoot: Container, /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */ - private fx: FxLayer + private fx: FxLayer, + /** Ассеты: спрайт снаряда плевка (tiles/spit). */ + private assets: AssetLoader ) {} /** Спавн-вспышка пепла (взрыв частиц). */ @@ -144,12 +146,12 @@ } } - // Снаряды + // Снаряды: спрайт пепельного плевка (tiles/spit) for (const [e, pr] of this.combat.projectiles) { let pv = this.projectileViews.get(e); if (!pv) { - pv = new Graphics(); - pv.circle(0, 0, 2).fill(0x86868f); + pv = new Sprite(this.assets.texture('tiles/spit')); + pv.anchor.set(0.5); this.fxRoot.addChild(pv); this.projectileViews.set(e, pv); } diff --git a/apps/game/tools/aiart/gen_b4.py b/apps/game/tools/aiart/gen_b4.py new file mode 100644 index 0000000..ce17662 --- /dev/null +++ b/apps/game/tools/aiart/gen_b4.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""B4: батч AI-генерации кандидатов-плейсхолдеров (SD1.5, GPU fp16). + +Модель: Retro Diffusion (LostMedia/RetroDiffusion, Public Domain Mark 1.0) — +SD1.5-файнтюн пиксель-арта, слушает промпты одиночных объектов (проба 2026-09-09: +очаг/часы — отличные, мот/свиток читаются). Листовая модель B3 +(SD_PixelArt_SpriteSheet_Generator) рисует только 4 фигурки персонажей — +для объектов непригодна. RTX 3090 fp16: ~1.3 с/вид, семена бесплатны. + +Список объектов — LIST (id, промпт): интерактивы-плейсхолдеры (InteractableViews), +снаряд плевка и иконки сумки 8×8 (арт-библия §4). Слоты назначает post_b4.mjs. + +Запуск (интерпретатор — venv-aiaudio, там torch cu130): + ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_b4.py # все объекты, SEEDS семян + ... gen_b4.py signpost hearth:7 # конкретные id[:seed] +Вывод: aiart/review-b4/raw/_.png (512×512 сырьё). +""" +import sys +import time +from pathlib import Path + +import torch +from diffusers import StableDiffusionPipeline + +MODEL_ID = 'LostMedia/RetroDiffusion' +STEPS = 20 +CFG = 7.0 +SEEDS = (1, 2, 3, 4) +NEG = ('blurry, photo, realistic, smooth gradients, text, watermark, ' + 'multiple objects, cropped, character, person, ' + 'warm background, brown background, orange background, sunset lighting') + +LIST = { + # --- интерактивы-плейсхолдеры (InteractableViews) --- + 'signpost': 'pixel art of a weathered wooden road signpost with an arrow board, dark wood', + 'mote': 'pixel art of a small mound of gray ash with a glowing warm ember inside', + 'tone_tree': 'pixel art of a dead tree trunk wrapped in black cables with small copper bells, on a plain pale gray background', + 'resonator': 'pixel art of a copper bell resonator machine on a rusty steel frame, on a plain pale gray background', + 'bell_rope': 'pixel art of a hanging bell pull rope with a wooden handle grip', + 'hearth': 'pixel art of a stone hearth fireplace with a warm fire inside', + 'note': 'pixel art of a small crumpled paper note lying flat on the ground', + 'counter': 'pixel art of a wooden market stall counter with crates and goods on top, side view', + 'spit': 'pixel art of a small blob of gray ash, a projectile', + # --- иконки сумки 8×8 (items.ts) --- + 'icon_clock': 'pixel art icon of a small copper pocket watch', + 'icon_cloth': 'pixel art icon of a folded waxed cloth mask', + 'icon_bellflower': 'pixel art icon of a small blue bellflower', + 'icon_salt': 'pixel art icon of a small pouch of salt', + 'icon_flask': 'pixel art icon of a small copper water flask', + 'icon_mote': 'pixel art icon of a small warm glowing ash mote', + 'icon_map': 'pixel art icon of a rolled paper map scroll', +} + + +def main() -> None: + args = sys.argv[1:] + wanted = {a.split(':')[0] for a in args} if args else set(LIST) + seeds_of = {} + for a in args: + if ':' in a: + k, s = a.split(':', 1) + seeds_of.setdefault(k, []).extend(int(x) for x in s.split(':')) + out_dir = Path(__file__).resolve().parent / 'review-b4' / 'raw' + out_dir.mkdir(parents=True, exist_ok=True) + + t0 = time.time() + pipe = StableDiffusionPipeline.from_pretrained( + MODEL_ID, torch_dtype=torch.float16, safety_checker=None, + ).to('cuda') + pipe.set_progress_bar_config(disable=True) + print(f'[load] {time.time() - t0:.1f}s', flush=True) + + for oid in [k for k in LIST if k in wanted]: + prompt = LIST[oid] + for seed in seeds_of.get(oid, list(SEEDS)): + gen = torch.Generator('cuda').manual_seed(seed) + img = pipe( + prompt, negative_prompt=NEG, num_inference_steps=STEPS, + guidance_scale=CFG, width=512, height=512, generator=gen, + ).images[0] + out = out_dir / f'{oid}_{seed}.png' + img.convert('RGBA').save(out) + print(f'[ok] {out.name}', flush=True) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/apps/game/tools/aiart/post_b4.mjs b/apps/game/tools/aiart/post_b4.mjs new file mode 100644 index 0000000..534111b --- /dev/null +++ b/apps/game/tools/aiart/post_b4.mjs @@ -0,0 +1,191 @@ +/** + * B4: пост-обработка кандидатов gen_b4.py в слоты арт-библии. + * Запуск: node apps/game/tools/aiart/post_b4.mjs + * raw/_.png → кроп фигуры (findFigures) → nearest в слот B4_SLOTS + * → квантизация в палитру игры → processed-b4/_.png + * → обзорный лист processed-b4/_sheet.png (кандидаты по объектам, для апрува) + * + * AI-вывод — только сырьё: в игру (assets/) попадает после ручного апрува. + */ +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { findFigures, quantize } from '@rpg/engine/tools/imaging.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RAW = join(HERE, 'review-b4', 'raw'); +const OUT = join(HERE, 'review-b4', 'processed'); + +/** Слоты кандидатов (id → w×h): интерактивы — по родам плейсхолдеров, иконки — 8×8. */ +const SLOTS = { + signpost: [16, 24], + mote: [16, 16], + tone_tree: [32, 48], + resonator: [32, 40], + bell_rope: [16, 24], + hearth: [32, 32], + note: [16, 16], + counter: [32, 16], + spit: [8, 8], + icon_clock: [8, 8], + icon_cloth: [8, 8], + icon_bellflower: [8, 8], + icon_salt: [8, 8], + icon_flask: [8, 8], + icon_mote: [8, 8], + icon_map: [8, 8] +}; + +/** Палитра игры как массив [r,g,b]. */ +const PAL = Object.entries(PALETTE).map(([, hex]) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}); + +/** Фигура листа: bbox крупнейшей связной компоненты (фон — угловой пиксель). */ +function figureRect(img) { + const boxes = findFigures(img, { maxCount: 8, minArea: 2000 }); + if (boxes.length) { + return boxes.reduce((a, b) => (a.w * a.h >= b.w * b.h ? a : b)); + } + return { x: 0, y: 0, w: img.width, h: img.height }; +} + +/** + * Маска фона наращиванием от краёв (BFS): фон RD-вывода — гладкий градиент, + * угловой пиксель его не описывает. Шаг с малым допуском (фон плавный), у + * границы объекта скачок — рост останавливается. + */ +function bgMask(img) { + const W = img.width; + const H = img.height; + const mask = new Uint8Array(W * H); + const queue = []; + const seed = (x, y) => { + const p = y * W + x; + if (!mask[p] && img.rgba[p * 4 + 3] >= 128) { + mask[p] = 1; + queue.push(x, y); + } + }; + for (let x = 0; x < W; x++) { + seed(x, 0); + seed(x, H - 1); + } + for (let y = 0; y < H; y++) { + seed(0, y); + seed(W - 1, y); + } + const close = (a, b) => + Math.abs(img.rgba[a] - img.rgba[b]) + Math.abs(img.rgba[a + 1] - img.rgba[b + 1]) + + Math.abs(img.rgba[a + 2] - img.rgba[b + 2]) < 40; + for (let q = 0; q < queue.length; q += 2) { + const x = queue[q]; + const y = queue[q + 1]; + const p = y * W + x; + const nb = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1] + ]; + for (const [nx, ny] of nb) { + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const n = ny * W + nx; + if (!mask[n] && close(p, n)) { + mask[n] = 1; + queue.push(nx, ny); + } + } + } + return mask; +} + +/** + * Даунскейл прямоугольника усреднением ячеек (RD-вывод гладкий — nearest + * сэмплирует шум, усреднение даёт ровные цветовые области). Пиксели фоновой + * маски в среднее не входят; ячейка без фигуры — прозрачная. + */ +function meanRect(img, rect, w, h) { + const bg = bgMask(img); + const out = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx0 = rect.x + Math.floor((x * rect.w) / w); + const sx1 = rect.x + Math.max(sx0 + 1, Math.floor(((x + 1) * rect.w) / w)); + const sy0 = rect.y + Math.floor((y * rect.h) / h); + const sy1 = rect.y + Math.max(sy0 + 1, Math.floor(((y + 1) * rect.h) / h)); + let r = 0, g = 0, b = 0, n = 0; + for (let sy = sy0; sy < sy1 && sy < img.height; sy++) { + for (let sx = sx0; sx < sx1 && sx < img.width; sx++) { + const i = (sy * img.width + sx) * 4; + if (bg[sy * img.width + sx] || img.rgba[i + 3] < 128) continue; + r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++; + } + } + if (n > 0) { + const i = (y * w + x) * 4; + out[i] = r / n; out[i + 1] = g / n; out[i + 2] = b / n; out[i + 3] = 255; + } + } + } + return out; +} + +/** Обзорный лист: кандидаты группами по объекту (имя — подпись группы). */ +function groupSheet(groups, path) { + const scale = 3; + const pad = 8; + const cellW = 40 * scale; + const cellH = 52 * scale; + const cols = Math.max(...groups.map((g) => g.items.length)); + const W = cols * (cellW + pad) + pad; + const H = groups.length * (cellH + pad + 14) + pad; + const bg = Buffer.alloc(W * H * 4); + for (let p = 0; p < bg.length; p += 4) { + bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255; + } + groups.forEach((g, gy) => { + const y0 = pad + gy * (cellH + pad + 14); + g.items.forEach(({ rgba, w, h }, i) => { + const x0 = pad + i * (cellW + pad); + for (let y = 0; y < h * scale; y++) { + for (let x = 0; x < w * scale; x++) { + const sp = ((y / scale) | 0) * w + ((x / scale) | 0); + if (rgba[sp * 4 + 3] === 0) continue; + bg.set( + rgba.subarray(sp * 4, sp * 4 + 4), + ((y0 + y) * W + x0 + x) * 4 + ); + } + } + }); + }); + writeFileSync(path, encodePng(W, H, Buffer.from(bg))); + return { W, H }; +} + +const files = readdirSync(RAW).filter((f) => f.endsWith('.png')); +mkdirSync(OUT, { recursive: true }); +const byId = new Map(); +for (const name of files) { + const id = name.replace(/_\d+\.png$/, ''); + const [w, h] = SLOTS[id]; + if (!w) throw new Error(`нет слота для ${id}`); + const img = decodePng(readFileSync(join(RAW, name))); + const rgba = quantize(meanRect(img, figureRect(img), w, h), PAL); + writeFileSync(join(OUT, name), encodePng(w, h, Buffer.from(rgba))); + if (!byId.has(id)) byId.set(id, []); + byId.get(id).push({ name, rgba, w, h }); +} + +const groups = [...byId.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, items]) => ({ id, items })); +const info = groupSheet(groups, join(OUT, '_sheet.png')); +console.log( + `обработано ${files.length} (${groups.length} объектов) -> ${OUT}\n` + + `лист: ${info.W}x${info.H}` +); \ No newline at end of file diff --git a/apps/game/tools/checks/art_b4.mjs b/apps/game/tools/checks/art_b4.mjs new file mode 100644 index 0000000..42a3718 --- /dev/null +++ b/apps/game/tools/checks/art_b4.mjs @@ -0,0 +1,108 @@ +/** + * Сценарий art_b4 — визуальная проверка AI-арта B4 (задача 5): + * столбы, моты, верёвка колокола, очаг, записка, прилавок, резонатор, + * деревья-механизмы, Старая Машина (view.tex) и иконки сумки 8×8. + * Скриншоты — /tmp/rpg_b4_*.png; численных assert'ов минимум: смена области, + * открытая сумка, инварианты. Запуск: node tools/agent.mjs run tools/checks/art_b4.mjs + */ +import { withChecks } from '../lib.mjs'; + +const shot = async (t, name) => { + await t.ctx.agent.step(2, { render: true }); + await t.ctx.page.screenshot({ path: `/tmp/rpg_b4_${name}.png` }); +}; + +export default async function ({ pretty }) { + return withChecks( + 'art_b4', + async (t) => { + const { c } = t; + let a; // агентный мост появляется после boot + await c.run('луга: столб и мот', async () => { + await t.boot(); + a = t.ctx.agent; + await t.sleepEnemies(); + await a.command('scene:teleport', { x: 47, y: 27 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'sign_meadows'); + await a.command('scene:teleport', { x: 15, y: 28 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'mote'); + return null; + }); + await c.run('сумка: иконки предметов', async () => { + for (const id of ['clock', 'cloth', 'bellflower', 'salt', 'water_flask', 'mote', 'map_scroll']) { + await a.command('scene:give', { id }); + } + await a.press('inventory'); + // Открытие — push с fade: сцена сменится, потом fade отыграть. + await a.waitFor('s.scene !== "location"', { timeoutTicks: 300 }); + await a.step(14, { render: true }); + await t.ctx.page.screenshot({ path: '/tmp/rpg_b4_inventory.png' }); + await a.press('menu'); + await a.waitFor('s.scene === "location"', { timeoutTicks: 300 }); + return null; + }); + await c.run('Звенец: столб и верёвка колокола', async () => { + await t.goToArea('zvenets', { x: 54, y: 28 }); + await a.command('scene:teleport', { x: 5, y: 19 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'sign_zvenets'); + await a.command('scene:teleport', { x: 27, y: 15 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'bell_rope'); + return null; + }); + await c.run('дом Ирвина: очаг и записка', async () => { + c.expect(await t.interactAt(13, 12), 'дверь дома Ирвина не открылась'); + await a.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await a.command('scene:teleport', { x: 8, y: 2 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'hearth_note'); + // Шаг в проём (5,8) — назад в Звенец. + c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из дома'); + await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('лавка Милы: прилавок', async () => { + c.expect(await t.interactAt(43, 19), 'дверь лавки не открылась'); + await a.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await a.command('scene:teleport', { x: 5, y: 5 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'counter_shop'); + c.expect(await a.walkTo(5, 8, { timeoutTicks: 3000 }), 'не вышли из лавки'); + await a.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('роща: дерево-механизм и резонатор', async () => { + await t.goToArea('rust_grove', { x: 54, y: 20 }); + const s = await a.snapshot(); + const tree = (s.interactables ?? []).find((i) => i.id === 'tone_tree_1'); + c.expect(tree, 'tone_tree_1 не найден', s.interactables?.map((i) => i.id)); + await a.command('scene:teleport', { x: tree.tile.x, y: tree.tile.y + 2 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'tone_tree'); + await a.command('scene:teleport', { x: 24, y: 18 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'resonator'); + return null; + }); + await c.run('зал Машины: Старая Машина', async () => { + await t.goToArea('old_siding', { x: 46, y: 20 }); + await t.goToArea('machine_hall', { x: 28, y: 31 }); + await a.command('scene:teleport', { x: 13, y: 4 }); + await a.waitFor('!s.transitioning', { timeoutTicks: 60 }); + await shot(t, 'machine_hall'); + return null; + }); + const inv = await a.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты не чисты', errs); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index acaf0b8..ccc568a 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -907,6 +907,108 @@ return c; } +/** + * Дерево-механизм Ржавой рощи: мёртвый металлический ствол в чёрных кабелях + * с медными колокольчиками (квест резонатора, interactables rust_grove). + */ +function toneTreeTile() { + const c = new Canvas(...SIZES.tall.tree); + + // Ствол: металл с изгибом, светлее с одной стороны, ржавчина у корней. + for (let y = 12; y <= 47; y++) { + const bend = Math.round(Math.sin((y - 12) * 0.11) * 2); + const half = y > 42 ? 3 : y > 18 ? 2 : 1; + for (let dx = -half; dx <= half; dx++) { + c.set(16 + bend + dx, y, y > 40 ? 'R1' : dx === half ? 'M2' : 'M1'); + } + c.set(16 + bend - half - 1, y, y > 40 ? 'R0' : 'M0'); + } + // Обрубки ветвей: слепые отростки в стороны. + for (const [x0, y0, x1, y1] of [ + [14, 14, 8, 10], + [19, 12, 25, 9], + [13, 20, 9, 18] + ]) { + line(c, x0, y0, x1, y1, 'M1'); + c.set(x1, y1, 'M2'); + } + + // Кабели: чёрные струны вдоль ствола + косые обмотки кольцами. + line(c, 12, 14, 15, 46, 'P0'); + line(c, 21, 13, 18, 46, 'P0'); + for (let y = 16; y <= 42; y += 5) line(c, 12, y, 21, y + 2, 'P0'); + + // Медные колокольчики на кабельных петлях (тепло — бронза, «жизнь»). + for (const [bx, by] of [ + [10, 12], + [24, 11], + [11, 22] + ]) { + c.set(bx, by - 1, 'P0'); // подвес + c.set(bx, by, 'B0'); + c.set(bx + 1, by, 'B1'); + c.set(bx, by + 1, 'B0'); + c.set(bx + 1, by + 1, 'B0'); + } + + // Корни в стороны и пепел у основания. + for (let i = 0; i < 4; i++) { + c.set(11 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + c.set(22 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + } + c.set(13, 47, 'P5'); + c.set(20, 47, 'P5'); + return c; +} + +/** + * Резонатор Клинта: медный колокол на ржавой стальной раме, с кабелем к чаше + * (interactables rust_grove, kind lever). + */ +function resonatorTile() { + const c = new Canvas(32, 40); + + // Ноги рамы: ржавый металл, расширяются к подошве. + for (let y = 18; y <= 37; y++) { + const w = y > 34 ? 3 : 2; + for (let dx = -w; dx <= w; dx++) { + c.set(8 + dx, y, dx === w ? 'R2' : 'R1'); + c.set(23 + dx, y, dx === -w ? 'R2' : 'R1'); + } + } + // Подошвы. + for (let x = 5; x <= 11; x++) c.set(x, 38, 'R0'); + for (let x = 20; x <= 26; x++) c.set(x, 38, 'R0'); + + // Верхняя балка и раскосы. + for (let x = 6; x <= 25; x++) { + c.set(x, 14, 'M2'); + c.set(x, 15, 'M1'); + } + line(c, 9, 16, 13, 24, 'M1'); + line(c, 22, 16, 18, 24, 'M1'); + + // Колокол: медный купол под балкой (тепло — бронза). + for (let y = 0; y < 9; y++) { + const half = Math.round(Math.sqrt(((y + 1) / 10) * 36)); + for (let dx = -half; dx <= half; dx++) { + c.set(15 + dx, 16 + y, dx === -half ? 'B1' : 'B0'); + } + } + // Кромка и язых. + for (let x = 12; x <= 18; x++) c.set(x, 25, 'B1'); + c.set(15, 27, 'M0'); + c.set(15, 28, 'M0'); + + // Кабель от языха к резонансной чаше на раме. + line(c, 15, 29, 15, 31, 'P0'); + for (let x = 11; x <= 20; x++) c.set(x, 32, 'M2'); + for (let x = 12; x <= 19; x++) c.set(x, 33, 'M1'); + // Блик на чаше. + c.set(13, 32, 'P5H'); + return c; +} + /** Старая Машина: уснувшая печь-автоматон (высокий тайл, слот башни). */ function machineTile() { const c = new Canvas(...SIZES.tall.machine); @@ -1257,6 +1359,9 @@ save(railTile(), 'tiles/rail.png'); save(stairsTile(), 'tiles/stairs.png'); save(machineTile(), 'tiles/machine.png'); +// Интерактивы Ржавой рощи — рукодельные (AI-кандидаты не дали чистого фона). +save(toneTreeTile(), 'tiles/tone_tree.png'); +save(resonatorTile(), 'tiles/resonator.png'); console.log('Генерация персонажей:'); save(heroDown1, 'chars/hero_down_1.png'); diff --git a/apps/game/tools/pixelart/sizes.mjs b/apps/game/tools/pixelart/sizes.mjs index 915fcc6..e0030b0 100644 --- a/apps/game/tools/pixelart/sizes.mjs +++ b/apps/game/tools/pixelart/sizes.mjs @@ -48,8 +48,8 @@ satellite: { heroLamp: [7, 8] } }; -/** Слоты вне реестра: рукодельные кадры сгустков и крупные AI-ассеты. */ -const EXTRA_SLOTS = ['14x12', '14x14', '48x48', '96x96', '128x112']; +/** Слоты вне реестра: рукодельные кадры сгустков, крупные AI-ассеты, иконки 8×8. */ +const EXTRA_SLOTS = ['8x8', '14x12', '14x14', '48x48', '96x96', '128x112']; const dim = (s) => (Array.isArray(s) ? `${s[0]}x${s[1]}` : `${s.w}x${s.h}`); diff --git a/docs/art-style.md b/docs/art-style.md index 861d75c..bc7364b 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -98,6 +98,12 @@ `apps/game/tools/aiart/atlas-manifest.json`); в `assets/` — только после ручного апрува обзорного листа (`status: approved`). Палитра — параметр манифеста; по умолчанию эти 32 цвета. +- Интерактивные объекты: `InteractableDef.view.tex` — спрайт из `tiles/`, + якорь низ-центр по тайлу (вместо плейсхолдера Graphics по `kind`). Слоты + реестра/линий арта: столб и верёвка 16×24, мот и записка 16×16, прилавок + 32×16, очаг 32×32, резонатор 32×40, дерево-механизм 32×48, снаряд плевка 8×8. +- Иконки предметов сумки: **8×8** в `ui/icon_.png` (ui/ — отдельная папка + ассетов), рисуются слева от строк списка; слот `8x8` — в `EXTRA_SLOTS`. - Шрифт интерфейса: системный monospace до появления пиксельного шрифта (8px в виртуальном разрешении). ## 3. Стиль @@ -144,6 +150,8 @@ | — | `tiles/chest.png` | сундук — 24×18, prop интерьеров: вощёное дерево T0/T1, бронзовая полоса по стыку крышки и замок B0/B1/B2 (см. `SIZES.prop.chest`) | | — | `tiles/grass_2/3.png`, `tiles/path_2.png`, `tiles/ash_2.png` | варианты текстур 32×16 (та же палитра, другой seed шума) — микс по хэшу координат, в `tiles` не попадают | | — | `tiles/decor_tuft/stone/bald.png` | декор 16×16, рисунок в ромб 16×8: кустик G1/G2/G2H, камешек P3/P5, проплешина G0/G1 — прозрачное поле, рисуются плашмя | +| — | интерактивы (view.tex) | столб (`signpost` 16×24), мот (`mote`), записка (`note`) 16×16, верёвка колокола (`bell_rope` 16×24), прилавок (`counter` 32×16), очаг (`hearth` 32×32), плевок-снаряд (`spit` 8×8), Старая Машина (`machine`, см. выше) — AI (Retro Diffusion, GPU) после апрува; резонатор (`resonator` 32×40) и дерево-механизм (`tone_tree` 32×48) — рукодельные в `gen.mjs` (у AI не вышел чистый фон на крупном) | +| — | `ui/icon_*.png` | иконки предметов 8×8: часы, полотно, колокольчик, соль, бутль, мот, карта — AI после апрува (`tools/aiart/gen_b4.py`) | ### Персонажи (16×24) @@ -156,10 +164,11 @@ | `chars/trader_mila.png` | idle | платок W3, тележка-ручка, ручной колокольчик B1 | | `chars/fauna_deer_1.png`, `fauna_deer_2.png` | стоит/шаг | безгласный олень: серо-оливковый G1/G2, рога M2, без тёплых пикселей | -### Интерьер/UI (позже) +### Интерьер/UI - Панель диалога: 9-patch, тёмный P1 с бронзовой окантовкой B0. -- Иконки: цветок, маска-полотно, колокольчик — 8×8. +- Иконки: 8×8 в `ui/icon_*.png` (готово: часы, полотно, колокольчик, соль, + бутль, мот, карта; новые — через `gen_b4.py` + апрув). ## 5. Что где хранится diff --git a/docs/demo.md b/docs/demo.md index d8ac27b..b844bd4 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -33,7 +33,7 @@ | Время суток (GameClock + dayNightFactor) | старт 8:00, часы в HUD нет — предмет «Медные часы» (Ирвин даёт с квестом): Enter в сумке показывает время; в сумке время заморожено | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | -| Panel/Button/MenuList | меню, настройки, сейвы, сумка | готово | +| Panel/Button/MenuList | меню, настройки, сейвы, сумка (строки с иконками предметов 8×8 `ui/icon_*`, синхронны окну скролла) | готово | | DialogueRunner + DialogueBox | графы `data/dialogues.ts`, ветки квеста | готово | | CutsceneRunner | кат-сцены сдачи квестов (посадка/настройка/финал: `until`, `moveView`, `skip()`, `cameraZoom`) | готово | | DebugOverlay | F3: fps/тайл/позиция/hp | готово | @@ -157,6 +157,13 @@ повторного диалога Милы по вару (`whenVar motes`), инвариант `interact-used-consistent` в мосте, `walkTo` пропускает узлы-объекты, сценарий `apps/game/tools/checks/interact-world.mjs`. +- ✅ **Батч 4b — арт интерактивов (B4, задача 5)** (готово): плейсхолдеры + Graphics заменены спрайтами через `InteractableDef.view.tex` (якорь низ-центр + по тайлу): столбы, моты, верёвка колокола, прилавок, очаг, записка, резонатор, + деревья-механизмы, Старая Машина, сундук; снаряд плевка — `tiles/spit`; + иконки предметов 8×8 в сумке. Источники: AI (Retro Diffusion, GPU ~1.3 с/вид, + `tools/aiart/gen_b4.py`+`post_b4.mjs`) и рукодельные (`toneTreeTile`/ + `resonatorTile` в `gen.mjs`); визуальный чек `apps/game/tools/checks/art_b4.mjs`. - ✅ **Батч 4 — ИИ врагов** (готово): LOS по сетке (`systems/combat/los.ts`, кэш раз в 3 тика), шум мира `CombatWorld.noise` (шаги героя 0.35, удар конусом 1.0, плевок/смерть 0.6; спящих будит только ≥ 0.7), состояния diff --git a/docs/plan.md b/docs/plan.md index 0b83bec..34239e1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -18,10 +18,10 @@ | # | Задача | Статус | |---|--------|--------| | 1 | Срез «меньше ошибок»: реестры ID, pre-commit, разрезание LocationScene, фикстуры, fail-fast waitFor | ✅ сделано (c379f20, 16b59d8, 39c6349) | -| 2 | AI-генерация текстур и спрайтов (работа B, ниже) | B0+B1 ✅ (PoC); B3 проба ✅ (SD-атласы, 6.6 мин/вид); B2 отложено — RD API или ручной арт | +| 2 | AI-генерация текстур и спрайтов (работа B, ниже) | B0+B1 ✅ (PoC); B3 проба ✅ (SD-атласы, 6.6 мин/вид); **B4 ✅ (2026-09-09, RTX 3090: интерактивы/снаряд/иконки в игре, ~1.3 с/вид)**; B2 отложено — RD API или ручной арт | | 3 | AI-генерация аудио (работа A, ниже) | A0 ✅ (2026-09-09, AudioLDM2 на GPU, апрув пользователя; в игру — ponds, ponds_night, zvenets, tower_hum); дальше A1 | | 4 | Акт 2: новые локации/сюжет по `docs/world.md` | очередь после акта 1+AI-ассетов | -| 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | +| 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | ✅ в основном сделано (2026-09-09, B4): интерактивы, снаряд плевка и иконки сумки — арт в игре; плейсхолдерный Graphics остался только у редких разовых объектов, догонять по мере надобности | | 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | ✅ сделано (2026-09-09): C0–C2 см. работу C; додержано в игре — большой дом 2×2 в Звенце (первый проп на карте), минимапа красит footprint пропа (`renderMinimap`), чек `checks/big_house.mjs` (коллизия, approach, глубина севера/юга) | | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | | 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E4 сделаны (2026-09-07) | @@ -186,6 +186,30 @@ ggml-ядра int8 медленнее fp32-oneDNN (34 мин против 11), VAE в q8 шумит. - **Меньше шагов** — единственный рабочий рычаг: 25→15 шагов даёт 11→6.6 мин без видимой деградации кадра; ниже 15 качество токен-ракурса падает. +- **B4 (задача 5, 2026-09-09, новая машина с RTX 3090)**: замена плейсхолдеров + интерактивов, снаряда и иконок сумки на арт. GPU меняет экономику: ~1.3 с/вид + fp16 (SD1.5, 20 шагов) против 6.6 мин CPU — сиды бесплатны, кандидатов можно + генерировать пачками. + - **Модель**: Retro Diffusion (HF `LostMedia/RetroDiffusion`, Public Domain + Mark 1.0, SD1.5-пиксель-финтюн) — одиночные объекты по промпту ведёт хорошо; + B3-модель листов (SD_PixelArt_SpriteSheet_Generator) для объектов непригодна + (всегда рисует 4 фигуры на кирпичном фоне). Sheet-модели не использовать. + - **Пайплайн**: `tools/aiart/gen_b4.py` (GPU-партия, реестр LIST, CLI + `id:seed:…`) → `tools/aiart/post_b4.mjs` (кроп фигуры → meanRect-даунскейл + усреднением ячеек с фоновой маской → квантизация в палитру → обзорный лист). + Фоновая маска — BFS-наращивание от краёв (фон RD — градиент, угловой пиксель + его не описывает; допуск шага 40, у границы объекта скачок). + - **В игру**: 7 интерактивов AI (столб, мот, верёвка колокола, прилавок, очаг, + записка, плевок-снаряд) + 7 иконок сумки 8×8 (`ui/icon_*`) через механизм + `InteractableDef.view.tex` (якорь низ-центр по тайлу, вместо Graphics- + плейсхолдера) и `Sprite` в `InventoryScene` (строки с `value: ''` — метка + прижата влево, иконки слева синхронны окну скролла). Старая Машина — AI. + - **Рукодельный фолбэк сработал**: tone_tree/resonator (32×40+) у RD на тёплом + градиентном фоне и с шумной деталью — даунскейл даёт кашу при любом кейнге + (8+16 новых сидов перепробовано). Нарисованы кодом в `gen.mjs` + (`toneTreeTile`/`resonatorTile`) — стиль единый с остальными тайлами. + Вывод: AI — для мелких/средних объектов с чистым силуэтом; крупные — код. + - Кандидаты: `tools/aiart/review-b4/` (raw+processed, 160+), в git не идут. ---