Compare commits

...

6 Commits

Author SHA1 Message Date
z.kirill
d60f9e0ce3 Версия профиля и миграция (E1)
Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда
появились улучшения. Оба раза старый файл читался молча и частично, а игрок
видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат
третий раз, поэтому версия заводится до них, а не после.

Правило чтения несимметрично намеренно:

  файл СТАРШЕ игры  — читается с миграцией (версии нет = формат 1)
  файл НОВЕЕ игры   — не читается вовсе, с объяснением в сообщении

Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой,
значит стереть десятки часов; отказ читать всего лишь просит не запускать
старую сборку.

Проверяется обеими сторонами в --descend: поддельный профиль формата 1
читается и сохраняет глубину и схрон, поддельный профиль из будущего
отвергается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:16:08 +03:00
z.kirill
4382af2322 Документация: границы движка, работа через модель, дизайн игры до лейтгейма
18-engine.md    границы Tile2D, проекции 2D, формат проекта, второй проект
  19-agent.md     инструменты --tool, скиллы, терминал в редакторе
  20-gdd.md       акты, петли, рост отряда, закон масштаба, бюджет 200 часов
  21-damage.md    семь стихий, статусы, десять реакций, формулы, триггеры
  22-loot.md      редкости, аффиксы, артефакты, зачарования, материалы
  23-endgame.md   Бездна, мутации, лидерборды, 262 ачивки

Плюс правки 01/02/07/15/16/17 под переехавшие пути и новые правила забега.

Дизайнерская часть держится на одном законе: угроза растёт квадратично, сила
отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ, а не числами. Из
него выведены и экономика опыта (41 млн против 44 млн дохода), и то, почему
реакции считаются от глубины, а не от урона оружия, и почему в Бездне
множитель HP разрешён, а во всём авторском контенте запрещён.

.claude/commands: скиллы /tile2d, /room, /floor — правила этого движка,
которые нельзя вывести из кода. Главное записано первым: не отчитываться об
успехе без прохода --tool validate.

Числа в этих документах — вход для инструментов, а не украшение. Расхождение
между таблицей и выводом инструмента означает, что неправ документ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:45 +03:00
z.kirill
7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00
z.kirill
f637a07d72 Содержимое двух проектов: DESCENT и HOLLOW
Игра стала проектом движка, и её содержимое переехало из assets/ в projects/.

  projects/descent  сама игра: восемь комнат, каталог из 28 сущностей,
                    изометрия, кривая спуска на сто этажей
  projects/hollow   второй проект: вид сверху, четыре комнаты, свой каталог,
                    короткая и злая кривая

Второй проект существует не для примера. Движок с одним проектом ничем не
отличается от игры с красиво названными каталогами, и проверить, что зашитых
знаний про DESCENT в движке не осталось, можно только вторым проектом
(docs/18-engine.md).

Он же сразу окупился: --check нашёл в комнате maw глухую камеру посередине —
ту же ошибку, что когда-то в storage. На сетке она выглядит нормально, но
точка спавна внутри недостижима, и этаж тихо становится легче.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:28 +03:00
z.kirill
8d35f4c2ec Конвейер ассетов: манифесты, риги, каталоги, инструменты
Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
assets/manifests и assets/*/catalog.yaml; PNG, .sfa и contact sheet остаются
за бортом, потому что воспроизводятся командой (docs/12-assets.md).

  assets/manifests   что и как рендерить: окружение, твари, агенты, предметы
  assets/agents      паперdoll-риг восьми направлений, каталог снаряжения
  assets/monsters    пять морфологий, каждая своим ригом (docs/14-monsters.md)
  assets/sources     исходные развёртки и импорты
  assets/art_masters мастер-развёртки, по которым сверяется стиль
  tools/             сборка ассетов, покрытие, генераторы, blender-скрипты
  generated/         заголовки с константами SF_ASSET_*: без них игра не
                     соберётся, поэтому они в репозитории, хотя и производные

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:09 +03:00
z.kirill
2713204c00 Не тащить в историю кеши, бэкапы и чужие ассеты
Перед первой фиксацией накопленного разобрал, что из незакоммиченного является
исходником, а что производным или чужим.

  .sfcache-*/ и .diag-*/ — 49 МБ кешей и диагностики SpriteForge. Плодятся
  каждым прогоном конвейера с другим набором ключей, восстанавливаются из
  assets/manifests.

  *.blend1 — резервные копии Blender по 12 МБ, которые он кладёт рядом с
  .blend при каждом сохранении.

  assets/vendor/ — 68 МБ чужих библиотек анимаций и базовых мешей, из них
  14 МБ один архив. Это вход конвейера, а не наш исходник: качается по ссылке
  из docs/14-monsters.md.

Итого из 103 МБ незакоммиченных ассетов в историю пойдёт 35 МБ, и все они —
источники истины: манифесты, каталоги, риги, мастер-развёртки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:04:55 +03:00
322 changed files with 24793 additions and 514 deletions

View File

@ -0,0 +1,21 @@
# Generate a modular agent sprite
Argument: `$ARGUMENTS` is `component animation` (for example `armor_vest idle`).
1. Read `assets/agents/catalog.yaml`. Do not invent dimensions or direction order.
2. Run `python tools/agent_assets.py prompt <component> <animation>` and use that exact art brief.
3. Prefer the local Blender pipeline. Create the shared rig once if missing:
`python tools/agent_assets.py blender-init`. Render the requested aligned layer with:
`python tools/agent_assets.py blender-render <component> <animation>`.
Blender is at `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe` and the
CLI finds it automatically (or accepts `--blender`). Never create a separate scene,
camera, scale or rig for equipment.
For a whole requested loadout or release set use
`python tools/agent_assets.py blender-render-all <components...> --animations idle walk attack`;
existing sheets are cached unless `--force` is explicitly requested.
4. If a raster image generator is available, request one 4x2 magenta-key board per animation frame, then run `python tools/agent_assets.py ingest <component> <animation> <boards...>` in frame order. Never hand-crop cells.
5. Run `python tools/agent_assets.py validate --strict`, then
`powershell -ExecutionPolicy Bypass -File tools/build_agent_assets.ps1`.
6. Inspect only with SpriteForge `validate` and `ascii`; do not open every generated frame. A human may request a contact sheet.
Visual lock: lean realistic covert operator, fitted dark sci-fi gear, Splinter Cell / Metal Gear / STALKER silhouette. Never bulky power armor, astronaut, space marine, chibi or Among Us. Canvas is 70x90 per direction, Diablo-style isometric orthographic view.

48
.claude/commands/floor.md Normal file
View File

@ -0,0 +1,48 @@
# Tune the difficulty curve
Request: `$ARGUMENTS`
The whole slope of the game lives in one place: `projects/<project>/project.txt`. The shape
of the curve is code (`engine/progression.cpp`), its coefficients are data. Read
`docs/16-descent.md` for what each number means and why it is shaped that way.
## Measure before changing
```bash
tile2d_editor --tool curve 1 20 # what depth means today, floor by floor
tile2d_editor --tool floor <depth> 1 # one floor: budget spent, population, loot
tile2d_editor --tool floor <depth> 42 # same depth, another seed — variance matters
```
Change one line of `project.txt`, run the same commands, and compare. A tuning change with
no before/after numbers is an opinion.
## What the numbers are for
| key | meaning | the trap |
|---|---|---|
| `danger` | base, linear growth, quadratic tail | pure linear reads as "same, but more"; pure exponential ends the run where the player just started understanding the rules |
| `density` | how often spawn points fire, and the cap | above the cap every point always fires, so floors stop differing |
| `loot` | drops and how fast tier climbs | tier must lag danger, or the squad is dressed for the rest of the game by floor ten |
| `sight` | how fast vision shrinks, and the floor | below the floor it stops being frightening and becomes "can't see where to go" |
| `xp` | experience multiplier per floor | must grow slower than danger, or skipping floors becomes the only sane play |
| `themes` | where each finish band starts | must ascend; the last band is open-ended on purpose |
Do **not** add creature HP or damage multipliers to make depth harder. "The same shambler
with triple HP" is not scarier, it is longer. Depth changes *who* comes out and how many.
## Verify
```bash
tile2d_editor --tool validate # set still covers the depths you moved
squad_proto --project <name> --descend 8 # the curve survives an actual run
```
Watch for `ЗАМЕЧАНИЕ` in validate: the floor assembled correctly but the room set no longer
keeps up with the curve. That means "draw more rooms", not "lower the curve" — unless the
curve is what you were asked to change.
## Report
The exact lines you changed, and the `curve` table before and after for the depths that
matter. Name the floors where behaviour changed most.

