From 9eeafc6df26fab1e276ef72ee5a16fa69d1ddb35 Mon Sep 17 00:00:00 2001 From: qwqpap <798292805@qq.com> Date: Sat, 1 Aug 2026 16:24:55 +0800 Subject: [PATCH 1/2] feat(editor): establish M0 authoring contracts --- AGENTS.md | 45 ++ README.md | 2 + docs/AUTHORING_RESOURCE_CONTRACTS.md | 120 ++++ docs/EDITOR_ARCHITECTURE.md | 17 +- docs/EDITOR_ROADMAP_TODO.md | 636 ++++++++++++++++++ .../pystg-resource-envelope-v1.schema.json | 27 + docs/schemas/pystg-scene-v1.schema.json | 4 + src/authoring/__init__.py | 50 ++ src/authoring/coordinates.py | 109 +++ src/authoring/migrations.py | 135 ++++ src/authoring/registry.py | 120 ++++ src/authoring/resources.py | 294 ++++++++ src/authoring/storage.py | 40 ++ src/editor/__init__.py | 15 +- src/editor/app.py | 355 +++++++++- src/editor/asset_index.py | 344 ++++++++++ src/editor/document.py | 114 ++-- src/editor/node_types.py | 352 ++++++++-- src/editor/resource_browser.py | 471 +++++++++++++ src/editor/workbench.py | 133 ++++ tests/test_authoring_coordinates.py | 56 ++ tests/test_authoring_resources.py | 182 +++++ tests/test_editor_app_smoke.py | 2 +- tests/test_editor_asset_index.py | 144 ++++ tests/test_editor_documents.py | 5 + tests/test_editor_node_registry.py | 86 +++ tests/test_editor_resource_browser.py | 179 +++++ tests/test_editor_scene_commands.py | 2 +- tests/test_editor_workbench.py | 73 ++ 29 files changed, 3969 insertions(+), 143 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/AUTHORING_RESOURCE_CONTRACTS.md create mode 100644 docs/EDITOR_ROADMAP_TODO.md create mode 100644 docs/schemas/pystg-resource-envelope-v1.schema.json create mode 100644 src/authoring/__init__.py create mode 100644 src/authoring/coordinates.py create mode 100644 src/authoring/migrations.py create mode 100644 src/authoring/registry.py create mode 100644 src/authoring/resources.py create mode 100644 src/authoring/storage.py create mode 100644 src/editor/asset_index.py create mode 100644 src/editor/resource_browser.py create mode 100644 src/editor/workbench.py create mode 100644 tests/test_authoring_coordinates.py create mode 100644 tests/test_authoring_resources.py create mode 100644 tests/test_editor_asset_index.py create mode 100644 tests/test_editor_node_registry.py create mode 100644 tests/test_editor_resource_browser.py create mode 100644 tests/test_editor_workbench.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..023b3cbe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# PySTG Agent Instructions + +This file applies to the whole repository. + +## Editor roadmap + +Before making substantial changes to the Godot-style authoring editor, read +[`docs/EDITOR_ROADMAP_TODO.md`](docs/EDITOR_ROADMAP_TODO.md) completely. + +The roadmap is the durable source of truth for: + +- implementation order and dependencies; +- editor/runtime architecture boundaries; +- the current milestone and deferred scope; +- completion gates and verification evidence. + +Do not begin a later roadmap phase merely because its UI is easier to demo. +Foundation, runtime parity, and preview gates must be completed in dependency +order. + +## Working rules + +1. Check `git status --short` before editing. Preserve unrelated user changes. +2. For substantial work, propose the intended roadmap task IDs, boundaries, and + tradeoffs before implementation. +3. Keep authoring documents as the source of truth. Generated Python is an + optional export and must not become the only runnable representation. +4. Never expand high-density bullets into scene-tree nodes or attach a Python + per-frame callback to every bullet. +5. Preview results must use the formal runtime path. Label structural tests, + simulated previews, and visually accepted results separately. +6. All document mutations initiated by editor UI must participate in Undo/Redo. +7. New document schema versions require migration and round-trip tests. +8. Use project-relative resource references and `ProjectContext`; do not add new + current-working-directory assumptions. +9. Do not mark a roadmap phase complete until its explicit gate passes. +10. After completing roadmap work, update the checkboxes and append concise, + reproducible evidence to the roadmap completion log. + +## Verification baseline + +Use the narrowest relevant tests while iterating. Before declaring a roadmap +gate complete, run the gate-specific checks plus the repository merge checks +listed in `docs/EDITOR_ARCHITECTURE.md`. Qt tests should use an offscreen +platform when no interactive display is available. diff --git a/README.md b/README.md index a11d74ad..4890a2ca 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ flowchart TB | [纹理资产系统](docs/TEXTURE_ASSET_SYSTEM.md) | 图集加载、精灵定义、动画配置 | | [开发工具链](docs/DEVTOOLS_PHASE1.md) | 资源校验、热重载、Pattern Lab 和符卡预览 | | [编辑器架构边界](docs/EDITOR_ARCHITECTURE.md) | 编辑器、文档、运行时和资源服务的依赖约束 | +| [作者资源契约](docs/AUTHORING_RESOURCE_CONTRACTS.md) | M0 资源头、引用、迁移、坐标、时间和注册表协议 | +| [编辑器长期路线 TODO](docs/EDITOR_ROADMAP_TODO.md) | 分阶段任务、依赖、Gate 与完成证据 | 也可以本地启动 VitePress 文档站点: diff --git a/docs/AUTHORING_RESOURCE_CONTRACTS.md b/docs/AUTHORING_RESOURCE_CONTRACTS.md new file mode 100644 index 00000000..db0b94a2 --- /dev/null +++ b/docs/AUTHORING_RESOURCE_CONTRACTS.md @@ -0,0 +1,120 @@ +# 作者资源契约(M0) + +本文冻结 Godot 式编辑器 Phase 0 使用的最小公共协议。弹幕配方、时间轴、UI +布局和背景的领域字段将在后续阶段分别定义;它们不得绕过这里的身份、版本、引用、 +坐标、时间和注册表契约。 + +## 文件与公共资源头 + +所有版本化作者资源保留 `*.pystg.json` 后缀。资源类型由 JSON 内的 `type` +决定,不依赖文件名猜测: + +```json +{ + "schema_version": 1, + "type": "pystg.pattern", + "id": "08ac589e-a51a-45dc-beb9-7af6f4e136db", + "name": "星符「星轨回廊」", + "symbol_name": "star_corridor", + "metadata": {} +} +``` + +M0 注册以下类型: + +| `type` | 资源浏览器类别 | 领域正文冻结阶段 | +|---|---|---| +| `pystg.scene` | Scene | 已有 v1,Phase 4 扩展时间轴 | +| `pystg.pattern` | Pattern | Phase 1 | +| `pystg.ui` | UI | Phase 6 | +| `pystg.background` | Background | Phase 6 | + +公共字段语义: + +- `schema_version`:该 `type` 的整数 schema 版本,不是应用版本。 +- `id`:稳定 UUID,用于引用和对象身份。 +- `name`:面向作者的 Unicode 显示名,可直接使用中文。 +- `symbol_name`:可选的便携 Python 标识符,仅在代码导出/脚本绑定需要时使用。 +- `metadata`:JSON 对象;不能替代需要验证的正式领域字段。 + +显示名、UUID 和脚本符号是三个不同概念。不得为了生成 Python 而限制显示名。 + +## 资源引用 + +新写入的引用统一使用: + +```text +res://assets/images/bullet.json#orb +res://game_content/patterns/star-ring.pystg.json +``` + +- `res://` 后是相对项目根目录的 POSIX 路径。 +- `#fragment` 是可选子资源名。 +- 禁止绝对路径、盘符、`.` 和 `..` 穿越。 +- 读取器在迁移期可接受旧的项目相对路径,但保存的新引用必须规范化为 `res://`。 +- 路径解析必须通过 `ResourceReference` 和 `ProjectContext`。 + +## 迁移 + +迁移由 `MigrationRegistry` 按 `(resource_type, from_version)` 显式注册,且每次只 +允许从 `N` 迁移到 `N+1`。迁移函数: + +1. 接收并返回 JSON object; +2. 不得改变 `type`; +3. 必须把 `schema_version` 精确增加 1; +4. 必须有输入 fixture、迁移结果和 round-trip 测试; +5. 遇到未来版本或缺失迁移路径时给出可操作错误,不能猜测降级。 + +旧无版本场景通过已注册的 `pystg.scene` v0→v1 迁移进入当前模型。 + +## 坐标 + +作者空间是与实际窗口缩放无关的逻辑画布: + +| 项目 | 约定 | +|---|---| +| 基准尺寸 | 384×448 | +| 作者原点 | 左上 | +| 作者 X | 向右 | +| 作者 Y | 向下 | +| 运行时原点 | 画面中心 | +| 运行时 X/Y | `[-1, 1]` | +| 运行时 Y | 向上 | + +转换公式由 `src.authoring.coordinates.CoordinateSpace` 唯一实现。视口放大到 +768×896 或其他尺寸不能改变最终运行时位置。 + +## 时间 + +- 文档主时间使用非负整数帧。 +- 默认 tick rate 为 60Hz。 +- 秒和拍是编辑器显示/输入单位,由 `Timebase` 转换。 +- 时间轴排序、对象身份和确定性回放不得依赖浮点秒相等判断。 + +## 注册表 + +`ResourceTypeRegistry` 为每个资源类型提供以下可选 contribution: + +- loader; +- validator; +- editor factory; +- compiler; +- preview handler。 + +`NodeTypeRegistry` 为每个场景节点提供: + +- 属性 schema 和 Inspector 提示; +- 父子约束和验证器; +- Viewport 表现; +- editor factory; +- runtime compiler。 + +编辑器外壳使用注册表查询,不得为了新资源/节点类型继续添加编译或绘制类型分支。 + +## Phase 0 与后续阶段的边界 + +M0 只冻结公共契约和贡献入口,不预先虚构 Pattern/UI/Background 正文字段。领域 +文档可以由 `GenericResourceDocument` 无损保存,在相应阶段由注册表替换为强类型 +loader/validator,并通过新的 schema 迁移演进。 + +Generated Python 始终是可选导出物,不是作者资源的唯一可运行表示。 diff --git a/docs/EDITOR_ARCHITECTURE.md b/docs/EDITOR_ARCHITECTURE.md index 534d4f34..60e9b889 100644 --- a/docs/EDITOR_ARCHITECTURE.md +++ b/docs/EDITOR_ARCHITECTURE.md @@ -21,7 +21,10 @@ Runtime / renderer / resource service ## 文档 -- 新场景文件使用 `pystg.scene` 类型和整数 `schema_version`。 +- 所有作者资源使用 `*.pystg.json`,由文档内的 `type` 区分 + `pystg.scene`、`pystg.pattern`、`pystg.ui` 和 `pystg.background`。 +- 公共资源头、引用、迁移、坐标和时间契约见 + [`AUTHORING_RESOURCE_CONTRACTS.md`](AUTHORING_RESOURCE_CONTRACTS.md)。 - 文档、节点和时间轴事件都有稳定 UUID。 - `DocumentStore` 只允许读写项目目录内文件,并使用原子替换保存。 - 新 schema 必须提供迁移函数和 round-trip 测试。 @@ -36,11 +39,21 @@ Runtime / renderer / resource service ## 资源 -- `ResourceService` 是运行时和编辑器创建资源模型的统一入口。 +- `ResourceTypeRegistry` 是版本化作者资源的 loader、validator、editor、compiler + 和 preview contribution 入口;编辑器外壳不得按资源类型增加编译分支。 +- `ResourceService` 继续负责当前运行时纹理目录和富编辑纹理兼容模型。 - `TextureAssetManager` 是当前正式运行时纹理目录。 - `UnifiedTextureManager` 暂作为富编辑类型兼容模型,由 `ResourceService.editor` 管理。 - 两种内部表示迁移完成前,关键资源必须通过契约测试证明解析结果一致。 +## 坐标与时间 + +- 作者画布使用固定逻辑像素,基准尺寸为 `384x448`,原点在左上,Y 向下。 +- 正式运行时坐标以画面中心为原点,X/Y 范围均为 `[-1, 1]`,Y 向上。 +- 编辑器、文档编译器和预览只能通过 `CoordinateSpace` 做两者转换。 +- 文档时间存储为声明 tick rate 下的非负整数帧;第一版 tick rate 为 60Hz。 +- 秒和拍仅为显示/输入单位,通过 `Timebase` 转换,不以浮点秒作为时间轴主键。 + ## 编辑操作 - Inspector、场景树和时间轴修改必须经过 `CommandStack`,以支持 Undo/Redo。 diff --git a/docs/EDITOR_ROADMAP_TODO.md b/docs/EDITOR_ROADMAP_TODO.md new file mode 100644 index 00000000..c39244b0 --- /dev/null +++ b/docs/EDITOR_ROADMAP_TODO.md @@ -0,0 +1,636 @@ +# PySTG Godot-style Editor Roadmap TODO + +> Durable execution plan for agents and maintainers. Read this file before +> substantial editor, authoring-document, preview, UI/background authoring, or +> plugin work. + +## Objective + +Build a low-threshold STG editor in which a new user can create and preview a +bullet pattern without writing Python, while advanced users can progressively +expand the same resource through curves, a typed behavior graph, scripts, +custom editor extensions, UI/background authoring, and external-event adapters. + +The product model is: + +```text +Godot-style workbench + + recipe-first danmaku authoring + + timeline orchestration + + formal runtime preview + + script/plugin escape hatches +``` + +## Current focus + +**Milestone M1 — Pattern IR and formal runtime execution** + +Phase 0 contracts are frozen. Keep later changes compatible with them or add +explicit schema migrations and contract tests. + +Next recommended task: **E1.1 — Define PatternDocument.** + +## Status and update rules + +- `[ ]` means planned or incomplete. +- `[x]` means the task and its acceptance criteria are complete. +- Put `BLOCKED:` after a task only when the blocking condition is concrete. +- Do not mark an entire phase complete from unit tests alone when its gate also + requires runtime or visual validation. +- When completing a task, add a dated entry to the Completion log with commands, + tests, artifacts, and known limitations. +- If the design changes, update the decision here before implementing code that + contradicts it. + +## Non-negotiable architecture boundaries + +1. **Bullets are not scene nodes.** Scene documents contain semantic entities + such as Stage, Boss, Spell, Emitter, and PatternInstance. High-density bullet + state remains in the NumPy/Numba pool. +2. **Documents are the source of truth.** Python generation is an optional + compatibility/export path. Arbitrary Python is not reverse-parsed into a + visual graph. +3. **Preview uses the formal runtime.** Approximate Qt-only simulations may be + used for diagnostics but must be labeled experimental and cannot establish + visual/runtime parity. +4. **Progressive disclosure uses one resource.** Recipe, curves, graph, and + ScriptBehavior are increasingly powerful views/extensions of the same + resource, not independent copies. +5. **Domain editors share infrastructure, not a universal graph.** Danmaku, UI, + background, scene, and script contexts share documents, resources, + Inspector, Undo/Redo, timeline, preview, and plugin contracts while retaining + domain-specific central views. +6. **Per-bullet behavior stays data-oriented.** Compile common motion to pool + fields, vectorized operations, or Numba-compatible kernels. Python callbacks + are for sparse controllers/emitters, not every bullet every frame. +7. **External input enters through typed events.** UDP, WebSocket, bots, and + platform adapters do not directly mutate gameplay subsystems. +8. **No unrestricted `eval`.** Expressions use a small typed/whitelisted AST. + +## Audited baseline (2026-08-01 snapshot) + +### Reusable foundation + +- `src/editor/document.py`: UUIDs, schema version, validation, migration hook. +- `src/editor/storage.py`: project-constrained atomic persistence. +- `src/editor/commands.py` and `scene_commands.py`: basic Undo/Redo commands. +- `src/editor/app.py`: dockable Qt workbench, scene tree, Inspector, 2D view, + resource drag/drop, Output and read-only Timeline. +- `src/editor/workbench.py`: central, bottom, and external plugin descriptors. +- `src/editor/asset_index.py` and `resource_browser.py`: project resource index, + thumbnails, JSON sprite/animation subresources. +- `src/devtools/pattern_lab.py`: prototype PatternSpec and deterministic pattern + parameter generation. +- `src/devtools/spell_preview.py`: pause, step, seek, reset, hot reload, error + retention, and runtime statistics. +- `src/game/stage/context.py`: high-level content-to-engine API. +- `src/game/bullet/optimized_pool.py`: data-oriented high-density bullet runtime. +- `src/ui/components.py` and `ui_tree.py`: serializable UI tree prototype. +- `src/game/background_render/`: data-driven background loading and live reload. +- `src/game/emoji_danmaku/udp_receiver.py`: first external-input adapter candidate. + +### Known gaps and hazards + +- Scene documents do not compile or instantiate into the formal stage runtime. +- Timeline is display-only and has no track/clip/keyframe mutation model. +- `PatternSpec` is a development model and code generation is one-way. +- Pattern names are currently constrained as Python identifiers; display names + and optional code symbols must be separated. +- Editor scene defaults use a 768x896 pixel canvas, runtime logical dimensions + are 384x448, and content APIs use normalized coordinates. A single coordinate + contract is required before position authoring expands. +- `classify_file()` currently treats every `*.pystg.json` as a scene rather than + inspecting its declared resource type. +- The current CommandStack lacks transactions/coalescing for slider drags and + multi-property edits. +- ResourceService is primarily a texture compatibility service, not yet a typed + authoring-resource loader/compiler registry. +- Background code contains overlapping Camera/Fog/Layer models that must be + unified before the editor freezes a public background schema. +- There is no general runtime EventBus; the UDP emoji path is specialized. +- The Qt workbench and resource-browser work was uncommitted in the audited + snapshot. Always re-check current Git state rather than assuming that remains + true. +- Declared NumPy/Numba pins and the audited active environment differed. Resolve + environment parity before performance gates. + +### Audited test evidence + +The following groups passed in the 2026-08-01 snapshot (55 tests total): + +```powershell +python -m pytest -q tests/test_editor_documents.py tests/test_editor_scene_commands.py tests/test_editor_workbench.py tests/test_editor_resource_browser.py +python -m pytest -q tests/test_devtools_pattern_lab.py tests/test_devtools_spell_preview.py tests/test_devtools_hotreload.py +python -m pytest -q tests/test_background_data_driven_parity.py tests/test_project_foundation.py +$env:QT_QPA_PLATFORM='offscreen'; python -m pytest -q tests/test_editor_app_smoke.py +``` + +This is historical evidence, not proof that the current checkout still passes. + +## Dependency order + +```text +Phase 0: contracts + -> Phase 1: pattern IR/runtime + -> Phase 2: controllable formal preview + -> Phase 3: first no-code vertical slice + -> Phase 4: editable timeline/stage program + -> Phase 5: graph/expressions/scripts + -> Phase 6: UI and background contexts + -> Phase 7: events, plugin SDK, packaging hardening +``` + +Phases may be researched in parallel, but public data/runtime contracts should +not be implemented out of order. + +--- + +## Phase 0 — Authoring contracts + +Goal: freeze the smallest durable document, coordinate, time, identity, and +registry contracts before expanding UI. + +### E0.1 Common typed resource envelope + +- [x] Define a common resource header with `schema_version`, `type`, UUID `id`, + Unicode `name`, and optional metadata. +- [x] Define initial resource types: `pystg.scene`, `pystg.pattern`, `pystg.ui`, + and `pystg.background`. +- [x] Separate human display name, UUID identity, and optional Python + `symbol_name`. +- [x] Decide whether all typed resources retain `*.pystg.json` or receive + domain-specific suffixes; document the decision. +- [x] Add round-trip and duplicate-ID validation tests. +- [x] Add explicit migration registration rather than a single hard-coded + scene migration chain. + +Acceptance: + +- Every initial resource type can load, validate, save, and reload without + semantic changes. +- Unicode display names never require a Python identifier. +- Newer unsupported schema versions fail with actionable errors. + +### E0.2 Resource references and typed registry + +- [x] Formalize `res://project/path#subresource` references. +- [x] Replace the `*.pystg.json == scene` classification shortcut with declared + type inspection. +- [x] Introduce a ResourceTypeRegistry for loaders, validators, migrations, + editor factories, compilers, and preview handlers. +- [x] Keep project-boundary checks through `ProjectContext`. +- [x] Add broken-reference and outside-project validation tests. + +Acceptance: + +- The resource browser distinguishes scene, pattern, UI, and background + documents and can report invalid resources without aborting the full scan. + +### E0.3 Coordinate and time contracts + +- [x] Define one logical gameplay coordinate space and document origin, axes, + bounds, and conversion behavior. +- [x] Add a CoordinateSpace service used by editor gizmos, document loading, + runtime compilation, and preview. +- [x] Decide document time storage (recommended: integer frames at a declared + tick rate) and display conversion to seconds/beats. +- [x] Remove new ad-hoc pixel/normalized conversion code. +- [x] Add conversion round-trip and viewport-scaling tests. + +Acceptance: + +- Dragging an emitter to a known editor position spawns at the same formal + runtime position across window scales. + +### E0.4 Property and node registries + +- [x] Replace the single hard-coded NODE_TYPES map with a registry. +- [x] Extend PropertySpec with enum, resource reference, unit, group, range, + curve/binding capability, conditional visibility, and editor hints. +- [x] Define initial semantic scene types: Stage, Boss, Spell, Emitter, and + PatternInstance. +- [x] Define valid parent/child constraints and registry-level validation. +- [x] Add registry collision and unknown-type tests. + +Acceptance: + +- A registered node type can supply its schema, Inspector fields, validation, + viewport behavior, and runtime compiler without modifying a central switch. + +### Phase 0 gate + +- [x] All E0 tasks complete. +- [x] Versioned schema and migrations documented. +- [x] Coordinate parity test passes. +- [x] Relevant tests pass in the pinned development environment. +- [x] Architecture review confirms later phases can compile through registries + without adding type-specific conditionals to the editor shell. + +--- + +## Phase 1 — Pattern IR and formal runtime execution + +Goal: make data-authored patterns directly runnable without generating Python. + +### E1.1 PatternDocument + +- [ ] Define recipe-level sections: Bullet, Shape, Aim, Schedule, Motion, and + Modifiers. +- [ ] Support initial shapes: ring, arc, line, spiral, and random distribution. +- [ ] Support initial scheduling: delay, interval, burst count, loop. +- [ ] Support fixed direction and aim-at-player. +- [ ] Support stable random seed configuration. +- [ ] Provide migration/import from the prototype PatternSpec. + +### E1.2 Pattern compiler + +- [ ] Implement `PatternDocument -> immutable PatternProgram` compilation. +- [ ] Resolve and validate resource references during compilation. +- [ ] Precompute static angles, speeds, and resource indices. +- [ ] Produce structured diagnostics containing resource ID and property path. +- [ ] Cache compiled programs using content/version identity. + +### E1.3 Pattern runner + +- [ ] Implement a fixed-tick PatternRunner that executes PatternProgram through + StageContext. +- [ ] Define start, pause, reset, stop, and deterministic replay semantics. +- [ ] Track ownership/tags so a pattern instance can clear or transform only its + own bullets. +- [ ] Add batch spawn APIs to StageContext and OptimizedBulletPool. +- [ ] Keep common bullet motion in NumPy/Numba-compatible data paths. + +### E1.4 Runtime parity tests + +- [ ] Compare compiled output against known PatternSpec parameter fixtures. +- [ ] Verify identical seeds produce identical spawn traces. +- [ ] Verify preview and gameplay runners produce identical traces. +- [ ] Add load/compile/run failure tests with actionable diagnostics. +- [ ] Add representative dense-burst performance measurements after dependency + versions are aligned. + +### Phase 1 gate + +- [ ] One PatternDocument runs in the formal game runtime without Python codegen. +- [ ] Ring, arc, spiral, aim, interval, multi-burst, and random-seed parity pass. +- [ ] Dense patterns do not require scene nodes or per-bullet Python callbacks. +- [ ] Runtime and structural tests pass; performance evidence is recorded. + +--- + +## Phase 2 — Controllable formal preview + +Goal: turn the existing preview runtime into an editor-controlled service. + +### E2.1 PreviewController contract + +- [ ] Define load, play, pause, step, seek, reset, stop, set-property, + set-player-position, set-seed, and get-stats commands. +- [ ] Define structured status, compile error, runtime error, and statistics + events. +- [ ] Preserve the last valid program when a hot reload fails. +- [ ] Make lifecycle/cleanup idempotent. + +### E2.2 Process transport + +- [ ] Implement QProcess lifecycle management. +- [ ] Use newline-delimited JSON over stdin/stdout for the first transport. +- [ ] Add protocol version negotiation and request IDs. +- [ ] Ensure child crashes and malformed output cannot freeze the editor. +- [ ] Add protocol and subprocess smoke tests. + +### E2.3 Preview surface + +- [ ] First integrate controllable external-window formal preview. +- [ ] Show frame, bullet count, update/render timing, seed, pause state, and last + compile/runtime error in the editor. +- [ ] Add optional diagnostic gizmos without changing formal bullet behavior. +- [ ] Research QOpenGLWidget + ModernGL attachment only after the controller + contract is stable. +- [ ] Treat full `main.py` embedding as deferred; start with spell/pattern + preview. + +### Phase 2 gate + +- [ ] Inspector-driven changes reload a PatternDocument without Python codegen. +- [ ] Pause, step, seek, reset, seed, and player-position control work. +- [ ] Formal preview survives an invalid edit and recovers after correction. +- [ ] Runtime parity and visual preview are both checked and recorded separately. + +--- + +## Phase 3 — First no-code vertical slice + +Goal: let a new user create, save, reopen, and formally preview a complete simple +spell without writing Python. + +### E3.1 Multi-document editing + +- [ ] Replace the single SceneEditorSession assumption with DocumentManager. +- [ ] Give each open document its own savepoint and CommandStack. +- [ ] Add close/save/revert behavior for multiple documents. +- [ ] Preserve resource selections and editor context per document. + +### E3.2 Undo transactions + +- [ ] Add command transactions for multi-property operations. +- [ ] Coalesce continuous slider/spinbox/gizmo drags into one undo step. +- [ ] Add timeline and resource-assignment command types. +- [ ] Verify Undo/Redo round-trips valid documents. + +### E3.3 Contextual Pattern workspace + +- [ ] Add Pattern as a first-class central editor context. +- [ ] Provide recipe Inspector controls with units and advanced sections. +- [ ] Add Bullet resource picking and drag/drop. +- [ ] Add emitter gizmo, player target gizmo, and optional trajectory guides. +- [ ] Connect property changes to PreviewController. +- [ ] Provide concise empty states and starter templates. + +### E3.4 Scene integration + +- [ ] Add Stage -> Boss -> Spell -> Emitter/PatternInstance creation flow. +- [ ] Support Pattern resource instancing rather than copying definitions. +- [ ] Compile the selected simple Spell into a runnable preview program. +- [ ] Provide structured Output messages that link to the failing node/property. + +### Phase 3 gate — first product milestone + +- [ ] A clean user flow can create a ring pattern, adjust count/speed/interval, + aim at the player, assign a bullet resource, save, reopen, and formally + preview it without touching Python. +- [ ] Undo/Redo covers creation, resource assignment, gizmo movement, and + property editing. +- [ ] Desktop interaction and representative narrow-layout behavior are checked. +- [ ] Structural, runtime, and visual acceptance are recorded separately. + +--- + +## Phase 4 — Editable timeline and StageProgram + +Goal: author a 30-60 second spell or stage segment through tracks and clips. + +### E4.1 Timeline document model + +- [ ] Replace the flat display-only event list with Track, Clip, and Keyframe + models. +- [ ] Give every track/clip/keyframe a stable UUID. +- [ ] Define start frame, duration, target UUID, channel, ordering, looping, + interpolation, and payload contracts. +- [ ] Define initial clips: Pattern, Movement, Audio, Event, and Property. +- [ ] Add migration from legacy flat TimelineEvent entries. + +### E4.2 Timeline editor + +- [ ] Build a QGraphicsScene-based ruler, tracks, clips, playhead, selection, and + snapping interaction. +- [ ] Add clip creation, movement, resize, duplication, deletion, and Undo/Redo. +- [ ] Connect playhead scrubbing to formal preview seek. +- [ ] Support zoom without changing stored frame values. +- [ ] Add keyboard and focus behavior tests. + +### E4.3 StageProgram compiler/runtime + +- [ ] Compile Scene + Timeline + referenced resources into StageProgram. +- [ ] Schedule pattern, movement, audio, property, and typed-event clips. +- [ ] Define conflict resolution when multiple clips target the same property. +- [ ] Retain ScriptEvent as an explicit escape hatch. +- [ ] Add deterministic stage trace tests. + +### Phase 4 gate + +- [ ] A 30-60 second spell can be authored and previewed using scene, pattern, + movement, audio, and property tracks. +- [ ] Timeline edits support Undo/Redo and survive save/reopen. +- [ ] Scrubbing and normal playback agree at deterministic checkpoints. + +--- + +## Phase 5 — Behavior graph, curves, expressions, scripts + +Goal: raise the authoring ceiling without replacing or forking recipe resources. + +### E5.1 Curves and bindings + +- [ ] Define reusable Curve resources/keyframes and interpolation modes. +- [ ] Allow eligible properties to bind constants, curves, variables, or + restricted expressions. +- [ ] Provide a small whitelisted expression AST; never unrestricted eval. +- [ ] Initially expose variables such as frame, time, burst index, player/boss + position, and deterministic random values. +- [ ] Compile expressions with property-path diagnostics. + +### E5.2 Typed behavior graph + +- [ ] Define stable node categories: Source, Shape, Aim, Schedule, Motion, + Modifier, Condition, Event, and ScriptBehavior. +- [ ] Define typed ports, connection rules, cycle policy, and graph validation. +- [ ] Compile graphs into the same PatternProgram used by recipe mode. +- [ ] Provide recipe-to-graph expansion without producing a detached copy. +- [ ] Build the graph UI only after compiler tests establish the contract. + +### E5.3 ScriptBehavior + +- [ ] Define explicit script lifecycle and typed context APIs. +- [ ] Support sparse controller/emitter logic and event hooks. +- [ ] Prevent accidental per-bullet Python update registration by default. +- [ ] Surface import/runtime errors through the same diagnostic protocol. +- [ ] Keep Python export optional and one-way. + +### Phase 5 gate + +- [ ] The same saved resource progresses from recipe to curves/expressions to + graph and optional script without format forking. +- [ ] Common graph motion remains on data-oriented runtime paths. +- [ ] Invalid graphs/expressions cannot crash the editor or corrupt the resource. + +--- + +## Phase 6 — UI and background authoring contexts + +Goal: reuse the workbench and runtime bridge for specialized UI and background +editing without forcing them into the danmaku graph. + +### E6.1 UI document/runtime alignment + +- [ ] Add UUIDs, schema version, typed resource header, and migrations to UI + documents. +- [ ] Add anchors, margins, horizontal/vertical/grid containers, styles/theme + references, data bindings, and animatable properties. +- [ ] Ensure every supported UI node has formal renderer behavior. +- [ ] Build UI-specific scene tree, canvas gizmos, Inspector, and resource drag. +- [ ] Add viewport-size/responsive preview presets. + +### E6.2 Background schema unification + +- [ ] Inventory and reconcile duplicate Camera, Fog, Layer, texture, scroll, and + blend fields. +- [ ] Define one BackgroundDocument consumed by editor and runtime. +- [ ] Add migration/import for existing background JSON files. +- [ ] Build layer list, camera/fog Inspector, transform gizmos, and timeline + bindings. +- [ ] Reuse formal background reload/render path for preview. + +### Phase 6 gate + +- [ ] UI and background resources share document, resource, Inspector, + Undo/Redo, timeline, and preview infrastructure. +- [ ] Existing shipped UI/background resources migrate or import without + unreviewed visual regressions. +- [ ] Desktop and responsive UI previews receive visual QA. + +--- + +## Phase 7 — Events, plugin SDK, and hardening + +Goal: support external integrations and long-term extensibility after the core +contracts have stabilized. + +### E7.1 Runtime EventBus + +- [ ] Define typed Event with type, source, frame/timestamp, and payload. +- [ ] Provide a main-thread queue and deterministic dispatch order. +- [ ] Add scene/timeline/script subscription and emission APIs. +- [ ] Refactor the emoji UDP path into the first EventAdapter. +- [ ] Add queue limits, malformed-event handling, and shutdown behavior. + +### E7.2 External adapter protocol + +- [ ] Define adapter lifecycle, configuration, health/status, and event schemas. +- [ ] Prefer out-of-process adapters for network-facing or untrusted code. +- [ ] Add local IPC transport without requiring a general web server. +- [ ] Add example UDP and WebSocket/bot adapters only after the protocol is + stable. +- [ ] Document security boundaries and non-goals. + +### E7.3 Plugin SDK + +- [ ] Define plugin manifest/API version. +- [ ] Register resource types, node types, Inspector editors, central/bottom + views, commands, importers, compilers, preview handlers, and adapters. +- [ ] Define plugin activation/deactivation and failure isolation. +- [ ] Decide project-local plugin discovery and Python package entry points. +- [ ] Add compatibility and duplicate-registration tests. + +### E7.4 Product hardening + +- [ ] Decide/migrate Qt binding before public distribution; prefer PySide6 for + an MIT/LGPL-compatible public editor unless a PyQt commercial license exists. +- [ ] Align declared and active dependency versions. +- [ ] Add autosave/recovery without bypassing atomic persistence. +- [ ] Persist safe workspace layout and open-document state. +- [ ] Add crash diagnostics and corrupt-document recovery UX. +- [ ] Add migration fixtures from every released schema version. +- [ ] Establish full structural, runtime, performance, and visual release gates. + +### Phase 7 gate + +- [ ] External events can drive authored content through typed contracts. +- [ ] A sample plugin can add a resource/node/editor/runtime contribution without + patching core registries. +- [ ] Packaging, licensing, recovery, migrations, and release QA are documented + and verified. + +--- + +## Explicitly deferred until their prerequisites pass + +- [ ] Full arbitrary Python round-trip parsing — intentionally not planned. +- [ ] Expanding bullets into Scene Tree objects — prohibited by architecture. +- [ ] A universal graph for UI, background, stage, and danmaku — not planned. +- [ ] Embedding the entire `main.py` game loop in Qt before PreviewController is + stable. +- [ ] Public plugin marketplace before the plugin API/versioning contract is + stable. +- [ ] Redis, web services, or remote collaboration infrastructure without a + demonstrated requirement. +- [ ] Binary document packing before JSON size/load measurements justify it. + +## Expected module direction + +Names are provisional; preserve responsibilities even if paths change. + +```text +src/authoring/ + resources.py common envelope and resource references + registry.py resource/node/compiler/preview registries + migrations.py version migration routing + coordinates.py formal editor/runtime coordinate conversion + +src/pattern/ + document.py PatternDocument + ir.py immutable PatternProgram + compiler.py document/graph -> program + runtime.py PatternRunner + expressions.py restricted expression AST + +src/editor/ + document_manager.py multi-document/savepoint ownership + preview_controller.py editor-side preview API + preview_protocol.py versioned process messages + contexts/ scene, pattern, timeline, UI, background views + +src/game/ + events.py runtime EventBus + stage/program.py StageProgram runtime +``` + +Avoid creating these modules merely to satisfy the directory sketch. Add them +when their roadmap task begins and a tested responsibility exists. + +## Merge and acceptance discipline + +For every milestone, report these separately: + +1. **Planned:** design/task is documented but not implemented. +2. **Structurally valid:** schema, unit, migration, and contract tests pass. +3. **Runtime valid:** formal runtime/preview behavior matches expected traces. +4. **Performance checked:** representative workload measured in the pinned + target environment. +5. **Visually accepted:** editor interaction and rendered output were inspected + locally; structural tests alone do not imply this state. + +Minimum repository checks remain defined in `docs/EDITOR_ARCHITECTURE.md`. + +## Completion log + +Append entries; do not rewrite old evidence. Keep each entry concise. + +### 2026-08-01 — Roadmap captured + +- Added the durable phased roadmap and root agent entry point. +- Recorded the audited editor/runtime baseline and known architectural gaps. +- No implementation phase was marked complete by this documentation-only step. + + +### 2026-08-01 — M0 authoring contracts complete + +- Froze the `*.pystg.json` typed envelope, four initial resource types, UUID + identity, Unicode display names, optional `symbol_name`, and explicit v0→v1 + scene migration. +- Added canonical `res://path#fragment` references, project-boundary validation, + atomic typed-resource storage, declared-type asset classification, and a + contribution registry for loaders, validators, editors, compilers, previews, + and migrations. +- Standardized authoring coordinates at 384×448 top-left/Y-down, runtime + coordinates at center-origin `[-1, 1]`/Y-up, and time at non-negative integer + frames with a declared tick rate. +- Replaced the hard-coded node map with an extensible registry and registered + Stage, Boss, Spell, Emitter, and PatternInstance schemas, constraints, + Inspector metadata, viewport behavior, and compiler contribution slots. +- Structural evidence: 36 M0/editor tests passed in an isolated Python 3.12 + environment with pinned NumPy 2.2.4, Numba 0.63.1, pytest 8.4.1, and PyQt5 + 5.15.10; the `touhou_guess` target environment passed the full 103-test suite. +- Repository evidence: `python -m compileall -q main.py src game_content tools` + passed; asset validation checked 71 JSON files, 745 sprites, and 142 images + with 0 errors and 0 warnings. +- Visual evidence: a native Qt 1440×900 render was inspected locally; the Scene + tree, 384×448 canvas, Inspector, and Output/Timeline/Assets tabs were readable + with no visible layout regression. Mouse automation was not accepted for the + custom `pythonw.exe`, so interaction acceptance remains test-backed. +- Known reproducibility limitation: a clean full `requirements-dev.txt` install + could not be completed from the configured Tsinghua mirror because + `imgui==2.0.0` had no binary wheel and its source build stalled. The existing + target conda environment was used for the full regression. +- Acceptance classification: M0 is structurally valid and visually inspected. + Pattern runtime parity and performance remain Phase 1+ work. diff --git a/docs/schemas/pystg-resource-envelope-v1.schema.json b/docs/schemas/pystg-resource-envelope-v1.schema.json new file mode 100644 index 00000000..4c7f0f6f --- /dev/null +++ b/docs/schemas/pystg-resource-envelope-v1.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pythonstg.dev/schemas/pystg-resource-envelope-v1.schema.json", + "title": "PySTG Authoring Resource Envelope", + "description": "Common versioned header. Domain schemas add their own fields.", + "type": "object", + "required": ["schema_version", "type", "id", "name"], + "properties": { + "schema_version": {"const": 1}, + "type": { + "enum": [ + "pystg.scene", + "pystg.pattern", + "pystg.ui", + "pystg.background" + ] + }, + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string", "minLength": 1}, + "symbol_name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "metadata": {"type": "object"} + }, + "additionalProperties": true +} diff --git a/docs/schemas/pystg-scene-v1.schema.json b/docs/schemas/pystg-scene-v1.schema.json index 8a47b856..dcc865af 100644 --- a/docs/schemas/pystg-scene-v1.schema.json +++ b/docs/schemas/pystg-scene-v1.schema.json @@ -9,6 +9,10 @@ "type": {"const": "pystg.scene"}, "id": {"type": "string", "format": "uuid"}, "name": {"type": "string", "minLength": 1}, + "symbol_name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, "metadata": {"type": "object"}, "root": {"$ref": "#/$defs/node"}, "timeline": { diff --git a/src/authoring/__init__.py b/src/authoring/__init__.py new file mode 100644 index 00000000..0c8e61e5 --- /dev/null +++ b/src/authoring/__init__.py @@ -0,0 +1,50 @@ +"""Stable, UI-independent authoring contracts for PySTG resources.""" + +from .coordinates import CoordinateSpace, Timebase +from .migrations import ( + MigrationError, + MigrationRegistry, + build_default_migration_registry, +) +from .registry import ( + ResourceTypeRegistry, + ResourceTypeSpec, + build_default_resource_type_registry, +) +from .resources import ( + AUTHORING_RESOURCE_TYPES, + BACKGROUND_RESOURCE_TYPE, + PATTERN_RESOURCE_TYPE, + RESOURCE_FILE_SUFFIX, + RESOURCE_SCHEMA_VERSION, + SCENE_RESOURCE_TYPE, + UI_RESOURCE_TYPE, + GenericResourceDocument, + ResourceDocumentError, + ResourceHeader, + ResourceReference, +) +from .storage import ResourceStore + +__all__ = [ + "AUTHORING_RESOURCE_TYPES", + "BACKGROUND_RESOURCE_TYPE", + "CoordinateSpace", + "GenericResourceDocument", + "MigrationError", + "MigrationRegistry", + "PATTERN_RESOURCE_TYPE", + "RESOURCE_FILE_SUFFIX", + "RESOURCE_SCHEMA_VERSION", + "ResourceDocumentError", + "ResourceHeader", + "ResourceReference", + "ResourceStore", + "ResourceTypeRegistry", + "ResourceTypeSpec", + "SCENE_RESOURCE_TYPE", + "Timebase", + "UI_RESOURCE_TYPE", + "build_default_migration_registry", + "build_default_resource_type_registry", +] diff --git a/src/authoring/coordinates.py b/src/authoring/coordinates.py new file mode 100644 index 00000000..41632895 --- /dev/null +++ b/src/authoring/coordinates.py @@ -0,0 +1,109 @@ +"""Formal authoring/runtime coordinate and timeline conversion contracts.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CoordinateSpace: + """Map top-left authoring pixels to center-origin runtime coordinates. + + Authoring coordinates use a fixed logical canvas independent of window or + framebuffer scale. Runtime X/Y both use ``[-1, 1]`` and positive Y points up. + """ + + logical_width: float = 384.0 + logical_height: float = 448.0 + + def __post_init__(self) -> None: + if self.logical_width <= 0 or self.logical_height <= 0: + raise ValueError("logical canvas dimensions must be positive") + + def authoring_to_runtime(self, x: float, y: float) -> tuple[float, float]: + return ( + (float(x) / self.logical_width) * 2.0 - 1.0, + 1.0 - (float(y) / self.logical_height) * 2.0, + ) + + def runtime_to_authoring(self, x: float, y: float) -> tuple[float, float]: + return ( + (float(x) + 1.0) * 0.5 * self.logical_width, + (1.0 - float(y)) * 0.5 * self.logical_height, + ) + + def viewport_to_authoring( + self, + x: float, + y: float, + *, + viewport_width: float, + viewport_height: float, + ) -> tuple[float, float]: + if viewport_width <= 0 or viewport_height <= 0: + raise ValueError("viewport dimensions must be positive") + return ( + float(x) * self.logical_width / float(viewport_width), + float(y) * self.logical_height / float(viewport_height), + ) + + def viewport_to_runtime( + self, + x: float, + y: float, + *, + viewport_width: float, + viewport_height: float, + ) -> tuple[float, float]: + authoring = self.viewport_to_authoring( + x, + y, + viewport_width=viewport_width, + viewport_height=viewport_height, + ) + return self.authoring_to_runtime(*authoring) + + +@dataclass(frozen=True) +class Timebase: + """Integer-frame storage with display conversion to seconds and beats.""" + + tick_rate: int = 60 + + def __post_init__(self) -> None: + if ( + isinstance(self.tick_rate, bool) + or not isinstance(self.tick_rate, int) + or self.tick_rate <= 0 + ): + raise ValueError("tick_rate must be a positive integer") + + def frames_to_seconds(self, frames: int) -> float: + self._validate_frames(frames) + return frames / self.tick_rate + + def seconds_to_frames(self, seconds: float) -> int: + if not math.isfinite(seconds) or seconds < 0: + raise ValueError("seconds must be finite and non-negative") + return int(math.floor(seconds * self.tick_rate + 0.5)) + + def frames_to_beats(self, frames: int, bpm: float) -> float: + self._validate_bpm(bpm) + return self.frames_to_seconds(frames) * bpm / 60.0 + + def beats_to_frames(self, beats: float, bpm: float) -> int: + self._validate_bpm(bpm) + if not math.isfinite(beats) or beats < 0: + raise ValueError("beats must be finite and non-negative") + return self.seconds_to_frames(beats * 60.0 / bpm) + + @staticmethod + def _validate_frames(frames: int) -> None: + if isinstance(frames, bool) or not isinstance(frames, int) or frames < 0: + raise ValueError("frames must be a non-negative integer") + + @staticmethod + def _validate_bpm(bpm: float) -> None: + if not math.isfinite(bpm) or bpm <= 0: + raise ValueError("bpm must be finite and positive") diff --git a/src/authoring/migrations.py b/src/authoring/migrations.py new file mode 100644 index 00000000..d14c89c1 --- /dev/null +++ b/src/authoring/migrations.py @@ -0,0 +1,135 @@ +"""Explicit, per-resource schema migration routing.""" + +from __future__ import annotations + +import copy +from collections.abc import Callable, Mapping +from typing import Any + +from .resources import ( + AUTHORING_RESOURCE_TYPES, + RESOURCE_SCHEMA_VERSION, + SCENE_RESOURCE_TYPE, + new_resource_id, +) + + +Migration = Callable[[dict[str, Any]], dict[str, Any]] + + +class MigrationError(ValueError): + """Raised when no safe schema migration path exists.""" + + +class MigrationRegistry: + def __init__(self) -> None: + self._current_versions: dict[str, int] = {} + self._migrations: dict[tuple[str, int], Migration] = {} + + def register_type(self, resource_type: str, current_version: int) -> None: + if not resource_type or current_version <= 0: + raise ValueError("resource type and a positive current version are required") + existing = self._current_versions.get(resource_type) + if existing is not None and existing != current_version: + raise ValueError( + f"Resource type {resource_type!r} already targets version {existing}" + ) + self._current_versions[resource_type] = current_version + + def register( + self, + resource_type: str, + from_version: int, + migration: Migration, + ) -> None: + if resource_type not in self._current_versions: + raise ValueError(f"Register resource type before migrations: {resource_type}") + if from_version < 0 or from_version >= self._current_versions[resource_type]: + raise ValueError("migration source must be below the current version") + key = (resource_type, from_version) + if key in self._migrations: + raise ValueError( + f"Duplicate migration for {resource_type} schema {from_version}" + ) + self._migrations[key] = migration + + def current_version(self, resource_type: str) -> int: + try: + return self._current_versions[resource_type] + except KeyError as exc: + raise MigrationError(f"Unknown resource type: {resource_type!r}") from exc + + def migrate( + self, + data: Mapping[str, Any], + *, + expected_type: str | None = None, + ) -> dict[str, Any]: + if not isinstance(data, Mapping): + raise MigrationError("resource must be an object") + migrated = copy.deepcopy(dict(data)) + resource_type = str(migrated.get("type") or expected_type or "") + if not resource_type: + raise MigrationError("resource.type is required to select migrations") + if expected_type is not None and resource_type != expected_type: + raise MigrationError( + f"Expected resource type {expected_type!r}, got {resource_type!r}" + ) + target = self.current_version(resource_type) + version = migrated.get("schema_version", 0) + if not isinstance(version, int) or isinstance(version, bool): + raise MigrationError("schema_version must be an integer") + if version > target: + raise MigrationError( + f"Resource schema {version} is newer than supported {target} " + f"for {resource_type}" + ) + while version < target: + migration = self._migrations.get((resource_type, version)) + if migration is None: + raise MigrationError( + f"No migration path for {resource_type} schema {version} -> {version + 1}" + ) + migrated = migration(copy.deepcopy(migrated)) + if not isinstance(migrated, dict): + raise MigrationError("migration must return an object") + next_version = migrated.get("schema_version") + if next_version != version + 1: + raise MigrationError( + f"Migration for {resource_type} schema {version} must produce " + f"schema {version + 1}, got {next_version!r}" + ) + if migrated.get("type") != resource_type: + raise MigrationError("migration may not change resource.type") + version = next_version + return migrated + + +def migrate_legacy_scene_v0(data: dict[str, Any]) -> dict[str, Any]: + root = data.get("root") + if root is None: + root = { + "id": new_resource_id(), + "type": "Stage", + "name": data.get("name", "Scene"), + "properties": {}, + "children": data.get("nodes", []), + } + return { + "schema_version": 1, + "type": SCENE_RESOURCE_TYPE, + "id": data.get("id") or new_resource_id(), + "name": data.get("name", "Scene"), + "symbol_name": data.get("symbol_name"), + "metadata": data.get("metadata", {}), + "root": root, + "timeline": data.get("timeline", []), + } + + +def build_default_migration_registry() -> MigrationRegistry: + registry = MigrationRegistry() + for resource_type in AUTHORING_RESOURCE_TYPES: + registry.register_type(resource_type, RESOURCE_SCHEMA_VERSION) + registry.register(SCENE_RESOURCE_TYPE, 0, migrate_legacy_scene_v0) + return registry diff --git a/src/authoring/registry.py b/src/authoring/registry.py new file mode 100644 index 00000000..ea94bd21 --- /dev/null +++ b/src/authoring/registry.py @@ -0,0 +1,120 @@ +"""Typed resource contribution registry shared by tools and future plugins.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass +from typing import Any + +from .migrations import MigrationRegistry, build_default_migration_registry +from .resources import ( + BACKGROUND_RESOURCE_TYPE, + PATTERN_RESOURCE_TYPE, + RESOURCE_SCHEMA_VERSION, + SCENE_RESOURCE_TYPE, + UI_RESOURCE_TYPE, + GenericResourceDocument, + ResourceDocumentError, +) + + +ResourceLoader = Callable[[Mapping[str, Any]], Any] +ResourceValidator = Callable[[Any], None] +Contribution = Callable[..., Any] + + +@dataclass(frozen=True) +class ResourceTypeSpec: + type_name: str + display_name: str + asset_kind: str + current_version: int = RESOURCE_SCHEMA_VERSION + loader: ResourceLoader | None = None + validator: ResourceValidator | None = None + editor_factory: Contribution | None = None + compiler: Contribution | None = None + preview_handler: Contribution | None = None + + def validate(self) -> None: + if not self.type_name or not self.display_name or not self.asset_kind: + raise ValueError("resource type, display name, and asset kind are required") + if self.current_version <= 0: + raise ValueError("resource current_version must be positive") + + +class ResourceTypeRegistry(Mapping[str, ResourceTypeSpec]): + def __init__(self, migrations: MigrationRegistry | None = None) -> None: + self.migrations = migrations or MigrationRegistry() + self._types: dict[str, ResourceTypeSpec] = {} + + def register(self, spec: ResourceTypeSpec) -> ResourceTypeSpec: + spec.validate() + if spec.type_name in self._types: + raise ValueError(f"Duplicate resource type: {spec.type_name}") + self.migrations.register_type(spec.type_name, spec.current_version) + self._types[spec.type_name] = spec + return spec + + def __getitem__(self, key: str) -> ResourceTypeSpec: + try: + return self._types[key] + except KeyError as exc: + raise KeyError(f"Unknown resource type: {key}") from exc + + def __iter__(self) -> Iterator[str]: + return iter(self._types) + + def __len__(self) -> int: + return len(self._types) + + def spec_for_payload(self, data: Mapping[str, Any]) -> ResourceTypeSpec: + resource_type = str(data.get("type") or "") + if not resource_type: + raise ResourceDocumentError("resource.type is required") + try: + return self[resource_type] + except KeyError as exc: + raise ResourceDocumentError(str(exc)) from exc + + def load( + self, + data: Mapping[str, Any], + *, + expected_type: str | None = None, + ) -> Any: + migrated = self.migrations.migrate(data, expected_type=expected_type) + spec = self.spec_for_payload(migrated) + loader = spec.loader or ( + lambda payload: GenericResourceDocument.from_dict( + payload, + expected_type=spec.type_name, + current_version=spec.current_version, + ) + ) + document = loader(migrated) + if spec.validator is not None: + spec.validator(document) + elif hasattr(document, "validate"): + document.validate() + return document + + def asset_kind_for_payload(self, data: Mapping[str, Any]) -> str: + return self.spec_for_payload(data).asset_kind + + +def build_default_resource_type_registry() -> ResourceTypeRegistry: + registry = ResourceTypeRegistry(build_default_migration_registry()) + for type_name, display_name, asset_kind in ( + (SCENE_RESOURCE_TYPE, "Scene", "scene"), + (PATTERN_RESOURCE_TYPE, "Pattern", "pattern"), + (UI_RESOURCE_TYPE, "UI", "ui"), + (BACKGROUND_RESOURCE_TYPE, "Background", "background"), + ): + registry.register( + ResourceTypeSpec( + type_name=type_name, + display_name=display_name, + asset_kind=asset_kind, + ) + ) + return registry diff --git a/src/authoring/resources.py b/src/authoring/resources.py new file mode 100644 index 00000000..37aebcf5 --- /dev/null +++ b/src/authoring/resources.py @@ -0,0 +1,294 @@ +"""Common identity, envelope, and reference contracts for authoring resources.""" + +from __future__ import annotations + +import copy +import json +import re +import uuid +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from src.core.project_context import ProjectContext + + +RESOURCE_SCHEMA_VERSION = 1 +RESOURCE_FILE_SUFFIX = ".pystg.json" + +SCENE_RESOURCE_TYPE = "pystg.scene" +PATTERN_RESOURCE_TYPE = "pystg.pattern" +UI_RESOURCE_TYPE = "pystg.ui" +BACKGROUND_RESOURCE_TYPE = "pystg.background" +AUTHORING_RESOURCE_TYPES = ( + SCENE_RESOURCE_TYPE, + PATTERN_RESOURCE_TYPE, + UI_RESOURCE_TYPE, + BACKGROUND_RESOURCE_TYPE, +) + +RESOURCE_HEADER_FIELDS = frozenset( + {"schema_version", "type", "id", "name", "symbol_name", "metadata"} +) +_SYMBOL_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class ResourceDocumentError(ValueError): + """Raised when an authoring resource violates the common contract.""" + + +def new_resource_id() -> str: + return str(uuid.uuid4()) + + +def validate_resource_id(value: Any, field_name: str = "resource.id") -> str: + text = str(value or "") + try: + uuid.UUID(text) + except (ValueError, AttributeError, TypeError) as exc: + raise ResourceDocumentError( + f"{field_name} must be a UUID, got {value!r}" + ) from exc + return text + + +def validate_json_object(value: Any, field_name: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ResourceDocumentError(f"{field_name} must be an object") + result = dict(value) + try: + json.dumps(result, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise ResourceDocumentError( + f"{field_name} must contain JSON-compatible values" + ) from exc + return result + + +def validate_unique_object_ids( + value: Any, + *, + reserved_ids: tuple[str, ...] = (), +) -> None: + """Reject duplicate UUID-valued ``id`` fields in a JSON resource tree.""" + + seen = set(reserved_ids) + + def visit(item: Any, path: str) -> None: + if isinstance(item, Mapping): + object_id = item.get("id") + if object_id is not None: + object_id = validate_resource_id(object_id, f"{path}.id") + if object_id in seen: + raise ResourceDocumentError( + f"Duplicate document object id: {object_id}" + ) + seen.add(object_id) + for key, child in item.items(): + visit(child, f"{path}.{key}") + elif isinstance(item, list): + for index, child in enumerate(item): + visit(child, f"{path}[{index}]") + + visit(value, "resource") + + +@dataclass +class ResourceHeader: + """Fields shared by every versioned authoring resource.""" + + type: str + name: str + id: str = field(default_factory=new_resource_id) + schema_version: int = RESOURCE_SCHEMA_VERSION + symbol_name: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def validate( + self, + *, + expected_type: str | None = None, + current_version: int = RESOURCE_SCHEMA_VERSION, + ) -> None: + self.id = validate_resource_id(self.id) + if not isinstance(self.schema_version, int) or isinstance( + self.schema_version, bool + ): + raise ResourceDocumentError("schema_version must be an integer") + if self.schema_version != current_version: + relation = "newer than" if self.schema_version > current_version else "older than" + raise ResourceDocumentError( + f"Resource schema {self.schema_version} is {relation} supported " + f"version {current_version}; migrate it before loading" + ) + if not isinstance(self.type, str) or not self.type.strip(): + raise ResourceDocumentError("resource.type must be a non-empty string") + if expected_type is not None and self.type != expected_type: + raise ResourceDocumentError( + f"Expected resource type {expected_type!r}, got {self.type!r}" + ) + if not isinstance(self.name, str) or not self.name.strip(): + raise ResourceDocumentError("resource.name must be a non-empty string") + if self.symbol_name is not None: + if not isinstance(self.symbol_name, str) or not _SYMBOL_RE.fullmatch( + self.symbol_name + ): + raise ResourceDocumentError( + "resource.symbol_name must be a portable Python identifier" + ) + self.metadata = validate_json_object(self.metadata, "resource.metadata") + + def to_dict(self) -> dict[str, Any]: + self.validate(current_version=self.schema_version) + payload: dict[str, Any] = { + "schema_version": self.schema_version, + "type": self.type, + "id": self.id, + "name": self.name, + "metadata": copy.deepcopy(self.metadata), + } + if self.symbol_name is not None: + payload["symbol_name"] = self.symbol_name + return payload + + @classmethod + def from_dict( + cls, + data: Mapping[str, Any], + *, + expected_type: str | None = None, + current_version: int = RESOURCE_SCHEMA_VERSION, + ) -> "ResourceHeader": + if not isinstance(data, Mapping): + raise ResourceDocumentError("resource must be an object") + header = cls( + schema_version=data.get("schema_version", 0), + type=data.get("type", ""), + id=data.get("id", ""), + name=data.get("name", ""), + symbol_name=data.get("symbol_name"), + metadata=validate_json_object(data.get("metadata", {}), "resource.metadata"), + ) + header.validate(expected_type=expected_type, current_version=current_version) + return header + + +@dataclass +class GenericResourceDocument: + """A typed envelope that preserves domain fields without freezing a schema.""" + + header: ResourceHeader + body: dict[str, Any] = field(default_factory=dict) + + @property + def type(self) -> str: + return self.header.type + + @property + def id(self) -> str: + return self.header.id + + @property + def name(self) -> str: + return self.header.name + + @property + def schema_version(self) -> int: + return self.header.schema_version + + def validate(self, *, current_version: int = RESOURCE_SCHEMA_VERSION) -> None: + self.header.validate(current_version=current_version) + self.body = validate_json_object(self.body, "resource body") + overlap = RESOURCE_HEADER_FIELDS.intersection(self.body) + if overlap: + raise ResourceDocumentError( + "resource body repeats header fields: " + ", ".join(sorted(overlap)) + ) + validate_unique_object_ids(self.body, reserved_ids=(self.header.id,)) + + def to_dict(self) -> dict[str, Any]: + self.validate(current_version=self.header.schema_version) + return {**self.header.to_dict(), **copy.deepcopy(self.body)} + + @classmethod + def from_dict( + cls, + data: Mapping[str, Any], + *, + expected_type: str | None = None, + current_version: int = RESOURCE_SCHEMA_VERSION, + ) -> "GenericResourceDocument": + header = ResourceHeader.from_dict( + data, + expected_type=expected_type, + current_version=current_version, + ) + body = { + key: copy.deepcopy(value) + for key, value in data.items() + if key not in RESOURCE_HEADER_FIELDS + } + document = cls(header=header, body=body) + document.validate(current_version=current_version) + return document + + +@dataclass(frozen=True) +class ResourceReference: + """Canonical ``res://path#subresource`` reference inside one project.""" + + path: PurePosixPath + subresource: str | None = None + + def __post_init__(self) -> None: + text = self.path.as_posix() + if text in {"", "."} or self.path.is_absolute(): + raise ResourceDocumentError("resource path must be project-relative") + if any(part in {"", ".", ".."} for part in self.path.parts): + raise ResourceDocumentError("resource path may not contain '.' or '..'") + if any(":" in part for part in self.path.parts): + raise ResourceDocumentError("resource path may not contain drive prefixes") + if self.subresource is not None: + if not self.subresource or "#" in self.subresource: + raise ResourceDocumentError("subresource must be a non-empty fragment") + + @property + def uri(self) -> str: + value = f"res://{self.path.as_posix()}" + return value + (f"#{self.subresource}" if self.subresource else "") + + @classmethod + def parse( + cls, + value: str, + *, + allow_legacy_project_path: bool = False, + ) -> "ResourceReference": + if not isinstance(value, str) or not value.strip(): + raise ResourceDocumentError("resource reference must be a non-empty string") + normalized = value.strip().replace("\\", "/") + if normalized.startswith("res://"): + normalized = normalized[6:] + elif not allow_legacy_project_path: + raise ResourceDocumentError("resource reference must start with 'res://'") + path_value, separator, fragment = normalized.partition("#") + if "#" in fragment: + raise ResourceDocumentError("resource reference contains multiple fragments") + return cls( + path=PurePosixPath(path_value), + subresource=fragment if separator else None, + ) + + def resolve( + self, + project: ProjectContext, + *, + must_exist: bool = False, + ) -> Path: + resolved = project.resolve(Path(*self.path.parts)) + project.relative(resolved) + if must_exist and not resolved.is_file(): + raise ResourceDocumentError(f"Referenced resource does not exist: {self.uri}") + return resolved diff --git a/src/authoring/storage.py b/src/authoring/storage.py new file mode 100644 index 00000000..a21465ff --- /dev/null +++ b/src/authoring/storage.py @@ -0,0 +1,40 @@ +"""Atomic persistence for generic typed authoring resources.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.core.atomic_io import atomic_write_json +from src.core.project_context import ProjectContext, get_project_context + +from .registry import ResourceTypeRegistry, build_default_resource_type_registry + + +class ResourceStore: + def __init__( + self, + project: ProjectContext | None = None, + registry: ResourceTypeRegistry | None = None, + ) -> None: + self.project = project or get_project_context() + self.registry = registry or build_default_resource_type_registry() + + def _path(self, path: str | Path) -> Path: + resolved = self.project.resolve(path) + self.project.relative(resolved) + return resolved + + def load(self, path: str | Path) -> Any: + source = self._path(path) + data = json.loads(source.read_text(encoding="utf-8")) + return self.registry.load(data) + + def save(self, document: Any, path: str | Path) -> Path: + target = self._path(path) + payload = document.to_dict() + # Loading through the registry proves current schema/type validation + # before the atomic replacement is attempted. + self.registry.load(payload) + return atomic_write_json(target, payload) diff --git a/src/editor/__init__.py b/src/editor/__init__.py index 49185b58..811f4ebb 100644 --- a/src/editor/__init__.py +++ b/src/editor/__init__.py @@ -9,7 +9,16 @@ TimelineEvent, ) from .storage import DocumentStore -from .node_types import NODE_TYPES, PropertySpec, make_default_root, make_node +from .node_types import ( + NODE_TYPES, + NODE_TYPE_REGISTRY, + NodeTypeRegistry, + NodeTypeSpec, + PropertySpec, + ViewportSpec, + make_default_root, + make_node, +) from .scene_commands import ( AddNodeCommand, MoveNodeCommand, @@ -33,7 +42,11 @@ "SceneDocument", "TimelineEvent", "NODE_TYPES", + "NODE_TYPE_REGISTRY", + "NodeTypeRegistry", + "NodeTypeSpec", "PropertySpec", + "ViewportSpec", "make_default_root", "make_node", "AddNodeCommand", diff --git a/src/editor/app.py b/src/editor/app.py index b823d40f..9a9b846a 100644 --- a/src/editor/app.py +++ b/src/editor/app.py @@ -57,9 +57,13 @@ ) from src.core.project_context import ProjectContext +from src.authoring.coordinates import CoordinateSpace +from src.authoring.resources import ResourceDocumentError, ResourceReference +from .asset_index import AssetRecord, load_subresource_preview from .document import DocumentError, EditorNode, SceneDocument from .node_types import NODE_TYPES, PropertySpec, make_node, property_specs +from .resource_browser import RESOURCE_MIME_TYPE, ResourceBrowserPanel from .scene_commands import ( AddNodeCommand, MoveNodeCommand, @@ -72,6 +76,7 @@ ) from .session import SceneEditorSession from .storage import DocumentStore +from .workbench import EditorPlugin, PluginRegistry, default_external_plugins APP_NAME = "PySTG Scene Editor" @@ -87,8 +92,16 @@ def build_preview_command( script_value = str(node.properties.get("script", "")).strip() if not script_value: raise ValueError("Selected SpellCard needs a script path.") - script_path = project.resolve(script_value) - project.relative(script_path) + try: + reference = ResourceReference.parse( + script_value, + allow_legacy_project_path=True, + ) + if reference.subresource is not None: + raise ResourceDocumentError("script references cannot use fragments") + script_path = reference.resolve(project) + except ResourceDocumentError as exc: + raise ValueError(str(exc)) from exc if not script_path.is_file(): raise ValueError(f"SpellCard script does not exist: {script_path}") arguments = [ @@ -182,6 +195,7 @@ def __init__( self.node_name = node.name self.grid_size = max(1, grid_size) self._drag_start = QPointF() + self._spec = NODE_TYPES.get(node.type) self._pixmap = self._load_pixmap(node, project) self.setFlags( QGraphicsItem.ItemIsMovable @@ -193,12 +207,16 @@ def __init__( @staticmethod def _load_pixmap(node: EditorNode, project: ProjectContext) -> QPixmap: - if node.type != "Sprite": + spec = NODE_TYPES.get(node.type) + preview_property = spec.viewport.preview_property if spec is not None else None + if preview_property is None: return QPixmap() - texture = str(node.properties.get("texture", "")).strip() + texture = str(node.properties.get(preview_property, "")).strip() if not texture: return QPixmap() - candidate = project.resolve(texture) + candidate, rect = load_subresource_preview(project, texture) + if candidate is None: + return QPixmap() try: project.relative(candidate) except Exception: @@ -208,6 +226,12 @@ def _load_pixmap(node: EditorNode, project: ProjectContext) -> QPixmap: pixmap = QPixmap(str(candidate)) if pixmap.isNull(): return QPixmap() + if rect is not None: + x, y, width, height = rect + clipped = pixmap.rect().intersected(QRectF(x, y, width, height).toRect()) + if clipped.isEmpty(): + return QPixmap() + pixmap = pixmap.copy(clipped) return pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation) def boundingRect(self) -> QRectF: @@ -215,9 +239,10 @@ def boundingRect(self) -> QRectF: def shape(self) -> QPainterPath: path = QPainterPath() - if self.node_type == "EnemySpawner": + shape = self._spec.viewport.shape if self._spec is not None else "box" + if shape == "circle": path.addEllipse(QRectF(-24, -24, 48, 48)) - elif self.node_type == "SpellCard": + elif shape == "diamond": polygon = [ QPointF(0, -28), QPointF(28, 0), @@ -233,20 +258,21 @@ def shape(self) -> QPainterPath: return path def paint(self, painter: QPainter, option, widget=None) -> None: - spec = NODE_TYPES.get(self.node_type) + spec = self._spec color = QColor(spec.color if spec else "#9aa4b2") painter.setRenderHint(QPainter.Antialiasing) painter.setPen(QPen(QColor("#f5f7ff") if self.isSelected() else color, 3 if self.isSelected() else 2)) painter.setBrush(QBrush(QColor(color.red(), color.green(), color.blue(), 72))) - if self.node_type == "EnemySpawner": + shape = spec.viewport.shape if spec is not None else "box" + label = spec.viewport.label if spec is not None else "NODE" + if shape == "circle": painter.drawEllipse(QRectF(-24, -24, 48, 48)) - painter.drawLine(-30, 0, 30, 0) - painter.drawLine(0, -30, 0, 30) - elif self.node_type == "SpellCard": + painter.drawText(QRectF(-21, -10, 42, 20), Qt.AlignCenter, label) + elif shape == "diamond": path = self.shape() painter.drawPath(path) - painter.drawText(QRectF(-25, -10, 50, 20), Qt.AlignCenter, "SC") + painter.drawText(QRectF(-25, -10, 50, 20), Qt.AlignCenter, label) else: painter.drawRoundedRect(QRectF(-28, -28, 56, 56), 5, 5) if not self._pixmap.isNull(): @@ -258,7 +284,7 @@ def paint(self, painter: QPainter, option, widget=None) -> None: ) painter.drawPixmap(target, self._pixmap, QRectF(self._pixmap.rect())) else: - painter.drawText(QRectF(-24, -10, 48, 20), Qt.AlignCenter, "SPR") + painter.drawText(QRectF(-24, -10, 48, 20), Qt.AlignCenter, label) painter.setPen(QColor("#e8ecf5")) painter.drawText(QRectF(-70, 34, 140, 22), Qt.AlignHCenter | Qt.AlignTop, self.node_name) @@ -283,6 +309,7 @@ def mouseReleaseEvent(self, event) -> None: class SceneViewport(QGraphicsView): nodeSelected = pyqtSignal(str) nodePositionRequested = pyqtSignal(str, float, float) + resourceDropped = pyqtSignal(object, float, float) def __init__(self, project: ProjectContext, parent=None): self.graphics_scene = QGraphicsScene(parent) @@ -292,12 +319,15 @@ def __init__(self, project: ProjectContext, parent=None): self._items: dict[str, NodeGraphicsItem] = {} self._grid_size = 16 self._background = QColor("#171a24") + self.coordinate_space = CoordinateSpace() self._fit_on_next_resize = True self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) self.setDragMode(QGraphicsView.RubberBandDrag) self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setFrameShape(QFrame.NoFrame) + self.setAcceptDrops(True) + self.viewport().setAcceptDrops(True) self.graphics_scene.selectionChanged.connect(self._selection_changed) def rebuild(self, document: SceneDocument) -> None: @@ -306,8 +336,9 @@ def rebuild(self, document: SceneDocument) -> None: self._items.clear() root = document.root - width = max(64, int(root.properties.get("width", 768))) - height = max(64, int(root.properties.get("height", 896))) + self.coordinate_space = document.coordinate_space + width = max(64, int(self.coordinate_space.logical_width)) + height = max(64, int(self.coordinate_space.logical_height)) self._grid_size = max(1, int(root.properties.get("grid_size", 16))) self._background = QColor(str(root.properties.get("background", "#171a24"))) if not self._background.isValid(): @@ -340,6 +371,10 @@ def select_node(self, node_id: str) -> None: for item_id, item in self._items.items(): item.setSelected(item_id == node_id) + def runtime_position(self, x: float, y: float) -> tuple[float, float]: + """Convert a gizmo position through the formal authoring contract.""" + return self.coordinate_space.authoring_to_runtime(x, y) + def _selection_changed(self) -> None: selected = self.graphics_scene.selectedItems() if selected and isinstance(selected[0], NodeGraphicsItem): @@ -370,6 +405,37 @@ def wheelEvent(self, event) -> None: return super().wheelEvent(event) + def dragEnterEvent(self, event) -> None: + if event.mimeData().hasFormat(RESOURCE_MIME_TYPE): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event) -> None: + if event.mimeData().hasFormat(RESOURCE_MIME_TYPE): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event) -> None: + if not event.mimeData().hasFormat(RESOURCE_MIME_TYPE): + super().dropEvent(event) + return + try: + payload = json.loads( + bytes(event.mimeData().data(RESOURCE_MIME_TYPE)).decode("utf-8") + ) + except (UnicodeDecodeError, json.JSONDecodeError): + event.ignore() + return + scene_position = self.mapToScene(event.pos()) + self.resourceDropped.emit( + payload, + float(scene_position.x()), + float(scene_position.y()), + ) + event.acceptProposedAction() + def resizeEvent(self, event) -> None: super().resizeEvent(event) if self._fit_on_next_resize: @@ -517,6 +583,10 @@ def __init__(self, project: ProjectContext): self._selected_id = self.session.document.root.id self._syncing_selection = False self._preview_process: QProcess | None = None + self._tool_processes: dict[str, QProcess] = {} + self._plugin_widgets: dict[str, QWidget] = {} + self.plugin_registry = PluginRegistry(project) + self._register_plugins() self._build_actions() self._build_ui() self._apply_theme() @@ -524,6 +594,36 @@ def __init__(self, project: ProjectContext): self.resize(1480, 920) self.setMinimumSize(960, 640) + def _register_plugins(self) -> None: + self.plugin_registry.register( + EditorPlugin( + id="resource_browser", + title="Assets", + description="Browse project files and JSON sprite/animation subresources.", + mode="bottom", + factory=lambda: ResourceBrowserPanel(self.project), + ) + ) + self.plugin_registry.register( + EditorPlugin( + id="bullet_aliases", + title="Bullet Aliases", + description="Edit bullet type and color to sprite mappings.", + mode="central", + factory=self._create_bullet_alias_editor, + ) + ) + for plugin in default_external_plugins(self.project): + self.plugin_registry.register(plugin) + + @staticmethod + def _create_bullet_alias_editor() -> QWidget: + from tools.bullet.bullet_alias_manager import BulletAliasManager + + editor = BulletAliasManager() + editor.setWindowFlags(Qt.Widget) + return editor + def _build_actions(self) -> None: self.action_new = self._action("New Scene", QKeySequence.New, self.new_scene) self.action_open = self._action("Open Scene…", QKeySequence.Open, self.open_scene) @@ -569,6 +669,16 @@ def _build_ui(self) -> None: ]) run_menu = self.menuBar().addMenu("&Run") run_menu.addActions([self.action_run, self.action_fit]) + tools_menu = self.menuBar().addMenu("&Tools") + for plugin in self.plugin_registry.all(): + action = tools_menu.addAction(plugin.title) + action.setObjectName(f"pluginAction_{plugin.id}") + action.setToolTip(plugin.description) + if plugin.shortcut: + action.setShortcut(plugin.shortcut) + action.triggered.connect( + lambda checked=False, plugin_id=plugin.id: self.open_plugin(plugin_id) + ) main_toolbar = QToolBar("Main", self) main_toolbar.setObjectName("mainToolbar") @@ -587,7 +697,18 @@ def _build_ui(self) -> None: self.viewport = SceneViewport(self.project) self.viewport.nodeSelected.connect(self._select_from_viewport) self.viewport.nodePositionRequested.connect(self._set_node_position) - self.setCentralWidget(self.viewport) + self.viewport.resourceDropped.connect(self._resource_dropped) + self.central_tabs = QTabWidget() + self.central_tabs.setObjectName("centralWorkbench") + self.central_tabs.setTabsClosable(True) + self.central_tabs.tabCloseRequested.connect(self._close_central_tab) + self.central_tabs.addTab(self.viewport, "Scene") + self.central_tabs.tabBar().setTabButton( + 0, + self.central_tabs.tabBar().RightSide, + None, + ) + self.setCentralWidget(self.central_tabs) self.tree = SceneTreeWidget() self.tree.currentItemChanged.connect(self._select_from_tree) @@ -653,17 +774,191 @@ def _build_ui(self) -> None: self.output.setReadOnly(True) self.output.document().setMaximumBlockCount(1000) self.timeline = TimelinePanel() - bottom_tabs = QTabWidget() - bottom_tabs.addTab(self.output, "Output") - bottom_tabs.addTab(self.timeline, "Timeline") + self.bottom_tabs = QTabWidget() + self.bottom_tabs.setObjectName("bottomWorkbench") + self.bottom_tabs.addTab(self.output, "Output") + self.bottom_tabs.addTab(self.timeline, "Timeline") + for plugin in self.plugin_registry.by_mode("bottom"): + widget = plugin.factory() + self._plugin_widgets[plugin.id] = widget + self.bottom_tabs.addTab(widget, plugin.title) + if plugin.id == "resource_browser": + self.resource_browser = widget + widget.resourceSelected.connect(self._resource_selected) + widget.resourceActivated.connect(self._resource_activated) bottom_dock = QDockWidget("Bottom Panel", self) bottom_dock.setObjectName("bottomDock") - bottom_dock.setWidget(bottom_tabs) + bottom_dock.setWidget(self.bottom_tabs) bottom_dock.setMinimumHeight(180) self.addDockWidget(Qt.BottomDockWidgetArea, bottom_dock) self.statusBar().showMessage(str(self.project.root)) + def open_plugin(self, plugin_id: str) -> None: + plugin = self.plugin_registry.get(plugin_id) + if plugin.mode == "bottom": + widget = self._plugin_widgets.get(plugin.id) + if widget is not None: + self.bottom_tabs.setCurrentWidget(widget) + return + if plugin.mode == "central": + existing = self._plugin_widgets.get(plugin.id) + if existing is not None: + self.central_tabs.setCurrentWidget(existing) + return + widget = plugin.factory() + widget.setProperty("editorPluginId", plugin.id) + self._plugin_widgets[plugin.id] = widget + index = self.central_tabs.addTab(widget, plugin.title) + self.central_tabs.setCurrentIndex(index) + self._log(f"[tool] opened {plugin.title}") + return + self._start_external_plugin(plugin) + + def _start_external_plugin(self, plugin: EditorPlugin) -> None: + running = self._tool_processes.get(plugin.id) + if running is not None and running.state() != QProcess.NotRunning: + self.statusBar().showMessage(f"{plugin.title} is already running", 3000) + return + if plugin.script is None or not plugin.script.is_file(): + self._show_error( + "Tool unavailable", + ValueError(f"Tool script does not exist: {plugin.script}"), + ) + return + process = QProcess(self) + process.setProgram(sys.executable) + process.setArguments([str(plugin.script)]) + process.setWorkingDirectory(str(self.project.root)) + process.setProcessChannelMode(QProcess.MergedChannels) + process.readyReadStandardOutput.connect( + lambda plugin_id=plugin.id: self._read_tool_output(plugin_id) + ) + process.finished.connect( + lambda exit_code, exit_status, plugin_id=plugin.id: ( + self._tool_finished(plugin_id, exit_code, exit_status) + ) + ) + process.errorOccurred.connect( + lambda error, title=plugin.title: self._log( + f"[tool:error] {title}: process error {int(error)}" + ) + ) + self._tool_processes[plugin.id] = process + process.start() + if not process.waitForStarted(3000): + self._tool_processes.pop(plugin.id, None) + self._show_error("Tool failed", ValueError(process.errorString())) + return + self._log(f"[tool] started {plugin.title} (PID {process.processId()})") + + def _read_tool_output(self, plugin_id: str) -> None: + process = self._tool_processes.get(plugin_id) + if process is None: + return + data = bytes(process.readAllStandardOutput()) + output = data.decode("utf-8", errors="replace").rstrip() + if output: + self._log(output) + + def _tool_finished(self, plugin_id: str, exit_code: int, exit_status) -> None: + del exit_status + self._read_tool_output(plugin_id) + plugin = self.plugin_registry.get(plugin_id) + self._log(f"[tool] {plugin.title} exited with code {exit_code}") + self._tool_processes.pop(plugin_id, None) + + def _close_central_tab(self, index: int) -> None: + if index <= 0: + return + widget = self.central_tabs.widget(index) + if widget is None or not widget.close(): + return + plugin_id = str(widget.property("editorPluginId") or "") + self.central_tabs.removeTab(index) + if plugin_id: + self._plugin_widgets.pop(plugin_id, None) + widget.deleteLater() + + def _resource_selected(self, record: AssetRecord) -> None: + self.statusBar().showMessage(record.resource_value, 3000) + + def _resource_activated(self, record: AssetRecord) -> None: + selected = self.session.node(self._selected_id) + if record.kind in {"image", "sprite"}: + if selected is not None and selected.type == "Sprite": + self.set_node_property( + selected.id, + "texture", + record.resource_value, + ) + else: + self._add_sprite_resource( + record.resource_value, + record.name, + ) + return + if record.kind == "script": + if selected is not None and selected.type == "SpellCard": + self.set_node_property( + selected.id, + "script", + record.resource_value, + ) + else: + self._log( + "[assets] Select a SpellCard before assigning a script." + ) + return + if record.kind == "json": + if record.path.name == "bullet_aliases.json": + self.open_plugin("bullet_aliases") + elif record.project_path.startswith("assets/images/"): + self.open_plugin("texture_editor") + + def _resource_dropped(self, payload: dict, x: float, y: float) -> None: + kind = str(payload.get("kind", "")) + value = str(payload.get("resource_value", "")).strip() + name = str(payload.get("name", "Sprite")).strip() or "Sprite" + if not value: + return + if kind in {"image", "sprite"}: + self._add_sprite_resource(value, name, x=x, y=y) + return + if kind == "script": + selected = self.session.node(self._selected_id) + if selected is not None and selected.type == "SpellCard": + self.set_node_property(selected.id, "script", value) + else: + self._log( + "[assets] Drop scripts while a SpellCard is selected." + ) + + def _add_sprite_resource( + self, + resource_value: str, + name: str, + *, + x: float | None = None, + y: float | None = None, + ) -> None: + parent = self.session.node(self._selected_id) or self.session.document.root + node = make_node("Sprite", name=Path(name).stem or "Sprite") + node.properties["texture"] = resource_value + if x is not None: + node.properties["x"] = float(x) + if y is not None: + node.properties["y"] = float(y) + self._apply_command( + AddNodeCommand( + self.session.document.root, + parent.id, + node, + label=f"Add {node.name}", + ), + select_id=node.id, + ) + def _apply_theme(self) -> None: QApplication.instance().setStyle("Fusion") self.setStyleSheet( @@ -682,8 +977,8 @@ def _apply_theme(self) -> None: padding: 7px; font-weight: 600; } - QTreeWidget, QTextEdit, QTableWidget, QLineEdit, - QSpinBox, QDoubleSpinBox, QScrollArea { + QTreeWidget, QListView, QTextEdit, QTableWidget, QLineEdit, + QComboBox, QSpinBox, QDoubleSpinBox, QScrollArea { background: #171a22; color: #dce2ee; border: 1px solid #353b49; @@ -776,9 +1071,8 @@ def _update_actions(self) -> None: def _update_title(self) -> None: name = self.session.path.name if self.session.path else self.session.document.name - marker = "*" if self.session.is_dirty else "" self.setWindowModified(self.session.is_dirty) - self.setWindowTitle(f"{marker}{name} — {APP_NAME}") + self.setWindowTitle(f"{name}[*] — {APP_NAME}") def _log(self, message: str) -> None: self.output.append(message) @@ -1148,10 +1442,21 @@ def closeEvent(self, event) -> None: if not self._confirm_discard(): event.ignore() return + for index in range(self.central_tabs.count() - 1, 0, -1): + widget = self.central_tabs.widget(index) + if widget is not None and not widget.close(): + event.ignore() + return if self._preview_process is not None and self._preview_process.state() != QProcess.NotRunning: self._preview_process.terminate() if not self._preview_process.waitForFinished(1500): self._preview_process.kill() + for process in tuple(self._tool_processes.values()): + if process.state() == QProcess.NotRunning: + continue + process.terminate() + if not process.waitForFinished(1500): + process.kill() event.accept() diff --git a/src/editor/asset_index.py b/src/editor/asset_index.py new file mode 100644 index 00000000..9a646adf --- /dev/null +++ b/src/editor/asset_index.py @@ -0,0 +1,344 @@ +"""Project resource indexing, including atlas subresources.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +from src.authoring.registry import build_default_resource_type_registry +from src.authoring.resources import ResourceDocumentError, ResourceReference +from src.core.project_context import ProjectContext, ProjectContextError + + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} +AUDIO_EXTENSIONS = {".wav", ".ogg", ".mp3", ".flac"} +FONT_EXTENSIONS = {".ttf", ".otf"} +SHADER_EXTENSIONS = {".glsl", ".vert", ".frag"} +TEXT_EXTENSIONS = {".md", ".txt", ".toml", ".ini", ".cfg", ".lua"} +INDEXED_EXTENSIONS = ( + IMAGE_EXTENSIONS + | AUDIO_EXTENSIONS + | FONT_EXTENSIONS + | SHADER_EXTENSIONS + | TEXT_EXTENSIONS + | {".json", ".py"} +) +RESOURCE_TYPES = build_default_resource_type_registry() + + +@dataclass(frozen=True) +class AssetRecord: + uri: str + path: Path + project_path: str + kind: str + name: str + subresource: str | None = None + preview_path: Path | None = None + rect: tuple[int, int, int, int] | None = None + metadata: dict[str, Any] = field(default_factory=dict, compare=False) + + @property + def resource_value(self) -> str: + return self.uri + + @property + def folder(self) -> str: + return Path(self.project_path).parent.as_posix() + + +def classify_file(path: Path, payload: dict[str, Any] | None = None) -> str: + suffix = path.suffix.lower() + if suffix in IMAGE_EXTENSIONS: + return "image" + if suffix in AUDIO_EXTENSIONS: + return "audio" + if suffix in FONT_EXTENSIONS: + return "font" + if suffix in SHADER_EXTENSIONS: + return "shader" + if suffix == ".json": + if path.name.endswith(".pystg.json"): + if payload is None and path.is_file(): + try: + loaded = json.loads(path.read_text(encoding="utf-8-sig")) + payload = loaded if isinstance(loaded, dict) else None + except (OSError, json.JSONDecodeError): + payload = None + if payload is not None: + try: + return RESOURCE_TYPES.asset_kind_for_payload(payload) + except Exception: + pass + return "resource" + return "json" + if suffix == ".py": + return "script" + return "text" + + +def _safe_rect(value: Any) -> tuple[int, int, int, int] | None: + if not isinstance(value, (list, tuple)) or len(value) < 4: + return None + try: + rect = tuple(int(value[index]) for index in range(4)) + except (TypeError, ValueError): + return None + if rect[2] <= 0 or rect[3] <= 0: + return None + return rect + + +def _resolve_texture_path( + config_path: Path, + config: dict[str, Any], + sprite: dict[str, Any] | None = None, +) -> Path | None: + sprite = sprite or {} + textures = config.get("textures") + if not isinstance(textures, dict): + textures = {} + source = str(sprite.get("source", "")).strip() + candidate = ( + textures.get(source) + or config.get("__image_filename") + or config.get("texture") + or textures.get("player") + ) + if not candidate: + image_path = sprite.get("image_path") + if isinstance(image_path, str) and image_path: + candidate = Path(image_path.replace("\\", "/")).name + if not isinstance(candidate, str) or not candidate.strip(): + return None + path = Path(candidate) + if path.is_absolute(): + return path.resolve() + direct = (config_path.parent / path).resolve() + if direct.is_file(): + return direct + if path.parts and path.parts[0].lower() in {"assets", "game_content"}: + for parent in config_path.parents: + project_relative = (parent / path).resolve() + if project_relative.is_file(): + return project_relative + return direct + + +def load_subresource_preview( + project: ProjectContext, + resource_value: str, +) -> tuple[Path | None, tuple[int, int, int, int] | None]: + try: + reference = ResourceReference.parse( + resource_value, + allow_legacy_project_path=True, + ) + source = reference.resolve(project) + except (ResourceDocumentError, ProjectContextError): + return None, None + if reference.subresource is None: + return source, None + try: + project.relative(source) + data = json.loads(source.read_text(encoding="utf-8-sig")) + except (OSError, ValueError, json.JSONDecodeError, ProjectContextError): + return None, None + sprites = data.get("sprites", {}) + if not isinstance(sprites, dict): + return None, None + sprite = sprites.get(reference.subresource) + if not isinstance(sprite, dict): + return None, None + return _resolve_texture_path(source, data, sprite), _safe_rect( + sprite.get("rect") or sprite.get("region") + ) + + +class AssetIndex: + def __init__(self, project: ProjectContext): + self.project = project + self.records: tuple[AssetRecord, ...] = () + self.errors: tuple[str, ...] = () + + def scan(self) -> tuple[AssetRecord, ...]: + records: list[AssetRecord] = [] + errors: list[str] = [] + for root in (self.project.assets, self.project.game_content): + if not root.is_dir(): + continue + for path in sorted(self._iter_files(root)): + try: + relative = self.project.relative(path).as_posix() + except ValueError: + continue + kind = classify_file(path) + records.append( + AssetRecord( + uri=f"res://{relative}", + path=path, + project_path=relative, + kind=kind, + name=path.name, + preview_path=path if kind == "image" else None, + metadata={"size": path.stat().st_size}, + ) + ) + if path.name.endswith(".pystg.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(payload, dict): + raise ValueError("typed resource must contain a JSON object") + RESOURCE_TYPES.load(payload) + except (OSError, ValueError, json.JSONDecodeError) as exc: + errors.append(f"{relative}: {exc}") + elif kind == "json": + try: + records.extend(self._json_subresources(path, relative)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + errors.append(f"{relative}: {exc}") + kind_order = { + "image": 0, + "sprite": 1, + "animation": 2, + "audio": 3, + "scene": 4, + "pattern": 5, + "ui": 6, + "background": 7, + "resource": 8, + "script": 9, + "json": 10, + } + records.sort( + key=lambda record: ( + record.folder.lower(), + kind_order.get(record.kind, 99), + record.name.lower(), + ) + ) + self.records = tuple(records) + self.errors = tuple(errors) + return self.records + + @staticmethod + def _iter_files(root: Path) -> Iterable[Path]: + for path in root.rglob("*"): + if not path.is_file(): + continue + if "__pycache__" in path.parts or any(part.startswith(".") for part in path.parts): + continue + if path.suffix.lower() in INDEXED_EXTENSIONS: + yield path.resolve() + + def _json_subresources( + self, + config_path: Path, + relative: str, + ) -> list[AssetRecord]: + data = json.loads(config_path.read_text(encoding="utf-8-sig")) + if not isinstance(data, dict): + return [] + records: list[AssetRecord] = [] + sprites = data.get("sprites", {}) + sprite_records: dict[str, AssetRecord] = {} + if isinstance(sprites, dict): + for name, sprite in sprites.items(): + if not isinstance(sprite, dict): + continue + rect = _safe_rect(sprite.get("rect") or sprite.get("region")) + preview_path = _resolve_texture_path(config_path, data, sprite) + record = AssetRecord( + uri=f"res://{relative}#{name}", + path=config_path, + project_path=relative, + kind="sprite", + name=str(name), + subresource=str(name), + preview_path=preview_path, + rect=rect, + metadata={ + "config": relative, + "source": sprite.get("source"), + "radius": sprite.get("radius"), + }, + ) + records.append(record) + sprite_records[str(name)] = record + + animations = data.get("animations", {}) + if ( + isinstance(animations, dict) + and isinstance(animations.get("animations"), dict) + ): + animations = animations["animations"] + if isinstance(animations, dict): + for name, animation in animations.items(): + if not isinstance(animation, dict): + continue + frames = animation.get("frames", []) + if not isinstance(frames, list): + frames = [] + first_name = None + if frames: + first = frames[0] + if isinstance(first, str): + first_name = first + elif isinstance(first, dict): + first_name = first.get("sprite") or first.get("name") + preview = sprite_records.get(str(first_name)) + preview_path = preview.preview_path if preview else None + preview_rect = preview.rect if preview else None + if preview is None and frames and isinstance(frames[0], dict): + preview_path = _resolve_texture_path(config_path, data) + preview_rect = _safe_rect( + frames[0].get("rect") or frames[0].get("region") + ) + strip = animation.get("strip") + if preview is None and isinstance(strip, dict): + preview_path = _resolve_texture_path(config_path, data) + preview_rect = _safe_rect( + [ + strip.get("x"), + strip.get("y"), + strip.get("width"), + strip.get("height"), + ] + ) + records.append( + AssetRecord( + uri=f"res://{relative}#{name}", + path=config_path, + project_path=relative, + kind="animation", + name=str(name), + subresource=str(name), + preview_path=preview_path, + rect=preview_rect, + metadata={ + "config": relative, + "frames": len(frames), + "fps": animation.get("fps"), + }, + ) + ) + return records + + def find(self, resource_value: str) -> AssetRecord | None: + try: + normalized = ResourceReference.parse( + resource_value, + allow_legacy_project_path=True, + ).uri + except ResourceDocumentError: + normalized = resource_value + return next( + ( + record + for record in self.records + if record.resource_value == normalized + ), + None, + ) diff --git a/src/editor/document.py b/src/editor/document.py index 13f3a72c..1583f90e 100644 --- a/src/editor/document.py +++ b/src/editor/document.py @@ -7,9 +7,21 @@ from dataclasses import dataclass, field from typing import Any, Iterable +from src.authoring.migrations import ( + MigrationError, + build_default_migration_registry, +) +from src.authoring.coordinates import CoordinateSpace, Timebase +from src.authoring.resources import ( + RESOURCE_SCHEMA_VERSION, + SCENE_RESOURCE_TYPE, + ResourceDocumentError, + ResourceHeader, +) -CURRENT_SCHEMA_VERSION = 1 -SCENE_DOCUMENT_TYPE = "pystg.scene" + +CURRENT_SCHEMA_VERSION = RESOURCE_SCHEMA_VERSION +SCENE_DOCUMENT_TYPE = SCENE_RESOURCE_TYPE class DocumentError(ValueError): @@ -138,24 +150,49 @@ class SceneDocument: id: str = field(default_factory=new_document_id) schema_version: int = CURRENT_SCHEMA_VERSION type: str = SCENE_DOCUMENT_TYPE + symbol_name: str | None = None timeline: list[TimelineEvent] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) + @property + def coordinate_space(self) -> CoordinateSpace: + return CoordinateSpace( + logical_width=float(self.root.properties.get("width", 384)), + logical_height=float(self.root.properties.get("height", 448)), + ) + + @property + def timebase(self) -> Timebase: + return Timebase( + int( + self.root.properties.get( + "tick_rate", + self.metadata.get("tick_rate", 60), + ) + ) + ) + def validate(self) -> None: - self.id = _valid_id(self.id, "document.id") - if self.schema_version != CURRENT_SCHEMA_VERSION: - raise DocumentError( - f"Unsupported schema_version {self.schema_version}; " - f"expected {CURRENT_SCHEMA_VERSION}" + try: + header = ResourceHeader( + schema_version=self.schema_version, + type=self.type, + id=self.id, + name=self.name, + symbol_name=self.symbol_name, + metadata=self.metadata, ) - if self.type != SCENE_DOCUMENT_TYPE: - raise DocumentError(f"Unsupported document type: {self.type!r}") - if not isinstance(self.name, str) or not self.name.strip(): - raise DocumentError("document.name must be a non-empty string") + header.validate( + expected_type=SCENE_DOCUMENT_TYPE, + current_version=CURRENT_SCHEMA_VERSION, + ) + except ResourceDocumentError as exc: + raise DocumentError(str(exc)) from exc + self.id = header.id + self.metadata = header.metadata if not isinstance(self.root, EditorNode): raise DocumentError("document.root must be an EditorNode") self.root.validate() - self.metadata = _json_object(self.metadata, "document.metadata") ids = {self.id} for node in self.root.walk(): @@ -172,7 +209,7 @@ def validate(self) -> None: def to_dict(self) -> dict[str, Any]: self.validate() - return { + payload = { "schema_version": self.schema_version, "type": self.type, "id": self.id, @@ -181,6 +218,9 @@ def to_dict(self) -> dict[str, Any]: "root": self.root.to_dict(), "timeline": [event.to_dict() for event in self.timeline], } + if self.symbol_name is not None: + payload["symbol_name"] = self.symbol_name + return payload @classmethod def from_dict(cls, data: dict[str, Any]) -> "SceneDocument": @@ -188,8 +228,9 @@ def from_dict(cls, data: dict[str, Any]) -> "SceneDocument": document = cls( schema_version=migrated["schema_version"], type=migrated["type"], - id=migrated["id"], - name=migrated["name"], + id=migrated.get("id", ""), + name=migrated.get("name", ""), + symbol_name=migrated.get("symbol_name"), metadata=_json_object(migrated.get("metadata", {}), "document.metadata"), root=EditorNode.from_dict(migrated["root"]), timeline=[ @@ -202,41 +243,10 @@ def from_dict(cls, data: dict[str, Any]) -> "SceneDocument": def migrate_document(data: dict[str, Any]) -> dict[str, Any]: - if not isinstance(data, dict): - raise DocumentError("document must be an object") - version = data.get("schema_version", 0) - if isinstance(version, bool) or not isinstance(version, int): - raise DocumentError("schema_version must be an integer") - if version > CURRENT_SCHEMA_VERSION: - raise DocumentError( - f"Document schema {version} is newer than supported " - f"{CURRENT_SCHEMA_VERSION}" + try: + return build_default_migration_registry().migrate( + data, + expected_type=SCENE_DOCUMENT_TYPE, ) - - migrated = dict(data) - if version == 0: - migrated = _migrate_v0_to_v1(migrated) - version = 1 - if version != CURRENT_SCHEMA_VERSION: - raise DocumentError(f"No migration path from schema_version {version}") - return migrated - - -def _migrate_v0_to_v1(data: dict[str, Any]) -> dict[str, Any]: - root = data.get("root") - if root is None: - root = { - "type": "Stage", - "name": data.get("name", "Scene"), - "properties": {}, - "children": data.get("nodes", []), - } - return { - "schema_version": 1, - "type": SCENE_DOCUMENT_TYPE, - "id": data.get("id") or new_document_id(), - "name": data.get("name", "Scene"), - "metadata": data.get("metadata", {}), - "root": root, - "timeline": data.get("timeline", []), - } + except MigrationError as exc: + raise DocumentError(str(exc)) from exc diff --git a/src/editor/node_types.py b/src/editor/node_types.py index faf87e8f..0d0e98e7 100644 --- a/src/editor/node_types.py +++ b/src/editor/node_types.py @@ -1,13 +1,19 @@ -"""Node definitions shared by the scene editor UI and document commands.""" +"""Extensible node/property contracts shared by editor views and compilers.""" from __future__ import annotations +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from typing import Any from .document import EditorNode +NodeValidator = Callable[[EditorNode], None] +NodeCompiler = Callable[[EditorNode, Any], Any] +EditorFactory = Callable[..., Any] + + @dataclass(frozen=True) class PropertySpec: key: str @@ -17,6 +23,37 @@ class PropertySpec: minimum: float | None = None maximum: float | None = None step: float | None = None + choices: tuple[Any, ...] = () + resource_types: tuple[str, ...] = () + unit: str | None = None + group: str = "General" + curve_capable: bool = False + binding_capable: bool = False + visible_when: tuple[str, Any] | None = None + editor_hint: str | None = None + + def validate(self) -> None: + if not self.key or not self.label: + raise ValueError("property key and label are required") + if not isinstance(self.value_type, type): + raise ValueError(f"property {self.key!r} value_type must be a type") + if self.minimum is not None and self.maximum is not None: + if self.minimum > self.maximum: + raise ValueError(f"property {self.key!r} has an invalid range") + if self.choices and self.default not in self.choices: + raise ValueError(f"property {self.key!r} default is not in its choices") + + +@dataclass(frozen=True) +class ViewportSpec: + shape: str = "box" + label: str = "NODE" + preview_property: str | None = None + movable: bool = True + + def validate(self) -> None: + if self.shape not in {"none", "box", "circle", "diamond"}: + raise ValueError(f"unsupported viewport shape: {self.shape}") @dataclass(frozen=True) @@ -25,76 +62,271 @@ class NodeTypeSpec: display_name: str color: str properties: tuple[PropertySpec, ...] - viewport_item: bool = True + viewport: ViewportSpec = ViewportSpec() + allowed_parents: tuple[str, ...] | None = None + allowed_children: tuple[str, ...] | None = None + validator: NodeValidator | None = None + editor_factory: EditorFactory | None = None + runtime_compiler: NodeCompiler | None = None + + @property + def viewport_item(self) -> bool: + return self.viewport.shape != "none" + + def validate(self) -> None: + if not self.type_name or not self.display_name: + raise ValueError("node type and display name are required") + self.viewport.validate() + keys: set[str] = set() + for prop in self.properties: + prop.validate() + if prop.key in keys: + raise ValueError( + f"node type {self.type_name!r} repeats property {prop.key!r}" + ) + keys.add(prop.key) + + +class NodeTypeRegistry(Mapping[str, NodeTypeSpec]): + def __init__(self) -> None: + self._types: dict[str, NodeTypeSpec] = {} + + def register(self, spec: NodeTypeSpec) -> NodeTypeSpec: + spec.validate() + if spec.type_name in self._types: + raise ValueError(f"Duplicate node type: {spec.type_name}") + self._types[spec.type_name] = spec + return spec + + def __getitem__(self, key: str) -> NodeTypeSpec: + try: + return self._types[key] + except KeyError as exc: + raise KeyError(f"Unknown node type: {key}") from exc + + def __iter__(self) -> Iterator[str]: + return iter(self._types) + + def __len__(self) -> int: + return len(self._types) + + def can_parent(self, parent_type: str, child_type: str) -> bool: + parent = self[parent_type] + child = self[child_type] + if parent.allowed_children is not None and child_type not in parent.allowed_children: + return False + if child.allowed_parents is not None and parent_type not in child.allowed_parents: + return False + return True + + def validate_node(self, node: EditorNode) -> None: + spec = self[node.type] + known_properties = {prop.key for prop in spec.properties} + unknown = set(node.properties).difference(known_properties) + if unknown: + raise ValueError( + f"Node {node.name!r} ({node.type}) has unknown properties: " + + ", ".join(sorted(unknown)) + ) + if spec.validator is not None: + spec.validator(node) + + def validate_tree(self, root: EditorNode) -> None: + self.validate_node(root) + for parent in root.walk(): + for child in parent.children: + self.validate_node(child) + if not self.can_parent(parent.type, child.type): + raise ValueError( + f"Node type {child.type!r} cannot be a child of {parent.type!r}" + ) + + def compile_node(self, node: EditorNode, context: Any = None) -> Any: + spec = self[node.type] + if spec.runtime_compiler is None: + raise ValueError(f"Node type {node.type!r} has no runtime compiler") + return spec.runtime_compiler(node, context) POSITION_PROPERTIES = ( - PropertySpec("x", "X", float, 384.0, -4096.0, 4096.0, 1.0), - PropertySpec("y", "Y", float, 448.0, -4096.0, 4096.0, 1.0), + PropertySpec("x", "X", float, 192.0, -4096.0, 4096.0, 1.0, unit="px", group="Transform"), + PropertySpec("y", "Y", float, 224.0, -4096.0, 4096.0, 1.0, unit="px", group="Transform"), ) -NODE_TYPES: dict[str, NodeTypeSpec] = { - "SceneRoot": NodeTypeSpec( - type_name="SceneRoot", - display_name="Scene Root", - color="#8aa1c1", - viewport_item=False, - properties=( - PropertySpec("width", "Canvas Width", int, 768, 64, 8192, 1), - PropertySpec("height", "Canvas Height", int, 896, 64, 8192, 1), - PropertySpec("grid_size", "Grid Size", int, 16, 1, 256, 1), - PropertySpec("background", "Background", str, "#171a24"), - ), - ), - "Sprite": NodeTypeSpec( - type_name="Sprite", - display_name="Sprite", - color="#59c2ff", - properties=POSITION_PROPERTIES + ( - PropertySpec("texture", "Texture", str, ""), - PropertySpec("scale", "Scale", float, 1.0, 0.01, 100.0, 0.05), - PropertySpec("rotation", "Rotation", float, 0.0, -3600.0, 3600.0, 1.0), - PropertySpec("visible", "Visible", bool, True), - ), - ), - "EnemySpawner": NodeTypeSpec( - type_name="EnemySpawner", - display_name="Enemy Spawner", - color="#ff9f5b", - properties=POSITION_PROPERTIES + ( - PropertySpec("enemy_script", "Enemy Script", str, ""), - PropertySpec("start_frame", "Start Frame", int, 0, 0, 10_000_000, 1), - PropertySpec("interval", "Interval", int, 60, 1, 1_000_000, 1), - PropertySpec("count", "Count", int, 1, 1, 1_000_000, 1), - ), - ), - "SpellCard": NodeTypeSpec( - type_name="SpellCard", - display_name="Spell Card", - color="#d98cff", - properties=POSITION_PROPERTIES + ( - PropertySpec("script", "Script", str, ""), - PropertySpec("class_name", "Class", str, ""), - PropertySpec("duration", "Duration", int, 3600, 1, 10_000_000, 1), - PropertySpec("boss_x", "Boss X", float, 0.0, -2.0, 2.0, 0.01), - PropertySpec("boss_y", "Boss Y", float, 0.55, -2.0, 2.0, 0.01), - ), - ), -} + +def build_default_node_type_registry() -> NodeTypeRegistry: + registry = NodeTypeRegistry() + + legacy_scene_children = ( + "Stage", + "Sprite", + "EnemySpawner", + "SpellCard", + "Boss", + "Spell", + "Emitter", + "PatternInstance", + ) + registry.register( + NodeTypeSpec( + type_name="SceneRoot", + display_name="Scene Root", + color="#8aa1c1", + viewport=ViewportSpec(shape="none"), + allowed_parents=(), + allowed_children=legacy_scene_children, + properties=( + PropertySpec("width", "Canvas Width", int, 384, 64, 8192, 1, unit="px", group="Canvas"), + PropertySpec("height", "Canvas Height", int, 448, 64, 8192, 1, unit="px", group="Canvas"), + PropertySpec("grid_size", "Grid Size", int, 16, 1, 256, 1, unit="px", group="Canvas"), + PropertySpec("background", "Background", str, "#171a24", group="Canvas", editor_hint="color"), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="Stage", + display_name="Stage", + color="#8aa1c1", + viewport=ViewportSpec(shape="none"), + allowed_parents=("SceneRoot",), + allowed_children=("Sprite", "Boss", "Spell", "Emitter", "PatternInstance"), + properties=( + PropertySpec("width", "Canvas Width", int, 384, 64, 8192, 1, unit="px", group="Canvas"), + PropertySpec("height", "Canvas Height", int, 448, 64, 8192, 1, unit="px", group="Canvas"), + PropertySpec("tick_rate", "Tick Rate", int, 60, 1, 1000, 1, unit="fps", group="Timing"), + PropertySpec("background", "Background", str, "", resource_types=("pystg.background",), group="Resources", editor_hint="resource"), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="Sprite", + display_name="Sprite", + color="#59c2ff", + viewport=ViewportSpec(shape="box", label="SPR", preview_property="texture"), + allowed_parents=("SceneRoot", "Stage", "Sprite"), + allowed_children=legacy_scene_children, + properties=POSITION_PROPERTIES + + ( + PropertySpec("texture", "Texture", str, "", resource_types=("image", "sprite"), group="Resources", editor_hint="resource"), + PropertySpec("scale", "Scale", float, 1.0, 0.01, 100.0, 0.05, group="Transform", curve_capable=True), + PropertySpec("rotation", "Rotation", float, 0.0, -3600.0, 3600.0, 1.0, unit="deg", group="Transform", curve_capable=True), + PropertySpec("visible", "Visible", bool, True, group="Rendering", binding_capable=True), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="EnemySpawner", + display_name="Enemy Spawner", + color="#ff9f5b", + viewport=ViewportSpec(shape="circle", label="+"), + allowed_parents=("SceneRoot", "Stage", "Sprite"), + allowed_children=legacy_scene_children, + properties=POSITION_PROPERTIES + + ( + PropertySpec("enemy_script", "Enemy Script", str, "", resource_types=("script",), group="Resources", editor_hint="resource"), + PropertySpec("start_frame", "Start Frame", int, 0, 0, 10_000_000, 1, unit="frame", group="Timing"), + PropertySpec("interval", "Interval", int, 60, 1, 1_000_000, 1, unit="frame", group="Timing"), + PropertySpec("count", "Count", int, 1, 1, 1_000_000, 1, group="Emission"), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="SpellCard", + display_name="Spell Card", + color="#d98cff", + viewport=ViewportSpec(shape="diamond", label="SC"), + allowed_parents=("SceneRoot", "Stage", "Sprite"), + allowed_children=legacy_scene_children, + properties=POSITION_PROPERTIES + + ( + PropertySpec("script", "Script", str, "", resource_types=("script",), group="Resources", editor_hint="resource"), + PropertySpec("class_name", "Class", str, "", group="Script"), + PropertySpec("duration", "Duration", int, 3600, 1, 10_000_000, 1, unit="frame", group="Timing"), + PropertySpec("boss_x", "Boss X", float, 0.0, -2.0, 2.0, 0.01, group="Legacy Runtime"), + PropertySpec("boss_y", "Boss Y", float, 0.55, -2.0, 2.0, 0.01, group="Legacy Runtime"), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="Boss", + display_name="Boss", + color="#ef8fc6", + viewport=ViewportSpec(shape="circle", label="BOSS"), + allowed_parents=("SceneRoot", "Stage"), + allowed_children=("Spell", "Emitter", "PatternInstance", "Sprite"), + properties=POSITION_PROPERTIES + + ( + PropertySpec("texture", "Texture", str, "", resource_types=("image", "sprite"), group="Resources", editor_hint="resource"), + PropertySpec("visible", "Visible", bool, True, group="Rendering", binding_capable=True), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="Spell", + display_name="Spell", + color="#d98cff", + viewport=ViewportSpec(shape="diamond", label="SPELL"), + allowed_parents=("SceneRoot", "Stage", "Boss"), + allowed_children=("Emitter", "PatternInstance", "Sprite"), + properties=( + PropertySpec("duration_frames", "Duration", int, 3600, 1, 10_000_000, 1, unit="frame", group="Timing"), + PropertySpec("script", "Script", str, "", resource_types=("script",), group="Advanced", editor_hint="resource"), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="Emitter", + display_name="Emitter", + color="#ffb45e", + viewport=ViewportSpec(shape="circle", label="EMIT"), + allowed_parents=("SceneRoot", "Stage", "Boss", "Spell"), + allowed_children=("PatternInstance",), + properties=POSITION_PROPERTIES + + ( + PropertySpec("rotation", "Rotation", float, 0.0, -3600.0, 3600.0, 1.0, unit="deg", group="Transform", curve_capable=True), + PropertySpec("enabled", "Enabled", bool, True, group="Behavior", binding_capable=True), + ), + ) + ) + registry.register( + NodeTypeSpec( + type_name="PatternInstance", + display_name="Pattern Instance", + color="#8f9cff", + viewport=ViewportSpec(shape="box", label="PAT"), + allowed_parents=("SceneRoot", "Stage", "Boss", "Spell", "Emitter"), + allowed_children=(), + properties=( + PropertySpec("pattern", "Pattern", str, "", resource_types=("pystg.pattern",), group="Resources", editor_hint="resource"), + PropertySpec("start_frame", "Start Frame", int, 0, 0, 10_000_000, 1, unit="frame", group="Timing"), + PropertySpec("enabled", "Enabled", bool, True, group="Behavior", binding_capable=True), + ), + ) + ) + return registry + + +NODE_TYPE_REGISTRY = build_default_node_type_registry() +# Backwards-compatible Mapping name used by the current editor shell. +NODE_TYPES: Mapping[str, NodeTypeSpec] = NODE_TYPE_REGISTRY def make_node(type_name: str, *, name: str | None = None) -> EditorNode: try: - spec = NODE_TYPES[type_name] + spec = NODE_TYPE_REGISTRY[type_name] except KeyError as exc: raise ValueError(f"Unsupported editor node type: {type_name}") from exc return EditorNode( type=type_name, name=name or spec.display_name, - properties={ - prop.key: prop.default - for prop in spec.properties - }, + properties={prop.key: prop.default for prop in spec.properties}, ) @@ -103,7 +335,5 @@ def make_default_root(name: str = "Scene") -> EditorNode: def property_specs(node_type: str) -> tuple[PropertySpec, ...]: - spec = NODE_TYPES.get(node_type) - if spec is None: - return () - return spec.properties + spec = NODE_TYPE_REGISTRY.get(node_type) + return spec.properties if spec is not None else () diff --git a/src/editor/resource_browser.py b/src/editor/resource_browser.py new file mode 100644 index 00000000..de202687 --- /dev/null +++ b/src/editor/resource_browser.py @@ -0,0 +1,471 @@ +"""Native resource browser panel for the unified editor workbench.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from PyQt5.QtCore import ( + QAbstractListModel, + QByteArray, + QModelIndex, + QMimeData, + QSize, + QSortFilterProxyModel, + Qt, + pyqtSignal, +) +from PyQt5.QtGui import QColor, QFont, QPainter, QPixmap +from PyQt5.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QPushButton, + QSplitter, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from src.core.project_context import ProjectContext + +from .asset_index import AssetIndex, AssetRecord + + +RESOURCE_MIME_TYPE = "application/x-pystg-resource" +RECORD_ROLE = Qt.UserRole + 1 + + +class ThumbnailProvider: + """Create and cache thumbnails for files and atlas subresources.""" + + def __init__(self, size: int = 80): + self.size = size + self._cache: dict[str, QPixmap] = {} + + def clear(self) -> None: + self._cache.clear() + + def thumbnail(self, record: AssetRecord) -> QPixmap: + cached = self._cache.get(record.uri) + if cached is not None: + return cached + + pixmap = self._load_preview(record) + if pixmap.isNull(): + pixmap = self._placeholder(record.kind) + else: + pixmap = pixmap.scaled( + self.size, + self.size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + canvas = QPixmap(self.size, self.size) + canvas.fill(QColor("#141720")) + painter = QPainter(canvas) + x = (self.size - pixmap.width()) // 2 + y = (self.size - pixmap.height()) // 2 + painter.drawPixmap(x, y, pixmap) + painter.end() + self._cache[record.uri] = canvas + return canvas + + @staticmethod + def _load_preview(record: AssetRecord) -> QPixmap: + if record.preview_path is None or not record.preview_path.is_file(): + return QPixmap() + pixmap = QPixmap(str(record.preview_path)) + if pixmap.isNull() or record.rect is None: + return pixmap + x, y, width, height = record.rect + clipped = pixmap.rect().intersected( + pixmap.rect().adjusted( + x, + y, + x + width - pixmap.width(), + y + height - pixmap.height(), + ) + ) + if clipped.isEmpty(): + return QPixmap() + return pixmap.copy(clipped) + + def _placeholder(self, kind: str) -> QPixmap: + colors = { + "audio": "#8fcf72", + "font": "#edb95f", + "shader": "#c792ea", + "scene": "#82aaff", + "pattern": "#c792ea", + "ui": "#89ddff", + "background": "#f0c674", + "resource": "#a7b0c0", + "script": "#f78c6c", + "json": "#89ddff", + "text": "#a7b0c0", + "animation": "#ffcb6b", + } + labels = { + "audio": "SFX", + "font": "Aa", + "shader": "FX", + "scene": "SCN", + "pattern": "PAT", + "ui": "UI", + "background": "BG", + "resource": "RES", + "script": "PY", + "json": "{}", + "text": "TXT", + "animation": "ANI", + } + pixmap = QPixmap(self.size, self.size) + pixmap.fill(QColor("#202532")) + painter = QPainter(pixmap) + painter.setPen(QColor(colors.get(kind, "#9aa4b2"))) + painter.setFont(QFont("Microsoft YaHei UI", 13, QFont.Bold)) + painter.drawRoundedRect(8, 8, self.size - 16, self.size - 16, 7, 7) + painter.drawText( + pixmap.rect(), + Qt.AlignCenter, + labels.get(kind, kind[:3].upper()), + ) + painter.end() + return pixmap + + +class AssetListModel(QAbstractListModel): + def __init__( + self, + records: tuple[AssetRecord, ...] = (), + thumbnails: ThumbnailProvider | None = None, + parent=None, + ): + super().__init__(parent) + self.records = records + self.thumbnails = thumbnails or ThumbnailProvider() + + def set_records(self, records: tuple[AssetRecord, ...]) -> None: + self.beginResetModel() + self.records = records + self.thumbnails.clear() + self.endResetModel() + + def rowCount(self, parent=QModelIndex()) -> int: + return 0 if parent.isValid() else len(self.records) + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self.records): + return None + record = self.records[index.row()] + if role == Qt.DisplayRole: + return record.name + if role == Qt.DecorationRole: + return self.thumbnails.thumbnail(record) + if role == Qt.ToolTipRole: + return f"{record.kind}\n{record.resource_value}" + if role == RECORD_ROLE: + return record + if role == Qt.SizeHintRole: + return QSize(112, 116) + return None + + def flags(self, index: QModelIndex): + flags = super().flags(index) + return flags | Qt.ItemIsDragEnabled if index.isValid() else flags + + def mimeTypes(self) -> list[str]: + return [RESOURCE_MIME_TYPE] + + def mimeData(self, indexes) -> QMimeData: + mime = QMimeData() + if not indexes: + return mime + record = self.data(indexes[0], RECORD_ROLE) + if record is None: + return mime + payload = { + "uri": record.uri, + "kind": record.kind, + "name": record.name, + "resource_value": record.resource_value, + } + mime.setData( + RESOURCE_MIME_TYPE, + QByteArray(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + ) + return mime + + def supportedDragActions(self): + return Qt.CopyAction + + +class AssetFilterProxyModel(QSortFilterProxyModel): + def __init__(self, parent=None): + super().__init__(parent) + self._query = "" + self._kind = "all" + self._folder = "" + self.setDynamicSortFilter(True) + + def set_query(self, value: str) -> None: + self._query = value.strip().casefold() + self.invalidateFilter() + + def set_kind(self, value: str) -> None: + self._kind = value + self.invalidateFilter() + + def set_folder(self, value: str) -> None: + self._folder = value.strip("/") + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + model = self.sourceModel() + index = model.index(source_row, 0, source_parent) + record = model.data(index, RECORD_ROLE) + if record is None: + return False + if self._kind != "all" and record.kind != self._kind: + return False + if self._folder and not ( + record.project_path == self._folder + or record.project_path.startswith(f"{self._folder}/") + ): + return False + if self._query: + haystack = ( + f"{record.name} {record.project_path} " + f"{record.kind} {record.subresource or ''}" + ).casefold() + if self._query not in haystack: + return False + return True + + +class ResourceBrowserPanel(QWidget): + resourceSelected = pyqtSignal(object) + resourceActivated = pyqtSignal(object) + + def __init__(self, project: ProjectContext, parent=None): + super().__init__(parent) + self.project = project + self.index = AssetIndex(project) + self.thumbnails = ThumbnailProvider() + self.model = AssetListModel(thumbnails=self.thumbnails) + self.proxy = AssetFilterProxyModel() + self.proxy.setSourceModel(self.model) + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + controls = QHBoxLayout() + self.search = QLineEdit() + self.search.setObjectName("assetSearch") + self.search.setPlaceholderText("Search resources…") + self.search.textChanged.connect(self.proxy.set_query) + self.kind_filter = QComboBox() + self.kind_filter.setObjectName("assetKindFilter") + self.kind_filter.addItem("All types", "all") + for kind in ( + "image", + "sprite", + "animation", + "audio", + "scene", + "pattern", + "ui", + "background", + "resource", + "script", + "json", + "font", + "shader", + "text", + ): + self.kind_filter.addItem(kind.title(), kind) + self.kind_filter.currentIndexChanged.connect( + lambda: self.proxy.set_kind( + str(self.kind_filter.currentData() or "all") + ) + ) + refresh_button = QPushButton("Refresh") + refresh_button.setObjectName("assetRefresh") + refresh_button.clicked.connect(self.refresh) + controls.addWidget(self.search, 1) + controls.addWidget(self.kind_filter) + controls.addWidget(refresh_button) + layout.addLayout(controls) + + splitter = QSplitter(Qt.Horizontal) + self.folder_tree = QTreeWidget() + self.folder_tree.setObjectName("assetFolders") + self.folder_tree.setHeaderHidden(True) + self.folder_tree.setMinimumWidth(150) + self.folder_tree.setMaximumWidth(280) + self.folder_tree.currentItemChanged.connect(self._folder_changed) + splitter.addWidget(self.folder_tree) + + self.asset_view = QListView() + self.asset_view.setObjectName("assetList") + self.asset_view.setModel(self.proxy) + self.asset_view.setViewMode(QListView.IconMode) + self.asset_view.setResizeMode(QListView.Adjust) + self.asset_view.setMovement(QListView.Static) + self.asset_view.setWrapping(True) + self.asset_view.setWordWrap(True) + self.asset_view.setSpacing(5) + self.asset_view.setIconSize(QSize(80, 80)) + self.asset_view.setSelectionMode(QAbstractItemView.SingleSelection) + self.asset_view.setDragEnabled(True) + self.asset_view.setDragDropMode(QAbstractItemView.DragOnly) + self.asset_view.setDefaultDropAction(Qt.CopyAction) + self.asset_view.selectionModel().currentChanged.connect( + self._selection_changed + ) + self.asset_view.doubleClicked.connect(self._activated) + splitter.addWidget(self.asset_view) + + detail = QWidget() + detail.setMinimumWidth(190) + detail.setMaximumWidth(320) + detail_layout = QVBoxLayout(detail) + self.preview = QLabel("Select a resource") + self.preview.setObjectName("assetPreview") + self.preview.setAlignment(Qt.AlignCenter) + self.preview.setMinimumSize(170, 150) + self.preview.setStyleSheet( + "background:#141720; border:1px solid #353b49;" + ) + self.detail_title = QLabel("") + self.detail_title.setWordWrap(True) + self.detail_text = QLabel("") + self.detail_text.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.detail_text.setWordWrap(True) + detail_layout.addWidget(self.preview) + detail_layout.addWidget(self.detail_title) + detail_layout.addWidget(self.detail_text) + detail_layout.addStretch() + splitter.addWidget(detail) + splitter.setStretchFactor(1, 1) + splitter.setSizes([180, 700, 220]) + layout.addWidget(splitter, 1) + + self.summary = QLabel("") + self.summary.setObjectName("assetSummary") + layout.addWidget(self.summary) + + def refresh(self) -> None: + records = self.index.scan() + self.model.set_records(records) + self._populate_folders(records) + message = f"{len(records)} resources" + if self.index.errors: + message += f" · {len(self.index.errors)} invalid JSON files skipped" + self.summary.setText(message) + self._clear_details() + + def _populate_folders(self, records: tuple[AssetRecord, ...]) -> None: + self.folder_tree.blockSignals(True) + self.folder_tree.clear() + all_item = QTreeWidgetItem(["All resources"]) + all_item.setData(0, Qt.UserRole, "") + self.folder_tree.addTopLevelItem(all_item) + nodes: dict[str, QTreeWidgetItem] = {"": all_item} + folders = sorted( + { + part + for record in records + for part in self._folder_ancestors(record.folder) + } + ) + for folder in folders: + parent_path = Path(folder).parent.as_posix() + if parent_path == ".": + parent_path = "" + parent = nodes.get(parent_path, all_item) + item = QTreeWidgetItem([Path(folder).name]) + item.setData(0, Qt.UserRole, folder) + parent.addChild(item) + nodes[folder] = item + all_item.setExpanded(True) + self.folder_tree.setCurrentItem(all_item) + self.folder_tree.blockSignals(False) + self.proxy.set_folder("") + + @staticmethod + def _folder_ancestors(folder: str) -> tuple[str, ...]: + if not folder or folder == ".": + return () + parts = Path(folder).parts + return tuple(Path(*parts[:index]).as_posix() for index in range(1, len(parts) + 1)) + + def _folder_changed( + self, + current: QTreeWidgetItem | None, + previous: QTreeWidgetItem | None, + ) -> None: + del previous + self.proxy.set_folder( + str(current.data(0, Qt.UserRole) or "") if current else "" + ) + + def _record(self, proxy_index: QModelIndex) -> AssetRecord | None: + if not proxy_index.isValid(): + return None + return self.proxy.data(proxy_index, RECORD_ROLE) + + def _selection_changed( + self, + current: QModelIndex, + previous: QModelIndex, + ) -> None: + del previous + record = self._record(current) + if record is None: + self._clear_details() + return + self._show_details(record) + self.resourceSelected.emit(record) + + def _activated(self, index: QModelIndex) -> None: + record = self._record(index) + if record is not None: + self.resourceActivated.emit(record) + + def _show_details(self, record: AssetRecord) -> None: + pixmap = self.thumbnails.thumbnail(record).scaled( + 150, + 150, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.preview.setPixmap(pixmap) + self.detail_title.setText(f"{record.name}") + lines = [ + f"Type: {record.kind}", + f"Path: {record.resource_value}", + ] + if record.rect: + lines.append("Rect: {}, {}, {}, {}".format(*record.rect)) + if record.metadata.get("size") is not None: + lines.append(f"Size: {record.metadata['size']} bytes") + if record.metadata.get("frames") is not None: + lines.append(f"Frames: {record.metadata['frames']}") + if record.metadata.get("fps") is not None: + lines.append(f"FPS: {record.metadata['fps']}") + self.detail_text.setText("\n".join(lines)) + + def _clear_details(self) -> None: + self.preview.clear() + self.preview.setText("Select a resource") + self.detail_title.clear() + self.detail_text.clear() diff --git a/src/editor/workbench.py b/src/editor/workbench.py new file mode 100644 index 00000000..3056a45d --- /dev/null +++ b/src/editor/workbench.py @@ -0,0 +1,133 @@ +"""Plugin descriptors and registry for the unified editor workbench.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Literal + +from PyQt5.QtWidgets import QWidget + +from src.core.project_context import ProjectContext + + +PluginMode = Literal["central", "bottom", "external"] +WidgetFactory = Callable[[], QWidget] + + +@dataclass(frozen=True) +class EditorPlugin: + id: str + title: str + description: str + mode: PluginMode + factory: WidgetFactory | None = None + script: Path | None = None + shortcut: str | None = None + + def validate(self, project: ProjectContext) -> None: + if not self.id or not self.title: + raise ValueError("Editor plugins require a stable id and title") + if self.mode not in {"central", "bottom", "external"}: + raise ValueError(f"Unsupported editor plugin mode: {self.mode}") + if self.mode in {"central", "bottom"} and self.factory is None: + raise ValueError(f"Plugin {self.id!r} requires a widget factory") + if self.mode == "external": + if self.script is None: + raise ValueError(f"External plugin {self.id!r} requires a script") + project.relative(self.script) + + +class PluginRegistry: + def __init__(self, project: ProjectContext): + self.project = project + self._plugins: dict[str, EditorPlugin] = {} + + def register(self, plugin: EditorPlugin) -> EditorPlugin: + plugin.validate(self.project) + if plugin.id in self._plugins: + raise ValueError(f"Duplicate editor plugin id: {plugin.id}") + self._plugins[plugin.id] = plugin + return plugin + + def get(self, plugin_id: str) -> EditorPlugin: + try: + return self._plugins[plugin_id] + except KeyError as exc: + raise KeyError(f"Unknown editor plugin: {plugin_id}") from exc + + def all(self) -> tuple[EditorPlugin, ...]: + return tuple(self._plugins.values()) + + def by_mode(self, mode: PluginMode) -> tuple[EditorPlugin, ...]: + return tuple(plugin for plugin in self._plugins.values() if plugin.mode == mode) + + +def default_external_plugins(project: ProjectContext) -> tuple[EditorPlugin, ...]: + tools = project.root / "tools" + definitions = ( + ( + "texture_editor", + "Texture Assets", + "Edit atlases, sprite regions, animations and laser configuration.", + "asset/asset_manager_qt.py", + ), + ( + "player_editor", + "Player", + "Edit player animation, stats, shots and options.", + "player/player_editor.py", + ), + ( + "enemy_editor", + "Enemy Aliases", + "Edit enemy sprite aliases and atlas zones.", + "enemy/enemy_alias_manager.py", + ), + ( + "background_editor", + "Background", + "Edit data-driven stage background layers.", + "stage/background_editor.py", + ), + ( + "danmaku_editor", + "Danmaku Script", + "Edit bullet patterns, timelines and generated async code.", + "stage/danmaku_script_editor.py", + ), + ( + "dialog_balloon_editor", + "Dialog Balloon", + "Edit dialog balloon assembly and layout.", + "dialog/dialog_balloon_editor.py", + ), + ( + "dialog_portrait_editor", + "Dialog Portrait", + "Edit dialog portrait placement and appearance.", + "dialog/dialog_portrait_editor.py", + ), + ( + "main_menu_editor", + "Main Menu", + "Edit the GLFW/ImGui main-menu layout.", + "main_menu_editor/run.py", + ), + ( + "portrait_layout_editor", + "Portrait Layout", + "Edit the GLFW/ImGui portrait render layout.", + "portrait_editor/run.py", + ), + ) + return tuple( + EditorPlugin( + id=plugin_id, + title=title, + description=description, + mode="external", + script=tools / relative, + ) + for plugin_id, title, description, relative in definitions + ) diff --git a/tests/test_authoring_coordinates.py b/tests/test_authoring_coordinates.py new file mode 100644 index 00000000..009dfc49 --- /dev/null +++ b/tests/test_authoring_coordinates.py @@ -0,0 +1,56 @@ +import pytest + +from src.authoring import CoordinateSpace, Timebase + + +@pytest.mark.parametrize( + ("authoring", "runtime"), + [ + ((0.0, 0.0), (-1.0, 1.0)), + ((384.0, 448.0), (1.0, -1.0)), + ((192.0, 224.0), (0.0, 0.0)), + ((192.0, 100.8), (0.0, 0.55)), + ], +) +def test_authoring_runtime_coordinate_contract(authoring, runtime): + coordinates = CoordinateSpace() + assert coordinates.authoring_to_runtime(*authoring) == pytest.approx(runtime) + assert coordinates.runtime_to_authoring(*runtime) == pytest.approx(authoring) + + +def test_viewport_scale_does_not_change_runtime_position(): + coordinates = CoordinateSpace() + logical = coordinates.viewport_to_runtime( + 96, + 112, + viewport_width=384, + viewport_height=448, + ) + doubled = coordinates.viewport_to_runtime( + 192, + 224, + viewport_width=768, + viewport_height=896, + ) + fractional = coordinates.viewport_to_runtime( + 144, + 168, + viewport_width=576, + viewport_height=672, + ) + assert logical == pytest.approx(doubled) + assert logical == pytest.approx(fractional) + + +def test_timebase_stores_frames_and_displays_seconds_and_beats(): + timebase = Timebase(60) + assert timebase.frames_to_seconds(90) == 1.5 + assert timebase.seconds_to_frames(1.5) == 90 + assert timebase.frames_to_beats(90, 120.0) == 3.0 + assert timebase.beats_to_frames(3.0, 120.0) == 90 + with pytest.raises(ValueError): + timebase.seconds_to_frames(-1) + with pytest.raises(ValueError): + timebase.frames_to_beats(1, 0) + with pytest.raises(ValueError): + Timebase(60.0) diff --git a/tests/test_authoring_resources.py b/tests/test_authoring_resources.py new file mode 100644 index 00000000..468c8c66 --- /dev/null +++ b/tests/test_authoring_resources.py @@ -0,0 +1,182 @@ +import json +from pathlib import Path + +import pytest + +from src.authoring import ( + AUTHORING_RESOURCE_TYPES, + GenericResourceDocument, + MigrationError, + MigrationRegistry, + ResourceDocumentError, + ResourceHeader, + ResourceReference, + ResourceStore, + ResourceTypeRegistry, + ResourceTypeSpec, + build_default_resource_type_registry, +) +from src.core.project_context import ProjectContext + + +def test_all_initial_resource_types_round_trip_atomically(tmp_path): + store = ResourceStore(ProjectContext(tmp_path)) + for index, resource_type in enumerate(AUTHORING_RESOURCE_TYPES): + document = GenericResourceDocument( + header=ResourceHeader( + type=resource_type, + name=f"中文资源 {index}", + symbol_name=f"resource_{index}", + metadata={"author": "测试"}, + ), + body={"objects": [{"id": str(__import__("uuid").uuid4()), "value": index}]}, + ) + path = store.save(document, f"assets/resources/{index}.pystg.json") + loaded = store.load(path) + + assert loaded.to_dict() == document.to_dict() + assert loaded.name == f"中文资源 {index}" + assert not list(path.parent.glob(f".{path.name}.*.tmp")) + + +def test_resource_header_separates_unicode_name_and_portable_symbol(): + header = ResourceHeader(type="pystg.pattern", name="星符「星轨回廊」") + header.validate() + assert header.symbol_name is None + + with pytest.raises(ResourceDocumentError, match="symbol_name"): + ResourceHeader( + type="pystg.pattern", + name="符卡", + symbol_name="中文不是符号", + ).validate() + + +def test_generic_resource_rejects_duplicate_ids_and_future_schema(): + header = ResourceHeader(type="pystg.ui", name="HUD") + document = GenericResourceDocument( + header=header, + body={"node": {"id": header.id}}, + ) + with pytest.raises(ResourceDocumentError, match="Duplicate"): + document.validate() + + registry = build_default_resource_type_registry() + with pytest.raises(MigrationError, match="newer"): + registry.load( + { + "schema_version": 99, + "type": "pystg.ui", + "id": header.id, + "name": "Future", + } + ) + + +def test_migration_registry_requires_an_explicit_step_by_step_path(): + migrations = MigrationRegistry() + migrations.register_type("demo.resource", 2) + migrations.register( + "demo.resource", + 0, + lambda data: { + **data, + "schema_version": 1, + "type": "demo.resource", + "first": True, + }, + ) + migrations.register( + "demo.resource", + 1, + lambda data: {**data, "schema_version": 2, "second": True}, + ) + + migrated = migrations.migrate({"name": "Legacy"}, expected_type="demo.resource") + assert migrated["schema_version"] == 2 + assert migrated["first"] and migrated["second"] + + incomplete = MigrationRegistry() + incomplete.register_type("demo.resource", 2) + incomplete.register( + "demo.resource", + 0, + lambda data: {**data, "schema_version": 1, "type": "demo.resource"}, + ) + with pytest.raises(MigrationError, match="No migration path"): + incomplete.migrate({}, expected_type="demo.resource") + + +def test_resource_type_registry_carries_editor_compiler_and_preview_contributions(): + migrations = MigrationRegistry() + registry = ResourceTypeRegistry(migrations) + compiled = object() + preview = object() + editor = object() + validated = [] + spec = ResourceTypeSpec( + type_name="demo.resource", + display_name="Demo", + asset_kind="demo", + loader=lambda data: dict(data), + validator=lambda document: validated.append(document["name"]), + editor_factory=lambda: editor, + compiler=lambda document: compiled, + preview_handler=lambda document: preview, + ) + + assert registry.register(spec) is spec + loaded = registry.load( + { + "schema_version": 1, + "type": "demo.resource", + "id": str(__import__("uuid").uuid4()), + "name": "Example", + } + ) + assert loaded["name"] == "Example" + assert validated == ["Example"] + assert registry["demo.resource"].editor_factory() is editor + assert registry["demo.resource"].compiler(loaded) is compiled + assert registry["demo.resource"].preview_handler(loaded) is preview + with pytest.raises(ValueError, match="Duplicate"): + registry.register(spec) + with pytest.raises(KeyError, match="Unknown"): + registry["missing.resource"] + + +def test_resource_reference_is_canonical_and_project_constrained(tmp_path): + project = ProjectContext(tmp_path) + source = tmp_path / "assets" / "atlas.json" + source.parent.mkdir(parents=True) + source.write_text("{}", encoding="utf-8") + + reference = ResourceReference.parse("res://assets/atlas.json#orb") + assert reference.uri == "res://assets/atlas.json#orb" + assert reference.resolve(project, must_exist=True) == source.resolve() + assert ResourceReference.parse( + "assets/atlas.json#orb", + allow_legacy_project_path=True, + ) == reference + + with pytest.raises(ResourceDocumentError, match="project-relative"): + ResourceReference.parse("res://") + + with pytest.raises(ResourceDocumentError, match="does not exist"): + ResourceReference.parse("res://assets/missing.png").resolve( + project, + must_exist=True, + ) + with pytest.raises(ResourceDocumentError, match="may not contain"): + ResourceReference.parse("res://../outside.json") + with pytest.raises(ResourceDocumentError, match="must start"): + ResourceReference.parse(str(tmp_path.parent / "outside.json")) + + +def test_resource_store_reports_invalid_typed_json(tmp_path): + path = tmp_path / "assets" / "bad.pystg.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"type": "pystg.pattern"}), encoding="utf-8") + + with pytest.raises((MigrationError, ResourceDocumentError)): + ResourceStore(ProjectContext(tmp_path)).load(path) diff --git a/tests/test_editor_app_smoke.py b/tests/test_editor_app_smoke.py index 13c53fa8..24f669ca 100644 --- a/tests/test_editor_app_smoke.py +++ b/tests/test_editor_app_smoke.py @@ -57,7 +57,7 @@ def test_editor_window_wires_tree_inspector_viewport_and_undo(tmp_path): assert window.session.node(sprite_id).properties["x"] == 128.0 assert window.session.is_dirty window.undo() - assert window.session.node(sprite_id).properties["x"] == 384.0 + assert window.session.node(sprite_id).properties["x"] == 192.0 window.undo() assert window.tree.topLevelItem(0).childCount() == 0 assert not window.session.is_dirty diff --git a/tests/test_editor_asset_index.py b/tests/test_editor_asset_index.py new file mode 100644 index 00000000..0a83344c --- /dev/null +++ b/tests/test_editor_asset_index.py @@ -0,0 +1,144 @@ +import json +from pathlib import Path + +from src.core.project_context import ProjectContext +from src.editor.asset_index import ( + AssetIndex, + classify_file, + load_subresource_preview, +) + + +def _write_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def test_asset_index_classifies_files_and_indexes_atlas_subresources(tmp_path): + assets = tmp_path / "assets" + atlas = assets / "images" / "atlas.png" + atlas.parent.mkdir(parents=True) + atlas.write_bytes(b"png") + config = atlas.with_suffix(".json") + _write_json( + config, + { + "__image_filename": "assets/images/atlas.png", + "sprites": { + "orb": {"rect": [4, 8, 16, 20]}, + "invalid": {"rect": [0, 0, 0, 1]}, + }, + "animations": { + "animations": { + "pulse": { + "frames": ["orb"], + "fps": 12, + }, + "strip": { + "frames": [{"rect": [0, 0, 8, 8]}], + } + } + }, + }, + ) + script = tmp_path / "game_content" / "stages" / "demo.py" + script.parent.mkdir(parents=True) + script.write_text("pass\n", encoding="utf-8") + + index = AssetIndex(ProjectContext(tmp_path)) + records = index.scan() + + assert index.errors == () + assert index.find("res://assets/images/atlas.json#orb").rect == (4, 8, 16, 20) + assert index.find("res://assets/images/atlas.json#orb").preview_path == atlas.resolve() + animation = index.find("res://assets/images/atlas.json#pulse") + assert animation.kind == "animation" + assert animation.rect == (4, 8, 16, 20) + assert animation.metadata["frames"] == 1 + assert animation.metadata["fps"] == 12 + strip_animation = index.find("res://assets/images/atlas.json#strip") + assert strip_animation.preview_path == atlas.resolve() + assert strip_animation.rect == (0, 0, 8, 8) + assert any( + record.kind == "script" + and record.project_path == "game_content/stages/demo.py" + for record in records + ) + + +def test_asset_index_resolves_texture_maps_and_reports_invalid_json(tmp_path): + player_dir = tmp_path / "assets" / "players" + player_dir.mkdir(parents=True) + texture = player_dir / "player.png" + texture.write_bytes(b"png") + config = player_dir / "player.json" + _write_json( + config, + { + "textures": {"main": "player.png"}, + "sprites": { + "idle": { + "source": "main", + "region": [1, 2, 3, 4], + } + }, + }, + ) + bad = tmp_path / "assets" / "broken.json" + bad.write_text("{", encoding="utf-8") + + project = ProjectContext(tmp_path) + index = AssetIndex(project) + index.scan() + record = index.find("res://assets/players/player.json#idle") + + assert record.preview_path == texture.resolve() + assert record.rect == (1, 2, 3, 4) + assert len(index.errors) == 1 + assert index.errors[0].startswith("assets/broken.json:") + assert load_subresource_preview( + project, + record.resource_value, + ) == (texture.resolve(), (1, 2, 3, 4)) + + +def test_classify_file_recognizes_typed_authoring_resources(tmp_path): + for filename, resource_type, expected in ( + ("level.pystg.json", "pystg.scene", "scene"), + ("ring.pystg.json", "pystg.pattern", "pattern"), + ("hud.pystg.json", "pystg.ui", "ui"), + ("forest.pystg.json", "pystg.background", "background"), + ): + path = tmp_path / filename + _write_json(path, {"type": resource_type}) + assert classify_file(path) == expected + assert classify_file(Path("unknown.pystg.json")) == "resource" + assert classify_file(Path("music.ogg")) == "audio" + assert classify_file(Path("effect.frag")) == "shader" + assert classify_file(Path("font.ttf")) == "font" + assert classify_file(Path("logic.py")) == "script" + + +def test_asset_index_reports_invalid_typed_resource_without_aborting(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + _write_json( + assets / "valid.pystg.json", + { + "schema_version": 1, + "type": "pystg.pattern", + "id": "08ac589e-a51a-45dc-beb9-7af6f4e136db", + "name": "Valid", + }, + ) + _write_json( + assets / "invalid.pystg.json", + {"schema_version": 1, "type": "pystg.pattern", "name": "Missing ID"}, + ) + + index = AssetIndex(ProjectContext(tmp_path)) + records = index.scan() + + assert any(record.kind == "pattern" for record in records) + assert len(index.errors) == 1 + assert index.errors[0].startswith("assets/invalid.pystg.json:") diff --git a/tests/test_editor_documents.py b/tests/test_editor_documents.py index 2a7e53f7..2a1b0749 100644 --- a/tests/test_editor_documents.py +++ b/tests/test_editor_documents.py @@ -15,6 +15,7 @@ def test_scene_document_atomic_round_trip_and_migration(tmp_path): store = DocumentStore(project) scene = SceneDocument( name="Stage Test", + symbol_name="stage_test", root=EditorNode( type="Stage", name="Root", @@ -27,6 +28,10 @@ def test_scene_document_atomic_round_trip_and_migration(tmp_path): loaded = store.load(path) assert loaded.to_dict() == scene.to_dict() + assert loaded.symbol_name == "stage_test" + assert loaded.coordinate_space.logical_width == 384 + assert loaded.coordinate_space.logical_height == 448 + assert loaded.timebase.tick_rate == 60 assert not list(path.parent.glob(f".{path.name}.*.tmp")) legacy = SceneDocument.from_dict({ diff --git a/tests/test_editor_node_registry.py b/tests/test_editor_node_registry.py new file mode 100644 index 00000000..5c6c9ec4 --- /dev/null +++ b/tests/test_editor_node_registry.py @@ -0,0 +1,86 @@ +import pytest + +from src.editor.document import EditorNode +from src.editor.node_types import ( + NODE_TYPE_REGISTRY, + NodeTypeRegistry, + NodeTypeSpec, + PropertySpec, + ViewportSpec, + make_node, +) + + +def test_default_registry_contains_semantic_m0_node_types(): + assert {"Stage", "Boss", "Spell", "Emitter", "PatternInstance"}.issubset( + NODE_TYPE_REGISTRY + ) + pattern = NODE_TYPE_REGISTRY["PatternInstance"] + pattern_property = next(prop for prop in pattern.properties if prop.key == "pattern") + assert pattern_property.resource_types == ("pystg.pattern",) + assert pattern_property.editor_hint == "resource" + assert NODE_TYPE_REGISTRY.can_parent("Spell", "Emitter") + assert NODE_TYPE_REGISTRY.can_parent("Emitter", "PatternInstance") + assert not NODE_TYPE_REGISTRY.can_parent("PatternInstance", "Emitter") + + +def test_registered_node_supplies_schema_viewport_validation_and_runtime_compiler(): + registry = NodeTypeRegistry() + compiled = object() + validated = [] + spec = NodeTypeSpec( + type_name="Custom", + display_name="Custom Node", + color="#ffffff", + properties=( + PropertySpec( + "speed", + "Speed", + float, + 2.0, + 0.0, + 30.0, + 0.1, + unit="game units/s", + group="Motion", + curve_capable=True, + binding_capable=True, + ), + ), + viewport=ViewportSpec(shape="circle", label="C"), + allowed_parents=(), + allowed_children=(), + validator=lambda node: validated.append(node.name), + editor_factory=lambda: "editor", + runtime_compiler=lambda node, context: compiled, + ) + registry.register(spec) + node = EditorNode(type="Custom", name="Example", properties={"speed": 3.0}) + + registry.validate_node(node) + assert validated == ["Example"] + assert registry["Custom"].viewport.shape == "circle" + assert registry["Custom"].editor_factory() == "editor" + assert registry.compile_node(node) is compiled + with pytest.raises(ValueError, match="Duplicate"): + registry.register(spec) + with pytest.raises(KeyError, match="Unknown"): + registry["Missing"] + + +def test_registry_rejects_invalid_relationships_and_unknown_properties(): + root = make_node("SceneRoot") + boss = make_node("Boss") + pattern = make_node("PatternInstance") + root.children.append(boss) + boss.children.append(pattern) + NODE_TYPE_REGISTRY.validate_tree(root) + + pattern.children.append(make_node("Emitter")) + with pytest.raises(ValueError, match="cannot be a child"): + NODE_TYPE_REGISTRY.validate_tree(root) + + bad = make_node("Emitter") + bad.properties["mystery"] = 1 + with pytest.raises(ValueError, match="unknown properties"): + NODE_TYPE_REGISTRY.validate_node(bad) diff --git a/tests/test_editor_resource_browser.py b/tests/test_editor_resource_browser.py new file mode 100644 index 00000000..43de77c7 --- /dev/null +++ b/tests/test_editor_resource_browser.py @@ -0,0 +1,179 @@ +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt5.QtCore import QPointF, Qt +from PyQt5.QtGui import QColor, QDropEvent, QPixmap +from PyQt5.QtWidgets import QApplication, QLabel + +from src.core.project_context import ProjectContext +from src.editor.app import EditorMainWindow, NodeGraphicsItem, SceneViewport +from src.editor.asset_index import AssetRecord +from src.editor.node_types import make_node +from src.editor.resource_browser import ( + RECORD_ROLE, + RESOURCE_MIME_TYPE, + AssetFilterProxyModel, + AssetListModel, +) +from src.editor.workbench import EditorPlugin + + +def _app(): + return QApplication.instance() or QApplication([]) + + +def test_asset_list_model_filters_and_exports_drag_payload(tmp_path): + app = _app() + records = ( + AssetRecord( + uri="res://assets/a.png", + path=tmp_path / "assets" / "a.png", + project_path="assets/a.png", + kind="image", + name="a.png", + ), + AssetRecord( + uri="res://game_content/demo.py", + path=tmp_path / "game_content" / "demo.py", + project_path="game_content/demo.py", + kind="script", + name="demo.py", + ), + ) + model = AssetListModel(records) + proxy = AssetFilterProxyModel() + proxy.setSourceModel(model) + + proxy.set_kind("script") + assert proxy.rowCount() == 1 + assert proxy.data(proxy.index(0, 0), RECORD_ROLE).name == "demo.py" + proxy.set_kind("all") + proxy.set_query("assets/") + assert proxy.rowCount() == 1 + + mime = model.mimeData([model.index(0, 0)]) + assert mime.hasFormat(RESOURCE_MIME_TYPE) + payload = json.loads(bytes(mime.data(RESOURCE_MIME_TYPE)).decode("utf-8")) + assert payload["resource_value"] == "res://assets/a.png" + app.processEvents() + + +def test_scene_viewport_accepts_resource_mime_drop(tmp_path): + app = _app() + record = AssetRecord( + uri="res://assets/orb.png", + path=tmp_path / "assets" / "orb.png", + project_path="assets/orb.png", + kind="image", + name="orb.png", + ) + model = AssetListModel((record,)) + mime = model.mimeData([model.index(0, 0)]) + event = QDropEvent( + QPointF(40, 50), + Qt.CopyAction, + mime, + Qt.LeftButton, + Qt.NoModifier, + ) + viewport = SceneViewport(ProjectContext(tmp_path)) + assert viewport.runtime_position(192.0, 224.0) == (0.0, 0.0) + dropped = [] + viewport.resourceDropped.connect( + lambda payload, x, y: dropped.append((payload, x, y)) + ) + + viewport.dropEvent(event) + + assert event.isAccepted() + assert dropped[0][0]["resource_value"] == "res://assets/orb.png" + assert dropped[0][0]["kind"] == "image" + viewport.close() + app.processEvents() + + +def test_workbench_contains_assets_and_assigns_resources(tmp_path): + app = _app() + image = tmp_path / "assets" / "images" / "orb.png" + image.parent.mkdir(parents=True) + pixmap = QPixmap(12, 10) + pixmap.fill(QColor("#ff00ff")) + assert pixmap.save(str(image)) + + window = EditorMainWindow(ProjectContext(tmp_path)) + assert [ + window.bottom_tabs.tabText(index) + for index in range(window.bottom_tabs.count()) + ] == ["Output", "Timeline", "Assets"] + assert window.findChild(QLabel, "assetSummary").text() == "1 resources" + assert window.findChild( + type(window.action_run), + "pluginAction_bullet_aliases", + ) is not None + + record = window.resource_browser.index.find("res://assets/images/orb.png") + window._resource_activated(record) + sprite = window.session.node(window._selected_id) + assert sprite.type == "Sprite" + assert sprite.properties["texture"] == "res://assets/images/orb.png" + + window._resource_dropped( + { + "kind": "image", + "name": "orb.png", + "resource_value": "res://assets/images/orb.png", + }, + 123.0, + 234.0, + ) + dropped = window.session.node(window._selected_id) + assert dropped.properties["x"] == 123.0 + assert dropped.properties["y"] == 234.0 + + central = EditorPlugin( + id="bullet_aliases", + title="Bullet Aliases", + description="test", + mode="central", + factory=lambda: QLabel("embedded"), + ) + window.plugin_registry._plugins["bullet_aliases"] = central + window.open_plugin("bullet_aliases") + assert window.central_tabs.count() == 2 + assert window.central_tabs.currentWidget().text() == "embedded" + window._close_central_tab(1) + assert window.central_tabs.count() == 1 + + window.session.reset() + window.close() + app.processEvents() + + +def test_sprite_preview_supports_json_subresource(tmp_path): + app = _app() + atlas = tmp_path / "assets" / "atlas.png" + atlas.parent.mkdir(parents=True) + source = QPixmap(32, 16) + source.fill(QColor("#00ff00")) + assert source.save(str(atlas)) + config = atlas.with_suffix(".json") + config.write_text( + json.dumps( + { + "__image_filename": "atlas.png", + "sprites": {"left": {"rect": [0, 0, 16, 16]}}, + } + ), + encoding="utf-8", + ) + node = make_node("Sprite") + node.properties["texture"] = "res://assets/atlas.json#left" + + preview = NodeGraphicsItem._load_pixmap(node, ProjectContext(tmp_path)) + + assert not preview.isNull() + assert preview.width() == 64 + assert preview.height() == 64 + app.processEvents() diff --git a/tests/test_editor_scene_commands.py b/tests/test_editor_scene_commands.py index 6edf3280..9801afe1 100644 --- a/tests/test_editor_scene_commands.py +++ b/tests/test_editor_scene_commands.py @@ -30,7 +30,7 @@ def test_scene_commands_round_trip_through_undo_redo(tmp_path): assert session.is_dirty assert session.undo() - assert sprite.properties["x"] == 384.0 + assert sprite.properties["x"] == 192.0 assert session.undo() assert sprite.name == "Player" assert session.undo() diff --git a/tests/test_editor_workbench.py b/tests/test_editor_workbench.py new file mode 100644 index 00000000..a57ebceb --- /dev/null +++ b/tests/test_editor_workbench.py @@ -0,0 +1,73 @@ +from pathlib import Path + +import pytest + +from src.core.project_context import ProjectContext, ProjectContextError +from src.editor.workbench import ( + EditorPlugin, + PluginRegistry, + default_external_plugins, +) + + +def test_plugin_registry_validates_modes_and_duplicate_ids(tmp_path): + project = ProjectContext(tmp_path) + registry = PluginRegistry(project) + plugin = EditorPlugin( + id="panel", + title="Panel", + description="Test panel", + mode="bottom", + factory=lambda: None, + ) + + assert registry.register(plugin) is plugin + assert registry.get("panel") is plugin + assert registry.by_mode("bottom") == (plugin,) + with pytest.raises(ValueError, match="Duplicate"): + registry.register(plugin) + with pytest.raises(ValueError, match="factory"): + registry.register( + EditorPlugin( + id="broken", + title="Broken", + description="", + mode="central", + ) + ) + with pytest.raises(ValueError, match="mode"): + registry.register( + EditorPlugin( + id="unknown", + title="Unknown", + description="", + mode="floating", + factory=lambda: None, + ) + ) + + +def test_external_plugins_stay_inside_project_and_keep_legacy_entrypoints(tmp_path): + project = ProjectContext(tmp_path) + plugins = default_external_plugins(project) + + assert len(plugins) == 9 + assert all(plugin.mode == "external" for plugin in plugins) + assert all(plugin.script.suffix == ".py" for plugin in plugins) + assert {plugin.id for plugin in plugins} >= { + "texture_editor", + "player_editor", + "danmaku_editor", + } + + registry = PluginRegistry(project) + with pytest.raises(ProjectContextError, match="outside"): + registry.register( + EditorPlugin( + id="outside", + title="Outside", + description="", + mode="external", + script=tmp_path.parent / "outside.py", + ) + ) From f087a2bcbbcfe62d08515f811050fb7acecdfb31 Mon Sep 17 00:00:00 2001 From: qwqpap <798292805@qq.com> Date: Sat, 1 Aug 2026 17:27:05 +0800 Subject: [PATCH 2/2] Complete M1 pattern IR and formal runtime execution --- README.md | 1 + docs/EDITOR_ROADMAP_TODO.md | 93 +++-- docs/PATTERN_RESOURCE_CONTRACT.md | 80 ++++ docs/schemas/pystg-pattern-v1.schema.json | 144 +++++++ src/authoring/registry.py | 27 +- src/devtools/pattern_runtime.py | 31 +- src/game/bullet/optimized_pool.py | 130 +++++- src/game/stage/context.py | 121 +++++- src/pattern/__init__.py | 52 +++ src/pattern/compiler.py | 383 +++++++++++++++++ src/pattern/document.py | 487 ++++++++++++++++++++++ src/pattern/ir.py | 62 +++ src/pattern/runtime.py | 227 ++++++++++ tests/conftest.py | 25 ++ tests/test_authoring_resources.py | 7 + tests/test_devtools_pattern_lab.py | 25 +- tests/test_editor_asset_index.py | 8 +- tests/test_pattern_compiler.py | 169 ++++++++ tests/test_pattern_document.py | 99 +++++ tests/test_pattern_parity.py | 115 +++++ tests/test_pattern_runtime.py | 220 ++++++++++ tools/benchmark_pattern_runtime.py | 71 ++++ 22 files changed, 2517 insertions(+), 60 deletions(-) create mode 100644 docs/PATTERN_RESOURCE_CONTRACT.md create mode 100644 docs/schemas/pystg-pattern-v1.schema.json create mode 100644 src/pattern/__init__.py create mode 100644 src/pattern/compiler.py create mode 100644 src/pattern/document.py create mode 100644 src/pattern/ir.py create mode 100644 src/pattern/runtime.py create mode 100644 tests/conftest.py create mode 100644 tests/test_pattern_compiler.py create mode 100644 tests/test_pattern_document.py create mode 100644 tests/test_pattern_parity.py create mode 100644 tests/test_pattern_runtime.py create mode 100644 tools/benchmark_pattern_runtime.py diff --git a/README.md b/README.md index 4890a2ca..3b1e903c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ flowchart TB | [开发工具链](docs/DEVTOOLS_PHASE1.md) | 资源校验、热重载、Pattern Lab 和符卡预览 | | [编辑器架构边界](docs/EDITOR_ARCHITECTURE.md) | 编辑器、文档、运行时和资源服务的依赖约束 | | [作者资源契约](docs/AUTHORING_RESOURCE_CONTRACTS.md) | M0 资源头、引用、迁移、坐标、时间和注册表协议 | +| [弹幕资源与正式运行时契约](docs/PATTERN_RESOURCE_CONTRACT.md) | M1 PatternDocument、不可变 IR、批量 runner 与预览/游戏同源边界 | | [编辑器长期路线 TODO](docs/EDITOR_ROADMAP_TODO.md) | 分阶段任务、依赖、Gate 与完成证据 | 也可以本地启动 VitePress 文档站点: diff --git a/docs/EDITOR_ROADMAP_TODO.md b/docs/EDITOR_ROADMAP_TODO.md index c39244b0..8320afdb 100644 --- a/docs/EDITOR_ROADMAP_TODO.md +++ b/docs/EDITOR_ROADMAP_TODO.md @@ -23,12 +23,12 @@ Godot-style workbench ## Current focus -**Milestone M1 — Pattern IR and formal runtime execution** +**Milestone M2 — Controllable formal preview** Phase 0 contracts are frozen. Keep later changes compatible with them or add explicit schema migrations and contract tests. -Next recommended task: **E1.1 — Define PatternDocument.** +Next recommended task: **E2.1 — Define PreviewController contract.** ## Status and update rules @@ -234,47 +234,47 @@ Goal: make data-authored patterns directly runnable without generating Python. ### E1.1 PatternDocument -- [ ] Define recipe-level sections: Bullet, Shape, Aim, Schedule, Motion, and +- [x] Define recipe-level sections: Bullet, Shape, Aim, Schedule, Motion, and Modifiers. -- [ ] Support initial shapes: ring, arc, line, spiral, and random distribution. -- [ ] Support initial scheduling: delay, interval, burst count, loop. -- [ ] Support fixed direction and aim-at-player. -- [ ] Support stable random seed configuration. -- [ ] Provide migration/import from the prototype PatternSpec. +- [x] Support initial shapes: ring, arc, line, spiral, and random distribution. +- [x] Support initial scheduling: delay, interval, burst count, loop. +- [x] Support fixed direction and aim-at-player. +- [x] Support stable random seed configuration. +- [x] Provide migration/import from the prototype PatternSpec. ### E1.2 Pattern compiler -- [ ] Implement `PatternDocument -> immutable PatternProgram` compilation. -- [ ] Resolve and validate resource references during compilation. -- [ ] Precompute static angles, speeds, and resource indices. -- [ ] Produce structured diagnostics containing resource ID and property path. -- [ ] Cache compiled programs using content/version identity. +- [x] Implement `PatternDocument -> immutable PatternProgram` compilation. +- [x] Resolve and validate resource references during compilation. +- [x] Precompute static angles, speeds, and resource indices. +- [x] Produce structured diagnostics containing resource ID and property path. +- [x] Cache compiled programs using content/version identity. ### E1.3 Pattern runner -- [ ] Implement a fixed-tick PatternRunner that executes PatternProgram through +- [x] Implement a fixed-tick PatternRunner that executes PatternProgram through StageContext. -- [ ] Define start, pause, reset, stop, and deterministic replay semantics. -- [ ] Track ownership/tags so a pattern instance can clear or transform only its +- [x] Define start, pause, reset, stop, and deterministic replay semantics. +- [x] Track ownership/tags so a pattern instance can clear or transform only its own bullets. -- [ ] Add batch spawn APIs to StageContext and OptimizedBulletPool. -- [ ] Keep common bullet motion in NumPy/Numba-compatible data paths. +- [x] Add batch spawn APIs to StageContext and OptimizedBulletPool. +- [x] Keep common bullet motion in NumPy/Numba-compatible data paths. ### E1.4 Runtime parity tests -- [ ] Compare compiled output against known PatternSpec parameter fixtures. -- [ ] Verify identical seeds produce identical spawn traces. -- [ ] Verify preview and gameplay runners produce identical traces. -- [ ] Add load/compile/run failure tests with actionable diagnostics. -- [ ] Add representative dense-burst performance measurements after dependency +- [x] Compare compiled output against known PatternSpec parameter fixtures. +- [x] Verify identical seeds produce identical spawn traces. +- [x] Verify preview and gameplay runners produce identical traces. +- [x] Add load/compile/run failure tests with actionable diagnostics. +- [x] Add representative dense-burst performance measurements after dependency versions are aligned. ### Phase 1 gate -- [ ] One PatternDocument runs in the formal game runtime without Python codegen. -- [ ] Ring, arc, spiral, aim, interval, multi-burst, and random-seed parity pass. -- [ ] Dense patterns do not require scene nodes or per-bullet Python callbacks. -- [ ] Runtime and structural tests pass; performance evidence is recorded. +- [x] One PatternDocument runs in the formal game runtime without Python codegen. +- [x] Ring, arc, spiral, aim, interval, multi-burst, and random-seed parity pass. +- [x] Dense patterns do not require scene nodes or per-bullet Python callbacks. +- [x] Runtime and structural tests pass; performance evidence is recorded. --- @@ -634,3 +634,42 @@ Append entries; do not rewrite old evidence. Keep each entry concise. target conda environment was used for the full regression. - Acceptance classification: M0 is structurally valid and visually inspected. Pattern runtime parity and performance remain Phase 1+ work. + +### 2026-08-01 — M1 pattern IR and formal runtime complete + +- Added strict `PatternDocument` v1 and Draft 2020-12 schema with Bullet, Shape, + Aim, Schedule, Motion, Modifiers, stable seed, Unicode display identity, and + direct import from the development `PatternSpec` without Python codegen. +- Added content/dependency-keyed compilation into immutable `PatternProgram` + templates, alias/direct-fragment resolution, optional sprite-index + precomputation, bounded compile size, and structured resource/property + diagnostics. +- Added deterministic fixed-tick `PatternRunner` lifecycle and owner-tag + isolation, plus vectorized `StageContext`/`OptimizedBulletPool` batch spawn, + translate, time-scale, and clear paths. Formal batches create no scene nodes + and install no per-bullet Python callbacks. +- Structural/runtime evidence: 45 focused Pattern/authoring/devtools tests + passed, and the complete target-environment regression passed (`135 passed`), + including three consecutive full-suite runs after stabilizing the shared Qt + application lifetime. Draft + 2020-12 schema validation and `python -m compileall -q main.py src + game_content tools tests` also passed. +- Parity evidence: ring, arc, spiral, flower compatibility, player/fixed aim, + interval/multi-burst scheduling, identical-seed replay, and two formal + preview/game contexts consuming the same runner produced matching traces. +- Performance evidence in `touhou_guess` (Python 3.12.9, NumPy 2.2.4, Numba + 0.63.1): 100 batches × 512 bullets spawned 51,200/51,200 bullets in 0.127511 + seconds (401,534 bullets/second), with 100 observed batch calls and zero + per-bullet callbacks. This is a recorded representative measurement, not a + universal frame-time guarantee. +- Repository evidence: asset validation checked 71 JSON files, 745 sprites, and + 142 images with 0 errors and 0 warnings; `git diff --check` passed. +- Visual evidence: M1 adds no editor surface, so no new visual interaction is + claimed. M2 will put controls and diagnostic overlays around this same formal + runtime rather than introduce a separate editor-only renderer. +- Acceptance classification: M1 is structurally valid, runtime valid, and + performance checked; visual acceptance is not applicable to this milestone. +- Final commands run in the `touhou_guess` environment: + `python -m pytest -q`, `python -m compileall -q main.py src game_content + tools tests`, `python tools/validate_assets.py --format json`, and + `python tools/benchmark_pattern_runtime.py`; `git diff --check` also passed. diff --git a/docs/PATTERN_RESOURCE_CONTRACT.md b/docs/PATTERN_RESOURCE_CONTRACT.md new file mode 100644 index 00000000..0c0a4732 --- /dev/null +++ b/docs/PATTERN_RESOURCE_CONTRACT.md @@ -0,0 +1,80 @@ +# Pattern resource contract (M1) + +`pystg.pattern` is a versioned authoring document that compiles directly into +the formal game runtime. The JSON document is the source of truth; generated +Python is an optional compatibility/export artifact. + +## Data flow + +```text +*.pystg.json + -> PatternDocument (validated recipe) + -> PatternCompiler (resource resolution and static precomputation) + -> PatternProgram (immutable IR) + -> PatternRunner (fixed 60 Hz scheduling) + -> StageContext.create_bullets_batch + -> OptimizedBulletPool (NumPy/Numba-compatible bullet state) +``` + +The editor preview and gameplay must consume the same `PatternProgram`, +`PatternRunner`, `StageContext`, and bullet-pool behavior. An editor may draw a +grid, handles, selections, guides, and diagnostic overlays above that output, +but an approximate editor-only simulator is not formal preview evidence. + +## V1 document sections + +The published schema is +[`schemas/pystg-pattern-v1.schema.json`](schemas/pystg-pattern-v1.schema.json). +It uses the common resource header plus these sections: + +- `bullet`: a type/color alias or direct `res://atlas.json#sprite` reference. +- `shape`: `ring`, `arc`, `line`, `spiral`, `random`, or legacy-compatible + `flower`, including count and logical runtime origin. +- `aim`: a fixed direction or a direction sampled toward the player at spawn. +- `schedule`: integer-frame delay and interval, bursts per loop, and finite or + infinite loop count (`null`). +- `motion`: initial speed plus data-oriented pool fields for friction, spin, + time scale, lifetime, scale, and axis bounce. +- `modifiers`: deterministic per-burst angle/speed offsets and random-speed + variation. +- `seed`: a stable non-negative 63-bit seed. + +Unknown fields are rejected in v1. A shape can contain at most 4096 bullets and +a schedule at most 4096 distinct burst templates. Compilation additionally +rejects programs that would precompute more than 1,000,000 bullet records. + +## Compilation and diagnostics + +`PatternCompiler` resolves aliases or direct sprite fragments, hashes document +and dependency contents, optionally resolves the sprite's integer runtime +index, and precomputes one immutable `BurstTemplate` per burst in a schedule +loop. Its cache identity includes the schema version, canonical document JSON, +resource contents, and sprite index. + +Compilation failures carry structured diagnostics with severity, code, +resource UUID, property path, and message. Missing files, missing fragments, +invalid alias maps, oversized programs, and sprite-index failures are errors; +the compiler does not silently substitute an authored resource. + +## Runner and ownership + +`PatternRunner.tick()` advances in integer frames. Its lifecycle is start, +pause/resume, reset, and stop. Resetting replays the same immutable program and +seed deterministically. Every runner has a non-zero owner tag, used to clear, +translate, or change time scale only for that instance's live bullets. + +Dense bullets are never scene nodes. A burst is written to the structured +NumPy pool as one batch, and common motion remains in pool fields and optimized +kernels. Pattern execution installs no per-bullet Python update/death/emitter +callbacks. + +## Compatibility + +`PatternDocument.from_pattern_spec()` imports the existing Pattern Lab model, +including its ring, arc, spiral, flower, interval, multi-burst, angle-offset, +and infinite-loop behavior. This path does not generate or reverse-parse +Python. + +Use `tools/benchmark_pattern_runtime.py` for the representative M1 dense-burst +measurement. Performance evidence is environment-specific and is recorded in +the editor roadmap completion log. diff --git a/docs/schemas/pystg-pattern-v1.schema.json b/docs/schemas/pystg-pattern-v1.schema.json new file mode 100644 index 00000000..62c24bf5 --- /dev/null +++ b/docs/schemas/pystg-pattern-v1.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pythonstg.dev/schemas/pystg-pattern-v1.schema.json", + "title": "PySTG PatternDocument v1", + "type": "object", + "required": [ + "schema_version", + "type", + "id", + "name", + "seed", + "bullet", + "shape", + "aim", + "schedule", + "motion", + "modifiers" + ], + "properties": { + "schema_version": {"const": 1}, + "type": {"const": "pystg.pattern"}, + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string", "minLength": 1}, + "symbol_name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "metadata": {"type": "object"}, + "seed": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807}, + "bullet": {"$ref": "#/$defs/bullet"}, + "shape": {"$ref": "#/$defs/shape"}, + "aim": {"$ref": "#/$defs/aim"}, + "schedule": {"$ref": "#/$defs/schedule"}, + "motion": {"$ref": "#/$defs/motion"}, + "modifiers": {"$ref": "#/$defs/modifiers"} + }, + "additionalProperties": false, + "$defs": { + "bullet": { + "type": "object", + "required": ["bullet_type", "color", "resource"], + "properties": { + "bullet_type": {"type": "string", "minLength": 1}, + "color": {"type": "string", "minLength": 1}, + "resource": {"type": ["string", "null"], "minLength": 1} + }, + "additionalProperties": false + }, + "shape": { + "type": "object", + "required": [ + "kind", + "count", + "origin_x", + "origin_y", + "angle_span", + "line_length", + "line_angle" + ], + "properties": { + "kind": { + "enum": ["ring", "arc", "line", "spiral", "random", "flower"] + }, + "count": {"type": "integer", "minimum": 1, "maximum": 4096}, + "origin_x": {"type": "number"}, + "origin_y": {"type": "number"}, + "angle_span": {"type": "number"}, + "line_length": {"type": "number", "minimum": 0}, + "line_angle": {"type": "number"} + }, + "additionalProperties": false + }, + "aim": { + "type": "object", + "required": ["mode", "angle"], + "properties": { + "mode": {"enum": ["fixed", "player"]}, + "angle": {"type": "number"} + }, + "additionalProperties": false + }, + "schedule": { + "type": "object", + "required": ["delay_frames", "interval_frames", "burst_count", "loop_count"], + "properties": { + "delay_frames": {"type": "integer", "minimum": 0}, + "interval_frames": { + "type": "integer", + "minimum": 1, + "maximum": 10000000 + }, + "burst_count": {"type": "integer", "minimum": 1, "maximum": 4096}, + "loop_count": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 1000000 + } + }, + "additionalProperties": false + }, + "motion": { + "type": "object", + "required": [ + "speed", + "friction", + "spin", + "time_scale", + "max_lifetime", + "render_scale", + "bounce_x", + "bounce_y" + ], + "properties": { + "speed": {"type": "number", "minimum": 0}, + "friction": {"type": "number", "minimum": 0}, + "spin": {"type": "number"}, + "time_scale": {"type": "number", "minimum": 0}, + "max_lifetime": {"type": "number", "minimum": 0}, + "render_scale": {"type": "number", "exclusiveMinimum": 0}, + "bounce_x": {"type": "boolean"}, + "bounce_y": {"type": "boolean"} + }, + "additionalProperties": false + }, + "modifiers": { + "type": "object", + "required": [ + "angle_offset_per_burst", + "speed_offset_per_burst", + "random_speed_variation" + ], + "properties": { + "angle_offset_per_burst": {"type": "number"}, + "speed_offset_per_burst": {"type": "number"}, + "random_speed_variation": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + } + } +} diff --git a/src/authoring/registry.py b/src/authoring/registry.py index ea94bd21..6e8e0dba 100644 --- a/src/authoring/registry.py +++ b/src/authoring/registry.py @@ -110,11 +110,26 @@ def build_default_resource_type_registry() -> ResourceTypeRegistry: (UI_RESOURCE_TYPE, "UI", "ui"), (BACKGROUND_RESOURCE_TYPE, "Background", "background"), ): - registry.register( - ResourceTypeSpec( - type_name=type_name, - display_name=display_name, - asset_kind=asset_kind, + if type_name == PATTERN_RESOURCE_TYPE: + # Local import keeps the common registry independent of domain + # modules while still providing typed loading and compilation. + from src.pattern import PatternDocument, compile_pattern + + registry.register( + ResourceTypeSpec( + type_name=type_name, + display_name=display_name, + asset_kind=asset_kind, + loader=PatternDocument.from_dict, + compiler=compile_pattern, + ) + ) + else: + registry.register( + ResourceTypeSpec( + type_name=type_name, + display_name=display_name, + asset_kind=asset_kind, + ) ) - ) return registry diff --git a/src/devtools/pattern_runtime.py b/src/devtools/pattern_runtime.py index d2c31d72..41964d2d 100644 --- a/src/devtools/pattern_runtime.py +++ b/src/devtools/pattern_runtime.py @@ -3,10 +3,11 @@ from __future__ import annotations import math -from dataclasses import dataclass +from dataclasses import dataclass, field from src.devtools.pattern_lab import PatternSpec, bullet_parameters from src.game.stage.context import StageContext +from src.pattern import PatternCompiler, PatternDocument, PatternProgram, PatternRunner @dataclass @@ -14,20 +15,30 @@ class PatternPlayback: spec: PatternSpec frame: int = 0 burst_index: int = 0 + owner_tag: int | None = None + program: PatternProgram = field(init=False) + runner: PatternRunner = field(init=False) - def reset(self) -> None: + def __post_init__(self) -> None: + self.spec.validate() + document = PatternDocument.from_pattern_spec(self.spec) + self.program = PatternCompiler().compile(document) + self.runner = PatternRunner(self.program, owner_tag=self.owner_tag) + self.owner_tag = self.runner.owner_tag + + def reset(self, ctx: StageContext | None = None) -> None: + self.runner.reset(ctx, clear_owned=ctx is not None) self.frame = 0 self.burst_index = 0 def update(self, ctx: StageContext) -> int: - """Advance playback by one frame and spawn bullets when due.""" - self.spec.validate() - spawned = 0 - if self.frame % self.spec.interval == 0: - spawned = spawn_pattern_burst(ctx, self.spec, self.burst_index) - self.burst_index = (self.burst_index + 1) % self.spec.bursts - self.frame += 1 - return spawned + """Advance the same formal runner used by gameplay by one fixed tick.""" + if self.runner.state.value == "stopped": + self.runner.start(ctx, reset=False) + result = self.runner.tick(ctx) + self.frame = self.runner.frame + self.burst_index = self.runner.emission_count % self.spec.bursts + return result.spawned_count def spawn_pattern_burst(ctx: StageContext, spec: PatternSpec, burst_index: int = 0) -> int: diff --git a/src/game/bullet/optimized_pool.py b/src/game/bullet/optimized_pool.py index ddd44555..47eed040 100644 --- a/src/game/bullet/optimized_pool.py +++ b/src/game/bullet/optimized_pool.py @@ -150,9 +150,10 @@ def __init__(self, max_bullets: int = 50000, sprite_registry: SpriteRegistry = N self.polar_motions: Dict[int, PolarMotion] = {} self.emitter_callbacks: Dict[int, Callable] = {} - self.spawn_queue: List[SpawnRequest] = [] - self.death_queue: List[DeathEvent] = [] - self.last_alive = np.zeros(max_bullets, dtype='i4') + self.spawn_queue: List[SpawnRequest] = [] + self.death_queue: List[DeathEvent] = [] + self.last_alive = np.zeros(max_bullets, dtype='i4') + self.batch_spawn_calls = 0 # ===== 渲染优化相关 ===== self._render_positions = np.zeros((max_bullets, 2), dtype='f4') @@ -374,7 +375,109 @@ def spawn_pattern( # ===== 发射器 (Emitter) ===== - def spawn_emitter(self, x: float, y: float, angle: float, speed: float, + def spawn_bullets_batch( + self, + positions, + angles, + speeds, + *, + sprite_id: str = '', + sprite_idx: int = -1, + acc: Tuple[float, float] = (0.0, 0.0), + max_lifetime: float = 0.0, + radius: float = 0.0, + friction: float = 0.0, + tag: int = 0, + time_scale: float = 1.0, + flags: int = FLAG_RENDER_ANGLE_LOCKED, + angular_vel: float = 0.0, + render_angles=None, + render_scale: float = 1.0, + curve_type: int = CURVE_NONE, + curve_param: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), + ) -> np.ndarray: + """Spawn heterogeneous bullets with one vectorized pool write. + + Angles are radians and speeds are normalized units per frame, matching + :meth:`spawn_bullet`. Capacity exhaustion is explicit: the returned + array contains only the slots that were actually allocated. + """ + position_array = np.asarray(positions, dtype=np.float32) + angle_array = np.asarray(angles, dtype=np.float32) + speed_array = np.asarray(speeds, dtype=np.float32) + if position_array.size == 0: + position_array = position_array.reshape((0, 2)) + if position_array.ndim != 2 or position_array.shape[1] != 2: + raise ValueError("positions must have shape (count, 2)") + if angle_array.ndim != 1 or speed_array.ndim != 1: + raise ValueError("angles and speeds must be one-dimensional") + count = len(position_array) + if len(angle_array) != count or len(speed_array) != count: + raise ValueError("positions, angles, and speeds must have equal length") + if not ( + np.all(np.isfinite(position_array)) + and np.all(np.isfinite(angle_array)) + and np.all(np.isfinite(speed_array)) + ): + raise ValueError("batch positions, angles, and speeds must be finite") + if np.any(speed_array < 0): + raise ValueError("batch speeds must be non-negative") + + if render_angles is None: + render_angle_array = angle_array + else: + render_angle_array = np.asarray(render_angles, dtype=np.float32) + if render_angle_array.ndim != 1 or len(render_angle_array) != count: + raise ValueError("render_angles must match the batch length") + if not np.all(np.isfinite(render_angle_array)): + raise ValueError("render_angles must be finite") + + available = min(count, len(self.free_indices)) + if available == 0: + return np.empty(0, dtype=np.intp) + if sprite_idx < 0: + sprite_idx = self.register_sprite(sprite_id) if sprite_id else 0 + + use_indices = np.fromiter( + (self.free_indices.pop() for _ in range(available)), + dtype=np.intp, + count=available, + ) + batch_positions = position_array[:available] + batch_angles = angle_array[:available] + batch_speeds = speed_array[:available] + d = self.data + d['pos'][use_indices] = batch_positions + d['vel'][use_indices, 0] = np.cos(batch_angles) * batch_speeds + d['vel'][use_indices, 1] = np.sin(batch_angles) * batch_speeds + d['acc'][use_indices] = acc + d['angle'][use_indices] = batch_angles + d['render_angle'][use_indices] = render_angle_array[:available] + d['angular_vel'][use_indices] = angular_vel + d['render_scale'][use_indices] = render_scale + d['speed'][use_indices] = batch_speeds + d['sprite_idx'][use_indices] = sprite_idx + d['radius'][use_indices] = radius + d['lifetime'][use_indices] = 0.0 + d['max_lifetime'][use_indices] = max_lifetime + d['friction'][use_indices] = friction + d['tag'][use_indices] = tag + d['time_scale'][use_indices] = time_scale + d['flags'][use_indices] = flags + d['curve_type'][use_indices] = curve_type + d['curve_param'][use_indices] = curve_param + d['alive'][use_indices] = 1 + self.batch_spawn_calls += 1 + + # Formal pattern batches never install per-bullet callbacks. Clear any + # stale sparse state defensively if a legacy path reused these slots. + for idx in use_indices.tolist(): + self.death_handlers.pop(int(idx), None) + self.polar_motions.pop(int(idx), None) + self.emitter_callbacks.pop(int(idx), None) + return use_indices + + def spawn_emitter(self, x: float, y: float, angle: float, speed: float, callback: Callable, **kwargs) -> int: """ 生成发射器节点(不渲染、不碰撞,有运动轨迹和每帧回调) @@ -423,10 +526,23 @@ def _clear_mask_now(self, mask) -> np.ndarray: return positions - def clear_by_tag(self, tag: int): + def clear_by_tag(self, tag: int) -> int: """按标签消除所有子弹""" - mask = (self.data['alive'] == 1) & (self.data['tag'] == tag) - self._clear_mask_now(mask) + mask = (self.data['alive'] == 1) & (self.data['tag'] == tag) + count = int(np.count_nonzero(mask)) + self._clear_mask_now(mask) + return count + + def translate_by_tag(self, tag: int, dx: float, dy: float) -> int: + """Translate alive bullets owned by ``tag`` with one vectorized write.""" + if not math.isfinite(dx) or not math.isfinite(dy): + raise ValueError("translation must be finite") + mask = (self.data['alive'] == 1) & (self.data['tag'] == tag) + count = int(np.count_nonzero(mask)) + if count: + self.data['pos'][mask, 0] += dx + self.data['pos'][mask, 1] += dy + return count def cancel_for_bomb(self, protected_tags=None) -> np.ndarray: """Cancel all bomb-clearable bullets and return canceled positions.""" diff --git a/src/game/stage/context.py b/src/game/stage/context.py index 5acc13e9..f0a4ca1b 100644 --- a/src/game/stage/context.py +++ b/src/game/stage/context.py @@ -187,7 +187,98 @@ def create_bullet(self, x: float, y: float, angle: float, speed: float, self._bullet_indices.append(idx) return idx - def create_polar_bullet(self, center, orbit_radius: float, theta: float, + def create_bullets_batch( + self, + *, + positions, + angles, + speeds, + bullet_type: str = "ball_m", + color: str = "red", + sprite_id: str = None, + sprite_idx: int = -1, + tag: int = 0, + friction: float = 0.0, + time_scale: float = 1.0, + bounce_x: bool = False, + bounce_y: bool = False, + spin: float = 0.0, + max_lifetime: float = 0.0, + render_scale: float = 1.0, + ) -> np.ndarray: + """Create a heterogeneous formal-runtime burst in one pool operation. + + This is the batch equivalent of :meth:`create_bullet`: public angles + are degrees and speeds are per second, while the pool receives radians + and normalized units per frame. + """ + position_array = np.asarray(positions, dtype=np.float32) + angle_array = np.asarray(angles, dtype=np.float32) + speed_array = np.asarray(speeds, dtype=np.float32) + if position_array.size == 0: + position_array = position_array.reshape((0, 2)) + if position_array.ndim != 2 or position_array.shape[1] != 2: + raise ValueError("positions must have shape (count, 2)") + if angle_array.ndim != 1 or speed_array.ndim != 1: + raise ValueError("angles and speeds must be one-dimensional") + if not (len(position_array) == len(angle_array) == len(speed_array)): + raise ValueError("positions, angles, and speeds must have equal length") + + resolved_sprite_id = sprite_id or self._resolve_sprite_id(bullet_type, color) + flags = FLAG_RENDER_ANGLE_LOCKED + if bounce_x: + flags |= FLAG_BOUNCE_X + if bounce_y: + flags |= FLAG_BOUNCE_Y + if spin != 0.0: + flags &= ~FLAG_RENDER_ANGLE_LOCKED + + angle_radians = np.deg2rad(angle_array).astype(np.float32, copy=False) + speed_per_frame = speed_array / 60.0 + if hasattr(self.bullet_pool, "spawn_bullets_batch"): + indices = self.bullet_pool.spawn_bullets_batch( + positions=position_array, + angles=angle_radians, + speeds=speed_per_frame, + sprite_id=resolved_sprite_id, + sprite_idx=sprite_idx, + tag=tag, + friction=friction, + time_scale=time_scale, + flags=flags, + angular_vel=math.radians(spin), + max_lifetime=max_lifetime, + render_scale=render_scale, + ) + else: + spawned = [] + for (x, y), angle_rad, speed_value in zip( + position_array, angle_radians, speed_per_frame + ): + idx = self.bullet_pool.spawn_bullet( + x=float(x), + y=float(y), + angle=float(angle_rad), + speed=float(speed_value), + sprite_id=resolved_sprite_id, + sprite_idx=sprite_idx, + tag=tag, + friction=friction, + time_scale=time_scale, + flags=flags, + angular_vel=math.radians(spin), + max_lifetime=max_lifetime, + render_scale=render_scale, + ) + if idx >= 0: + spawned.append(idx) + indices = np.asarray(spawned, dtype=np.intp) + + result = np.asarray(indices, dtype=np.intp) + self._bullet_indices.extend(int(index) for index in result) + return result + + def create_polar_bullet(self, center, orbit_radius: float, theta: float, radial_speed: float = 0.0, angular_velocity: float = 0.0, bullet_type: str = "ball_m", color: str = "red", render_mode: str = "velocity", angle_offset: float = 0.0, @@ -284,7 +375,7 @@ def clear_all_bullets(self): self.bullet_pool.clear_all() self._bullet_indices.clear() - def clear_bullets_by_tag(self, tag: int): + def clear_bullets_by_tag(self, tag: int) -> int: """按标签消除所有子弹""" self.bullet_pool.clear_by_tag(tag) @@ -296,7 +387,31 @@ def bullets_by_tag_to_item(self, tag: int): mask = (self.bullet_pool.data['alive'] == 1) & (self.bullet_pool.data['tag'] == tag) positions = self.bullet_pool.data['pos'][mask].copy() self._item_pool.spawn_points_from_positions(positions, attract=True) - self.bullet_pool.clear_by_tag(tag) + owned = { + idx + for idx in self._bullet_indices + if 0 <= idx < len(self.bullet_pool.data['alive']) + and self.bullet_pool.data['alive'][idx] == 1 + and self.bullet_pool.data['tag'][idx] == tag + } + result = self.bullet_pool.clear_by_tag(tag) + self._bullet_indices = [ + idx for idx in self._bullet_indices if idx not in owned + ] + return len(owned) if result is None else int(result) + + def translate_bullets_by_tag(self, tag: int, dx: float, dy: float) -> int: + """Translate one owner group without touching other live bullets.""" + if hasattr(self.bullet_pool, "translate_by_tag"): + return int(self.bullet_pool.translate_by_tag(tag, dx, dy)) + mask = ( + (self.bullet_pool.data['alive'] == 1) + & (self.bullet_pool.data['tag'] == tag) + ) + count = int(np.count_nonzero(mask)) + self.bullet_pool.data['pos'][mask, 0] += dx + self.bullet_pool.data['pos'][mask, 1] += dy + return count def set_time_scale(self, scale: float, tag: int = None): """设置子弹时间缩放(tag=None 影响全部)""" diff --git a/src/pattern/__init__.py b/src/pattern/__init__.py new file mode 100644 index 00000000..e8116742 --- /dev/null +++ b/src/pattern/__init__.py @@ -0,0 +1,52 @@ +"""Typed pattern authoring, compilation, and formal runtime execution.""" + +from .compiler import ( + PatternCompileError, + PatternCompiler, + PatternDiagnostic, + compile_pattern, +) +from .document import ( + AIM_MODES, + PATTERN_SHAPES, + AimSpec, + BulletSpec, + ModifierSpec, + MotionSpec, + PatternDocument, + PatternDocumentError, + ScheduleSpec, + ShapeSpec, +) +from .ir import BurstTemplate, PatternProgram +from .runtime import ( + PatternRunner, + PatternRunnerState, + PatternRuntimeError, + PatternSpawnEvent, + PatternTickResult, +) + +__all__ = [ + "AIM_MODES", + "PATTERN_SHAPES", + "AimSpec", + "BulletSpec", + "BurstTemplate", + "ModifierSpec", + "MotionSpec", + "PatternCompileError", + "PatternCompiler", + "PatternDiagnostic", + "PatternDocument", + "PatternDocumentError", + "PatternProgram", + "PatternRunner", + "PatternRunnerState", + "PatternRuntimeError", + "PatternSpawnEvent", + "PatternTickResult", + "ScheduleSpec", + "ShapeSpec", + "compile_pattern", +] diff --git a/src/pattern/compiler.py b/src/pattern/compiler.py new file mode 100644 index 00000000..035a34cc --- /dev/null +++ b/src/pattern/compiler.py @@ -0,0 +1,383 @@ +"""PatternDocument to immutable PatternProgram compilation.""" + +from __future__ import annotations + +import hashlib +import json +import math +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from src.authoring.resources import ResourceDocumentError, ResourceReference +from src.core.project_context import ProjectContext + +from .document import PatternDocument, PatternDocumentError +from .ir import BurstTemplate, PatternProgram + + +SpriteIndexResolver = Callable[[str], int] +MAX_COMPILED_BULLETS = 1_000_000 + + +@dataclass(frozen=True) +class PatternDiagnostic: + severity: str + code: str + resource_id: str + path: str + message: str + + +class PatternCompileError(ValueError): + def __init__(self, diagnostics: tuple[PatternDiagnostic, ...]): + self.diagnostics = diagnostics + message = "; ".join( + f"{item.path}: {item.message}" for item in diagnostics + ) + super().__init__(message or "pattern compilation failed") + + +def _clean(value: float, digits: int = 6) -> float: + rounded = round(float(value), digits) + return 0.0 if rounded == 0 else rounded + + +def _diagnostic( + document: PatternDocument, + code: str, + path: str, + message: str, +) -> PatternDiagnostic: + return PatternDiagnostic( + severity="error", + code=code, + resource_id=document.id, + path=path, + message=message, + ) + + +def _shape_values( + document: PatternDocument, + burst_index: int, +) -> BurstTemplate: + shape = document.shape + motion = document.motion + modifiers = document.modifiers + count = shape.count + burst_angle = modifiers.angle_offset_per_burst * burst_index + base_speed = motion.speed + modifiers.speed_offset_per_burst * burst_index + if base_speed < 0: + raise PatternDocumentError( + "modifiers.speed_offset_per_burst", + f"produces negative speed at burst {burst_index}", + ) + + positions = [(0.0, 0.0)] * count + speed_factors = [1.0] * count + + if shape.kind == "ring": + angles = [burst_angle + index * 360.0 / count for index in range(count)] + elif shape.kind == "arc": + if abs(shape.angle_span) >= 360.0: + start = 0.0 + step = shape.angle_span / count + else: + start = -shape.angle_span / 2.0 + step = 0.0 if count == 1 else shape.angle_span / (count - 1) + angles = [burst_angle + start + index * step for index in range(count)] + elif shape.kind == "spiral": + step = shape.angle_span / max(1, count) + angles = [burst_angle + index * step for index in range(count)] + speed_factors = [ + 0.65 + 0.55 * (index / max(1, count - 1)) + for index in range(count) + ] + elif shape.kind == "flower": + angles = [burst_angle + index * 360.0 / count for index in range(count)] + speed_factors = [ + 0.55 + + 0.45 + * abs(math.sin(math.radians(index * shape.angle_span))) + for index in range(count) + ] + elif shape.kind == "line": + angles = [burst_angle] * count + direction = math.radians(shape.line_angle) + for index in range(count): + t = 0.0 if count == 1 else index / (count - 1) - 0.5 + distance = t * shape.line_length + positions[index] = ( + math.cos(direction) * distance, + math.sin(direction) * distance, + ) + elif shape.kind == "random": + seed = document.seed + burst_index * 0x9E3779B97F4A7C15 + rng = random.Random(seed & 0x7FFF_FFFF_FFFF_FFFF) + if abs(shape.angle_span) >= 360.0: + low, high = sorted((0.0, shape.angle_span)) + else: + low, high = sorted((-shape.angle_span / 2.0, shape.angle_span / 2.0)) + angles = [burst_angle + rng.uniform(low, high) for _ in range(count)] + variation = modifiers.random_speed_variation + speed_factors = [ + rng.uniform(1.0 - variation, 1.0 + variation) + for _ in range(count) + ] + else: # PatternDocument validation makes this unreachable. + raise PatternDocumentError("shape.kind", f"unsupported shape {shape.kind!r}") + + return BurstTemplate( + position_offsets=tuple( + (_clean(x), _clean(y)) for x, y in positions + ), + angle_offsets=tuple(_clean(value) for value in angles), + speeds=tuple(_clean(base_speed * factor) for factor in speed_factors), + ) + + +class PatternCompiler: + """Compiler with content/dependency keyed in-memory caching.""" + + def __init__(self) -> None: + self._cache: dict[str, PatternProgram] = {} + + def clear_cache(self) -> None: + self._cache.clear() + + def compile( + self, + document: PatternDocument, + *, + project: ProjectContext | None = None, + sprite_index_resolver: SpriteIndexResolver | None = None, + ) -> PatternProgram: + try: + document.validate() + except PatternDocumentError as exc: + raise PatternCompileError( + (_diagnostic(document, "invalid_document", exc.path, exc.detail),) + ) from exc + + sprite_id, dependency_token = self._resolve_sprite(document, project) + sprite_index = -1 + if sprite_index_resolver is not None and sprite_id: + try: + sprite_index = int(sprite_index_resolver(sprite_id)) + except Exception as exc: + raise PatternCompileError( + ( + _diagnostic( + document, + "sprite_resolution_failed", + "bullet.resource", + str(exc), + ), + ) + ) from exc + + canonical = json.dumps( + document.to_dict(), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + identity = "\0".join( + ( + document.id, + str(document.schema_version), + canonical, + dependency_token, + str(sprite_index), + ) + ) + content_hash = hashlib.sha256(identity.encode("utf-8")).hexdigest() + cached = self._cache.get(content_hash) + if cached is not None: + return cached + + compiled_bullets = document.shape.count * document.schedule.burst_count + if compiled_bullets > MAX_COMPILED_BULLETS: + raise PatternCompileError( + ( + _diagnostic( + document, + "program_too_large", + "schedule.burst_count", + f"would precompute {compiled_bullets} bullets; " + f"the v1 limit is {MAX_COMPILED_BULLETS}", + ), + ) + ) + + try: + templates = tuple( + _shape_values(document, burst_index) + for burst_index in range(document.schedule.burst_count) + ) + except PatternDocumentError as exc: + raise PatternCompileError( + (_diagnostic(document, "invalid_program", exc.path, exc.detail),) + ) from exc + + program = PatternProgram( + resource_id=document.id, + schema_version=document.schema_version, + content_hash=content_hash, + name=document.name, + seed=document.seed, + origin=(document.shape.origin_x, document.shape.origin_y), + aim_mode=document.aim.mode, + aim_angle=document.aim.angle, + delay_frames=document.schedule.delay_frames, + interval_frames=document.schedule.interval_frames, + burst_count=document.schedule.burst_count, + loop_count=document.schedule.loop_count, + bullet_type=document.bullet.bullet_type, + color=document.bullet.color, + resource_uri=document.bullet.resource, + sprite_id=sprite_id, + sprite_index=sprite_index, + friction=document.motion.friction, + spin=document.motion.spin, + time_scale=document.motion.time_scale, + max_lifetime=document.motion.max_lifetime, + render_scale=document.motion.render_scale, + bounce_x=document.motion.bounce_x, + bounce_y=document.motion.bounce_y, + templates=templates, + ) + self._cache[content_hash] = program + return program + + def _resolve_sprite( + self, + document: PatternDocument, + project: ProjectContext | None, + ) -> tuple[str, str]: + resource = document.bullet.resource + if resource is not None: + try: + reference = ResourceReference.parse(resource) + except ResourceDocumentError as exc: + raise PatternCompileError( + ( + _diagnostic( + document, + "invalid_resource_reference", + "bullet.resource", + str(exc), + ), + ) + ) from exc + if project is None: + raise PatternCompileError( + ( + _diagnostic( + document, + "project_required", + "bullet.resource", + "a ProjectContext is required to resolve this resource", + ), + ) + ) + try: + path = reference.resolve(project, must_exist=True) + except ResourceDocumentError as exc: + raise PatternCompileError( + ( + _diagnostic( + document, + "missing_resource", + "bullet.resource", + str(exc), + ), + ) + ) from exc + if reference.subresource is None: + raise PatternCompileError( + ( + _diagnostic( + document, + "missing_sprite_fragment", + "bullet.resource", + "a sprite resource must include a #fragment", + ), + ) + ) + try: + source_bytes = path.read_bytes() + payload = json.loads(source_bytes.decode("utf-8-sig")) + except (OSError, UnicodeError, ValueError) as exc: + raise PatternCompileError( + ( + _diagnostic( + document, + "invalid_sprite_resource", + "bullet.resource", + f"cannot read sprite resource: {exc}", + ), + ) + ) from exc + sprites = payload.get("sprites") if isinstance(payload, dict) else None + if not isinstance(sprites, dict) or reference.subresource not in sprites: + raise PatternCompileError( + ( + _diagnostic( + document, + "missing_sprite_subresource", + "bullet.resource", + f"sprite fragment {reference.subresource!r} was not found", + ), + ) + ) + dependency_hash = hashlib.sha256(source_bytes).hexdigest() + return reference.subresource, f"{reference.uri}:{dependency_hash}" + + if project is None: + return "", "alias:runtime" + aliases = project.root / "assets" / "bullet_aliases.json" + try: + source_bytes = aliases.read_bytes() + payload = json.loads(source_bytes.decode("utf-8-sig")) + mapping = payload["mapping"] + if not isinstance(mapping, dict): + raise TypeError("mapping must be an object") + type_mapping = mapping[document.bullet.bullet_type] + if not isinstance(type_mapping, dict): + raise TypeError("bullet type mapping must be an object") + sprite_id = type_mapping[document.bullet.color] + if not isinstance(sprite_id, str) or not sprite_id.strip(): + raise TypeError("sprite id must be a non-empty string") + except (OSError, ValueError, KeyError, TypeError) as exc: + raise PatternCompileError( + ( + _diagnostic( + document, + "unknown_bullet_alias", + "bullet", + "cannot resolve " + f"{document.bullet.bullet_type}/{document.bullet.color}: {exc}", + ), + ) + ) from exc + dependency_hash = hashlib.sha256(source_bytes).hexdigest() + return sprite_id, f"aliases:{dependency_hash}" + + +_DEFAULT_COMPILER = PatternCompiler() + + +def compile_pattern( + document: PatternDocument, + *, + project: ProjectContext | None = None, + sprite_index_resolver: SpriteIndexResolver | None = None, +) -> PatternProgram: + return _DEFAULT_COMPILER.compile( + document, + project=project, + sprite_index_resolver=sprite_index_resolver, + ) diff --git a/src/pattern/document.py b/src/pattern/document.py new file mode 100644 index 00000000..9ff86146 --- /dev/null +++ b/src/pattern/document.py @@ -0,0 +1,487 @@ +"""Versioned recipe document for data-authored danmaku patterns.""" + +from __future__ import annotations + +import math +from dataclasses import asdict, dataclass, is_dataclass +from typing import Any, Mapping + +from src.authoring.resources import ( + PATTERN_RESOURCE_TYPE, + RESOURCE_SCHEMA_VERSION, + ResourceDocumentError, + ResourceHeader, +) + + +PATTERN_SHAPES = ("ring", "arc", "line", "spiral", "random", "flower") +AIM_MODES = ("fixed", "player") + + +class PatternDocumentError(ResourceDocumentError): + """Raised when a PatternDocument field violates the v1 contract.""" + + def __init__(self, path: str, message: str): + self.path = path + self.detail = message + super().__init__(f"{path}: {message}") + + +def _object(value: Any, path: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise PatternDocumentError(path, "must be an object") + return value + + +def _known(data: Mapping[str, Any], allowed: set[str], path: str) -> None: + unknown = set(data).difference(allowed) + if unknown: + raise PatternDocumentError( + path, + "unknown fields: " + ", ".join(sorted(unknown)), + ) + + +def _finite(value: Any, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise PatternDocumentError(path, "must be a number") + result = float(value) + if not math.isfinite(result): + raise PatternDocumentError(path, "must be finite") + return result + + +def _integer(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise PatternDocumentError(path, "must be an integer") + return value + + +def _boolean(value: Any, path: str) -> bool: + if not isinstance(value, bool): + raise PatternDocumentError(path, "must be a boolean") + return value + + +def _text(value: Any, path: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise PatternDocumentError(path, "must be a non-empty string") + return value.strip() + + +@dataclass(frozen=True) +class BulletSpec: + bullet_type: str = "ball_m" + color: str = "red" + resource: str | None = None + + def validate(self, path: str = "bullet") -> None: + _text(self.bullet_type, f"{path}.bullet_type") + _text(self.color, f"{path}.color") + if self.resource is not None: + _text(self.resource, f"{path}.resource") + + @classmethod + def from_dict(cls, value: Any) -> "BulletSpec": + data = _object(value, "bullet") + _known(data, {"bullet_type", "color", "resource"}, "bullet") + spec = cls( + bullet_type=data.get("bullet_type", "ball_m"), + color=data.get("color", "red"), + resource=data.get("resource"), + ) + spec.validate() + return spec + + +@dataclass(frozen=True) +class ShapeSpec: + kind: str = "ring" + count: int = 24 + origin_x: float = 0.0 + origin_y: float = 0.65 + angle_span: float = 360.0 + line_length: float = 1.0 + line_angle: float = 0.0 + + def validate(self, path: str = "shape") -> None: + if self.kind not in PATTERN_SHAPES: + raise PatternDocumentError( + f"{path}.kind", + "must be one of: " + ", ".join(PATTERN_SHAPES), + ) + count = _integer(self.count, f"{path}.count") + if not 1 <= count <= 4096: + raise PatternDocumentError(f"{path}.count", "must be in 1..4096") + _finite(self.origin_x, f"{path}.origin_x") + _finite(self.origin_y, f"{path}.origin_y") + _finite(self.angle_span, f"{path}.angle_span") + line_length = _finite(self.line_length, f"{path}.line_length") + if line_length < 0: + raise PatternDocumentError(f"{path}.line_length", "must be non-negative") + _finite(self.line_angle, f"{path}.line_angle") + + @classmethod + def from_dict(cls, value: Any) -> "ShapeSpec": + data = _object(value, "shape") + _known( + data, + { + "kind", + "count", + "origin_x", + "origin_y", + "angle_span", + "line_length", + "line_angle", + }, + "shape", + ) + spec = cls(**data) + spec.validate() + return spec + + +@dataclass(frozen=True) +class AimSpec: + mode: str = "fixed" + angle: float = 270.0 + + def validate(self, path: str = "aim") -> None: + if self.mode not in AIM_MODES: + raise PatternDocumentError( + f"{path}.mode", + "must be one of: " + ", ".join(AIM_MODES), + ) + _finite(self.angle, f"{path}.angle") + + @classmethod + def from_dict(cls, value: Any) -> "AimSpec": + data = _object(value, "aim") + _known(data, {"mode", "angle"}, "aim") + spec = cls(**data) + spec.validate() + return spec + + +@dataclass(frozen=True) +class ScheduleSpec: + delay_frames: int = 0 + interval_frames: int = 20 + burst_count: int = 1 + loop_count: int | None = 1 + + def validate(self, path: str = "schedule") -> None: + delay = _integer(self.delay_frames, f"{path}.delay_frames") + interval = _integer(self.interval_frames, f"{path}.interval_frames") + bursts = _integer(self.burst_count, f"{path}.burst_count") + if delay < 0: + raise PatternDocumentError(f"{path}.delay_frames", "must be non-negative") + if not 1 <= interval <= 10_000_000: + raise PatternDocumentError( + f"{path}.interval_frames", "must be in 1..10000000" + ) + if not 1 <= bursts <= 4096: + raise PatternDocumentError( + f"{path}.burst_count", "must be in 1..4096" + ) + if self.loop_count is not None: + loops = _integer(self.loop_count, f"{path}.loop_count") + if not 1 <= loops <= 1_000_000: + raise PatternDocumentError( + f"{path}.loop_count", "must be null or in 1..1000000" + ) + + @classmethod + def from_dict(cls, value: Any) -> "ScheduleSpec": + data = _object(value, "schedule") + _known( + data, + {"delay_frames", "interval_frames", "burst_count", "loop_count"}, + "schedule", + ) + spec = cls(**data) + spec.validate() + return spec + + +@dataclass(frozen=True) +class MotionSpec: + speed: float = 2.0 + friction: float = 0.0 + spin: float = 0.0 + time_scale: float = 1.0 + max_lifetime: float = 0.0 + render_scale: float = 1.0 + bounce_x: bool = False + bounce_y: bool = False + + def validate(self, path: str = "motion") -> None: + speed = _finite(self.speed, f"{path}.speed") + friction = _finite(self.friction, f"{path}.friction") + _finite(self.spin, f"{path}.spin") + time_scale = _finite(self.time_scale, f"{path}.time_scale") + lifetime = _finite(self.max_lifetime, f"{path}.max_lifetime") + render_scale = _finite(self.render_scale, f"{path}.render_scale") + _boolean(self.bounce_x, f"{path}.bounce_x") + _boolean(self.bounce_y, f"{path}.bounce_y") + if speed < 0: + raise PatternDocumentError(f"{path}.speed", "must be non-negative") + if friction < 0: + raise PatternDocumentError(f"{path}.friction", "must be non-negative") + if time_scale < 0: + raise PatternDocumentError(f"{path}.time_scale", "must be non-negative") + if lifetime < 0: + raise PatternDocumentError( + f"{path}.max_lifetime", "must be non-negative" + ) + if render_scale <= 0: + raise PatternDocumentError(f"{path}.render_scale", "must be positive") + + @classmethod + def from_dict(cls, value: Any) -> "MotionSpec": + data = _object(value, "motion") + _known( + data, + { + "speed", + "friction", + "spin", + "time_scale", + "max_lifetime", + "render_scale", + "bounce_x", + "bounce_y", + }, + "motion", + ) + spec = cls(**data) + spec.validate() + return spec + + +@dataclass(frozen=True) +class ModifierSpec: + angle_offset_per_burst: float = 0.0 + speed_offset_per_burst: float = 0.0 + random_speed_variation: float = 0.0 + + def validate(self, path: str = "modifiers") -> None: + _finite(self.angle_offset_per_burst, f"{path}.angle_offset_per_burst") + _finite(self.speed_offset_per_burst, f"{path}.speed_offset_per_burst") + variation = _finite( + self.random_speed_variation, + f"{path}.random_speed_variation", + ) + if not 0 <= variation <= 1: + raise PatternDocumentError( + f"{path}.random_speed_variation", "must be in 0..1" + ) + + @classmethod + def from_dict(cls, value: Any) -> "ModifierSpec": + data = _object(value, "modifiers") + _known( + data, + { + "angle_offset_per_burst", + "speed_offset_per_burst", + "random_speed_variation", + }, + "modifiers", + ) + spec = cls(**data) + spec.validate() + return spec + + +@dataclass +class PatternDocument: + header: ResourceHeader + bullet: BulletSpec + shape: ShapeSpec + aim: AimSpec + schedule: ScheduleSpec + motion: MotionSpec + modifiers: ModifierSpec + seed: int = 0 + + @property + def id(self) -> str: + return self.header.id + + @property + def name(self) -> str: + return self.header.name + + @property + def symbol_name(self) -> str | None: + return self.header.symbol_name + + @property + def schema_version(self) -> int: + return self.header.schema_version + + @property + def type(self) -> str: + return self.header.type + + def validate(self) -> None: + try: + self.header.validate( + expected_type=PATTERN_RESOURCE_TYPE, + current_version=RESOURCE_SCHEMA_VERSION, + ) + except ResourceDocumentError as exc: + if isinstance(exc, PatternDocumentError): + raise + raise PatternDocumentError("header", str(exc)) from exc + self.bullet.validate() + self.shape.validate() + self.aim.validate() + self.schedule.validate() + self.motion.validate() + self.modifiers.validate() + seed = _integer(self.seed, "seed") + if not 0 <= seed <= 0x7FFF_FFFF_FFFF_FFFF: + raise PatternDocumentError("seed", "must be in 0..2^63-1") + + def to_dict(self) -> dict[str, Any]: + self.validate() + return { + **self.header.to_dict(), + "seed": self.seed, + "bullet": asdict(self.bullet), + "shape": asdict(self.shape), + "aim": asdict(self.aim), + "schedule": asdict(self.schedule), + "motion": asdict(self.motion), + "modifiers": asdict(self.modifiers), + } + + @classmethod + def new( + cls, + name: str = "New Pattern", + *, + symbol_name: str | None = None, + ) -> "PatternDocument": + document = cls( + header=ResourceHeader( + type=PATTERN_RESOURCE_TYPE, + name=name, + symbol_name=symbol_name, + ), + bullet=BulletSpec(), + shape=ShapeSpec(), + aim=AimSpec(), + schedule=ScheduleSpec(), + motion=MotionSpec(), + modifiers=ModifierSpec(), + ) + document.validate() + return document + + @classmethod + def from_dict(cls, value: Any) -> "PatternDocument": + data = _object(value, "pattern") + _known( + data, + { + "schema_version", + "type", + "id", + "name", + "symbol_name", + "metadata", + "seed", + "bullet", + "shape", + "aim", + "schedule", + "motion", + "modifiers", + }, + "pattern", + ) + try: + header = ResourceHeader.from_dict( + data, + expected_type=PATTERN_RESOURCE_TYPE, + current_version=RESOURCE_SCHEMA_VERSION, + ) + except ResourceDocumentError as exc: + raise PatternDocumentError("header", str(exc)) from exc + required = ("bullet", "shape", "aim", "schedule", "motion", "modifiers") + missing = [field for field in required if field not in data] + if missing: + raise PatternDocumentError( + "pattern", + "missing sections: " + ", ".join(missing), + ) + document = cls( + header=header, + bullet=BulletSpec.from_dict(data["bullet"]), + shape=ShapeSpec.from_dict(data["shape"]), + aim=AimSpec.from_dict(data["aim"]), + schedule=ScheduleSpec.from_dict(data["schedule"]), + motion=MotionSpec.from_dict(data["motion"]), + modifiers=ModifierSpec.from_dict(data["modifiers"]), + seed=data.get("seed", 0), + ) + document.validate() + return document + + @classmethod + def from_pattern_spec( + cls, + value: Any, + *, + display_name: str | None = None, + ) -> "PatternDocument": + """Import the development ``PatternSpec`` without Python codegen.""" + + if is_dataclass(value): + data = asdict(value) + elif isinstance(value, Mapping): + data = dict(value) + else: + data = dict(vars(value)) + legacy_name = _text(data.get("name", "LabPattern"), "PatternSpec.name") + shape_kind = _text(data.get("pattern", "ring"), "PatternSpec.pattern") + document = cls( + header=ResourceHeader( + type=PATTERN_RESOURCE_TYPE, + name=display_name or legacy_name, + symbol_name=legacy_name, + metadata={"imported_from": "PatternSpec"}, + ), + bullet=BulletSpec( + bullet_type=data.get("bullet_type", "ball_m"), + color=data.get("color", "red"), + ), + shape=ShapeSpec( + kind=shape_kind, + count=data.get("count", 24), + origin_x=data.get("x", 0.0), + origin_y=data.get("y", 0.65), + angle_span=data.get("angle_span", 360.0), + ), + aim=AimSpec(mode="fixed", angle=data.get("start_angle", 270.0)), + schedule=ScheduleSpec( + delay_frames=0, + interval_frames=data.get("interval", 20), + burst_count=data.get("bursts", 1), + loop_count=None, + ), + motion=MotionSpec( + speed=data.get("speed", 2.0), + spin=data.get("spin", 0.0), + ), + modifiers=ModifierSpec( + angle_offset_per_burst=data.get("angle_offset_per_burst", 0.0), + ), + seed=data.get("seed", 0), + ) + document.validate() + return document diff --git a/src/pattern/ir.py b/src/pattern/ir.py new file mode 100644 index 00000000..2ee1db30 --- /dev/null +++ b/src/pattern/ir.py @@ -0,0 +1,62 @@ +"""Immutable intermediate representation consumed by the pattern runtime.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BurstTemplate: + """Precomputed per-bullet values for one burst within a schedule loop.""" + + position_offsets: tuple[tuple[float, float], ...] + angle_offsets: tuple[float, ...] + speeds: tuple[float, ...] + + @property + def count(self) -> int: + return len(self.angle_offsets) + + @property + def angles(self) -> tuple[float, ...]: + return self.angle_offsets + + +@dataclass(frozen=True) +class PatternProgram: + """Compiled, immutable pattern data with no authoring-model dependency.""" + + resource_id: str + schema_version: int + content_hash: str + name: str + seed: int + origin: tuple[float, float] + aim_mode: str + aim_angle: float + delay_frames: int + interval_frames: int + burst_count: int + loop_count: int | None + bullet_type: str + color: str + resource_uri: str | None + sprite_id: str + sprite_index: int + friction: float + spin: float + time_scale: float + max_lifetime: float + render_scale: float + bounce_x: bool + bounce_y: bool + templates: tuple[BurstTemplate, ...] + + @property + def total_emissions(self) -> int | None: + if self.loop_count is None: + return None + return self.burst_count * self.loop_count + + def template_for_emission(self, emission_index: int) -> BurstTemplate: + return self.templates[emission_index % self.burst_count] diff --git a/src/pattern/runtime.py b/src/pattern/runtime.py new file mode 100644 index 00000000..802fba68 --- /dev/null +++ b/src/pattern/runtime.py @@ -0,0 +1,227 @@ +"""Fixed-tick execution of immutable PatternProgram objects.""" + +from __future__ import annotations + +import math +import itertools +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from .ir import PatternProgram + + +class PatternRunnerState(str, Enum): + STOPPED = "stopped" + RUNNING = "running" + PAUSED = "paused" + FINISHED = "finished" + ERROR = "error" + + +class PatternRuntimeError(RuntimeError): + def __init__(self, resource_id: str, frame: int, message: str): + self.resource_id = resource_id + self.frame = frame + self.path = "runtime" + self.detail = message + super().__init__(f"{resource_id} at frame {frame}: {message}") + + +@dataclass(frozen=True) +class PatternSpawnEvent: + frame: int + burst_index: int + loop_index: int + owner_tag: int + positions: tuple[tuple[float, float], ...] + angles: tuple[float, ...] + speeds: tuple[float, ...] + indices: tuple[int, ...] + + @property + def requested_count(self) -> int: + return len(self.angles) + + @property + def spawned_count(self) -> int: + return len(self.indices) + + +@dataclass(frozen=True) +class PatternTickResult: + frame: int + state: PatternRunnerState + event: PatternSpawnEvent | None = None + + @property + def spawned_count(self) -> int: + return self.event.spawned_count if self.event is not None else 0 + + +_OWNER_TAGS = itertools.count(100_000) + + +def _next_owner_tag() -> int: + """Allocate a process-unique tag in the author-owned positive namespace.""" + tag = next(_OWNER_TAGS) + if tag > 2_147_483_647: + raise RuntimeError("pattern owner tag namespace exhausted") + return tag + + +class PatternRunner: + """One deterministic runtime instance of a compiled pattern. + + The runner schedules bursts only. Bullet motion remains in the formal + NumPy/Numba pool and no per-bullet Python update callbacks are installed. + """ + + def __init__( + self, + program: PatternProgram, + *, + instance_id: str | None = None, + owner_tag: int | None = None, + ) -> None: + self.program = program + self.instance_id = instance_id or str(uuid.uuid4()) + self.owner_tag = int(owner_tag) if owner_tag is not None else _next_owner_tag() + if self.owner_tag < 100: + raise ValueError("owner_tag must be at least 100 (0..99 are engine-reserved)") + self.state = PatternRunnerState.STOPPED + self.frame = 0 + self.emission_count = 0 + self.last_event: PatternSpawnEvent | None = None + self.last_error: PatternRuntimeError | None = None + + def start( + self, + context: Any | None = None, + *, + reset: bool = True, + clear_owned: bool = True, + ) -> None: + if reset: + self.reset(context, clear_owned=clear_owned) + self.state = PatternRunnerState.RUNNING + + def pause(self) -> None: + if self.state == PatternRunnerState.RUNNING: + self.state = PatternRunnerState.PAUSED + + def resume(self) -> None: + if self.state == PatternRunnerState.PAUSED: + self.state = PatternRunnerState.RUNNING + + def reset(self, context: Any | None = None, *, clear_owned: bool = True) -> None: + if clear_owned and context is not None: + self.clear_owned(context) + self.state = PatternRunnerState.STOPPED + self.frame = 0 + self.emission_count = 0 + self.last_event = None + self.last_error = None + + def stop(self, context: Any | None = None, *, clear_owned: bool = True) -> None: + self.reset(context, clear_owned=clear_owned) + + def clear_owned(self, context: Any) -> None: + context.clear_bullets_by_tag(self.owner_tag) + + def set_owned_time_scale(self, context: Any, scale: float) -> None: + if not math.isfinite(scale) or scale < 0: + raise ValueError("time scale must be finite and non-negative") + context.set_time_scale(scale, tag=self.owner_tag) + + def translate_owned(self, context: Any, dx: float, dy: float) -> int: + if not math.isfinite(dx) or not math.isfinite(dy): + raise ValueError("translation must be finite") + return int(context.translate_bullets_by_tag(self.owner_tag, dx, dy)) + + def tick(self, context: Any) -> PatternTickResult: + current_frame = self.frame + if self.state != PatternRunnerState.RUNNING: + return PatternTickResult(current_frame, self.state) + + event = None + if self._emission_due(current_frame): + try: + event = self._spawn(context, current_frame) + except Exception as exc: + error = PatternRuntimeError( + self.program.resource_id, + current_frame, + str(exc), + ) + self.last_error = error + self.state = PatternRunnerState.ERROR + raise error from exc + self.last_event = event + self.emission_count += 1 + total = self.program.total_emissions + if total is not None and self.emission_count >= total: + self.state = PatternRunnerState.FINISHED + + self.frame += 1 + return PatternTickResult(current_frame, self.state, event) + + def advance(self, context: Any, frames: int) -> tuple[PatternTickResult, ...]: + if isinstance(frames, bool) or not isinstance(frames, int) or frames < 0: + raise ValueError("frames must be a non-negative integer") + return tuple(self.tick(context) for _ in range(frames)) + + def _emission_due(self, frame: int) -> bool: + total = self.program.total_emissions + if total is not None and self.emission_count >= total: + return False + if frame < self.program.delay_frames: + return False + return (frame - self.program.delay_frames) % self.program.interval_frames == 0 + + def _spawn(self, context: Any, frame: int) -> PatternSpawnEvent: + burst_index = self.emission_count % self.program.burst_count + loop_index = self.emission_count // self.program.burst_count + template = self.program.template_for_emission(self.emission_count) + origin_x, origin_y = self.program.origin + base_angle = self.program.aim_angle + if self.program.aim_mode == "player": + player = context.get_player() + if player is None: + raise RuntimeError("player aim requires an active player") + base_angle = math.degrees( + math.atan2(player.y - origin_y, player.x - origin_x) + ) + positions = tuple( + (origin_x + x, origin_y + y) + for x, y in template.position_offsets + ) + angles = tuple(base_angle + value for value in template.angle_offsets) + indices = context.create_bullets_batch( + positions=positions, + angles=angles, + speeds=template.speeds, + bullet_type=self.program.bullet_type, + color=self.program.color, + sprite_id=self.program.sprite_id or None, + sprite_idx=self.program.sprite_index, + tag=self.owner_tag, + friction=self.program.friction, + time_scale=self.program.time_scale, + spin=self.program.spin, + max_lifetime=self.program.max_lifetime, + render_scale=self.program.render_scale, + bounce_x=self.program.bounce_x, + bounce_y=self.program.bounce_y, + ) + return PatternSpawnEvent( + frame=frame, + burst_index=burst_index, + loop_index=loop_index, + owner_tag=self.owner_tag, + positions=positions, + angles=angles, + speeds=template.speeds, + indices=tuple(int(index) for index in indices), + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..00206964 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Shared test-process fixtures and native runtime lifetime guards.""" + +from __future__ import annotations + +import os + +import pytest + + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt5.QtWidgets import QApplication + + +# PyQt permits only one QApplication per process. Keeping the wrapper alive +# for the entire pytest session prevents individual test-local references from +# destroying and recreating the native application between editor tests, which +# can otherwise terminate Windows with 0xC0000409 during Qt teardown. +_SESSION_QT_APP = QApplication.instance() or QApplication([]) +_SESSION_QT_APP.setQuitOnLastWindowClosed(False) + + +@pytest.fixture(scope="session") +def qapp_session(): + return _SESSION_QT_APP diff --git a/tests/test_authoring_resources.py b/tests/test_authoring_resources.py index 468c8c66..b8f64ff5 100644 --- a/tests/test_authoring_resources.py +++ b/tests/test_authoring_resources.py @@ -17,6 +17,7 @@ build_default_resource_type_registry, ) from src.core.project_context import ProjectContext +from src.pattern import PatternDocument def test_all_initial_resource_types_round_trip_atomically(tmp_path): @@ -31,6 +32,12 @@ def test_all_initial_resource_types_round_trip_atomically(tmp_path): ), body={"objects": [{"id": str(__import__("uuid").uuid4()), "value": index}]}, ) + if resource_type == "pystg.pattern": + document = PatternDocument.new( + name=document.name, + symbol_name=f"resource_{index}", + ) + document.header.metadata = {"author": "typed-test"} path = store.save(document, f"assets/resources/{index}.pystg.json") loaded = store.load(path) diff --git a/tests/test_devtools_pattern_lab.py b/tests/test_devtools_pattern_lab.py index 75f7a08c..95e89a9a 100644 --- a/tests/test_devtools_pattern_lab.py +++ b/tests/test_devtools_pattern_lab.py @@ -1,7 +1,7 @@ import pytest from src.devtools.pattern_lab import PatternSpec, export_spellcard, simulate_burst -from src.devtools.pattern_runtime import preview_positions, spawn_pattern_burst +from src.devtools.pattern_runtime import PatternPlayback, preview_positions, spawn_pattern_burst def test_pattern_lab_export_matches_spellcard_api_names(): @@ -58,3 +58,26 @@ def test_native_pattern_runtime_supports_multiple_modes(): assert len(ring) == len(spiral) == len(flower) == 4 assert spiral != ring assert flower != ring + + +def test_pattern_playback_uses_formal_batch_runner_not_per_bullet_calls(): + calls = [] + + class FakeContext: + def create_bullets_batch(self, **kwargs): + calls.append(kwargs) + return list(range(len(kwargs["angles"]))) + + def create_bullet(self, **kwargs): + raise AssertionError("formal playback must not use per-bullet spawning") + + playback = PatternPlayback( + PatternSpec(pattern="ring", count=4, interval=2, bursts=2), + owner_tag=4242, + ) + + assert playback.update(FakeContext()) == 4 + assert playback.update(FakeContext()) == 0 + assert playback.update(FakeContext()) == 4 + assert len(calls) == 2 + assert playback.runner.program is playback.program diff --git a/tests/test_editor_asset_index.py b/tests/test_editor_asset_index.py index 0a83344c..80e832e7 100644 --- a/tests/test_editor_asset_index.py +++ b/tests/test_editor_asset_index.py @@ -7,6 +7,7 @@ classify_file, load_subresource_preview, ) +from src.pattern import PatternDocument def _write_json(path: Path, value) -> None: @@ -124,12 +125,7 @@ def test_asset_index_reports_invalid_typed_resource_without_aborting(tmp_path): assets.mkdir() _write_json( assets / "valid.pystg.json", - { - "schema_version": 1, - "type": "pystg.pattern", - "id": "08ac589e-a51a-45dc-beb9-7af6f4e136db", - "name": "Valid", - }, + PatternDocument.new("Valid").to_dict(), ) _write_json( assets / "invalid.pystg.json", diff --git a/tests/test_pattern_compiler.py b/tests/test_pattern_compiler.py new file mode 100644 index 00000000..eeaddf83 --- /dev/null +++ b/tests/test_pattern_compiler.py @@ -0,0 +1,169 @@ +from dataclasses import FrozenInstanceError, replace +import json + +import pytest + +from src.core.project_context import ProjectContext +from src.devtools.pattern_lab import PatternSpec, bullet_parameters +from src.pattern import ( + BulletSpec, + PatternCompileError, + PatternCompiler, + PatternDocument, +) + + +def _from_spec(**changes): + spec = PatternSpec(**changes) + return spec, PatternDocument.from_pattern_spec(spec) + + +@pytest.mark.parametrize("mode", ["ring", "arc", "spiral", "flower"]) +def test_compiled_templates_match_pattern_lab_parameters(mode): + spec, document = _from_spec( + name=f"{mode.title()}Parity", + pattern=mode, + count=7, + bursts=3, + angle_span=135.0, + start_angle=241.0, + angle_offset_per_burst=11.25, + speed=2.75, + ) + program = PatternCompiler().compile(document) + + for burst_index in range(spec.bursts): + template = program.templates[burst_index] + actual = list(zip( + (program.aim_angle + angle for angle in template.angle_offsets), + template.speeds, + )) + expected = bullet_parameters(spec, burst_index) + assert [angle for angle, _ in actual] == pytest.approx( + [angle for angle, _ in expected], abs=1e-6 + ) + assert [speed for _, speed in actual] == pytest.approx( + [speed for _, speed in expected], abs=1e-6 + ) + + +def test_compiler_returns_cached_immutable_program(): + document = PatternDocument.new("Cached") + compiler = PatternCompiler() + + first = compiler.compile(document) + second = compiler.compile(PatternDocument.from_dict(document.to_dict())) + + assert first is second + with pytest.raises(FrozenInstanceError): + first.name = "mutated" + + +def test_line_shape_precomputes_offsets_and_motion_direction(): + document = PatternDocument.new() + document.shape = replace( + document.shape, + kind="line", + count=3, + line_length=2.0, + line_angle=90.0, + ) + template = PatternCompiler().compile(document).templates[0] + + assert [value for point in template.position_offsets for value in point] == pytest.approx( + [0.0, -1.0, 0.0, 0.0, 0.0, 1.0] + ) + assert template.angle_offsets == (0.0, 0.0, 0.0) + + +def test_random_shape_is_seed_deterministic(): + first = PatternDocument.new() + first.shape = replace(first.shape, kind="random", count=16, angle_span=80.0) + first.schedule = replace(first.schedule, burst_count=2) + first.modifiers = replace(first.modifiers, random_speed_variation=0.4) + first.seed = 1234 + second = PatternDocument.from_dict(first.to_dict()) + second.header.id = PatternDocument.new().id + third = PatternDocument.from_dict(first.to_dict()) + third.header.id = PatternDocument.new().id + third.seed = 1235 + + compiler = PatternCompiler() + assert compiler.compile(first).templates == compiler.compile(second).templates + assert compiler.compile(first).templates != compiler.compile(third).templates + + +def test_direct_sprite_resource_and_sprite_index_are_resolved(tmp_path): + atlas = tmp_path / "assets" / "atlas.json" + atlas.parent.mkdir(parents=True) + atlas.write_text(json.dumps({"sprites": {"orb": {"rect": [0, 0, 8, 8]}}}), encoding="utf-8") + document = PatternDocument.new() + document.bullet = BulletSpec(resource="res://assets/atlas.json#orb") + + program = PatternCompiler().compile( + document, + project=ProjectContext(tmp_path), + sprite_index_resolver=lambda sprite_id: 17 if sprite_id == "orb" else -1, + ) + + assert program.sprite_id == "orb" + assert program.sprite_index == 17 + + +def test_broken_resource_diagnostic_names_resource_and_property(tmp_path): + document = PatternDocument.new() + document.bullet = BulletSpec(resource="res://assets/missing.json#orb") + + with pytest.raises(PatternCompileError) as caught: + PatternCompiler().compile(document, project=ProjectContext(tmp_path)) + + diagnostic = caught.value.diagnostics[0] + assert diagnostic.resource_id == document.id + assert diagnostic.path == "bullet.resource" + assert diagnostic.code == "missing_resource" + + +def test_missing_sprite_fragment_is_actionable(tmp_path): + atlas = tmp_path / "atlas.json" + atlas.write_text(json.dumps({"sprites": {"other": {}}}), encoding="utf-8") + document = PatternDocument.new() + document.bullet = BulletSpec(resource="res://atlas.json#orb") + + with pytest.raises(PatternCompileError) as caught: + PatternCompiler().compile(document, project=ProjectContext(tmp_path)) + + assert caught.value.diagnostics[0].code == "missing_sprite_subresource" + assert "orb" in caught.value.diagnostics[0].message + + +def test_alias_dependency_content_participates_in_cache_key(tmp_path): + aliases = tmp_path / "assets" / "bullet_aliases.json" + aliases.parent.mkdir(parents=True) + aliases.write_text(json.dumps({"mapping": {"ball_m": {"red": "orb_a"}}}), encoding="utf-8") + project = ProjectContext(tmp_path) + compiler = PatternCompiler() + document = PatternDocument.new() + + first = compiler.compile(document, project=project) + aliases.write_text(json.dumps({"mapping": {"ball_m": {"red": "orb_b"}}}), encoding="utf-8") + second = compiler.compile(document, project=project) + + assert first.sprite_id == "orb_a" + assert second.sprite_id == "orb_b" + assert first.content_hash != second.content_hash + + +def test_unknown_alias_reports_structured_bullet_path(tmp_path): + aliases = tmp_path / "assets" / "bullet_aliases.json" + aliases.parent.mkdir(parents=True) + aliases.write_text(json.dumps({"mapping": {"ball_m": {"blue": "orb"}}}), encoding="utf-8") + document = PatternDocument.new() + + with pytest.raises(PatternCompileError) as caught: + PatternCompiler().compile(document, project=ProjectContext(tmp_path)) + + diagnostic = caught.value.diagnostics[0] + assert diagnostic.code == "unknown_bullet_alias" + assert diagnostic.resource_id == document.id + assert diagnostic.path == "bullet" + assert "ball_m/red" in diagnostic.message diff --git a/tests/test_pattern_document.py b/tests/test_pattern_document.py new file mode 100644 index 00000000..7a8ad93b --- /dev/null +++ b/tests/test_pattern_document.py @@ -0,0 +1,99 @@ +import json +from dataclasses import replace +from pathlib import Path + +import jsonschema +import pytest + +from src.authoring import ResourceStore, build_default_resource_type_registry +from src.core.project_context import ProjectContext +from src.devtools.pattern_lab import PatternSpec +from src.pattern import ( + PatternCompileError, + PatternCompiler, + PatternDocument, + PatternDocumentError, + PatternProgram, + ScheduleSpec, +) + + +def test_pattern_document_round_trip_preserves_unicode_display_name(): + document = PatternDocument.new("星符『星轨回廊』", symbol_name="StarCorridor") + + loaded = PatternDocument.from_dict(document.to_dict()) + + assert loaded.to_dict() == document.to_dict() + assert loaded.name == "星符『星轨回廊』" + assert loaded.symbol_name == "StarCorridor" + + +def test_pattern_document_rejects_unknown_fields_and_path_is_actionable(): + payload = PatternDocument.new().to_dict() + payload["shape"]["mystery"] = 1 + + with pytest.raises(PatternDocumentError) as caught: + PatternDocument.from_dict(payload) + + assert caught.value.path == "shape" + assert "mystery" in caught.value.detail + + +def test_pattern_document_matches_published_draft_2020_schema(): + schema_path = Path("docs/schemas/pystg-pattern-v1.schema.json") + schema = json.loads(schema_path.read_text(encoding="utf-8")) + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.validate(PatternDocument.new().to_dict(), schema) + + +def test_pattern_spec_import_preserves_prototype_schedule_and_identity(): + spec = PatternSpec( + name="LegacySpiral", + pattern="spiral", + count=9, + bursts=4, + interval=7, + angle_offset_per_burst=12.5, + ) + + document = PatternDocument.from_pattern_spec(spec, display_name="旧版螺旋") + + assert document.name == "旧版螺旋" + assert document.symbol_name == "LegacySpiral" + assert document.schedule == ScheduleSpec( + delay_frames=0, + interval_frames=7, + burst_count=4, + loop_count=None, + ) + assert document.header.metadata["imported_from"] == "PatternSpec" + + +def test_resource_store_loads_patterns_as_typed_documents(tmp_path): + project = ProjectContext(tmp_path) + store = ResourceStore(project) + document = PatternDocument.new("Typed") + + path = store.save(document, "patterns/typed.pystg.json") + loaded = store.load(path) + + assert isinstance(loaded, PatternDocument) + assert loaded.to_dict() == document.to_dict() + + +def test_default_resource_registry_exposes_formal_pattern_compiler(): + registry = build_default_resource_type_registry() + contribution = registry["pystg.pattern"] + + program = contribution.compiler(PatternDocument.new("Registry Compile")) + + assert isinstance(program, PatternProgram) + + +def test_compile_template_budget_rejects_pathological_documents(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=4096) + document.schedule = replace(document.schedule, burst_count=4096) + + with pytest.raises(PatternCompileError, match="precompute"): + PatternCompiler().compile(document) diff --git a/tests/test_pattern_parity.py b/tests/test_pattern_parity.py new file mode 100644 index 00000000..4b645f1e --- /dev/null +++ b/tests/test_pattern_parity.py @@ -0,0 +1,115 @@ +from dataclasses import replace + +import numpy as np + +from src.authoring import ResourceStore +from src.core.project_context import ProjectContext +from src.devtools.pattern_lab import PatternSpec, bullet_parameters +from src.devtools.pattern_runtime import PatternPlayback +from src.game.bullet.optimized_pool import OptimizedBulletPool +from src.game.stage.context import StageContext +from src.pattern import PatternCompiler, PatternDocument, PatternRunner + + +class DummyPlayer: + pos = [0.1, -0.7] + + +def _run_trace(program): + pool = OptimizedBulletPool(max_bullets=128) + context = StageContext(pool, DummyPlayer()) + runner = PatternRunner(program, owner_tag=4242) + runner.start(context) + trace = [] + while runner.state.value == "running": + result = runner.tick(context) + if result.event: + indices = np.asarray(result.event.indices, dtype=np.intp) + trace.append(( + result.event.frame, + pool.data["pos"][indices].copy(), + pool.data["vel"][indices].copy(), + pool.data["tag"][indices].copy(), + )) + return trace + + +def test_same_document_and_seed_produce_identical_formal_traces(): + document = PatternDocument.new() + document.shape = replace(document.shape, kind="random", count=12) + document.schedule = replace(document.schedule, interval_frames=2, burst_count=3) + document.modifiers = replace(document.modifiers, random_speed_variation=0.3) + document.seed = 987654 + compiler = PatternCompiler() + + first = _run_trace(compiler.compile(document)) + second = _run_trace(compiler.compile(PatternDocument.from_dict(document.to_dict()))) + + assert len(first) == len(second) + for left, right in zip(first, second): + assert left[0] == right[0] + for left_array, right_array in zip(left[1:], right[1:]): + assert np.array_equal(left_array, right_array) + + +def test_preview_and_game_contexts_consume_the_same_program_and_trace(): + spec = PatternSpec( + name="FormalParity", + pattern="spiral", + count=8, + bursts=3, + interval=1, + angle_offset_per_burst=9.0, + ) + preview_pool = OptimizedBulletPool(max_bullets=64) + preview_context = StageContext(preview_pool, DummyPlayer()) + preview = PatternPlayback(spec, owner_tag=4242) + game_pool = OptimizedBulletPool(max_bullets=64) + game_context = StageContext(game_pool, DummyPlayer()) + game = PatternRunner(preview.program, owner_tag=4242) + game.start(game_context) + preview_trace = [] + game_trace = [] + for _ in range(3): + assert preview.update(preview_context) == spec.count + game_result = game.tick(game_context) + preview_event = preview.runner.last_event + game_event = game_result.event + preview_indices = np.asarray(preview_event.indices, dtype=np.intp) + game_indices = np.asarray(game_event.indices, dtype=np.intp) + preview_trace.append(( + preview_event.frame, + preview_pool.data["pos"][preview_indices].copy(), + preview_pool.data["vel"][preview_indices].copy(), + )) + game_trace.append(( + game_event.frame, + game_pool.data["pos"][game_indices].copy(), + game_pool.data["vel"][game_indices].copy(), + )) + + assert game.program is preview.program + assert [item[0] for item in preview_trace] == [0, 1, 2] + assert bullet_parameters(spec, 2) == list(zip( + ( + preview.program.aim_angle + value + for value in preview.program.templates[2].angle_offsets + ), + preview.program.templates[2].speeds, + )) + for preview, game in zip(preview_trace, game_trace): + assert all(np.array_equal(a, b) for a, b in zip(preview[1:], game[1:])) + + +def test_saved_document_loads_compiles_and_executes_without_python_codegen(tmp_path): + document = PatternDocument.new("No Codegen") + store = ResourceStore(ProjectContext(tmp_path)) + path = store.save(document, "patterns/no-codegen.pystg.json") + loaded = store.load(path) + program = store.registry["pystg.pattern"].compiler(loaded) + + trace = _run_trace(program) + + assert len(trace) == 1 + assert len(trace[0][1]) == document.shape.count + assert not list(tmp_path.rglob("*.py")) diff --git a/tests/test_pattern_runtime.py b/tests/test_pattern_runtime.py new file mode 100644 index 00000000..166df8d0 --- /dev/null +++ b/tests/test_pattern_runtime.py @@ -0,0 +1,220 @@ +from dataclasses import replace +import math + +import numpy as np +import pytest + +from src.game.bullet.optimized_pool import OptimizedBulletPool +from src.game.stage.context import StageContext +from src.pattern import ( + AimSpec, + PatternCompiler, + PatternDocument, + PatternRunner, + PatternRunnerState, + PatternRuntimeError, + MotionSpec, +) + + +class DummyPlayer: + def __init__(self, x=0.0, y=-0.8): + self.pos = [x, y] + + +def _runtime(document, capacity=128): + pool = OptimizedBulletPool(max_bullets=capacity) + context = StageContext(pool, DummyPlayer()) + runner = PatternRunner(PatternCompiler().compile(document), owner_tag=1001) + return pool, context, runner + + +def test_fixed_tick_schedule_delay_interval_bursts_and_loops(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=2) + document.schedule = replace( + document.schedule, + delay_frames=2, + interval_frames=3, + burst_count=2, + loop_count=2, + ) + pool, context, runner = _runtime(document) + runner.start(context) + + results = runner.advance(context, 12) + events = [result.event for result in results if result.event] + + assert [event.frame for event in events] == [2, 5, 8, 11] + assert [(event.loop_index, event.burst_index) for event in events] == [ + (0, 0), (0, 1), (1, 0), (1, 1) + ] + assert runner.state == PatternRunnerState.FINISHED + assert np.count_nonzero(pool.data["alive"]) == 8 + + +def test_runner_pause_resume_reset_and_stop_lifecycle(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=1) + document.schedule = replace(document.schedule, loop_count=None) + _, context, runner = _runtime(document) + runner.start(context) + runner.tick(context) + runner.pause() + + paused_frame = runner.frame + assert runner.tick(context).state == PatternRunnerState.PAUSED + assert runner.frame == paused_frame + runner.resume() + runner.tick(context) + runner.reset(context) + assert runner.state == PatternRunnerState.STOPPED + assert runner.frame == runner.emission_count == 0 + runner.start(context) + runner.stop(context) + assert runner.state == PatternRunnerState.STOPPED + + +def test_reset_replays_the_same_random_trace_deterministically(): + document = PatternDocument.new() + document.shape = replace(document.shape, kind="random", count=6) + document.schedule = replace(document.schedule, burst_count=2, loop_count=1) + document.modifiers = replace(document.modifiers, random_speed_variation=0.25) + document.seed = 8128 + pool, context, runner = _runtime(document) + + def play_once(): + runner.start(context) + return [ + (result.event.positions, result.event.angles, result.event.speeds) + for result in runner.advance(context, 21) + if result.event is not None + ] + + first = play_once() + runner.reset(context) + second = play_once() + + assert first == second + + +def test_player_aim_and_fixed_aim_share_batch_runtime(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=1, origin_x=0.0, origin_y=0.0) + document.aim = AimSpec(mode="player", angle=99.0) + pool = OptimizedBulletPool(max_bullets=4) + context = StageContext(pool, DummyPlayer(1.0, 1.0)) + runner = PatternRunner(PatternCompiler().compile(document), owner_tag=1002) + runner.start(context) + + event = runner.tick(context).event + + assert event.angles == pytest.approx((45.0,)) + assert pool.data["angle"][event.indices[0]] == pytest.approx(math.pi / 4) + + +def test_owner_operations_are_isolated_and_data_oriented(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=3) + pool = OptimizedBulletPool(max_bullets=16) + context = StageContext(pool, DummyPlayer()) + first = PatternRunner(PatternCompiler().compile(document), owner_tag=2001) + second = PatternRunner(PatternCompiler().compile(document), owner_tag=2002) + first.start(context) + second.start(context) + first_event = first.tick(context).event + second_event = second.tick(context).event + second_positions = pool.data["pos"][list(second_event.indices)].copy() + + assert first.translate_owned(context, 0.25, -0.5) == 3 + first.set_owned_time_scale(context, 0.0) + assert np.all(pool.data["time_scale"][list(first_event.indices)] == 0.0) + assert np.all(pool.data["time_scale"][list(second_event.indices)] == 1.0) + assert np.array_equal(pool.data["pos"][list(second_event.indices)], second_positions) + first.clear_owned(context) + assert np.all(pool.data["alive"][list(first_event.indices)] == 0) + assert np.all(pool.data["alive"][list(second_event.indices)] == 1) + assert not pool.emitter_callbacks + assert not pool.death_handlers + + +def test_compiled_motion_is_written_to_data_fields_in_one_batch(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=4) + document.motion = MotionSpec( + speed=3.0, + friction=0.15, + spin=90.0, + time_scale=0.75, + max_lifetime=4.5, + render_scale=1.25, + bounce_x=True, + bounce_y=True, + ) + pool, context, runner = _runtime(document) + runner.start(context) + + event = runner.tick(context).event + indices = np.asarray(event.indices, dtype=np.intp) + + assert pool.batch_spawn_calls == 1 + assert np.all(pool.data["friction"][indices] == pytest.approx(0.15)) + assert np.all(pool.data["time_scale"][indices] == pytest.approx(0.75)) + assert np.all(pool.data["max_lifetime"][indices] == pytest.approx(4.5)) + assert np.all(pool.data["render_scale"][indices] == pytest.approx(1.25)) + assert np.all(pool.data["angular_vel"][indices] == pytest.approx(math.pi / 2)) + assert np.all((pool.data["flags"][indices] & 0x0001) != 0) + assert np.all((pool.data["flags"][indices] & 0x0002) != 0) + assert not pool.emitter_callbacks + assert not pool.death_handlers + + +def test_automatic_owner_tags_are_unique_and_engine_namespace_is_rejected(): + program = PatternCompiler().compile(PatternDocument.new()) + + first = PatternRunner(program) + second = PatternRunner(program) + + assert first.owner_tag != second.owner_tag + assert first.owner_tag >= 100 + with pytest.raises(ValueError, match="engine-reserved"): + PatternRunner(program, owner_tag=99) + + +def test_pool_capacity_returns_partial_spawn_without_callbacks(): + document = PatternDocument.new() + document.shape = replace(document.shape, count=5) + pool, context, runner = _runtime(document, capacity=3) + runner.start(context) + + event = runner.tick(context).event + + assert event.requested_count == 5 + assert event.spawned_count == 3 + assert len(pool.free_indices) == 0 + assert pool.batch_spawn_calls == 1 + assert not pool.emitter_callbacks + + +def test_runtime_failure_has_resource_frame_and_actionable_detail(): + document = PatternDocument.new() + document.aim = AimSpec(mode="player") + runner = PatternRunner(PatternCompiler().compile(document), owner_tag=3001) + + class MissingPlayerContext: + def clear_bullets_by_tag(self, tag): + return 0 + + def get_player(self): + return None + + context = MissingPlayerContext() + runner.start(context) + + with pytest.raises(PatternRuntimeError) as caught: + runner.tick(context) + + assert caught.value.resource_id == document.id + assert caught.value.frame == 0 + assert "active player" in caught.value.detail + assert runner.state == PatternRunnerState.ERROR diff --git a/tools/benchmark_pattern_runtime.py b/tools/benchmark_pattern_runtime.py new file mode 100644 index 00000000..caaa9ac0 --- /dev/null +++ b/tools/benchmark_pattern_runtime.py @@ -0,0 +1,71 @@ +"""Measure formal PatternRunner batch-spawn throughput for the M1 gate.""" + +from __future__ import annotations + +import argparse +from dataclasses import replace +import json +from pathlib import Path +import sys +from time import perf_counter + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.game.bullet.optimized_pool import OptimizedBulletPool +from src.game.stage.context import StageContext +from src.pattern import PatternCompiler, PatternDocument, PatternRunner + + +class _Player: + pos = [0.0, -0.8] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--count", type=int, default=512) + parser.add_argument("--bursts", type=int, default=100) + parser.add_argument("--pool-size", type=int, default=60000) + args = parser.parse_args() + + document = PatternDocument.new("M1 Dense Burst Benchmark") + document.shape = replace(document.shape, count=args.count) + document.schedule = replace( + document.schedule, + interval_frames=1, + burst_count=args.bursts, + loop_count=1, + ) + compile_start = perf_counter() + program = PatternCompiler().compile(document) + compile_seconds = perf_counter() - compile_start + + pool = OptimizedBulletPool(max_bullets=args.pool_size) + context = StageContext(pool, _Player()) + runner = PatternRunner(program, owner_tag=900001) + runner.start(context) + spawn_start = perf_counter() + results = runner.advance(context, args.bursts) + spawn_seconds = perf_counter() - spawn_start + spawned = sum(result.spawned_count for result in results) + payload = { + "count_per_burst": args.count, + "bursts": args.bursts, + "requested_bullets": args.count * args.bursts, + "spawned_bullets": spawned, + "pool_size": args.pool_size, + "compile_seconds": round(compile_seconds, 6), + "spawn_seconds": round(spawn_seconds, 6), + "bullets_per_second": round(spawned / spawn_seconds, 2) if spawn_seconds else None, + "batch_spawn_calls": pool.batch_spawn_calls, + "batch_api": pool.batch_spawn_calls == args.bursts, + "per_bullet_callbacks": len(pool.death_handlers) + len(pool.emitter_callbacks), + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if spawned == args.count * args.bursts else 1 + + +if __name__ == "__main__": + raise SystemExit(main())