diff --git a/CLAUDE.md b/CLAUDE.md index ba045e1..77a6e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,21 @@ ### Где что лежит +- `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** +- `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. - `apps/game/src/data/` — весь контент: карта (`map.ts`, id тайлов + генератор), диалоги (`dialogues.ts`), NPC (`npcs.ts`). -- `apps/game/src/scenes/` — BootScene → MenuScene → LocationScene; сцены меняются через `SceneManager.replace/push/pop`. -- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь), диалоги с флагами сюжета. +- `apps/game/src/scenes/` — BootScene (грузит ассеты) → MenuScene → LocationScene; сцены меняются через `SceneManager.replace/push/pop`. +- `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация направлений), диалоги с флагами сюжета. - Сюжетные флаги живут в `DialogueSystem.flags` и сохраняются в автосейв `autosave` (Esc в локации). +### Пиксель-арт + +- `tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node tools/pixelart/gen.mjs`. +- Выход — `apps/game/assets/` (`tiles/*.png`, `chars/*.png`); загружается `AssetLoader` в BootScene, ключ = путь без расширения. +- Палитра — только из `tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. +- Имена файлов и размеры спрайтов фиксированы арт-библией — при замене сгенерированного арта нарисованным руками их не менять. +- Тайл «дерево» — высокий объект: `TileMapData.tall` c `TallSpec { height, ground }` (спрайт якорится низом в центр ромба, под ним рисуется ground-тайл). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач (todowrite/TaskCreate) и иди по нему автономно, не спрашивая подтверждения на каждый шаг. diff --git a/README.md b/README.md index 075ca08..90f162b 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,29 @@ ```bash npm install -npm run dev # http://localhost:5173 -npm test # тесты движка -npm run build # прод-сборка +npm run dev # http://localhost:5173 +npm test # тесты движка +npm run build # прод-сборка +node tools/pixelart/gen.mjs # перегенерация пиксель-арта ``` ## Структура ``` -packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, диалоги -apps/game # RPG: сцены, локации, NPC, диалоги, сюжет +docs/world.md # библия мира: сеттинг, локации, персонажи, сюжет +docs/art-style.md # арт-библия: палитра, размеры, стиль, чеклист ассетов +packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, диалоги +apps/game # RPG: сцены, локации, NPC, диалоги, сюжет +apps/game/assets # пиксель-арт (генерируется tools/pixelart) +tools/pixelart # генератор арта: PNG из кода, палитра в одном месте ``` -Правило: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры. +Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). -## Сюжет (черновик) +## Мир -Мир после Великого Пепелища. Степь дышит пеплом, и только лунные колокольчики — травы-возвращенцы — держат его на месте. Игрок — звонарь, чей род обязан пересеивать колокольчики и отсеивать пепел. Первый акт: выжженные луга, старейшина Ирвин и торговка Мила. +Сорок лет назад Северные Печи горели девять дней — и пепел накрыл край. Пепел гасит звук +и цвет; против него работает только резонанс. Звонари прозванивают дороги, лунные +колокольчики пьют пепел из земли. Полная библия — в `docs/world.md`. -## Плейсхолдеры - -Вся графика сейчас — процедурные плейсхолдеры (ромбы тайлов, прямоугольные «звонари»). Настоящий пиксель-арт появится в `apps/game/assets/`. \ No newline at end of file +Первый акт: выжженные луга, Серые пруды, старейшина Ирвин и безгласная торговка Мила. \ No newline at end of file diff --git a/apps/game/assets/chars/elder_irwin.png b/apps/game/assets/chars/elder_irwin.png new file mode 100644 index 0000000..038b7b9 --- /dev/null +++ b/apps/game/assets/chars/elder_irwin.png Binary files differ diff --git a/apps/game/assets/chars/hero_down_1.png b/apps/game/assets/chars/hero_down_1.png new file mode 100644 index 0000000..1290c5a --- /dev/null +++ b/apps/game/assets/chars/hero_down_1.png Binary files differ diff --git a/apps/game/assets/chars/hero_down_2.png b/apps/game/assets/chars/hero_down_2.png new file mode 100644 index 0000000..b4e2a8e --- /dev/null +++ b/apps/game/assets/chars/hero_down_2.png Binary files differ diff --git a/apps/game/assets/chars/hero_side_1.png b/apps/game/assets/chars/hero_side_1.png new file mode 100644 index 0000000..1b96208 --- /dev/null +++ b/apps/game/assets/chars/hero_side_1.png Binary files differ diff --git a/apps/game/assets/chars/hero_side_2.png b/apps/game/assets/chars/hero_side_2.png new file mode 100644 index 0000000..cc4d0fb --- /dev/null +++ b/apps/game/assets/chars/hero_side_2.png Binary files differ diff --git a/apps/game/assets/chars/hero_up_1.png b/apps/game/assets/chars/hero_up_1.png new file mode 100644 index 0000000..149083a --- /dev/null +++ b/apps/game/assets/chars/hero_up_1.png Binary files differ diff --git a/apps/game/assets/chars/hero_up_2.png b/apps/game/assets/chars/hero_up_2.png new file mode 100644 index 0000000..149083a --- /dev/null +++ b/apps/game/assets/chars/hero_up_2.png Binary files differ diff --git a/apps/game/assets/chars/trader_mila.png b/apps/game/assets/chars/trader_mila.png new file mode 100644 index 0000000..b71dad8 --- /dev/null +++ b/apps/game/assets/chars/trader_mila.png Binary files differ diff --git a/apps/game/assets/tiles/ash.png b/apps/game/assets/tiles/ash.png new file mode 100644 index 0000000..41d6fdc --- /dev/null +++ b/apps/game/assets/tiles/ash.png Binary files differ diff --git a/apps/game/assets/tiles/bellflower.png b/apps/game/assets/tiles/bellflower.png new file mode 100644 index 0000000..05f7a5e --- /dev/null +++ b/apps/game/assets/tiles/bellflower.png Binary files differ diff --git a/apps/game/assets/tiles/grass.png b/apps/game/assets/tiles/grass.png new file mode 100644 index 0000000..7cb694c --- /dev/null +++ b/apps/game/assets/tiles/grass.png Binary files differ diff --git a/apps/game/assets/tiles/path.png b/apps/game/assets/tiles/path.png new file mode 100644 index 0000000..fd5abfc --- /dev/null +++ b/apps/game/assets/tiles/path.png Binary files differ diff --git a/apps/game/assets/tiles/tree.png b/apps/game/assets/tiles/tree.png new file mode 100644 index 0000000..fc2bd74 --- /dev/null +++ b/apps/game/assets/tiles/tree.png Binary files differ diff --git a/apps/game/assets/tiles/water.png b/apps/game/assets/tiles/water.png new file mode 100644 index 0000000..c9f5881 --- /dev/null +++ b/apps/game/assets/tiles/water.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index f752368..4265657 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -1,7 +1,7 @@ -import { Engine, SaveManager, type Camera, type Container, type Renderer, type SceneManager } from '@rpg/engine'; +import { Engine, SaveManager, AssetLoader, type Renderer, type SceneManager } from '@rpg/engine'; /** - * Игровой контекст: движок, сохранения, общие для сцен ссылки. + * Игровой контекст: движок, сохранения, загрузчик ассетов. * Движок ничего не знает об этом классе — он только потребляет его API. */ export class Game { @@ -10,9 +10,29 @@ static readonly VIRTUAL_H = 270; readonly saves: SaveManager; + readonly assets: AssetLoader; + + /** Все ассеты игры: ключ = путь в apps/game/assets без расширения. */ + static readonly ASSET_KEYS = [ + 'tiles/grass', + 'tiles/path', + 'tiles/water', + 'tiles/ash', + 'tiles/bellflower', + 'tiles/tree', + 'chars/hero_down_1', + 'chars/hero_down_2', + 'chars/hero_up_1', + 'chars/hero_up_2', + 'chars/hero_side_1', + 'chars/hero_side_2', + 'chars/elder_irwin', + 'chars/trader_mila' + ]; constructor(readonly engine: Engine) { this.saves = new SaveManager(window.localStorage); + this.assets = new AssetLoader((key) => `${import.meta.env.BASE_URL}assets/${key}.png`); } get scenes(): SceneManager { @@ -23,11 +43,11 @@ return this.engine.renderer; } - get camera(): Camera { + get camera() { return this.engine.camera; } - get worldRoot(): Container { + get worldRoot() { return this.engine.renderer.worldRoot; } } \ No newline at end of file diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index dd17297..daca4ff 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -1,7 +1,8 @@ import type { DialogueLine } from '@rpg/engine'; /** - * Диалоги NPC. Ключ — id диалога; игра переключает диалоги по флагам сюжета. + * Диалоги NPC (по docs/world.md, акт 1). + * Мила — безгласная: говорит шёпотом, коротко. Ирвин — экономит дыхание. */ export interface Dialogue { id: string; @@ -12,24 +13,25 @@ elder_first: { id: 'elder_first', lines: [ - { speaker: 'Старейшина Ирвин', text: 'Ты вернулся, звонарь. Степь всё ещё дышит пеплом.' }, - { speaker: 'Старейшина Ирвин', text: 'Три лунных колокольчика заросли по ту сторону пруда. Их свет держит пепел на месте.' }, - { speaker: 'Старейшина Ирвин', text: 'Найди их, пока ветра не перенесли пепел на поляну. Тогда мы успеем отсеять его.' }, - { speaker: 'Звонарь', text: 'Я принесу колокольчики, Ирвин. Степь выживала и не в такие пепелища.' } + { speaker: 'Старейшина Ирвин', text: 'Вернулся. Хорошо. Слышал гул на закате? Это пепел дышит у прудов.' }, + { speaker: 'Старейшина Ирвин', text: 'Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.' }, + { speaker: 'Старейшина Ирвин', text: 'Собери. Посади здесь, на лугу. Поляна без цветов — поляна без завтра.' }, + { speaker: 'Звонарь', text: 'Прозвоню дорогу до прудов и вернусь до темноты.' } ] }, elder_repeat: { id: 'elder_repeat', lines: [ - { speaker: 'Старейшина Ирвин', text: 'Колокольчики ждут тебя у пруда, звонарь. Пепел не будет ждать.' } + { speaker: 'Старейшина Ирвин', text: 'Цветы ждут у прудов, звонарь. А пепел не ждёт.' } ] }, trader_first: { id: 'trader_first', lines: [ - { speaker: 'Торговка Мила', text: 'Свежий звон у тебя в походной сумке, звонарь? Не суйся к пруду без воды.' }, - { speaker: 'Торговка Мила', text: 'Пепел там ложится на губы. Одна глотка — и голос сядет на неделю.' }, - { speaker: 'Звонарь', text: 'Запомню. Спасибо за предостережение, Мила.' } + { speaker: 'Торговка Мила', text: '*звонит ручным колокольчиком дважды* ...Стой.' }, + { speaker: 'Торговка Мила', text: 'У прудов... свежий накат. Дышать поверх — голос отдашь. Как я отдала.' }, + { speaker: 'Торговка Мила', text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.' }, + { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } ] } }; \ No newline at end of file diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index cafc4ff..031b725 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -1,4 +1,4 @@ -import type { TileMapData } from '@rpg/engine'; +import type { TileMapData, TallSpec } from '@rpg/engine'; /** id тайлов луга «Пепельные луга». */ export const TILES = { @@ -6,13 +6,16 @@ PATH: 1, WATER: 2, TREE: 3, - ASH: 4 + ASH: 4, + BELLFLOWER: 5 } as const; const BLOCKED = [TILES.WATER, TILES.TREE]; -/** Высота «высоких» объектов (деревья), для объёма. */ -const TALL_OBJECTS: Record = { [TILES.TREE]: 14 }; +/** Дерево — высокий объект 48px, под ним рисуется трава. */ +const TALL_OBJECTS: Record = { + [TILES.TREE]: { height: 48, ground: TILES.GRASS } +}; /** Детерминированный ГПСЧ, чтобы локация выглядела одинаково между запусками. */ function mulberry32(seed: number): () => number { @@ -72,6 +75,18 @@ } } + // Лунные колокольчики у берегов прудов (квестовый декор, проходимы). + for (const [fx, fy] of [ + [10, 17], + [11, 19], + [18, 5], + [22, 9] + ]) { + if (tiles[at(fx, fy)] === TILES.GRASS || tiles[at(fx, fy)] === TILES.ASH) { + tiles[at(fx, fy)] = TILES.BELLFLOWER; + } + } + // Рощи деревьев по краям. for (let i = 0; i < 60; i++) { const edge = rng() < 0.5; diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index e19b754..b0e6020 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -1,16 +1,16 @@ -/** NPC локации: тайл, диалоги и флаг сюжета после первого разговора. */ +/** NPC локации: тайл, спрайт, диалоги и флаг сюжета после первого разговора. */ export interface NpcDef { id: string; name: string; tile: { x: number; y: number }; + /** Ключ спрайта в ассетах (chars/.png). */ + sprite: string; /** Первый диалог (когда флаг ещё не поднят). */ dialogueFirst: string; /** Повторный диалог. */ dialogueRepeat: string; /** Флаг, поднимаемый после первого диалога. */ flagKey: string; - /** Плейсхолдерные цвета (плащ, шляпа). */ - colors: { cloak: number; hat: number }; } export const NPCS: NpcDef[] = [ @@ -18,18 +18,18 @@ id: 'elder', name: 'Старейшина Ирвин', tile: { x: 13, y: 15 }, + sprite: 'elder_irwin', dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', - flagKey: 'metElder', - colors: { cloak: 0x7a6a55, hat: 0x5c4f3e } + flagKey: 'metElder' }, { id: 'trader', name: 'Торговка Мила', tile: { x: 16, y: 12 }, + sprite: 'trader_mila', dialogueFirst: 'trader_first', dialogueRepeat: 'trader_first', - flagKey: 'metTrader', - colors: { cloak: 0x6d4459, hat: 0x4a2f3d } + flagKey: 'metTrader' } ]; \ No newline at end of file diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index f7e3317..53bba4a 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,25 +1,57 @@ +import { Container, Graphics, Text } from 'pixi.js'; import type { Scene } from '@rpg/engine'; -import type { Game } from '../Game'; +import { Game } from '../Game'; import { MenuScene } from './MenuScene'; /** - * Загрузочная сцена: пока ассетов нет, просто ждём кадр-два и уходим в меню. - * Здесь же в будущем — прогрессивная загрузка текстур и звуковых пакетов. + * Загрузочная сцена: грузит ассеты с прогресс-баром, затем меню. */ export class BootScene implements Scene { - private wait = 0.2; + private view = new Container(); + private bar: Graphics; + private started = false; + private progress = -1; - constructor(private game: Game) {} + constructor(private game: Game) { + const label = new Text({ + text: 'пепел оседает...', + style: { fontFamily: 'monospace', fontSize: 8, fill: 0x8a8a9a } + }); + label.anchor.set(0.5); + label.position.set(240, 120); + this.bar = new Graphics(); + this.view.addChild(label, this.bar); + this.game.renderer.uiRoot.addChild(this.view); + } enter(): void {} - exit(): void {} + exit(): void { + this.view.destroy({ children: true }); + } - update(dt: number): void { - this.wait -= dt; - if (this.wait <= 0) { - void this.game.scenes.replace(new MenuScene(this.game)); + update(_dt: number): void { + if (!this.started) { + this.started = true; + void this.game.assets + .load(Game.ASSET_KEYS, (p) => { + this.progress = p; + }) + .then(() => { + this.progress = 1; + void this.game.scenes.replace(new MenuScene(this.game)); + }); } + if (this.progress >= 0) { + this.drawBar(this.progress); + this.progress = -1; // перерисовать один раз на изменение + } + } + + private drawBar(p: number): void { + this.bar.clear(); + this.bar.rect(180, 136, 120, 6).fill(0x23232b); + this.bar.rect(180, 136, Math.round(120 * p), 6).fill(0xd99a32); } render(): void {} diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 169a5c2..a5ab118 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -1,17 +1,18 @@ -import { Container, Graphics, Text } from 'pixi.js'; +import { Container, Graphics, Text, Texture } from 'pixi.js'; import { World, IsometricTileMap, isoToScreen, screenToIsoExact, + Sprite, DEFAULT_ISO, type Scene, type Camera } from '@rpg/engine'; import { Game } from '../Game'; -import { buildMeadowsMap } from '../data/map'; +import { buildMeadowsMap, TILES } from '../data/map'; import { NPCS, type NpcDef } from '../data/npcs'; -import { PlayerController } from '../systems/PlayerController'; +import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; import { MenuScene, type SaveData } from './MenuScene'; @@ -35,12 +36,12 @@ this.camera = game.camera; const data = buildMeadowsMap(); - this.map = new IsometricTileMap(data, new Map(), DEFAULT_ISO); + this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO); this.mapLayer.addChild(this.map.view); this.game.renderer.worldRoot.addChild(this.mapLayer); const startTile = save?.pos ?? { x: 14, y: 14 }; - this.player = new PlayerController(this.world, this.map, startTile); + this.player = new PlayerController(this.world, this.map, this.heroTextures(), startTile); this.mapLayer.addChild(this.player.view); for (const def of NPCS) { @@ -109,6 +110,29 @@ render(): void {} + /** Текстуры тайлов из загруженных ассетов (id -> Texture). */ + private tileTextures(): Map { + const a = this.game.assets; + return new Map([ + [TILES.GRASS, a.texture('tiles/grass')], + [TILES.PATH, a.texture('tiles/path')], + [TILES.WATER, a.texture('tiles/water')], + [TILES.ASH, a.texture('tiles/ash')], + [TILES.BELLFLOWER, a.texture('tiles/bellflower')], + [TILES.TREE, a.texture('tiles/tree')] + ]); + } + + /** Кадры героя из ассетов. */ + private heroTextures(): HeroTextures { + const a = this.game.assets; + return { + down: [a.texture('chars/hero_down_1'), a.texture('chars/hero_down_2')], + up: [a.texture('chars/hero_up_1'), a.texture('chars/hero_up_2')], + side: [a.texture('chars/hero_side_1'), a.texture('chars/hero_side_2')] + }; + } + /** Экранные координаты центра ромба тайла (с Origin карты). */ private tileCenter(tx: number, ty: number): { x: number; y: number } { const p = isoToScreen(tx, ty, DEFAULT_ISO); @@ -155,18 +179,17 @@ private makeNpcView(def: NpcDef): Container { const view = new Container(); - const body = new Graphics(); const p = this.tileCenter(def.tile.x, def.tile.y); - body.rect(-3, -9, 6, 9).fill(def.colors.cloak); - body.rect(-3, -12, 6, 3).fill(def.colors.hat); - body.rect(-1, -11, 2, 1).fill(0x1c1c22); view.position.set(p.x, p.y); - view.addChild(body); + + const sprite = new Sprite(this.game.assets.texture(`chars/${def.sprite}`)); + sprite.anchor.set(0.5, 1); // ноги в центре ромба + view.addChild(sprite); // Маркер «с ним можно говорить»: восклицательный штрих. const marker = new Graphics(); - marker.rect(-1, -17, 2, 4).fill(0xf0d878); - marker.rect(-1, -11, 2, 2).fill(0xf0d878); + marker.rect(-1, -27, 2, 4).fill(0xf0d878); + marker.rect(-1, -21, 2, 2).fill(0xf0d878); view.addChild(marker); return view; } diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 24d62b7..8050742 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -5,21 +5,37 @@ isoToScreen, screenToIsoExact, moveTo, + FrameAnimation, Container, - Graphics, + Sprite, + Texture, DEFAULT_ISO, type Entity, type Vec2 } from '@rpg/engine'; +/** Кадры героя по направлениям. side смотрит влево; вправо — флип. */ +export interface HeroTextures { + down: [Texture, Texture]; + up: [Texture, Texture]; + side: [Texture, Texture]; +} + +type Facing = 'down' | 'up' | 'left' | 'right'; + /** - * Управление героем: путь по клику (A*), плавное движение по маршруту. - * Спрайт — плейсхолдер из Graphics (пока нет арта); позиция — центр ромба тайла. + * Управление героем: путь по клику (A*), плавное движение по маршруту, + * покадровая анимация ходьбы по направлению движения. */ export class PlayerController { readonly entity: Entity; readonly view: Container; + private sprite: Sprite; + private anim: FrameAnimation; + private facing: Facing = 'down'; + private textures: HeroTextures; + /** Позиция в виртуальных пикселях (источник истины, ноги героя). */ private pos: Vec2; private path: { x: number; y: number }[] = []; @@ -27,18 +43,21 @@ /** Скорость в виртуальных пикселях/сек. */ private speed = 56; - constructor(world: World, private map: IsometricTileMap, startTile: { x: number; y: number }) { + constructor( + world: World, + private map: IsometricTileMap, + textures: HeroTextures, + startTile: { x: number; y: number } + ) { this.entity = world.createEntity(); world.addComponent(this.entity, 'pos', { ...startTile }); + this.textures = textures; this.view = new Container(); - const body = new Graphics(); - // Плейсхолдер героя: тёмный плащ и шляпа звонаря. - body.rect(-3, -9, 6, 9).fill(0x3b4a63); - body.rect(-2, -3, 4, 3).fill(0x2a3548); - body.rect(-3, -12, 6, 3).fill(0x8a6d4b); - body.rect(-1, -11, 2, 1).fill(0x1c1c22); - this.view.addChild(body); + this.sprite = new Sprite(textures.down[0]); + this.sprite.anchor.set(0.5, 1); // ноги в точке позиции + this.view.addChild(this.sprite); + this.anim = new FrameAnimation(this.sprite, textures.down, 6, true); const p = isoToScreen(startTile.x, startTile.y); this.pos = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 }; @@ -67,7 +86,10 @@ this.pos = moveTo(this.pos, this.waypoint, this.speed * dt); if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { this.advanceWaypoint(); + return; } + this.faceMovement(); + this.anim.update(dt); this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); } @@ -88,6 +110,30 @@ ); } + /** Направление — по доминирующей оси движения к следующей точке пути. */ + private faceMovement(): void { + if (!this.waypoint) return; + const dx = this.waypoint.x - this.pos.x; + const dy = this.waypoint.y - this.pos.y; + let facing: Facing = this.facing; + if (Math.abs(dx) >= Math.abs(dy)) { + facing = dx >= 0 ? 'right' : 'left'; + } else { + facing = dy >= 0 ? 'down' : 'up'; + } + if (facing !== this.facing) { + this.facing = facing; + const frames = + facing === 'down' + ? this.textures.down + : facing === 'up' + ? this.textures.up + : this.textures.side; + this.anim.setFrames([...frames], false); + this.sprite.scale.x = facing === 'right' ? -1 : 1; + } + } + private advanceWaypoint(): void { const next = this.path.shift(); if (next) { diff --git a/docs/art-style.md b/docs/art-style.md new file mode 100644 index 0000000..e2d1a0b --- /dev/null +++ b/docs/art-style.md @@ -0,0 +1,130 @@ +# Пепельные луга — арт-библия пиксель-арта + +> Основа — `docs/world.md`. Ключевое правило арта из сеттинга: +> **тёплый цвет — только жизнь.** Золото колокольчика, бронза, огонь кузни. +> Всё остальное — серо-оливковое, ржавое, тёмное. + +## 1. Палитра (32 цвета) + +Движок и генератор (`tools/pixelart/`) используют ровно эти цвета. Имя цвета — тоже +комментарий к настроению. + +### Пепел (нейтраль) + +| Слот | HEX | Роль | +|---|---|---| +| P0 | `#17171d` | контур, самая тёмная тень | +| P1 | `#23232b` | тень плащей, ночная сторона | +| P2 | `#2f2f38` | тёмный серый | +| P3 | `#3e3e48` | пепельный накат, тень тайлов | +| P4 | `#52525c` | базовый серый | +| P5 | `#6a6a74` | светлая пыль | +| P6 | `#86868f` | пепельный бугор, свет | +| P5H| `#a3a3ac` | выгоревший свет (редко!) | + +### Мёртвая трава (оливково-серая) + +| Слот | HEX | Роль | +|---|---|---| +| G0 | `#2b3026` | тень травы | +| G1 | `#3d4436` | базовый луг | +| G2 | `#525b44` | трава, свет | +| G2H| `#687053` | сухие стебли | + +### Ржавчина и металл (индастриал) + +| Слот | HEX | Роль | +|---|---|---| +| R0 | `#3a2822` | тёмная ржавчина, тень | +| R1 | `#5c3b2e` | базовая ржавчина | +| R2 | `#82503a` | светлая ржавчина | +| M0 | `#26262e` | тёмный металл | +| M1 | `#44454e` | сталь | +| M2 | `#61626c` | светлый металл | + +### Вода (Серые пруды) + +| Слот | HEX | Роль | +|---|---|---| +| W0 | `#22343c` | глубина | +| W1 | `#32505c` | базовая | +| W2 | `#4a737f` | блики, рябь | + +### Тепло (только жизнь!) + +| Слот | HEX | Роль | +|---|---|---| +| B0 | `#8a6a2e` | бронза, тень | +| B1 | `#d99a32` | бронза, свет / цветок | +| B2 | `#f2cd68` | золото,highlight цветка и колоколов | +| F0 | `#d97f2b` | огонь кузни, уголёк | +| F1 | `#f2b45a` | огонь, свет | + +### Люди и ткань + +| Слот | HEX | Роль | +|---|---|---| +| C0 | `#3a4356` | плащ звонаря (тень) | +| C1 | `#4d5a72` | плащ звонаря (свет) | +| W3 | `#8a8f7d` | вощёное полотно, Мила | +| S0 | `#8a6d55` | кожа, тень | +| S1 | `#c9a689` | кожа, свет | +| T0 | `#5a4a3c` | шляпа/плащ Ирвина | +| T1 | `#77634f` | шляпа/плащ Ирвина, свет | + +Правила палитры: чёрный/белый запрещены (крайности — P0 и P5H). B1/B2/F0/F1 занимают +не более 5% площади любого спрайта — золото должно быть событием, а не заливкой. + +## 2. Формат и размеры + +- Тайл: ромб **32×16** (движок, `DEFAULT_ISO`). +- Персонаж: **16×24**, якорь — центр между ступней (низ спрайта = центр ромба тайла). +- Дерево (высокий объект): **32×48**, якорь — центр ствола у земли. +- Пропсы (бочки, тележка Милы, рельсы): 16×16 или 16×24, тоже якорь у земли. +- Шрифт интерфейса: системный monospace до появления пиксельного шрифта (8px в виртуальном разрешении). + +## 3. Стиль + +1. **Контур**: 1px `P0` только на внешнем силуэте; внутренние формы отделяются тенью, не контуром. +2. **Свет**: сверху-слева. Три тона на форму: тень → база → свет. Хайлайт — 1px, редко. +3. **Дизеринг**: разрешён шахматный 1px на границе двух тонов, не более 2px полосы. +4. **Цветы жизни**: цветок = 3 пикселя B1 + 1px B2 сверху. Никаких «нарисованных» лепестков крупнее. +5. **Механизмы**: ржавчина по нижним граням, сталь сверху. Пепел ложится на горизонтальные грани (P5 сверху рельсов). +6. **Анимация ходьбы**: 2 кадра (ноги в разнобой) + idle. Читается на 16×24 — крупность важнее плавности. + +## 4. Ассеты и чеклист + +Все PNG — из `apps/game/assets/`, генерируются `tools/pixelart/gen.mjs`. + +### Тайлы (32×16, id совпадает с `apps/game/src/data/map.ts`) + +| id | файл | содержание | +|---|---|---| +| 0 | `tiles/grass.png` | серая трава, редкие сухие стебли | +| 1 | `tiles/path.png` | звонёная тропа: утоптанный пепел, светлее травы | +| 2 | `tiles/water.png` | Серый пруд: W1 база, блики W2 | +| 3 | `tiles/tree.png` | дерево Ржавой рощи — 32×48, tall-объект | +| 4 | `tiles/ash.png` | пепельный бугор: P4/P6 с тёмным ободом | +| 5 | `tiles/bellflower.png` | трава + цветок B1/B2 (квестовый тайл) | + +### Персонажи (16×24) + +| файл | кадры | содержание | +|---|---|---| +| `chars/hero_down_1.png`, `hero_down_2.png` | ходьба вниз | звонарь: плащ C0/C1, шляпа T0, колокольчик B0 | +| `chars/hero_up_1.png`, `hero_up_2.png` | ходьба вверх | спина: узел плаща, лампа | +| `chars/hero_side_1.png`, `hero_side_2.png` | ходьба влево | вправо — горизонтальный флип | +| `chars/elder_irwin.png` | idle | длинный плащ T0/T1, борода S0, стилос | +| `chars/trader_mila.png` | idle | платок W3, тележка-ручка, ручной колокольчик B1 | + +### Интерьер/UI (позже) + +- Панель диалога: 9-patch, тёмный P1 с бронзовой окантовкой B0. +- Иконки: цветок, маска-полотно, колокольчик — 8×8. + +## 5. Что где хранится + +- `tools/pixelart/gen.mjs` — генератор: пиксель-карты в ASCII + энкодер PNG (без зависимостей). +- `apps/game/assets/` — выход генератора, загружается `AssetLoader` по ключу = пути без расширения + (`tiles/grass` → `assets/tiles/grass.png`). +- После замены сгенерированного арта на нарисованный руками имена файлов и размеры не меняются. \ No newline at end of file diff --git a/docs/world.md b/docs/world.md new file mode 100644 index 0000000..dbfa0e8 --- /dev/null +++ b/docs/world.md @@ -0,0 +1,118 @@ +# Пепельные луга — библия мира + +> Тон: **меланхолия с надеждой**. Пепел и утраты, но люди пересеивают травы и мир восстанавливается. +> Масштаб: **один регион** на первую игру, с намёками на мир за его пределами. +> Эстетика: **пепельный индастриал** — остатки механизмов, рельсы, ржавчина, колокола. + +## 1. Великий Пепелопад + +Сорок лет назад Северные Печи — гигантские плавильни, питавшие весь край, — горели девять дней. +Небо стало пепельным, и пепел пошёл вниз. Машины встали, города уснули под серым одеялом. +Но мир не умер: пепел не огонь, он тишина. + +**Пепел** — не пыль и не яд в привычном смысле. Он гасит звук и цвет: там, где лежит толстым +пластом («пепельный накат»), голоса садятся за час, птицы молчат, краски выцветают до серого. +Пепел «дремлет» — но дышит: медленно течёт по низинам волнами, которые называют **тихими +степями**. Тихая степь — это не место, это движение. + +## 2. Звук против пепла + +Главный закон мира: **резонанс усыпляет пепел.** Правильный звон — низкий, медленный, +«по дыханию земли» — заставляет пепел уплотняться и спать. Гулкий шум, напротив, его +будит и поднимает в воздух. + +Отсюда вся культура края: + +- **Звонари** — хранители, что ходят с переносными резонаторами (медные колокола на стальной + раме) и «звонят дороги»: утаптывают и прозванивают тропы, чтобы по ним можно было ходить + и возить поклажу. Звонёная тропа — единственный надёжный путь между поселениями. +- **Звонная книга** — свод тонов и «проходных» маршрутов, ведётся старейшинами. Каждый + колокол помнит свой голос; переплавка колокола — преступление против памяти. +- **Безгласные** — те, у кого пепел забрал голос (дышали накатом). Говорят колокольчиками + в руке: короткий звон — «да», два — «нет», дробь — тревога. Их уважают, но обходят. + +## 3. Лунные колокольчики + +Трава-возвращенец. Бледно-золотой цветок на сером стебле, на закате тихо гудит — «где цветок +гудит, пепел пьёт». Корни колокольчика вытягивают пепел из земли и превращают его в **серую +землю** — единственную почву, что снова принимает посев. Поляна колокольчиков через пару +лет становится лугом, способным кормить. + +Поэтому колокольчик — не декор и не валюта. Это буквально надежда, посаженная в землю. + +## 4. Регион: Низина Звенца (игровой регион) + +Один край, всё в пешей доступности. В центре — станция старой железной дороги, которая +и стала деревней. + +### Локации + +| Локация | Что это | Роль в игре | +|---|---|---| +| **Выжженные луга** | Стартовая поляна. Серая трава, пепельные бугры, тропа к Звенцу. | Обучение, первый разговор с Ирвином | +| **Звенец** | Деревня вокруг старой железнодорожной водонапорной башни с колоколом. Кузня-мастерская, лавка Милы, звонная книга. | Хаб: квесты, торговля, сейф-точка | +| **Серые пруды** | Пруды отработанной воды у остова плавильни. Мутная вода, туман-выдох. У берегов — лунные колокольчики. | Акт 1: собрать три цветка | +| **Ржавая роща** | Мёртвый лес, оплетённый кабелями и трубами. Деревья стоят как механизмы. | Акт 2: тишина и тайна, резонатор Клинта | +| **Старый разъезд** | Руины узловой станции. Под землёй — **Старая Машина**: уснувшая печь-автоматон, чьё дыхание двигает пепел. | Финал акта: первый звон Большого Колокола | + +### Звенец (деревня) + +Дома сложены из вагонных стен и шпал, крыши — из платформ. Колокол башни отлит из рельсов. +Люди здесь «сварщики тишины»: чинят резонаторы, плавят бронзу, ведут звонную книгу. +Звенец не крепость и не лагерь — это мастерская. Люди здесь чинят мир, а не воюют с ним. + +## 5. Персонажи + +- **Звонарь** (герой, безымянный) — молодой звонарь из Звенца. Носит плащ, на поясе — + ручной колокольчик и лампа. Немой для игрока (реплики — выборы), как принято у звонарей + в пути. **Игровая роль**: глаза игрока, руки сюжета. +- **Старейшина Ирвин** — хранитель звонной книги, старик с бронзовым стилосом. Спокойный, + слегка усталый; говорит коротко, как экономит дыхание. Даёт задания, читает тоны. + *Не злодей и не мудрец-оракул — просто последний, кто помнит, как всё работало.* +- **Торговка Мила** — безгласная. Ходит с ручным колокольчиком и тележкой, торгует солью, + вощёным полотном и водой в медных бутлях. Говорит шёпотом, звонит ответы. Единственная, + кто ходит дальше Низины — знает про разъезд то, чего не знает Ирвин. +- **Клинт, кузнец-починщик** (акт 2) — ворчун, чинит резонаторы и «оживляет» механизмы + старого мира. Ворчит, что колокол башни «на пол-тона ниже, чем в книге». + +## 6. Сюжетная арка акта 1 (прототип) + +1. **Пепел дышит.** Ирвин: три поляны лунных колокольчиков у Серых прудов задохнутся, + если не собрать цветы до выдоха тихой степи. → Квест «Три цветка». +2. **Мила предупреждает.** Шёпотом: у прудов лежит свежий накат, дышать поверх — потерять + голос. Даёт вощёное полотно (маска). Первое знакомство с безгласными. +3. **Сбор цветов** — тихая, медичная вылазка: туман, звук шагов, звон колокольчика + как «проверка воздуха» (механика: звонит — пепел на секунду видно). +4. **Возвращение.** Цветы в землю, поляна зазеленела (визуально — тайл меняется). + Первый «посев надежды» — эмоциональный якорь игры. +5. **Крючок.** У прудов слышно низкий гул снизу. Мила: «Это разъезд. Это Машина дышит». + Акт 2 — Ржавая роща, разъезд, Большой Колокол. + +## 7. Существа и опасности + +Никаких «монстров» — враг тут не персонаж, а состояние мира. + +- **Пепельные мотыли** — серые тени в воздухе, где пепел потревожен. Не атакуют, но + выдают, что тишина нарушена. +- **Безгласные олени** — выцветшие олени, потерявшие голос. Приближаются к звону, + стоят у колокольчиков. Пейзажные «друзья», не добыча. +- **Накат** (опасность) — свежий пласт пепла: дышать поверх опасно. В игре — зоны, + требующие маски/обхода. +- **Старая Машина** (финал акта) — не босс в привычном смысле: механизм, который надо + не убить, а **уложить спать правильным звоном**. + +## 8. Темы + +- **Пересев**: забота как действие, а не слова. Каждый саженный цветок — прогресс игрока. +- **Память в бронзе**: колокол = память. Переплавка прошлого допустима, но цена — голос. +- **Тихая надежда**: мир серый, но золото колокольчика всегда заметно. Правило арта: + тёплый цвет только там, где жизнь. + +## 9. Правила сеттинга (для сценариев и арта) + +1. Тёплый цвет (золото, уголёк) — только жизнь: цветы, бронза, огонь кузни. Всё остальное — + серо-оливковое, ржавое, тёмное. +2. Звук — ресурс. Любое громкое действие в мире имеет цену. +3. Люди не воюют друг с другом. Враг — тишина и пепел. +4. Механизмы старого мира спят, но не мертвы. Они дышат, и это не всегда хорошо. +5. Никакой «магии»: всё мистическое — резонанс, биология трав, дыхание машин. \ No newline at end of file diff --git a/package.json b/package.json index e4551fa..5814600 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "dev": "npm run dev --workspace @rpg/game", "build": "npm run build --workspace @rpg/game", + "art": "node tools/pixelart/gen.mjs", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game" diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2067fd9..9f104e8 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -27,7 +27,7 @@ } from './math/iso'; // map -export { IsometricTileMap, type TileMapData } from './map/IsometricTileMap'; +export { IsometricTileMap, type TileMapData, type TallSpec } from './map/IsometricTileMap'; export { findPath, type Grid } from './map/pathfinding'; // render diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 3cf1552..49cb841 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -3,6 +3,16 @@ import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso'; /** + * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или, + * если текстуры нет, колонной высотой heightPx. + * ground — id тайла, каким рисовать землю под объектом (по умолчанию — сам объект). + */ +export interface TallSpec { + height: number; + ground?: number; +} + +/** * Изометрическая тайл-карта. * Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture. * Проходимость определяется набором блокирующих id; тайлы «высоких» объектов @@ -15,8 +25,8 @@ tiles: number[]; /** Тайлы с этими id непроходимы. */ blocked: number[]; - /** Тайлы с этими id рисуются как вертикальные объекты (высота heightPx). */ - tall?: Record; + /** Тайлы с этими id рисуются как вертикальные объекты. */ + tall?: Record; } export class IsometricTileMap implements Grid { @@ -25,6 +35,7 @@ readonly view: Container; private blockedSet: Set; + private tallSpecs: Map; get width(): number { return this.data.width; @@ -38,28 +49,36 @@ this.data = data; this.iso = iso; this.blockedSet = new Set(data.blocked); + this.tallSpecs = new Map( + Object.entries(data.tall ?? {}).map(([id, spec]) => [ + Number(id), + typeof spec === 'number' ? { height: spec } : spec + ]) + ); this.view = new Container(); const ground = new Container(); const objects = new Container(); this.view.addChild(ground, objects); - // Рисуем по строкам (ty) внутри — для простоты; сортировка глубины по (tx + ty) - // для объектов обеспечивается порядком добавления по диагоналям ниже. + // Сортировка глубины по диагонали (tx + ty) — классика изометрии. const cells: { tx: number; ty: number; id: number }[] = []; for (let ty = 0; ty < data.height; ty++) { for (let tx = 0; tx < data.width; tx++) { cells.push({ tx, ty, id: data.tiles[ty * data.width + tx] }); } } - // Глубина = tx + ty (классика изометрии). cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty)); for (const cell of cells) { - const tex = textures.get(cell.id); + const tall = this.tallSpecs.get(cell.id); const p = isoToScreen(cell.tx, cell.ty, iso); - if (tex) { - const s = new Sprite(tex); + + // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). + const groundId = tall?.ground ?? cell.id; + const groundTex = textures.get(groundId); + if (groundTex) { + const s = new Sprite(groundTex); s.anchor.set(0.5, 0); s.position.set(p.x, p.y); ground.addChild(s); @@ -73,33 +92,27 @@ ground.addChild(g); } - const tallHeight = data.tall?.[cell.id]; - if (tallHeight !== undefined) { - // Столб высоты: ромб сверху + тёмные грани, чтобы объект читался объёмным. - const block = new Graphics(); - block.poly([ - p.x, p.y - tallHeight, - p.x + iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight, - p.x, p.y + iso.tileH - tallHeight, - p.x - iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight - ]); - block.fill(0x6b5a44); - // Грани вниз на высоту tallHeight. - block.poly([ - p.x - iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight, - p.x, p.y + iso.tileH - tallHeight, - p.x, p.y + iso.tileH, - p.x - iso.tileW / 2, p.y + iso.tileH / 2 - ]); - block.fill(0x4a3b2a); - block.poly([ - p.x + iso.tileW / 2, p.y + iso.tileH / 2 - tallHeight, - p.x, p.y + iso.tileH - tallHeight, - p.x, p.y + iso.tileH, - p.x + iso.tileW / 2, p.y + iso.tileH / 2 - ]); - block.fill(0x352a1e); - objects.addChild(block); + // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. + if (tall) { + const objTex = textures.get(cell.id); + if (objTex) { + const s = new Sprite(objTex); + s.anchor.set(0.5, 1); + s.position.set(p.x, p.y + iso.tileH / 2); + objects.addChild(s); + } else { + const block = new Graphics(); + const h = tall.height; + const hw = iso.tileW / 2; + const hh = iso.tileH / 2; + block.poly([p.x, p.y - h, p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x - hw, p.y + hh - h]); + block.fill(0x6b5a44); + block.poly([p.x - hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); + block.fill(0x4a3b2a); + block.poly([p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x + hw, p.y + hh]); + block.fill(0x352a1e); + objects.addChild(block); + } } } } diff --git a/tools/pixelart/canvas.mjs b/tools/pixelart/canvas.mjs new file mode 100644 index 0000000..9bdb000 --- /dev/null +++ b/tools/pixelart/canvas.mjs @@ -0,0 +1,109 @@ +/** + * Мини-канвас с палитрой: пиксель, ромб, шум, ASCII-карты спрайтов. + */ +import { RGBA, TRANSPARENT } from './palette.mjs'; +import { encodePng } from './png.mjs'; + +export class Canvas { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = Buffer.alloc(width * height * 4); + this.data.fill(0); + } + + set(x, y, color, alpha = 255) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (y * this.width + x) * 4; + if (color === null) { + this.data.fill(0, i, i + 4); + return; + } + const [r, g, b] = RGBA[color] ?? color; + this.data[i] = r; + this.data[i + 1] = g; + this.data[i + 2] = b; + this.data[i + 3] = alpha; + } + + get(x, y) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; + const i = (y * this.width + x) * 4; + if (this.data[i + 3] === 0) return null; + return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; + } + + toPng() { + return encodePng(this.width, this.height, this.data); + } +} + +/** Детерминированный ГПСЧ (mulberry32). */ +export function rng(seed) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, + * тень на нижних. light/dark — ключи палитры или null. + * Центр ромба (cx, cy), ширина w, высота h. + */ +export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { + const hw = w / 2; + const hh = h / 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const rx = x - hw + 0.5; + const ry = y - hh + 0.5; + if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { + canvas.set(cx - hw + x, cy - hh + y, base); + } + } + } + for (let i = 0; i < hw; i++) { + // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. + const leftY = cy - Math.round((i / hw) * hh); + const rightY = cy - hh + Math.round((i / hw) * hh); + if (light) { + canvas.set(cx - hw + i, leftY, light); + canvas.set(cx + i, rightY, light); + } + // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. + const botLeftY = cy + Math.round((i / hw) * hh); + const botRightY = cy + hh - Math.round((i / hw) * hh); + if (dark) { + canvas.set(cx - hw + i, botLeftY, dark); + canvas.set(cx + i, botRightY, dark); + } + } +} + +/** + * ASCII-карта -> спрайт. Каждый символ — ключ палитры; '.' — прозрачный пиксель. + * Дополнительные символы передаются в extra ('x' -> 'P1' и т.п.). + */ +export function fromAscii(rows, extra = {}) { + const h = rows.length; + const w = Math.max(...rows.map((r) => r.length)); + const canvas = new Canvas(w, h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < rows[y].length; x++) { + const ch = rows[y][x]; + if (ch === '.') { + continue; + } + const key = extra[ch] ?? ch; + if (!RGBA[key]) { + throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); + } + canvas.set(x, y, key); + } + } + return canvas; +} \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs new file mode 100644 index 0000000..8a1357c --- /dev/null +++ b/tools/pixelart/gen.mjs @@ -0,0 +1,404 @@ +/** + * Генератор пиксель-арта «Пепельных лугов». + * Запуск: node tools/pixelart/gen.mjs + * Выход: apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Canvas, fillDiamond, fromAscii, rng } from './canvas.mjs'; + +const OUT = fileURLToPath(new URL('../../apps/game/assets/', import.meta.url)); + +// ---------- тайлы 32x16 ---------- + +/** Ромб с зерном пепла/травы. */ +function tileBase(base, light, dark, seed, specks) { + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, base, light, dark); + const r = rng(seed); + for (let i = 0; i < specks; i++) { + const x = Math.floor(r() * 32); + const y = Math.floor(r() * 16); + if (c.get(x, y)) { + c.set(x, y, r() < 0.5 ? dark : base); + } + } + return c; +} + +function grassTile() { + const c = tileBase('G1', 'G2', 'G0', 11, 26); + // Сухие стебли: 1-2 пикселя G2H. + const r = rng(77); + for (let i = 0; i < 5; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 3 + Math.floor(r() * 10); + if (c.get(x, y)) { + c.set(x, y, 'G2H'); + } + } + return c; +} + +function pathTile() { + const c = tileBase('P4', 'P5', 'P3', 23, 30); + // Утоптанный камешек и след колеи. + const r = rng(31); + for (let i = 0; i < 4; i++) { + const x = 2 + Math.floor(r() * 28); + const y = 2 + Math.floor(r() * 12); + if (c.get(x, y)) c.set(x, y, 'P2'); + } + return c; +} + +function waterTile() { + const c = tileBase('W1', 'W2', 'W0', 31, 0); + // Рябь: короткие горизонтальные штрихи W2 и глубина W0. + const r = rng(5); + for (let i = 0; i < 5; i++) { + const x = 4 + Math.floor(r() * 22); + const y = 2 + Math.floor(r() * 12); + c.set(x, y, 'W2'); + c.set(x + 1, y, 'W2'); + } + for (let i = 0; i < 3; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 5 + Math.floor(r() * 9); + c.set(x, y, 'W0'); + c.set(x + 1, y, 'W0'); + } + return c; +} + +function ashTile() { + // Пепельный бугор: светлее травы, тёмный обод по нижней грани. + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, 'P4', 'P6', 'P3'); + const r = rng(9); + for (let i = 0; i < 10; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 4 + Math.floor(r() * 9); + if (c.get(x, y)) c.set(x, y, 'P5H'); + } + return c; +} + +function bellflowerTile() { + const c = grassTile(); + // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь. + const flowers = [ + [10, 7], + [21, 9], + [16, 5] + ]; + for (const [x, y] of flowers) { + c.set(x, y - 1, 'B2'); + c.set(x - 1, y, 'B1'); + c.set(x, y, 'B1'); + c.set(x + 1, y, 'B1'); + c.set(x, y + 1, 'G2H'); // стебель + } + return c; +} + +/** Дерево Ржавой рощи 32x48: ржавый ствол, редкая серо-оливковая крона, кабель. */ +function treeTile() { + const c = new Canvas(32, 48); + const r = rng(2026); + + const ellipse = (cx, cy, rx, ry, color) => { + for (let y = -ry; y <= ry; y++) { + for (let x = -rx; x <= rx; x++) { + if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) { + c.set(cx + x, cy + y, color); + } + } + } + }; + + // Крона: три перекрывающихся «облака» мёртвой листвы. + ellipse(16, 9, 10, 7, 'G0'); + ellipse(9, 14, 6, 5, 'G0'); + ellipse(23, 13, 7, 5, 'G0'); + // Внутренний тон и редкие «живые» пряди. + ellipse(15, 10, 7, 4, 'G1'); + ellipse(10, 14, 3, 2, 'G1'); + ellipse(21, 12, 4, 2, 'G1'); + for (let i = 0; i < 26; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 3 + Math.floor(r() * 12); + if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2'); + } + // Прорехи (мёртвое дерево): прозрачные проколы в кроне. + for (const [x, y] of [ + [13, 6], + [19, 12], + [8, 13], + [22, 8] + ]) { + c.set(x, y, null); + c.set(x + 1, y, null); + } + + // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны. + for (let y = 18; y <= 47; y++) { + const sway = Math.round(Math.sin((y - 18) * 0.09) * 1.5); + const half = y > 40 ? 2 : 1; + for (let dx = -half; dx <= half; dx++) { + c.set(16 + sway + dx, y, y > 38 ? 'R1' : 'M1'); + } + c.set(16 + sway - half - 1, y, y > 38 ? 'R0' : 'M0'); + } + // Корни. + for (let i = 0; i < 4; i++) { + c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + } + // Пепел на корнях. + c.set(13, 46, 'P5'); + c.set(19, 46, 'P5'); + + // Кабель через крону и вокруг ствола. + for (let x = 4; x <= 27; x++) { + const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1); + if (!c.get(x, y)) c.set(x, y, 'M2'); + else c.set(x, y, 'M2'); + } + for (let y = 22; y <= 44; y += 7) { + c.set(14, y, 'M0'); + c.set(19, y, 'M0'); + } + + return c; +} + +// ---------- персонажи 16x24 ---------- + +// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, +// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1). +const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' }; + +const heroDown1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '....ooSSSSoo....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oCCCCo.....', + '...oCcCccCcCo...', + '...oCcCccCcCo...', + '...oCccCcCccCo..', + '...oCcCccCcCo...', + '...obBccccBbo...', + '...oCcCccCcCo...', + '...oCccCcCccCo..', + '....oCooCoo.....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +], HERO_MAP); + +// Второй кадр ходьбы: ноги сошлись (средняя фаза). +const heroDown2 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '....ooSSSSoo....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oCCCCo.....', + '...oCcCccCcCo...', + '...oCcCccCcCo...', + '...oCccCcCccCo..', + '...oCcCccCcCo...', + '...obBccccBbo...', + '...oCcCccCcCo...', + '...oCccCcCccCo..', + '....oCo.oCo.....', + '....oPo.oPo.....', + '....oPo.oPo.....', + '....oPPooPPo....', + '.....oPPPPo.....', + '......oooo......', + '................' +], HERO_MAP); + +// Спина: капюшон, узел плаща, лампа на поясе (F1 — единственный тёплый пиксель). +const heroUp1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '.....oTttTo.....', + '....ooCooCoo....', + '...oCCCCCCCCo...', + '...oCcCccCcCo...', + '...oCccCcCccCo..', + '...oCcCccCcCo...', + '...oCcCcccCcCo..', + '...oCccCcCccCo..', + '...oCcccccccCo..', + '...oCffCCffCo...', + '...oCcCccCcCo...', + '....oCooCoo.....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................', + '................' +], { ...HERO_MAP, f: 'F0' }); + +const heroUp2 = heroUp1; // для спины ходьба читается слабо — оставляем один кадр + +const heroSide1 = fromAscii([ + '................', + '.....ooooo......', + '....oTTTTTo.....', + '...oTtttttto....', + '...ottttttto....', + '.....oSSSSo.....', + '.....oSSoPo.....', + '.....oSsSo......', + '.....oSSSo......', + '.....oCCCCo.....', + '....oCcCccCo....', + '....oCccCcCo....', + '....oCcCccCo....', + '....oCcccBbo....', + '....oCcCccCo....', + '....oCccCcCo....', + '.....oCoCco.....', + '.....oPo.Po.....', + '.....oPo.Po.....', + '....oPPo.PPo....', + '....oPPo.PPo....', + '.....oo...oo....', + '................', + '................' +], HERO_MAP); + +// Шаг в бок: передняя нога вытянута. +const heroSide2 = fromAscii([ + '................', + '.....ooooo......', + '....oTTTTTo.....', + '...oTtttttto....', + '...ottttttto....', + '.....oSSSSo.....', + '.....oSSoPo.....', + '.....oSsSo......', + '.....oSSSo......', + '.....oCCCCo.....', + '....oCcCccCo....', + '....oCccCcCo....', + '....oCcCccCo....', + '....oCcccBbo....', + '....oCcCccCo....', + '....oCccCcCo....', + '.....oCoCco.....', + '....oPo..Po.....', + '...oPPo..oPo....', + '...oPPo...PPo...', + '....oo....PPo...', + '..........oo....', + '................', + '................' +], HERO_MAP); + +const elderIrwin = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '....ooSSSSoo....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oTTTTo.....', + '....oTssssTo....', + '...oTTsssSTo....', + '...oTtTssTtTo...', + '...oTttTTttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '....oTtTTtTo....', + '....oTtttTo.....', + '....oTttttTo....', + '....oTttttTo....', + '....oTttttTo....', + '....oooooooo....' +], HERO_MAP); + +const traderMila = fromAscii([ + '................', + '.....oooooo.....', + '....oWWWWWWo....', + '...oWwWWWwWo....', + '...oWWWWWWWo....', + '....oWSSSSo.....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oWWWWo.....', + '....oWwWWwWo....', + '...oWWWWWWWWo...', + '...oWWWWWWWWo...', + '...oWWWBWWWWo...', + '...oWWWWWWWWo...', + '....oWWWWWWo....', + '....oWooWoo.....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................', + '................' +], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); + +// ---------- вывод ---------- + +mkdirSync(OUT + 'tiles', { recursive: true }); +mkdirSync(OUT + 'chars', { recursive: true }); + +function save(canvas, rel) { + writeFileSync(OUT + rel, canvas.toPng()); + console.log(` ${rel}`); +} + +console.log('Генерация тайлов:'); +save(grassTile(), 'tiles/grass.png'); +save(pathTile(), 'tiles/path.png'); +save(waterTile(), 'tiles/water.png'); +save(ashTile(), 'tiles/ash.png'); +save(bellflowerTile(), 'tiles/bellflower.png'); +save(treeTile(), 'tiles/tree.png'); + +console.log('Генерация персонажей:'); +save(heroDown1, 'chars/hero_down_1.png'); +save(heroDown2, 'chars/hero_down_2.png'); +save(heroUp1, 'chars/hero_up_1.png'); +save(heroUp2, 'chars/hero_up_2.png'); +save(heroSide1, 'chars/hero_side_1.png'); +save(heroSide2, 'chars/hero_side_2.png'); +save(elderIrwin, 'chars/elder_irwin.png'); +save(traderMila, 'chars/trader_mila.png'); + +console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/tools/pixelart/palette.mjs b/tools/pixelart/palette.mjs new file mode 100644 index 0000000..a0bf80f --- /dev/null +++ b/tools/pixelart/palette.mjs @@ -0,0 +1,58 @@ +/** + * Палитра из docs/art-style.md — единственный источник цветов арта. + */ + +export const PALETTE = { + // Пепел + P0: '#17171d', + P1: '#23232b', + P2: '#2f2f38', + P3: '#3e3e48', + P4: '#52525c', + P5: '#6a6a74', + P6: '#86868f', + P5H: '#a3a3ac', + // Мёртвая трава + G0: '#2b3026', + G1: '#3d4436', + G2: '#525b44', + G2H: '#687053', + // Ржавчина и металл + R0: '#3a2822', + R1: '#5c3b2e', + R2: '#82503a', + M0: '#26262e', + M1: '#44454e', + M2: '#61626c', + // Вода + W0: '#22343c', + W1: '#32505c', + W2: '#4a737f', + // Тепло — только жизнь + B0: '#8a6a2e', + B1: '#d99a32', + B2: '#f2cd68', + F0: '#d97f2b', + F1: '#f2b45a', + // Люди и ткань + C0: '#3a4356', + C1: '#4d5a72', + W3: '#8a8f7d', + S0: '#8a6d55', + S1: '#c9a689', + T0: '#5a4a3c', + T1: '#77634f' +}; + +/** Прозрачный пиксель. */ +export const TRANSPARENT = [0, 0, 0, 0]; + +export function hexToRgba(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255]; +} + +/** Ключи палитры -> RGBA-значения (кэш). */ +export const RGBA = Object.fromEntries( + Object.entries(PALETTE).map(([k, v]) => [k, hexToRgba(v)]) +); \ No newline at end of file diff --git a/tools/pixelart/png.mjs b/tools/pixelart/png.mjs new file mode 100644 index 0000000..744dbdf --- /dev/null +++ b/tools/pixelart/png.mjs @@ -0,0 +1,61 @@ +/** + * PNG-энкодер без зависимостей: RGBA-пиксели -> PNG-файл (zlib из node:zlib). + * Цвета — ключи палитры из palette.mjs. + */ +import { deflateSync } from 'node:zlib'; + +// CRC32 для чанков PNG. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) { + c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ +export function encodePng(width, height, rgba) { + // Сырые строки: байт фильтра (0 = None) + строка пикселей. + const raw = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) { + raw[y * (width * 4 + 1)] = 0; + rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); +} \ No newline at end of file