49
.claude/commands/room.md Normal file
View File

@ -0,0 +1,49 @@
# Author or repair a room
Request: `$ARGUMENTS`
A room is a text file: `projects/<project>/rooms/<name>.room`. Edit it directly — there is
no binary step. Format and reasoning: `docs/15-engine-and-editor.md`.
## Before writing
1. `tile2d_editor --tool rooms` — what the set already has, and at which depths.
2. `tile2d_editor --tool ascii <similar-room>` — read a working room of the same kind.
3. `tile2d_editor --tool catalog monster <depth>` — what may be placed at that depth, with
its cost in the danger budget.
A new room must answer "what does this one do that the others don't": a wide arena, a
corridor where the formation cannot turn, a pocket that forces the squad to split. Another
rectangle with spawn points is not a room, it is filler.
## Hard constraints
- size between 5×5 and 14×14 (`ROOM_SLOT` is 16, the room must leave rock around it);
- doors only on the border, and at least one — a room without doors is never connected;
- **every floor tile must be reachable from the doors.** A sealed inner chamber looks
perfectly normal in the grid and silently makes the floor easier: whatever spawns inside
can neither reach the squad nor be killed. This has already happened twice in this repo
(`storage`, then `maw`);
- spawn points stand on floor, never in a wall, and their table is never empty;
- ids in the table must exist in that project's catalog.
Spawn tables hold **weights and a chance**, not a creature: `spawn 6 4 0.65 rusher:3 shambler:2`
means "this point fires 65% of the time, and then it is a rusher three times out of five".
Depth decides who is allowed and what the floor can afford; the room only says where.
## Verify
```bash
tile2d_editor --tool room <name> # doors, spawn tables, problems
tile2d_editor --tool ascii <name> # the grid, with spawn points numbered
tile2d_editor --tool validate # the whole set, including depth coverage
tile2d_editor --tool map <depth> 42 # the room inside an assembled floor
```
`validate` returning 1 means the set is broken — fix it before reporting. `map` is how you
see whether the room actually connects: look for its shape and check that the corridors
reach it.
## Report
The room's name, size, what it is for, and the numbers from `validate` and one `map` run.

View File

@ -0,0 +1,25 @@
# Produce a game sprite
Request: `$ARGUMENTS`
Treat this as an asset-production task, not a request to draw a one-off shape in C++.
1. Read only the relevant catalog and documentation:
- agent/body/armor/weapon: `assets/agents/catalog.yaml`, `.claude/commands/agent-sprite.md`;
- monster: `assets/monsters/catalog.yaml`, `docs/14-monsters.md`;
- environment/item: `assets/content_coverage.yaml`, `tools/generate_static_assets.py`.
2. Preserve the visual lock: grounded near-future sci-fi horror and body horror. Agents are
lean covert operators, never bulky space marines. Camera is Diablo-style orthographic
isometry. Agent paper-doll layers are exactly 70x90 and share the existing rig/pivot.
3. Extend a catalog/generator/rig; never commit a single hard-coded runtime Sprite as the
final asset. New agent equipment is one layer per animation, not every loadout combination.
4. Render only the requested/new component. Reuse the existing `.blend`; do not rebuild all
rigs unless their geometry changed.
5. Run `powershell -ExecutionPolicy Bypass -File tools/build_assets.ps1` and fix every strict
validation or coverage failure before reporting success.
6. Verify through text (`sf validate`, `sf ascii`, coverage). Do not open generated PNG/SFA.
If the human asks to inspect it, create a grid using `tools/sprite_preview.py` and give the
path; the human may open that preview.
Report the generated asset IDs, supported animations/directions, build status, and exact
command needed to run the game with `--sf`.

View File

@ -0,0 +1,50 @@
# Work on a Tile2D project
Request: `$ARGUMENTS`
Tile2D is the engine (`engine/`); a game is a project (`projects/<name>/`). The editor
(`tile2d_editor`) belongs to the engine and opens any project. Read `docs/18-engine.md`
before touching engine code, `docs/15-engine-and-editor.md` for the editor and room format.
## The loop
Content is plain text in git — rooms, entity catalog, project manifest. Edit the files
directly. What you cannot get from reading files is **consequences**: whether the room
assembles into a floor, whether a spawn point is reachable, whether the floor spends its
danger budget. That is what the tools are for.
```bash
tile2d_editor --tool help # every tool, with arguments
tile2d_editor --tool project # manifest, paths, difficulty curve
tile2d_editor --tool rooms # the set, with problems flagged
tile2d_editor --tool ascii <room> # room grid as text
tile2d_editor --tool floor <depth> [seed] # assemble a floor, report the numbers
tile2d_editor --tool map <depth> [seed] # that floor as an ASCII map
tile2d_editor --tool curve <from> <to> # what depth means, per floor
tile2d_editor --tool validate # whole set: exit code 1 means broken
```
Every tool takes `--project <name>`; inside the editor terminal `TILE2D_PROJECT` is already
set, so the bare command targets the open project.
## Rules that are easy to break here
1. **Never report success without `--tool validate` passing.** Exit code 1 means broken.
A room that looks right in a diff can still seal a pocket off from its own door.
2. **Verify through text, never by opening generated PNG/SFA** (see CLAUDE.md). Floor
layout is `--tool map`; a screenshot is `squad_proto --clean --shot`.
3. **Entity ids are the game's border, not the engine's.** The engine places any id from
the project catalog; `squad_proto` only turns into a creature the ids listed in
`src/sim/spawn_catalog.cpp`. A new id needs a row there too, or the floor spawns nothing.
4. **On-screen text is Latin.** raylib's built-in font has no Cyrillic and draws it as
question marks. Console output and comments stay Russian.
5. **Acceptance is measured in the sandbox.** `--accept` and `--headless` never load a
project. Do not wire content into them.
6. **The editor links only against the engine.** Anything in `editor/` reaching into
`sim/`, `ai/` or `ui/` is a build error, and that is deliberate.
## Report
State what you changed, paste the numbers that prove it (`validate`, `floor`, `curve`), and
name anything you left broken. If a check fails and you could not fix it, say so — do not
describe the intended state as if it were the measured one.

25
.gitignore vendored
View File

@ -17,6 +17,31 @@ main
# Это отладочные снимки; те, что нужны документации, лежат в docs/.
/*.png
# --- ассеты ------------------------------------------------------------------
# Собранная библиотека спрайтов лежит в build/sprites (уже покрыто build/ выше),
# кеш SpriteForge — здесь. И то и другое производное: источник истины —
# assets/manifests. Сгенерированный generated/spriteforge_assets.h, наоборот,
# в репозитории: без него игра не соберётся.
.sfcache/
contact-sheet.png
# Кеши и диагностика SpriteForge с суффиксами: их плодит каждый прогон конвейера
# с другим набором ключей. Всё это производное от assets/manifests и весит
# впятеро больше исходников.
.sfcache-*/
.diag-*/
# Резервные копии Blender: он кладёт .blend1 рядом с .blend при каждом
# сохранении. Файл на двенадцать мегабайт, который никому не нужен.
*.blend1
# --- сторонние ассеты --------------------------------------------------------
# assets/vendor — чужие библиотеки анимаций и базовые меши (68 МБ, из них 14 МБ
# один архив). Это ВХОД конвейера, а не его результат, но и не наш исходник:
# качается по ссылке из docs/14-monsters.md и восстанавливается одной командой.
# Держать чужой архив в своей истории — способ навсегда утяжелить клон.
assets/vendor/
# --- среда -------------------------------------------------------------------
.vs/
.cache/

View File

@ -19,14 +19,92 @@
| [docs/09-acceptance.md](docs/09-acceptance.md) | критерии приёмки (спек 11) и их текущие цифры | приёмка |
| [docs/10-atmosphere.md](docs/10-atmosphere.md) | **хоррор-слой**: свет, туман войны, трассеры, напряжение | картинка и атмосфера |
| [docs/11-melee.md](docs/11-melee.md) | **ближний бой**: сектор удара, три фазы, свой круг, дуга | клинки, классы оружия |
| [docs/12-assets.md](docs/12-assets.md) | **ассеты**: конвейер SpriteForge, манифесты, загрузчик, hot reload | добавить или пересобрать спрайт |
| [docs/13-combat.md](docs/13-combat.md) | **бой**: каталог оружия, твари, снаряжение, меню и HUD | оружие, враги, инвентарь, UI |
| [docs/14-monsters.md](docs/14-monsters.md) | **твари-ассеты**: Blender-риг на пять морфологий, листы, проверки | нарисовать или пересобрать тварь |
| [docs/15-engine-and-editor.md](docs/15-engine-and-editor.md) | **движок и редактор**: три цели сборки, каталог сущностей, формат комнат, генератор уровней | делать комнаты, менять генерацию, трогать границу движка |
| [docs/16-descent.md](docs/16-descent.md) | **спуск**: глубина как первичное понятие, бюджет опасности, лут, кривая сложности | менять сложность, добавлять тварей и лут |
| [docs/17-extraction.md](docs/17-extraction.md) | **экстракшен**: эвакуация, профиль и схрон, чекпоинты, что теряется | правила забега, снаряжение между вылазками |
| [docs/18-engine.md](docs/18-engine.md) | **Tile2D**: границы движка, проекции 2D, формат проекта, второй проект | трогать движок, заводить проект, добавлять вид отображения |
| [docs/19-agent.md](docs/19-agent.md) | **движок и ЛЛМ**: инструменты `--tool`, скиллы, терминал в редакторе | работать над проектом через claude/codex |
| [docs/20-gdd.md](docs/20-gdd.md) | **дизайн игры**: акты, петли, рост отряда, закон масштаба, 200 часов | что и зачем строим дальше |
| [docs/21-damage.md](docs/21-damage.md) | **урон**: семь стихий, статусы, реакции, формулы, триггеры | боевая математика |
| [docs/22-loot.md](docs/22-loot.md) | **добыча**: редкости, аффиксы, артефакты, зачарования, материалы | предметы и экономика |
| [docs/23-endgame.md](docs/23-endgame.md) | **лейтгейм**: Бездна, мутации, лидерборды, 262 ачивки | всё после сотого этажа |
## Быстрые факты
- Исходный спек: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md) — источник истины по правилам.
- Сборка: `cmake -S . -B build && cmake --build build --config Release``build/bin/squad_proto.exe`.
- Сборка: `cmake -S . -B build && cmake --build build --config Release`.
Целей три: `tile2d` (библиотека), `build/bin/squad_proto.exe` (игра),
`build/bin/tile2d_editor.exe` (редактор комнат).
- **Содержимое щупают инструментами, а не глазами**: `tile2d_editor --tool help`.
Правка делается файлами (комнаты и каталог — текст), последствия читаются
командой: `--tool validate`, `--tool floor`, `--tool map`
([19-agent.md](docs/19-agent.md)). Код возврата 1 = поломка.
- В редакторе снизу терминал (клавиша `` ` ``): те же инструменты, git, сборка и
запросы к `claude`/`codex` прямо оттуда. Открытый проект уходит потомкам
переменной `TILE2D_PROJECT`.
- Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям.
- Все числа — в [src/tuning.h](src/tuning.h). Магических констант по коду быть не должно.
Содержимое проверяется отдельно: `tile2d_editor.exe --check` (комнаты и покрытие глубин),
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
- **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
Числа не должны спасать — иначе хоррор кончается на сороковом этаже
([20-gdd.md](docs/20-gdd.md)).
- **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а
обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
`DescribeFloor` ([engine/progression.h](engine/progression.h)); размазывать её
по генератору, каталогу и рендеру нельзя ([16-descent.md](docs/16-descent.md)).
- **Редактор линкуется только с движком.** Обращение из `editor/` к `sim/`,
`ai/` или `ui/` — ошибка сборки, и так задумано ([15-engine-and-editor.md](docs/15-engine-and-editor.md)).
Редактор принадлежит движку: он открывает любой проект (`--project <имя>`) и
ни одной игры по имени не знает.
- Тексты НА ЭКРАНЕ — латиницей: встроенный шрифт raylib кириллицы не знает и
рисует её вопросами. Комментарии и вывод в консоль остаются русскими.
- Числа игры — в [src/tuning.h](src/tuning.h), размеры движка — в
[engine/config.h](engine/config.h). Магических констант по коду быть не должно.
- `src/ai/` не знает про рендер, изометрию и ввод. Это правило не нарушать.
- Правило «не бить своих» одно на все классы оружия: у ствола это отрезок,
у клинка — сектор. Ослаблять его нельзя нигде.
- `RenderGame` принимает `const Game&` — эффекты физически не могут влиять на симуляцию.
- Приёмка меряется в ПЕСОЧНИЦЕ: `Game::combatMode` выключен, в мире мишени,
на бойцах нет снаряжения. Ломать это нельзя — иначе `--accept` меряет другое.
## Ассеты
Спрайты собирает внешний CLI **SpriteForge** (`C:\Users\uuu\Documents\spriteforge`).
Полный порядок работы — в [docs/12-assets.md](docs/12-assets.md), здесь только запреты:
- игра хранит манифесты `assets/manifests/`, исходники `assets/sources/` и
сгенерированный `generated/spriteforge_assets.h`; собранное лежит в
`build/sprites/` и в git не попадает;
- не открывать `build/sprites/*.sfa`, `.sfcache/`, сгенерированные PNG и
contact sheet: проверка только текстом — `sf list`, `sf describe`, `sf ascii`,
`sf validate`, `sf stats`;
- строковых ID ассетов в C++ нет — только константы `SF_ASSET_*`;
- не править репозиторий SpriteForge ради одной игровой сущности.
# Agent sprite production
For ANY request to create, change, replace, or add game art, follow `.claude/commands/sprite.md`
(the `/sprite` workflow) automatically even when the user did not type the slash command.
Never satisfy an art request with a one-off procedural runtime drawing.
When asked to create an agent, armor, or weapon sprite, use `/agent-sprite` and
`assets/agents/catalog.yaml`; do not draw a one-off procedural humanoid. Agent art is
an aligned paper-doll: body, armor and held weapon are separate 70x90, eight-direction
layers sharing one rig and pivot. Run `tools/build_agent_assets.ps1` after ingestion.
Start the game with `--sf-agents` (or `--sf`) to see the generated layers. Blender is
installed at `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe`.
# Monster sprite production
Enemies do NOT go through the agent paper-doll. They have their own Blender-first
pipeline: `assets/monsters/catalog.yaml`, `tools/blender/monster_rig.py`,
`tools/monster_assets.py` (`monster-init`, `monster-render`, `monster-render-all`,
`validate`, `sync`). Five kinds, five distinct morphologies — never one humanoid
recoloured five times. Asset ids are `<kind>_<animation>`; renaming them breaks
`tools/asset_coverage.py`. Full rules in [docs/14-monsters.md](docs/14-monsters.md).

View File

@ -24,30 +24,89 @@ if(NOT raylib_FOUND)
endif()
# ---------------------------------------------------------------------------
# Исходники
# SpriteForge: загрузчик .sfa это один сторонний заголовок sfa.h из соседнего
# репозитория. Копий в игре нет; собранные ассеты и generated/ отдельно.
# ---------------------------------------------------------------------------
file(GLOB_RECURSE SQUAD_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
set(SPRITEFORGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../spriteforge" CACHE PATH
"Каталог репозитория SpriteForge (нужен только его include/sfa.h)")
add_executable(squad_proto ${SQUAD_SOURCES})
find_path(SPRITEFORGE_INCLUDE_DIR
NAMES sfa.h
HINTS "${SPRITEFORGE_DIR}/include"
NO_DEFAULT_PATH)
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(squad_proto PRIVATE raylib)
if(MSVC)
target_compile_options(squad_proto PRIVATE /W4 /utf-8 /permissive-)
else()
target_compile_options(squad_proto PRIVATE -Wall -Wextra)
if(NOT SPRITEFORGE_INCLUDE_DIR)
message(FATAL_ERROR
"sfa.h не найден в ${SPRITEFORGE_DIR}/include.\n"
"Задать путь: cmake -S . -B build -DSPRITEFORGE_DIR=<путь к репозиторию spriteforge>")
endif()
# ---------------------------------------------------------------------------
# Tile2D движок; всё остальное стоит на нём.
#
# tile2d engine/: софтверный фреймбуфер, проекции 2D, тайлмап,
# тайлсет с темами, формат комнат, генератор уровней,
# каталог сущностей, формат проекта. Про правила боя,
# оружие и отряд не знает ничего.
# tile2d_editor editor/: редактор комнат. Инструмент ДВИЖКА: открывает
# любой проект, а не один конкретный.
# squad_proto src/: игра «Descent», один из проектов движка.
#
# Граница проведена ЛИНКОВКОЙ, а не договорённостью: ни движок, ни редактор не
# видят ни одного символа из sim/ и ai/, поэтому «редактор полез в правила
# игры» ошибка сборки, а не то, что надо заметить на ревью.
#
# Раньше состав движка задавался списком файлов: каталоги src/render и src/world
# делили обе цели, и без списка граница расползлась бы при первом новом .cpp.
# Теперь у движка СВОЙ каталог, и решение «это движок» принимается тем, куда
# положен файл. Список стал бы вторым источником истины о том же самом.
# ---------------------------------------------------------------------------
file(GLOB ENGINE_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/engine/*.cpp")
file(GLOB EDITOR_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/editor/*.cpp")
file(GLOB_RECURSE GAME_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
add_library(tile2d STATIC ${ENGINE_SOURCES})
target_include_directories(tile2d PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}" # engine/..., editor/...
"${CMAKE_CURRENT_SOURCE_DIR}/generated" # spriteforge_assets.h, `sf codegen`
"${SPRITEFORGE_INCLUDE_DIR}")
target_link_libraries(tile2d PUBLIC raylib)
add_executable(squad_proto ${GAME_SOURCES})
target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(squad_proto PRIVATE tile2d)
add_executable(tile2d_editor ${EDITOR_SOURCES})
target_link_libraries(tile2d_editor PRIVATE tile2d)
# Пример. Он здесь не «для демонстрации»: это ВТОРОЙ потребитель движка помимо
# игры, и только он ловит случай, когда что-то общее незаметно уехало в src/.
# Игра и редактор такого не заметят редактор не рисует мир, а игра сама и есть
# то место, куда уезжает.
file(GLOB SAMPLE_WALK_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/samples/walk/*.cpp")
add_executable(tile2d_walk ${SAMPLE_WALK_SOURCES})
target_link_libraries(tile2d_walk PRIVATE tile2d)
foreach(target tile2d squad_proto tile2d_editor tile2d_walk)
if(MSVC)
target_compile_options(${target} PRIVATE /W4 /utf-8 /permissive-)
else()
target_compile_options(${target} PRIVATE -Wall -Wextra)
endif()
endforeach()
if(WIN32)
target_link_libraries(squad_proto PRIVATE winmm)
target_link_libraries(tile2d PUBLIC winmm)
endif()
# Единый каталог для бинарника при любом генераторе (VS/Ninja/Makefiles)
set_target_properties(squad_proto PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin")
# Единый каталог для бинарников при любом генераторе (VS/Ninja/Makefiles)
foreach(target squad_proto tile2d_editor tile2d_walk)
set_target_properties(${target} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin")
endforeach()

Binary file not shown.

View File

@ -0,0 +1,43 @@
version: 1
frame: {width: 70, height: 90, pivot_x: 35, pivot_y: 87}
directions: [south, southwest, west, northwest, north, northeast, east, southeast]
animations:
idle: {frames: 4, fps: 8}
walk: {frames: 8, fps: 12}
attack: {frames: 6, fps: 12}
# Одна камера на все компоненты и оба конвейера (Blender и растровый).
# elevation_deg = atan(TILE_H / TILE_W) = atan(0.5): ровно тот же 2:1, по
# которому в игре лежит пол (src/tuning.h). Разъедься эти два числа — фигура
# встала бы к полу под другим углом, чем ромб под ней.
camera:
elevation_deg: 26.565
# Alien-Shooter-like gameplay scale: an upright 1.8 m operator occupies
# roughly 28-30 internal pixels, not half the screen.
pixels_per_unit: 28.0
distance: 10.0 # только для клиппинга: проекция ортографическая
body_height: 1.80 # метры, худой взрослый; не бронированный громила
source_model: "assets/vendor/quaternius-universal-animation-library/files/Animation Library[Standard]/Godot/AnimationLibrary_Godot_Standard.glb"
source_rotation_deg: 0.0
source_scale: 0.96
source_ground_z: 0.0
# Low-resolution silhouette compensation applied after skin deformation.
# It preserves the authored skeleton/actions but prevents arms and legs from
# collapsing to one-pixel wires in a 70x90 sprite cell.
source_silhouette_thickness: 0.022
# Every component is rendered against the same skeleton/camera. Missing source
# files are allowed while authoring; `agent_assets.py validate --complete`
# turns them into errors for a release build.
components:
body_sleek: {slot: body, style: sleek infiltration body}
armor_jacket: {slot: armor, style: fitted field jacket and thin tactical harness}
armor_vest: {slot: armor, style: light low-profile ballistic vest}
armor_plate: {slot: armor, style: segmented medium ceramic chest plates}
armor_shroud: {slot: armor, style: close-fitting stealth shroud, no cape}
weapon_sword: {slot: weapon, style: narrow tactical longsword}
weapon_dagger: {slot: weapon, style: compact combat dagger}
weapon_rifle: {slot: weapon, style: compact suppressed assault rifle}
weapon_shotgun: {slot: weapon, style: short industrial combat shotgun}
weapon_pistol: {slot: weapon, style: suppressed service pistol}
weapon_bow: {slot: weapon, style: compact near-future compound bow}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,60 @@
version: 1
art_direction:
world: "derelict near-future facility, industrial sci-fi horror, invasive wet biomass"
agents: "lean covert operators, grounded tactical sci-fi; human, not bulky power armor"
enemies: "readable body-horror anatomy, surgically mutated flesh and industrial implants"
agents:
components:
body: [body_sleek]
armor: [armor_jacket, armor_vest, armor_plate, armor_shroud]
weapon: [weapon_sword, weapon_dagger, weapon_rifle, weapon_shotgun, weapon_pistol, weapon_bow]
animations: [idle, walk, attack]
release_requires_all_combinations: true
enemies:
kinds: [shambler, rusher, spitter, brute, lurker]
animations: [walk, attack, hit, death]
environment:
required:
- floor_clean
- floor_cracked
- floor_grate
- floor_biomass
- wall_industrial
- wall_infested
- prop_crate
- prop_barrel
- prop_locker
item_icons:
required:
- item_icon_rifle
- item_icon_pistol
- item_icon_shotgun
- item_icon_smg
- item_icon_magnum
- item_icon_bow
- item_icon_saber
- item_icon_knife
- item_icon_cleaver
- item_icon_jacket
- item_icon_vest
- item_icon_plate
- item_icon_shroud
- item_icon_lantern
- item_icon_charm
- item_icon_spurs
- item_icon_bandage
- item_icon_stimpack
procedural_runtime_exemptions:
# These are temporal primitives, not reusable bitmap assets.
- bullet_tracer
- muzzle_flash
- impact_spark
- melee_arc
- fog_of_war
- blood_field
- ui_chrome

View File

@ -0,0 +1,56 @@
# Production agent sources rendered directly by SpriteForge. Project-specific
# object names live here; the standalone tool only understands logical layers.
version: 1
defaults:
backend: blender
dirs: 8
fps: 12
size: 70x90
postprocess_scale: 0.25
pivot: "140,348"
tags: [agent, modular, blender]
agent_body_sleek:
rig: ../agents/agent_rig.blend
parts: {body: body_sleek_mesh}
anims: [idle, walk, attack]
params: &agent_render
executable: blender
render_size: 280x360
ortho_scale: 3.2142857
elevation: 26.565
camera_distance: 10
# With the 26.565 degree camera this projects world origin (the point
# between the feet) to the declared supersampled pivot at y=348.
camera_target: [0, 0, 1.70]
direction_offset_deg: -90
engine: BLENDER_EEVEE
samples: 16
actions: {idle: idle, walk: walk, attack: attack}
frames: {idle: 4, walk: 8, attack: 6}
loop_sampling: true
part_members:
body: [body_sleek_mesh, body_sleek_helmet, body_sleek_visor,
body_sleek_respirator, body_sleek_collar]
agent_armor_vest:
tags: [agent, modular, blender, armor, paperdoll_layer]
rig: ../agents/agent_rig.blend
parts: {armor: armor_vest_shell}
anims: [idle, walk, attack]
params:
<<: *agent_render
part_members:
armor: [armor_vest_shell, armor_vest_chest_plate,
armor_vest_mag_l, armor_vest_mag_r]
agent_weapon_rifle:
tags: [agent, modular, blender, weapon, paperdoll_layer]
rig: ../agents/agent_rig.blend
parts: {weapon: weapon_rifle_receiver}
anims: [idle, walk, attack]
params:
<<: *agent_render
part_members:
weapon: [weapon_rifle_receiver, weapon_rifle_barrel,
weapon_rifle_stock, weapon_rifle_optic]

View File

@ -0,0 +1,239 @@
# Собрано `python tools/agent_assets.py sync`.
# Записи с backend: import перегенерируются по PNG в assets/sources/agents/sleek/.
# Записи с любым другим backend (например blender) sync СОХРАНЯЕТ как есть:
# правь их руками здесь, это единственное место, где такая правка переживает sync.
version: 1
defaults:
dirs: 8
pivot: "35,87"
agent_body_sleek_idle:
backend: import
source: ../sources/agents/sleek/body_sleek_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, body, idle]
agent_body_sleek_walk:
backend: import
source: ../sources/agents/sleek/body_sleek_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, body, walk]
agent_body_sleek_attack:
backend: import
source: ../sources/agents/sleek/body_sleek_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, body, attack]
agent_armor_jacket_idle:
backend: import
source: ../sources/agents/sleek/armor_jacket_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, armor, idle, paperdoll_layer]
agent_armor_jacket_walk:
backend: import
source: ../sources/agents/sleek/armor_jacket_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, armor, walk, paperdoll_layer]
agent_armor_jacket_attack:
backend: import
source: ../sources/agents/sleek/armor_jacket_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, armor, attack, paperdoll_layer]
agent_armor_vest_idle:
backend: import
source: ../sources/agents/sleek/armor_vest_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, armor, idle, paperdoll_layer]
agent_armor_vest_walk:
backend: import
source: ../sources/agents/sleek/armor_vest_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, armor, walk, paperdoll_layer]
agent_armor_vest_attack:
backend: import
source: ../sources/agents/sleek/armor_vest_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, armor, attack, paperdoll_layer]
agent_armor_plate_idle:
backend: import
source: ../sources/agents/sleek/armor_plate_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, armor, idle, paperdoll_layer]
agent_armor_plate_walk:
backend: import
source: ../sources/agents/sleek/armor_plate_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, armor, walk, paperdoll_layer]
agent_armor_plate_attack:
backend: import
source: ../sources/agents/sleek/armor_plate_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, armor, attack, paperdoll_layer]
agent_armor_shroud_idle:
backend: import
source: ../sources/agents/sleek/armor_shroud_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, armor, idle, paperdoll_layer]
agent_armor_shroud_walk:
backend: import
source: ../sources/agents/sleek/armor_shroud_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, armor, walk, paperdoll_layer]
agent_armor_shroud_attack:
backend: import
source: ../sources/agents/sleek/armor_shroud_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, armor, attack, paperdoll_layer]
agent_weapon_sword_idle:
backend: import
source: ../sources/agents/sleek/weapon_sword_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_sword_walk:
backend: import
source: ../sources/agents/sleek/weapon_sword_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_sword_attack:
backend: import
source: ../sources/agents/sleek/weapon_sword_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]
agent_weapon_dagger_idle:
backend: import
source: ../sources/agents/sleek/weapon_dagger_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_dagger_walk:
backend: import
source: ../sources/agents/sleek/weapon_dagger_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_dagger_attack:
backend: import
source: ../sources/agents/sleek/weapon_dagger_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]
agent_weapon_rifle_idle:
backend: import
source: ../sources/agents/sleek/weapon_rifle_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_rifle_walk:
backend: import
source: ../sources/agents/sleek/weapon_rifle_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_rifle_attack:
backend: import
source: ../sources/agents/sleek/weapon_rifle_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]
agent_weapon_shotgun_idle:
backend: import
source: ../sources/agents/sleek/weapon_shotgun_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_shotgun_walk:
backend: import
source: ../sources/agents/sleek/weapon_shotgun_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_shotgun_attack:
backend: import
source: ../sources/agents/sleek/weapon_shotgun_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]
agent_weapon_pistol_idle:
backend: import
source: ../sources/agents/sleek/weapon_pistol_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_pistol_walk:
backend: import
source: ../sources/agents/sleek/weapon_pistol_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_pistol_attack:
backend: import
source: ../sources/agents/sleek/weapon_pistol_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]
agent_weapon_bow_idle:
backend: import
source: ../sources/agents/sleek/weapon_bow_idle.png
fps: 8
params: {frame_width: 70, frame_height: 90, frames: 32}
tags: [agent, weapon, idle, paperdoll_layer]
agent_weapon_bow_walk:
backend: import
source: ../sources/agents/sleek/weapon_bow_walk.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 64}
tags: [agent, weapon, walk, paperdoll_layer]
agent_weapon_bow_attack:
backend: import
source: ../sources/agents/sleek/weapon_bow_attack.png
fps: 12
params: {frame_width: 70, frame_height: 90, frames: 48}
tags: [agent, weapon, attack, paperdoll_layer]

View File

@ -0,0 +1,29 @@
# One-frame visual/geometry gate for iterating on the production agent rig.
# It is intentionally outside the normal manifest set and builds in seconds.
version: 1
agent_body_gate:
backend: blender
rig: ../../agents/agent_rig.blend
parts: {body: body_sleek_mesh}
anims: [idle]
dirs: 1
fps: 8
size: 70x90
postprocess_scale: 0.25
pivot: "140,348"
tags: [diagnostic, agent, blender]
params:
executable: blender
render_size: 280x360
ortho_scale: 3.2142857
elevation: 26.565
camera_distance: 10
camera_target: [0, 0, 1.70]
direction_offset_deg: -90
engine: BLENDER_EEVEE
samples: 16
actions: {idle: idle}
frames: {idle: 1}
part_members:
body: [body_sleek_mesh, body_sleek_helmet, body_sleek_visor,
body_sleek_respirator, body_sleek_collar]

View File

@ -0,0 +1,26 @@
version: 1
rusher_gate:
backend: blender
rig: ../../monsters/rusher.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk]
dirs: 1
fps: 14
size: 96x112
postprocess_scale: 0.5
pivot: "96,176"
tags: [diagnostic, enemy, blender]
params:
executable: blender
render_size: 192x224
ortho_scale: 3.5
elevation: 26.565
camera_distance: 12
camera_target: [0, 0, 1.118]
direction_offset_deg: -90
engine: BLENDER_EEVEE
samples: 12
actions: {walk: rusher_walk}
frames: {walk: 1}
loop_sampling: {walk: true}
part_prefixes: {body: rusher_}

View File

@ -0,0 +1,8 @@
# Legacy placeholder manifest retained so old build commands keep working.
# Real eight-direction body-horror enemies are generated into
# monsters.generated.yaml by tools/monster_assets.py.
version: 1
defaults:
dirs: 1
fps: 10
pivot: bottom_center

View File

@ -0,0 +1,31 @@
# Тайлы уровня. Геометрия обязана совпадать с src/tuning.h:
# ромб пола TILE_W x TILE_H = 32 x 16
# блок стены TILE_W x WALL_H = 32 x 32 (крыша 16 + вертикальные грани 16)
version: 1
defaults:
dirs: 1
fps: 12
pivot: bottom_center
# Шахматка пола — два варианта. Ромб 32x16, pivot по центру ромба.
floor_slab:
backend: import
source: ../sources/imports/floor_slab.png
pivot: "15,8"
tags: [environment, floor]
floor_slab_dim:
backend: import
source: ../sources/imports/floor_slab_dim.png
pivot: "15,8"
tags: [environment, floor]
# Блок стены: верхняя грань 32x16 + боковые грани 16px вниз = 32x32.
# pivot — нижний край блока (низ боковых граней), anchor в C++ перекладывает
# его на центр крыши (TILE_HALF_W, TILE_HALF_H + TILE_H).
wall_block:
backend: import
source: ../sources/imports/wall_block.png
pivot: "15,31"
tags: [environment, wall]

View File

@ -0,0 +1,71 @@
# Five production monster rigs rendered directly by SpriteForge. Each asset
# contains every gameplay clip; no intermediate sprite sheets are required.
version: 1
defaults:
backend: blender
dirs: 8
fps: 12
size: 96x112
postprocess_scale: 0.5
pivot: "96,176"
tags: [enemy, body_horror, blender, consolidated]
shambler:
rig: ../monsters/shambler.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk, attack, hit, death]
params: &monster_render
executable: blender
render_size: 192x224
ortho_scale: 3.5
elevation: 26.565
camera_distance: 12
camera_target: [0, 0, 1.118]
direction_offset_deg: -90
engine: BLENDER_EEVEE
samples: 12
frames: {walk: 8, attack: 6, hit: 4, death: 8}
animation_fps: {walk: 8, attack: 10, hit: 11, death: 8}
loop_sampling: {walk: true, attack: false, hit: false, death: false}
actions: {walk: shambler_walk, attack: shambler_attack, hit: shambler_hit, death: shambler_death}
part_prefixes: {body: shambler_}
rusher:
rig: ../monsters/rusher.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk, attack, hit, death]
params:
<<: *monster_render
animation_fps: {walk: 14, attack: 17, hit: 20, death: 14}
actions: {walk: rusher_walk, attack: rusher_attack, hit: rusher_hit, death: rusher_death}
part_prefixes: {body: rusher_}
spitter:
rig: ../monsters/spitter.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk, attack, hit, death]
params:
<<: *monster_render
animation_fps: {walk: 9, attack: 11, hit: 13, death: 9}
actions: {walk: spitter_walk, attack: spitter_attack, hit: spitter_hit, death: spitter_death}
part_prefixes: {body: spitter_}
brute:
rig: ../monsters/brute.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk, attack, hit, death]
params:
<<: *monster_render
animation_fps: {walk: 7, attack: 8, hit: 10, death: 7}
actions: {walk: brute_walk, attack: brute_attack, hit: brute_hit, death: brute_death}
part_prefixes: {body: brute_}
lurker:
rig: ../monsters/lurker.blend
parts: {body: unused_when_prefix_is_set}
anims: [walk, attack, hit, death]
params:
<<: *monster_render
animation_fps: {walk: 12, attack: 14, hit: 17, death: 12}
actions: {walk: lurker_walk, attack: lurker_attack, hit: lurker_hit, death: lurker_death}
part_prefixes: {body: lurker_}

View File

@ -0,0 +1,151 @@
# Собрано `python tools/monster_assets.py sync`.
# Записи с backend: import перегенерируются по PNG в assets/sources/monsters/.
# Записи с любым другим backend sync СОХРАНЯЕТ как есть: правь их руками
# здесь, это единственное место, где такая правка переживает sync.
#
# ID ассета — <kind>_<animation>: ровно то, что требует
# tools/asset_coverage.py от группы enemy. Переименуешь — упадёт покрытие.
version: 1
defaults:
dirs: 8
pivot: "48,88"
shambler_walk:
backend: import
source: ../sources/monsters/shambler_walk.png
fps: 8
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, shambler, walk]
shambler_attack:
backend: import
source: ../sources/monsters/shambler_attack.png
fps: 10
params: {frame_width: 96, frame_height: 112, frames: 48}
tags: [enemy, shambler, attack]
shambler_hit:
backend: import
source: ../sources/monsters/shambler_hit.png
fps: 11
params: {frame_width: 96, frame_height: 112, frames: 32}
tags: [enemy, shambler, hit]
shambler_death:
backend: import
source: ../sources/monsters/shambler_death.png
fps: 8
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, shambler, death]
rusher_walk:
backend: import
source: ../sources/monsters/rusher_walk.png
fps: 14
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, rusher, walk]
rusher_attack:
backend: import
source: ../sources/monsters/rusher_attack.png
fps: 17
params: {frame_width: 96, frame_height: 112, frames: 48}
tags: [enemy, rusher, attack]
rusher_hit:
backend: import
source: ../sources/monsters/rusher_hit.png
fps: 20
params: {frame_width: 96, frame_height: 112, frames: 32}
tags: [enemy, rusher, hit]
rusher_death:
backend: import
source: ../sources/monsters/rusher_death.png
fps: 14
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, rusher, death]
spitter_walk:
backend: import
source: ../sources/monsters/spitter_walk.png
fps: 9
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, spitter, walk]
spitter_attack:
backend: import
source: ../sources/monsters/spitter_attack.png
fps: 11
params: {frame_width: 96, frame_height: 112, frames: 48}
tags: [enemy, spitter, attack]
spitter_hit:
backend: import
source: ../sources/monsters/spitter_hit.png
fps: 13
params: {frame_width: 96, frame_height: 112, frames: 32}
tags: [enemy, spitter, hit]
spitter_death:
backend: import
source: ../sources/monsters/spitter_death.png
fps: 9
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, spitter, death]
brute_walk:
backend: import
source: ../sources/monsters/brute_walk.png
fps: 7
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, brute, walk]
brute_attack:
backend: import
source: ../sources/monsters/brute_attack.png
fps: 8
params: {frame_width: 96, frame_height: 112, frames: 48}
tags: [enemy, brute, attack]
brute_hit:
backend: import
source: ../sources/monsters/brute_hit.png
fps: 10
params: {frame_width: 96, frame_height: 112, frames: 32}
tags: [enemy, brute, hit]
brute_death:
backend: import
source: ../sources/monsters/brute_death.png
fps: 7
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, brute, death]
lurker_walk:
backend: import
source: ../sources/monsters/lurker_walk.png
fps: 12
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, lurker, walk]
lurker_attack:
backend: import
source: ../sources/monsters/lurker_attack.png
fps: 14
params: {frame_width: 96, frame_height: 112, frames: 48}
tags: [enemy, lurker, attack]
lurker_hit:
backend: import
source: ../sources/monsters/lurker_hit.png
fps: 17
params: {frame_width: 96, frame_height: 112, frames: 32}
tags: [enemy, lurker, hit]
lurker_death:
backend: import
source: ../sources/monsters/lurker_death.png
fps: 12
params: {frame_width: 96, frame_height: 112, frames: 64}
tags: [enemy, lurker, death]

View File

@ -0,0 +1,166 @@
version: 1
defaults:
dirs: 1
fps: 1
floor_clean:
backend: import
source: ../sources/world/floor_clean.png
pivot: "15,8"
tags: [environment, floor]
floor_cracked:
backend: import
source: ../sources/world/floor_cracked.png
pivot: "15,8"
tags: [environment, floor]
floor_grate:
backend: import
source: ../sources/world/floor_grate.png
pivot: "15,8"
tags: [environment, floor]
floor_biomass:
backend: import
source: ../sources/world/floor_biomass.png
pivot: "15,8"
tags: [environment, floor]
wall_industrial:
backend: import
source: ../sources/world/wall_industrial.png
pivot: "15,31"
tags: [environment, wall]
wall_infested:
backend: import
source: ../sources/world/wall_infested.png
pivot: "15,31"
tags: [environment, wall]
prop_crate:
backend: import
source: ../sources/world/prop_crate.png
pivot: bottom_center
tags: [environment, prop]
prop_barrel:
backend: import
source: ../sources/world/prop_barrel.png
pivot: bottom_center
tags: [environment, prop]
prop_locker:
backend: import
source: ../sources/world/prop_locker.png
pivot: bottom_center
tags: [environment, prop]
item_icon_rifle:
backend: import
source: ../sources/items/item_icon_rifle.png
pivot: center
tags: [ui, item]
item_icon_pistol:
backend: import
source: ../sources/items/item_icon_pistol.png
pivot: center
tags: [ui, item]
item_icon_shotgun:
backend: import
source: ../sources/items/item_icon_shotgun.png
pivot: center
tags: [ui, item]
item_icon_smg:
backend: import
source: ../sources/items/item_icon_smg.png
pivot: center
tags: [ui, item]
item_icon_magnum:
backend: import
source: ../sources/items/item_icon_magnum.png
pivot: center
tags: [ui, item]
item_icon_bow:
backend: import
source: ../sources/items/item_icon_bow.png
pivot: center
tags: [ui, item]
item_icon_saber:
backend: import
source: ../sources/items/item_icon_saber.png
pivot: center
tags: [ui, item]
item_icon_knife:
backend: import
source: ../sources/items/item_icon_knife.png
pivot: center
tags: [ui, item]
item_icon_cleaver:
backend: import
source: ../sources/items/item_icon_cleaver.png
pivot: center
tags: [ui, item]
item_icon_jacket:
backend: import
source: ../sources/items/item_icon_jacket.png
pivot: center
tags: [ui, item]
item_icon_vest:
backend: import
source: ../sources/items/item_icon_vest.png
pivot: center
tags: [ui, item]
item_icon_plate:
backend: import
source: ../sources/items/item_icon_plate.png
pivot: center
tags: [ui, item]
item_icon_shroud:
backend: import
source: ../sources/items/item_icon_shroud.png
pivot: center
tags: [ui, item]
item_icon_lantern:
backend: import
source: ../sources/items/item_icon_lantern.png
pivot: center
tags: [ui, item]
item_icon_charm:
backend: import
source: ../sources/items/item_icon_charm.png
pivot: center
tags: [ui, item]
item_icon_spurs:
backend: import
source: ../sources/items/item_icon_spurs.png
pivot: center
tags: [ui, item]
item_icon_bandage:
backend: import
source: ../sources/items/item_icon_bandage.png
pivot: center
tags: [ui, item]
item_icon_stimpack:
backend: import
source: ../sources/items/item_icon_stimpack.png
pivot: center
tags: [ui, item]

BIN
assets/monsters/brute.blend Normal file

Binary file not shown.

View File

@ -0,0 +1,62 @@
# Каталог тварей: единственный источник геометрии monster-конвейера.
#
# Твари — НЕ бумажная кукла. У агента слои обязаны совпадать пиксель в пиксель,
# поэтому там один силуэт на всех. Здесь наоборот: пять видов обязаны читаться
# по пятну, не глядя на детали, — значит у каждого своя морфология, свой рост и
# своя походка. Общее у них ровно две вещи: камера и точка привязки.
version: 1
# Ячейка листа и общий pivot. pivot_y НЕ на нижней кромке: изометрический пол
# уходит от точки опоры ВНИЗ по экрану, и всё, что у твари позади (хвост
# бегуна, задние лапы затаившегося), проецируется ниже точки опоры. Девять
# строк под pivot — это запас на глубину подставки, а не «воздух».
# Monsters may sprawl wider than the 70x90 human paper-doll when dying. The
# camera scale stays unchanged: this is transparent safety padding, not a
# larger creature. Human agent layers remain exactly 70x90.
frame: {width: 96, height: 112, pivot_x: 48, pivot_y: 88}
# Порядок направлений тот же, что у агентов: по -45° от юга.
directions: [south, southwest, west, northwest, north, northeast, east, southeast]
# Кадры на ОДНО направление. Лист direction-major: сначала все кадры юга,
# затем юго-запада и так далее.
animations:
walk: {frames: 8, fps: 10}
attack: {frames: 6, fps: 12}
hit: {frames: 4, fps: 14}
death: {frames: 8, fps: 10}
# Камера ровно та же, что у агентов: elevation_deg = atan(TILE_H / TILE_W) =
# atan(0.5). Разъедься эти числа — тварь встала бы к полу под другим углом,
# чем боец рядом с ней.
camera:
elevation_deg: 26.565
# Common mutants read slightly larger than an operator; brute dominates.
pixels_per_unit: 32.0
distance: 12.0 # только для клиппинга: проекция ортографическая
# height_m — справочная высота силуэта в метрах, ею меряется читаемость
# размеров: громила обязан быть вдвое выше затаившегося.
# fps_scale — темп клипа относительно animations.fps: бредущий волочится,
# бегун частит. Ассету это даёт разный fps при одинаковом числе кадров.
kinds:
shambler:
style: "flayed penitent; thorax cranked open, ribs splayed outward, one arm grown past the knee"
height_m: 1.58
fps_scale: 0.8
rusher:
style: "quadruped sprinter; horizontal spine, digitigrade hind legs, scythe forelimbs, headless split-jaw bloom"
height_m: 0.78
fps_scale: 1.4
spitter:
style: "pressure vessel; dragging gland sac, implanted throat valve and nozzle, vestigial arms"
height_m: 1.22
fps_scale: 0.9
brute:
style: "the fused; a second half-absorbed body grafted to the shoulder, bolted plating, hammer of fused bone"
height_m: 1.96
fps_scale: 0.7
lurker:
style: "arachnid harvester; flat sutured carapace slung under four long arching limbs, eyeless hook wedge"
height_m: 0.74
fps_scale: 1.2

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

21
assets/sources/README.md Normal file
View File

@ -0,0 +1,21 @@
# assets/sources
Исходники ассетов, на которые ссылаются манифесты из `assets/manifests/`.
- `imports/` — готовые PNG для бэкенда `import` (поле `source`);
- `rigs/``.blend`-риги для бэкенда `blender` (поле `rig`).
Процедурные ассеты исходников не имеют: генератор и параметры целиком описаны
в манифесте. Пути в манифесте разрешаются относительно самого манифеста,
поэтому импорт выглядит так:
```yaml
health_potion:
backend: import
source: ../sources/imports/health_potion.png
size: 32x32
tags: [item, consumable]
```
Собранные `.sfa` и `palettes.sfp` сюда не кладутся — они живут в `build/sprites/`
и не версионируются. Подробности: [docs/12-assets.md](../../docs/12-assets.md).

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,292 @@
"""Генератор листов анимации тварей для SpriteForge (бэкенд import).
Зачем скрипт, а не нарисованные руками PNG: силуэт твари обязан совпадать с
процедурным запасным вариантом из src/render/sprites.cpp иначе переключение
--sf-enemies меняло бы не «плавность», а саму тварь, и игрок переставал бы её
узнавать. Поэтому здесь ровно та же геометрия (объединение эллипсов, свет по
верхне-левой кромке, обводка по силуэту), только конечности сдвигаются по фазе
шага.
Скрипт одноразовый и детерминированный: ни одного случайного числа, тот же
хеш-шум, что в C++. Перегенерация даёт побитово тот же PNG.
python assets/sources/make_enemy_sheets.py
Пишет листы в assets/sources/imports/<kind>_walk.png по кадрам в ряд.
"""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image
OUT_DIR = Path(__file__).resolve().parent / "imports"
FRAMES = 6 # кадров в цикле шага
# --- цвет --------------------------------------------------------------------
# Тот же ShadeColor, что в src/render/sprites.cpp: множитель по RGB с обрезкой.
def shade(rgb: tuple[int, int, int], k: float) -> tuple[int, int, int]:
return tuple(max(0, min(255, int(c * k))) for c in rgb)
# Хеш из src/core/hash.h. Нужен, чтобы рваный низ у твари совпадал с
# процедурным вариантом кадр в кадр.
def hash2(a: int, b: int) -> int:
h = (a * 0x9E3779B1) & 0xFFFFFFFF
h ^= (b + 0x85EBCA6B + ((h << 6) & 0xFFFFFFFF) + (h >> 2)) & 0xFFFFFFFF
h &= 0xFFFFFFFF
h ^= h >> 15
h = (h * 0x2C1B3C6D) & 0xFFFFFFFF
h ^= h >> 12
return h
def hash01(h: int) -> float:
return (h & 0xFFFFFF) / float(0x1000000)
# --- силуэт ------------------------------------------------------------------
class Canvas:
def __init__(self, w: int, h: int) -> None:
self.w, self.h = w, h
self.px: list[list[tuple[int, int, int] | None]] = [[None] * w for _ in range(h)]
def get(self, x: int, y: int):
if x < 0 or y < 0 or x >= self.w or y >= self.h:
return None
return self.px[y][x]
def set(self, x: int, y: int, c) -> None:
if 0 <= x < self.w and 0 <= y < self.h:
self.px[y][x] = c
def paint(self, x: int, y: int, c) -> None:
"""Записать только по уже занятому пикселю: силуэт не расширяется."""
if self.get(x, y) is not None:
self.set(x, y, c)
def image(self) -> Image.Image:
img = Image.new("RGBA", (self.w, self.h), (0, 0, 0, 0))
out = img.load()
for y in range(self.h):
for x in range(self.w):
c = self.px[y][x]
if c is not None:
out[x, y] = (c[0], c[1], c[2], 255)
return img
def fill_blobs(cv: Canvas, blobs, body) -> None:
for y in range(cv.h):
for x in range(cv.w):
for cx, cy, rx, ry in blobs:
dx = (x - cx) / max(0.5, rx)
dy = (y - cy) / max(0.5, ry)
if dx * dx + dy * dy <= 1.0:
cv.set(x, y, body)
break
def shade_silhouette(cv: Canvas, body) -> None:
"""Объём по краю: свет сверху-слева. Копия ShadeSilhouette из sprites.cpp."""
hilite, shadow, deep = shade(body, 1.32), shade(body, 0.66), shade(body, 0.48)
snapshot = [row[:] for row in cv.px]
def at(x: int, y: int):
if x < 0 or y < 0 or x >= cv.w or y >= cv.h:
return None
return snapshot[y][x]
for y in range(cv.h):
for x in range(cv.w):
if at(x, y) is None:
continue
lit = at(x - 2, y - 1) is None
dark = at(x + 2, y + 1) is None
if lit and not dark:
cv.set(x, y, hilite)
elif dark and not lit:
cv.set(x, y, shadow)
elif dark:
cv.set(x, y, deep)
def outline(cv: Canvas, col) -> None:
snapshot = [row[:] for row in cv.px]
def at(x: int, y: int):
if x < 0 or y < 0 or x >= cv.w or y >= cv.h:
return None
return snapshot[y][x]
for y in range(cv.h):
for x in range(cv.w):
if at(x, y) is None:
continue
if (x == 0 or y == 0 or x == cv.w - 1 or y == cv.h - 1
or at(x - 1, y) is None or at(x + 1, y) is None
or at(x, y - 1) is None or at(x, y + 1) is None):
cv.set(x, y, col)
def tatter_bottom(cv: Canvas, seed: int, max_cut: int) -> None:
for x in range(cv.w):
cut = int(hash01(hash2(seed, x)) * (max_cut + 1))
for i in range(cut):
cv.set(x, cv.h - 1 - i, None)
def eyes(cv: Canvas, x0: int, x1: int, y: int, col) -> None:
cv.set(x0, y, col)
cv.set(x1, y, col)
# --- твари -------------------------------------------------------------------
# Фаза шага: swing — размах конечностей, bob — подскок корпуса. Обе выведены из
# одного угла, поэтому цикл замкнут и на стыке кадров не дёргается.
def phase(frame: int) -> tuple[float, float]:
a = 2.0 * math.pi * frame / FRAMES
return math.sin(a), math.cos(2.0 * a)
def shambler(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(13, 17)
dy = 0.6 * bob # тяжёлый переваливающийся шаг
fill_blobs(cv, [
(6.5, 10.0 + dy, 4.2, 6.0),
(8.0 + 0.5 * swing, 4.5 + dy, 3.0, 2.8),
(2.5 - 0.9 * swing, 10.5 + dy, 1.8, 4.5),
(10.5 + 0.9 * swing, 11.5 + dy, 1.6, 4.0),
(5.0 + 0.8 * swing, 15.0, 2.4, 2.4),
], body)
shade_silhouette(cv, body)
maw = shade(body, 0.20)
for x in range(7, 10):
cv.paint(int(x + 0.5 * swing), int(5 + dy), maw)
tatter_bottom(cv, 0x5A17, 2)
outline(cv, shade(body, 0.28))
return cv
def rusher(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(11, 18)
dy = 0.9 * bob # быстрый частый шаг: подскок заметнее
fill_blobs(cv, [
(5.0, 10.5 + dy, 2.6, 6.0),
(7.0 + 0.4 * swing, 3.8 + dy, 2.4, 3.2),
(8.6 + 1.1 * swing, 8.0 + dy, 1.4, 3.6),
(2.0 - 1.1 * swing, 11.0 + dy, 1.3, 3.4),
(4.5 + 1.0 * swing, 15.5, 2.2, 2.2),
], body)
shade_silhouette(cv, body)
eyes(cv, int(6 + 0.4 * swing), int(8 + 0.4 * swing), int(3 + dy), shade(body, 1.85))
tatter_bottom(cv, 0x9E37, 3)
outline(cv, shade(body, 0.30))
return cv
def spitter(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(16, 14)
dy = 0.5 * bob
fill_blobs(cv, [
(8.0, 8.0 + dy, 6.6, 4.8),
(8.0, 3.6 + dy, 3.4, 2.6),
(3.0 - 0.7 * swing, 12.0, 1.8, 1.8),
(13.0 + 0.7 * swing, 12.0, 1.8, 1.8),
], body)
shade_silhouette(cv, body)
maw, bile = shade(body, 0.18), shade(body, 1.70)
for x in range(5, 12):
cv.paint(x, int(4 + dy), maw)
for x in range(6, 11):
cv.paint(x, int(5 + dy), maw)
for y in range(7, 11):
for x in range(2, cv.w - 2):
if (x * 3 + y * 7) % 13 == 0:
cv.paint(x, y, bile)
outline(cv, shade(body, 0.30))
return cv
def brute(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(22, 25)
dy = 0.7 * bob # туша качается медленно и широко
fill_blobs(cv, [
(11.0, 9.0 + dy, 9.0, 6.0),
(11.0, 16.0 + dy, 5.5, 6.0),
(11.0, 4.5 + dy, 3.0, 2.8),
(2.5 - 0.8 * swing, 17.0 + dy, 2.6, 5.5),
(19.5 + 0.8 * swing, 17.0 + dy, 2.6, 5.5),
(7.0 + 1.2 * swing, 23.0, 3.0, 2.2),
(15.0 - 1.2 * swing, 23.0, 3.0, 2.2),
], body)
shade_silhouette(cv, body)
bone, dark = shade(body, 1.55), shade(body, 0.34)
for i in range(4):
cv.paint(3 + i, int(5 + i + dy), bone)
cv.paint(cv.w - 4 - i, int(5 + i + dy), bone)
for y in range(cv.h - 6, cv.h - 2):
for x in range(1, 4):
cv.paint(x, y, bone)
cv.paint(cv.w - 1 - x, y, bone)
eyes(cv, 10, 12, int(4 + dy), dark)
outline(cv, shade(body, 0.25))
return cv
def lurker(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(17, 15)
dy = 0.5 * bob
# Паучий ход: лапы работают в противофазе парами, тело почти не качается.
fill_blobs(cv, [
(8.5, 9.0 + dy, 4.0, 3.6),
(8.5, 5.5 + dy, 2.4, 2.2),
(2.0 - 0.8 * swing, 7.0 - 0.9 * swing, 1.3, 4.6),
(15.0 + 0.8 * swing, 7.0 + 0.9 * swing, 1.3, 4.6),
(4.0 + 0.9 * swing, 12.5, 1.4, 2.2),
(13.0 - 0.9 * swing, 12.5, 1.4, 2.2),
], body)
shade_silhouette(cv, body)
claw = shade(body, 1.75)
cv.paint(1, 2, claw)
cv.paint(2, 3, claw)
cv.paint(cv.w - 2, 2, claw)
cv.paint(cv.w - 3, 3, claw)
eyes(cv, 7, 10, int(5 + dy), shade(body, 1.9))
outline(cv, shade(body, 0.26))
return cv
# Цвета — копия EnemyDef из src/sim/target.cpp. Разъедутся — тварь сменит цвет
# при переключении на собранные спрайты, и это будет видно сразу.
KINDS = [
("shambler", shambler, (116, 52, 52), 13, 17),
("rusher", rusher, (158, 74, 44), 11, 18),
("spitter", spitter, (96, 132, 72), 16, 14),
("brute", brute, (142, 60, 78), 22, 25),
("lurker", lurker, (88, 62, 118), 17, 15),
]
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for name, maker, colour, w, h in KINDS:
sheet = Image.new("RGBA", (w * FRAMES, h), (0, 0, 0, 0))
for f in range(FRAMES):
sheet.paste(maker(f, colour).image(), (f * w, 0))
path = OUT_DIR / f"{name}_walk.png"
sheet.save(path)
print(f"{path.name}: {w * FRAMES}x{h} ({FRAMES} кадров по {w}x{h})")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Some files were not shown because too many files have changed in this diff Show More