Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c0886348 | ||
|
|
87a33b0abc | ||
|
|
35eb0e2503 | ||
|
|
0cc882ef4b | ||
|
|
e96ca22ef3 | ||
|
|
e75a4944bf | ||
|
|
44afaa3133 | ||
|
|
ce46b5d97e |
@@ -13,6 +13,10 @@ You are the Lead Systems Architect specializing in Godot 4 and idiomatic GDScrip
|
||||
3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows.
|
||||
4. **Log:** Append trade-offs to `docs/architecture/tech_debt_and_optimizations.md`.
|
||||
|
||||
## Feature worktree
|
||||
|
||||
Before writing a feature document, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the user has not chosen yet, ask the orchestrator to get that preference before writing. Use the supplied path whether it is a worktree or the current checkout; do not create a worktree or switch branches yourself. Follow `AGENTS.md`.
|
||||
|
||||
### Shared Stage inspector UI
|
||||
For an in-stage selected-object inspector, first read `docs/specs/stage_panel_ui_system.md`
|
||||
and inspect `scripts/shared/stage_ui/` plus the `stage_panels` theme tokens. Use the existing
|
||||
|
||||
@@ -32,6 +32,8 @@ Clothing currently has no standalone format. Author clothing as layered shapes i
|
||||
|
||||
## Review workflow
|
||||
|
||||
Before creating an asset review bundle, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before writing. Use the selected path whether it is a worktree or the current checkout; do not create a worktree or switch branches yourself. Follow `AGENTS.md`.
|
||||
|
||||
Create every unapproved deliverable under `content/review/artist/<request-slug>/`. Use descriptive, versioned filenames and never overwrite an existing production asset unless the user explicitly requests replacement.
|
||||
|
||||
Each review bundle must contain:
|
||||
|
||||
@@ -40,6 +40,7 @@ Wait for the user to answer before analyzing or proposing feature work.
|
||||
|
||||
## Editing limits
|
||||
|
||||
- **Worktree choice:** Before preparing an accepted phase brief or updating the backlog, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before writing. Use the supplied path whether it is a worktree or the current checkout. Do not create a worktree, switch branches, or write into another checkout yourself. Follow `AGENTS.md`.
|
||||
- You may edit only `docs/ideas/feature_backlog.md` and accepted phase briefs under `docs/plans/` during normal use. Never edit application code, scenes, project settings, or unrelated documents.
|
||||
- Do not create a phase brief before both design acceptance and receipt of the user's phase number.
|
||||
- Keep idea discussion conversational and concise. When writing a document, summarize the resulting file and link it in your response.
|
||||
|
||||
@@ -9,10 +9,12 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan
|
||||
|
||||
## Rules
|
||||
|
||||
- **Worktree choice:** Before persistent edits, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before editing. Use the supplied path whether it is a worktree or the current checkout. Do not create a worktree, switch branches, merge, or edit another checkout yourself. Follow `AGENTS.md` for the selected path.
|
||||
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
|
||||
- **Static Typing:** Enforce explicit type hints for all signatures and variables.
|
||||
- **Decoupling:** Follow "call down, signal up".
|
||||
- **Validation:** Resolve the Godot 4.7.1 binary for the current platform using `AGENTS.md`. Run syntax/import checks, relevant existing suites, and focused automated tests of the feature's observable outcomes. For runtime interactions, exercise a minimal scene or fixture through the real production path, advance frames or physics ticks, and assert the result. Fix failures and rerun the affected checks. Inspect rendered output when available; state clearly when visual behavior could not be checked.
|
||||
- **Windows visual verification:** The Windows screenshot service repeatedly times out on Project Manager/Godot windows. Do not retry it; use a focused Godot GUI run or harness that saves a viewport PNG, inspect the saved image locally, and assert the relevant interactions in the same focused verification workflow.
|
||||
|
||||
## Output Format
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
developer_instructions = """
|
||||
## Windows UI visual checks
|
||||
|
||||
The Windows screenshot service repeatedly times out when capturing Project Manager/Godot windows. Do not rely on or retry its live-window feed. When a visual implementation check is needed, direct the developer to run a focused Godot scene or harness that saves a viewport PNG and inspect that local image; pair it with interaction checks for the relevant controls.
|
||||
|
||||
## Worktree choice
|
||||
|
||||
You are read-only and must not create worktrees or write project files. For any handoff that will create project files, confirm that the orchestrator has asked the user whether to use a worktree and supplied the selected project path and branch. Do not require a worktree when the user chooses the current checkout.
|
||||
|
||||
## 0. Editor Visual Style Guide
|
||||
|
||||
Before making, mocking, reviewing, or suggesting any Prop Editor or Stickman Editor UI/UX
|
||||
change, read `docs/guides/editor_visual_style_guide.md`. Use its documented implemented
|
||||
patterns and shared theme ownership as the starting point, then check relevant feature specs
|
||||
and current controls for workflow-specific behavior. Clearly distinguish current behavior
|
||||
from recommendations. If a proposal intentionally departs from the guide, explain why and
|
||||
identify the departure in the proposal or spec. Follow explicit user direction when it differs.
|
||||
|
||||
## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
|
||||
|
||||
* **Shared Stage Inspector UI:** Before proposing an in-stage selected-object inspector, read
|
||||
|
||||
@@ -16,4 +16,5 @@ You are a technical writer for Godot 4 / GDScript projects. You document game ar
|
||||
|
||||
1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files.
|
||||
2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions.
|
||||
3. **Worktree Choice:** Before any persistent documentation work, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it first. Use the selected path whether it is a worktree or the current checkout; do not create a worktree yourself.
|
||||
"""
|
||||
@@ -7,6 +7,10 @@ description: "Create or revise Stickman Studio props, stickmen, embedded clothin
|
||||
|
||||
Use the `artist` subagent for project graphical-asset and animation work. This skill manages delegation, review, approval, and handoff; it does not replace the Artist's visual judgment.
|
||||
|
||||
## Choose a worktree before creating files
|
||||
|
||||
Before creating an asset review bundle or other project files, ask whether the user wants this task to use a Git worktree unless they have already stated a preference. For small, self-contained asset changes, offer the current checkout as the default. Wait for the answer before creating a worktree. Follow `AGENTS.md`, and pass Artist the absolute path and branch the user selected, whether that is a worktree or the current checkout. Do not require a worktree when the user declines one.
|
||||
|
||||
## Delegate to Artist
|
||||
|
||||
Spawn `artist` with `agent_type: "artist"`. Reuse the same Artist for revisions while it is available. Tell it that other agents may share the workspace and that it must preserve unrelated changes.
|
||||
|
||||
@@ -37,6 +37,8 @@ Treat the user's clear statement that they are ready to work on the discussed, s
|
||||
|
||||
## Stage 3: Write and hand off the brief
|
||||
|
||||
Once the user is ready and the phase number is known, have Designer create the accepted product/design brief at `docs/plans/phase_<number>_<feature_slug>.md`, following `docs/plans/README.md`. The brief should cover the goal and audience, desired child outcome, behavior/flow and feedback, story or directing value where relevant, success criteria, included and excluded scope, known risks/dependencies, project references, and remaining questions for the spec-pipeline. Keep architecture/API decisions for the architect's later technical specification in `docs/specs/`.
|
||||
Once the user is ready and the phase number is known, ask whether the brief and later feature work should use a Git worktree unless the user has already stated a preference for this task. Follow the choice and the `Feature branches and worktrees` rules in `AGENTS.md`; do not create a worktree before the user answers. Use the selected checkout consistently for the brief, later specs, implementation, and verification. Pass its absolute path and current branch to Designer. Do not switch branches or write outside the selected checkout.
|
||||
|
||||
Have Designer create the accepted product/design brief at `docs/plans/phase_<number>_<feature_slug>.md`, following `docs/plans/README.md`. The brief should cover the goal and audience, desired child outcome, behavior/flow and feedback, story or directing value where relevant, success criteria, included and excluded scope, known risks/dependencies, project references, and remaining questions for the spec-pipeline. Keep architecture/API decisions for the architect's later technical specification in `docs/specs/`.
|
||||
|
||||
Do not overwrite an existing phase brief without the user's direction. Confirm the resulting file exists, update the matching backlog entry to `Planned` with a link to the brief, then tell the user where the plan is using a clickable local file link. Stop there; this skill creates the design handoff, not the technical spec or implementation.
|
||||
|
||||
@@ -5,8 +5,17 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
|
||||
|
||||
## Execution Logic
|
||||
|
||||
### Feature branch and worktree setup
|
||||
|
||||
Before creating a worktree for a task, ask the user whether they want one and wait for the answer. For small, self-contained fixes or documentation changes, offer the current checkout as the default; for larger feature phases, explain the benefit of a dedicated worktree. Do not create a worktree before the user chooses. Keep the user's choice for the task from the first persistent artifact through implementation and verification.
|
||||
|
||||
Follow the `Feature branches and worktrees` rules in `AGENTS.md`: inspect `git status --short`, the current branch, and worktree list before the first persistent change. If the user chooses the current checkout, stay on its branch. If they choose a worktree, reuse a matching one or create `.worktrees/<feature-slug>` on `feature/<feature-slug>` from the integration branch, including the phase number when known. Do not switch branches, transfer uncommitted changes without direction, or merge automatically.
|
||||
|
||||
Pass every delegated agent the absolute selected project path and its branch, whether that path is a worktree or the current checkout. Tell each agent to use it for all repository reads, writes, Godot runs, and visual checks. Do not stop file-writing work solely because the user chose not to use a worktree.
|
||||
|
||||
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
|
||||
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
|
||||
- Before proposing, mocking, reviewing, or suggesting UI/UX changes, `@uiux` reads `docs/guides/editor_visual_style_guide.md` and checks the relevant implemented controls/specs. Treat the guide as the current visual baseline; distinguish implemented behavior from recommendations and explain intentional departures. This also applies to direct `@uiux` requests outside the pipeline.
|
||||
- For an in-stage selected-object inspector, `@uiux` first reviews `docs/specs/stage_panel_ui_system.md` and the current shared controls under `scripts/shared/stage_ui/`; proposals should build on that system unless the interaction needs justify a specific exception.
|
||||
- Follow the concept-count and visual-mockup protocol in `.codex/agents/uiux.toml`; it also applies when the user asks `@uiux` for ideas outside a formal pipeline run.
|
||||
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
|
||||
@@ -15,6 +24,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
|
||||
2. **Phase 0b: UI Spec Generation**
|
||||
- User approves an option or provides feedback.
|
||||
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic).
|
||||
- Carry forward the style guide's applicable patterns and token ownership. If the approved direction departs from them, record the reason and the specific difference in the spec.
|
||||
- For Stage inspector work, name which shared components and theme tokens the design uses or proposes to extend. Treat dialogs and other editor panels as separate workflows unless their interaction model fits the inspector kit.
|
||||
|
||||
3. **Phase 1a: Architecture Proposal & Clarification**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
.worktrees/
|
||||
.images/
|
||||
/android/
|
||||
addons/
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
|
||||
- **Main scene:** `res://scenes/main.tscn` (set as `run/main_scene` in `project.godot`; hosts the Stickman Editor)
|
||||
|
||||
## Feature branches and worktrees
|
||||
|
||||
- Before creating a Git worktree for a task, ask the user whether they want this task to use one. Do not create a worktree until they answer. For small, self-contained fixes and documentation changes, offer the current checkout as the default; for larger feature phases, explain why a worktree may help and let the user choose.
|
||||
- Read-only discussion and proposals do not need a worktree. Before the first persistent change, inspect `git status --short`, `git branch --show-current`, and `git worktree list`.
|
||||
- If the user chooses the current checkout, keep working on its current branch. Do not switch branches, stash, reset, clean, move, or overwrite unrelated work.
|
||||
- If the user chooses a worktree, reuse a matching one when present. Otherwise, create `.worktrees/<feature-slug>` with branch `feature/<feature-slug>`, based on the integration branch (usually `main`). Include the phase number in the slug when known, such as `phase-20-stage-search`. Example: `git worktree add .worktrees/phase-20-stage-search -b feature/phase-20-stage-search main`. Confirm the path and branch with `git worktree list` and `git branch --show-current`.
|
||||
- If relevant uncommitted changes need to be carried into a chosen worktree, ask how to handle them before proceeding.
|
||||
- Pass every subagent the absolute path and branch the user chose, whether that is a worktree or the current checkout. All project reads, writes, Godot commands, and visual checks must target that path. Do not require a worktree when the user chose the current checkout.
|
||||
- Do not merge feature branches automatically. Keep each feature branch available for review and merge only when the user asks or the active workflow explicitly authorizes it.
|
||||
- `.worktrees/` is a local worktree container and must remain gitignored.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `scripts/` and `scenes/` stay as separate roots with matching feature groups. Editor controllers live in `scripts/editors/{stickman,prop,stage}`; stage, stickman runtime, props, terrain, and speech systems live in `scripts/{stage,characters/stickman,props,terrain,speech}`.
|
||||
@@ -35,6 +46,11 @@
|
||||
- A load failure shows a "Could Not Load Scene" dialog and falls back to a blank scene.
|
||||
- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines.
|
||||
|
||||
## Visual verification on Windows
|
||||
|
||||
- The Windows screenshot service repeatedly times out when capturing the Project Manager or Godot windows. Do not retry or depend on its live-window feed.
|
||||
- For visual UI verification, run a focused Godot scene or test harness that exercises the relevant controls and saves a viewport PNG, then inspect that image locally. Keep interaction assertions in the focused run as well; use headless runs for behavior that does not need rendered output.
|
||||
|
||||
## Project overview
|
||||
|
||||
Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a
|
||||
@@ -78,6 +94,10 @@ the configured `run/main_scene`.
|
||||
custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit
|
||||
delegation request. The UI/UX agent is read-only; carry its design guidance into implementation
|
||||
separately when implementation is requested.
|
||||
- Before making, mocking, reviewing, or suggesting Prop or Stickman UI/UX changes, have `@uiux`
|
||||
read `docs/guides/editor_visual_style_guide.md` and consult the relevant implemented patterns
|
||||
and feature specs. Treat the guide as the current visual baseline; call out intentional
|
||||
departures and distinguish current behavior from recommendations.
|
||||
- For new or revised child-facing dialogs, popups, and short setup flows, follow the
|
||||
[Dialog Design Principles](docs/specs/dialog_design_principles.md). Use rounded containers
|
||||
and controls, clear borders, blue primary actions with bold white labels, comfortable button
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Moving Prop Chase",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"y": -240.0,
|
||||
"x": -180.0
|
||||
},
|
||||
"zoom": 0.55
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -960.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -768.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -384.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 0.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 384.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 768.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 960.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"y": -225.0,
|
||||
"x": -700.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test2_chaser",
|
||||
"stage_name": "Chaser",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "wait",
|
||||
"action_id": "action_phase20b_test2_wait",
|
||||
"duration": 1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "action_phase20b_test2_pursue",
|
||||
"type": "move_to",
|
||||
"style": "run",
|
||||
"destination": {
|
||||
"kind": "prop",
|
||||
"last_known_approach": {
|
||||
"y": -215.0,
|
||||
"x": -30.0
|
||||
},
|
||||
"target_prop_id": "stage_phase20b_test2_baseball",
|
||||
"approach_offset": {
|
||||
"y": 0.0,
|
||||
"x": -80.0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_ramp",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"y": -38.0,
|
||||
"x": 160.0
|
||||
},
|
||||
"rotation": 30.0,
|
||||
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
|
||||
"stage_object_id": "stage_phase20b_test2_ramp",
|
||||
"stage_name": "Tilted Ramp",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": true,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": false,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
},
|
||||
{
|
||||
"file_id": "prop_rolling_baseball",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"y": -215.0,
|
||||
"x": 50.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/baseball.prop",
|
||||
"stage_object_id": "stage_phase20b_test2_baseball",
|
||||
"stage_name": "Rolling Baseball",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "physics",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 0.99999862909317,
|
||||
"y": 0.99999862909317
|
||||
},
|
||||
"material_preset": 0
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1793685806,
|
||||
"source_path": "",
|
||||
"autosave": false,
|
||||
"modified_unix": 1793686116
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Timeout and Missing Target",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"zoom": 0.15,
|
||||
"position": {
|
||||
"x": -2800.0,
|
||||
"y": -240.0
|
||||
}
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -7680.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -7488.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -7296.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -7104.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -6912.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -6720.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -6528.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -6336.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -6144.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5952.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5760.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_12",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5568.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_13",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5376.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_14",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5184.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_15",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4992.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_16",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4800.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_17",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4608.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_18",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4416.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_19",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4224.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_20",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4032.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_21",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3840.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_22",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3648.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_23",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3456.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_24",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3264.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_25",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3072.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_26",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2880.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_27",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2688.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_28",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2496.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_29",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2304.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_30",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2112.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_31",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1920.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_32",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1728.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_33",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1536.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_34",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1344.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_35",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1152.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_36",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_37",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_38",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_39",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_40",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_41",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_42",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_43",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_44",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_45",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_46",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_47",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1152.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_48",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1344.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_49",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1536.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_50",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1728.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_51",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1920.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_timeout_test",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -6000.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test3_chaser",
|
||||
"stage_name": "Chaser",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "action_phase20b_test3_chase",
|
||||
"type": "move_to",
|
||||
"destination": {
|
||||
"target_prop_id": "stage_phase20b_test3_target",
|
||||
"last_known_approach": {
|
||||
"x": -30.0,
|
||||
"y": -215.0
|
||||
},
|
||||
"kind": "prop",
|
||||
"approach_offset": {
|
||||
"x": -80.0,
|
||||
"y": 0.0
|
||||
}
|
||||
},
|
||||
"style": "run"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_ramp",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 160.0,
|
||||
"y": -38.0
|
||||
},
|
||||
"rotation": 30.0,
|
||||
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
|
||||
"stage_object_id": "stage_phase20b_test3_ramp",
|
||||
"stage_name": "Rolling Ramp",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": true,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": false,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
},
|
||||
{
|
||||
"file_id": "prop_timeout_target",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 50.0,
|
||||
"y": -215.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/baseball.prop",
|
||||
"stage_object_id": "stage_phase20b_test3_target",
|
||||
"stage_name": "Target Ball",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "physics",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 0.99999862909317,
|
||||
"y": 0.99999862909317
|
||||
},
|
||||
"material_preset": 0
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"autosave": false,
|
||||
"source_path": "",
|
||||
"modified_unix": 1793757248,
|
||||
"created_unix": 1793757048
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Pause During Pursuit",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": -650.0,
|
||||
"y": -240.0
|
||||
},
|
||||
"zoom": 0.42
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -1920
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -1728
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -1536
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -1344
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -1152
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_12",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_13",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_14",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_15",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_16",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_17",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 1152
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_18",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 1344
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_19",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 1536
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_test4_walker",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -1300.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test4_walker",
|
||||
"stage_name": "Walker",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "wait",
|
||||
"duration": 0.5,
|
||||
"action_id": "action_phase20b_test4_wait"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "move_to",
|
||||
"style": "run",
|
||||
"destination": {
|
||||
"kind": "prop",
|
||||
"target_prop_id": "stage_phase20b_test4_ball",
|
||||
"approach_offset": {
|
||||
"x": -80.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"last_known_approach": {
|
||||
"x": -30.0,
|
||||
"y": -215.0
|
||||
}
|
||||
},
|
||||
"action_id": "action_phase20b_test4_pursue"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test4_ramp",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 160.0,
|
||||
"y": -38.0
|
||||
},
|
||||
"rotation": 30.0,
|
||||
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
|
||||
"stage_object_id": "stage_phase20b_test4_ramp",
|
||||
"stage_name": "Tilted Ramp",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": true,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": false,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test4_ball",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 50.0,
|
||||
"y": -215.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/baseball.prop",
|
||||
"stage_object_id": "stage_phase20b_test4_ball",
|
||||
"stage_name": "Rolling Ball",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "physics",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 0
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1793765197,
|
||||
"source_path": "",
|
||||
"autosave": false,
|
||||
"modified_unix": 1793765197
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Target Identity and Repair",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -240.0
|
||||
},
|
||||
"zoom": 0.519999980926514
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -620.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test5_walker",
|
||||
"stage_name": "Walker",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "move_to",
|
||||
"style": "walk",
|
||||
"destination": {
|
||||
"kind": "prop",
|
||||
"target_prop_id": "stage_phase20b_test5_briefcase",
|
||||
"approach_offset": {
|
||||
"x": -40.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"last_known_approach": {
|
||||
"x": 220.0,
|
||||
"y": -225.0
|
||||
}
|
||||
},
|
||||
"action_id": "action_phase20b_test5_move_to_prop"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "move_to",
|
||||
"style": "walk",
|
||||
"destination": {
|
||||
"kind": "place",
|
||||
"position": {
|
||||
"x": 360.0,
|
||||
"y": -225.0
|
||||
}
|
||||
},
|
||||
"action_id": "action_phase20b_test5_free_place"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_1",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 260.0,
|
||||
"y": 64.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/briefcase.prop",
|
||||
"stage_object_id": "stage_phase20b_test5_briefcase",
|
||||
"stage_name": "Named Briefcase",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1793765197,
|
||||
"modified_unix": 1793765903.57,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - DIRECT Editing and Focus",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -240.0
|
||||
},
|
||||
"zoom": 0.52
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_test6_direct",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -500.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test6_direct",
|
||||
"stage_name": "Director",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": []
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test6_table",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 320.0,
|
||||
"y": 32.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
|
||||
"stage_object_id": "stage_phase20b_test6_table",
|
||||
"stage_name": "Coffee Table",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": true,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": false,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test6_case",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 560.0,
|
||||
"y": 64.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/briefcase.prop",
|
||||
"stage_object_id": "stage_phase20b_test6_case",
|
||||
"stage_name": "Briefcase",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1793765197,
|
||||
"source_path": "",
|
||||
"autosave": false,
|
||||
"modified_unix": 1793765197
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Target Picking and Cancel",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -240.0
|
||||
},
|
||||
"zoom": 0.52
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": -192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 192
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 384
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 576
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 768
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"y": 160.0,
|
||||
"x": 960
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_test7_capture",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -520.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test7_capture",
|
||||
"stage_name": "Director",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "pick_up_prop",
|
||||
"target_prop_id": "stage_phase20b_test7_case",
|
||||
"hand": "auto",
|
||||
"action_id": "action_phase20b_test7_pickup"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "set_down_prop",
|
||||
"target_position": {
|
||||
"x": 320.0,
|
||||
"y": -256.0
|
||||
},
|
||||
"action_id": "action_phase20b_test7_setdown"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test7_case",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": -220.0,
|
||||
"y": 64.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/briefcase.prop",
|
||||
"stage_object_id": "stage_phase20b_test7_case",
|
||||
"stage_name": "Briefcase",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": true,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
},
|
||||
{
|
||||
"file_id": "prop_test7_table",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 320.0,
|
||||
"y": 32.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
|
||||
"stage_object_id": "stage_phase20b_test7_table",
|
||||
"stage_name": "Coffee Table",
|
||||
"roles": {
|
||||
"depth": "behind",
|
||||
"start_motion": "still",
|
||||
"holds_other_props": true,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": false,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false
|
||||
},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
"wobble_on_impact": false,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1793765197,
|
||||
"source_path": "",
|
||||
"autosave": false,
|
||||
"modified_unix": 1793765197
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"version": "1.9",
|
||||
"scene_name": "Phase 20b - Together Timing",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": -300.0,
|
||||
"y": -240.0
|
||||
},
|
||||
"zoom": 0.55
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 160.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"finish_id": "finish_ground_grass"
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_timing_test",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -400.0,
|
||||
"y": -225.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://content/stickmen/break.stk",
|
||||
"stage_object_id": "stage_phase20b_test3_walker",
|
||||
"stage_name": "Walker",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_plan": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"destination": {
|
||||
"kind": "place",
|
||||
"position": {
|
||||
"x": -220.0,
|
||||
"y": -225.0
|
||||
}
|
||||
},
|
||||
"action_id": "action_phase20b_test3_walk",
|
||||
"type": "move_to",
|
||||
"style": "walk"
|
||||
},
|
||||
{
|
||||
"type": "speak",
|
||||
"text": "I am walking and talking!",
|
||||
"stop_current": false,
|
||||
"action_id": "action_phase20b_test3_say",
|
||||
"duration": 3.0,
|
||||
"keep_acting": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "action_phase20b_test3_wait",
|
||||
"type": "wait",
|
||||
"duration": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"source_path": "",
|
||||
"created_unix": 1793745212,
|
||||
"autosave": false,
|
||||
"modified_unix": 1793745212
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/tools/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) |
|
||||
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) |
|
||||
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/editors/stage/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) |
|
||||
| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) |
|
||||
| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Phase 20a specifies AABB broad phase plus visible-geometry narrow phase for characters and props, with a small screen-space line tolerance, to make overlapping-object choices match the clicked art. Terrain, scenery, and areas retain AABB selection in that bounded phase; keep this debt open until implementation and the remaining object types are reviewed. See `docs/specs/phase_20a_stage_identities_and_names.md`. (2026‑08‑27; spec update 2026-10-25) |
|
||||
| 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once content/props/other stickmen are registered as obstacles. (2026‑08‑29) |
|
||||
| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the visible-AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) |
|
||||
| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `docs/archive/plans/PHASE_4b_SPEC.md` §3. (2026-09-02) |
|
||||
@@ -91,6 +91,11 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 79 | **Editor chrome has two existing token sources and scene-level literals** — PropTheme owns current Prop UI values while Stickman scenes hardcode bars, labels, and borders. | Medium | Spec prepared; implementation pending | Add one validated `EditorVisualTheme` source for shared semantic chrome and editor-local `Theme` instances. Retain PropTheme for prop material/primitive/canvas values and keep Stickman canvas drawing tokens in a separate namespace. Migrate legacy Prop `ui` accessors deliberately so a chrome role has one final authority; use SandboxTheme values as a visual reference without a runtime dependency or Stage inspector component reuse. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||
| 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||
| 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||
| 82 | **Phase 20a introduces persistent stage identity alongside temporary save IDs** — props already use persistent `stage_object_id`, while stickmen currently depend on runtime instance IDs and each save regenerates `file_id` by World order. | Medium | Spec prepared; implementation pending | Keep `file_id` for the current rule remap, retain existing prop IDs, and add a persistent character ID plus scene-unique display names independent of assets. Migrate legacy character IDs deterministically from their pre-migration `file_id`; preserve IDs and names through missing-asset placeholders. A scene-scoped World scan for name allocation is simpler than a mutable registry at current scene sizes. Phase 20b–20e may consume these IDs for authored links; this spec does not implement those links. See `docs/specs/phase_20a_stage_identities_and_names.md`. (2026-10-25) |
|
||||
| 83 | **Phase 20b needs persistent action identity and a plan migration across flat queue callers** — current integer IDs regenerate at load/PLAY, and flat encoders strip unknown action fields. | High | Spec prepared; implementation pending | Save v1.9 nested steps and scene-unique string action IDs; derive deterministic IDs from actor ID and flat index for older scenes. Keep IDs across edit/reorder/regroup/retarget, regenerate on actor duplication, and validate current-format collisions before World apply. Adapt queue/editor/visual/rule callers through typed plan APIs and a derived flat compatibility view; never persist session integer IDs or use stage names as keys. The v1.8 `speak.keep_acting` overlap may require a narrow legacy-only execution flag when it cannot become a compatible Move/Wait+Say step. See `docs/specs/phase_20b_action_plans_and_direct_authoring.md`. (2026-10-26) |
|
||||
| 84 | **Live named-prop pursuit can churn navigation or finish at a stale point** — a rigid prop can translate and spin while an authored Move runs. | Medium | Spec prepared; tuning pending | Store a world-aligned offset from the stable prop ID, retarget only for meaningful translation without restarting gait, and cap unpaused pursuit time. Check both current approach arrival and live prop reach before success; a missing target, blocked route, invalid projection, or timeout fails and emits no reached-endpoint event. Initial 16-unit/0.1-second retarget and 12-second budget values are tuning candidates for repeatable behavior and rendered checks, not permanent schema fields. Future Follow has a separate continuing stop contract. See `docs/specs/phase_20b_action_plans_and_direct_authoring.md`. (2026-10-26) |
|
||||
| 85 | **A just-set-down prop can be bumped by the releasing stickman during the next queued move** — the actor may still be beside the new placement when the following action begins. | Low | Implemented; 57 focused runtime checks pass | Set Down alone adds a temporary collision exception for the releasing rig, and that rig's movement sweep skips the prop until its collision shapes remain clear by 24 px for two physics frames. Keep the authored released flags, support contact, and all other actors' contacts unchanged; Drop, Throw, and ragdoll release receive no grace. This deliberately revises Phase 17a's earlier no-owner-grace rule to protect the immediate Set Down → Move sequence. See `docs/specs/phase_17a_prop_roles.md`. (2026-11-02) |
|
||||
| 86 | **A stickman can bump a reserved pickup target while approaching it** — normal collision handling can move the prop before the reach completes. | Low | Implemented; 68 focused runtime checks pass | Keep a pair-specific exception for the reserving actor and target throughout approach/reach. Successful pickup clears it through the HELD collision profile; cancel/failure changes it to clearance grace. The target retains support and contact with every other actor and prop. See `docs/specs/phase_17a_prop_roles.md`. (2026-11-02) |
|
||||
|
||||
---
|
||||
|
||||
@@ -194,4 +199,12 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
|
||||
| 2026-10-22 | Prepared and revised the Phase 19 Plain Background architecture specification (`docs/specs/phase_19_plain_background.md`) for final user sign-off. The accepted composed Stage backdrop view serves live Stage and previews; scene v1.7 persists active kind, last Sky ID, and confirmed plain color while migrating all older scenes as Sky. Color drafts stay out of Save/autosave, and theme-backed grid and two-tone gizmo overlays address light fills without changing art. Per user feedback, the current-color chip is the sole custom-picker entry, the separate More colors button is removed, and the custom picker uses a simple Preview label without a temporary-state helper message. Logged #78 for the chooser extension and rendered verification gates. No runtime code or tests changed. |
|
||||
|
||||
| 2026-10-25 | Prepared the Phase 20a identities, names, and overlap-selection architecture specification (`docs/specs/phase_20a_stage_identities_and_names.md`) for user sign-off. It specifies persistent character/prop IDs and scene-unique names alongside the existing temporary rule `file_id`, deterministic legacy migration to scene v1.8, shared EDIT name inspectors, and a visible-geometry click check with a transient overlap preview. Updated open #12 for the bounded character/prop precision plan and added #82 for the identity and migration trade-off. No runtime code or tests changed. |
|
||||
|
||||
| 2026-10-26 | Prepared the Phase 20b Step Stack UI/architecture specification (`docs/specs/phase_20b_action_plans_and_direct_authoring.md`) for final user sign-off. It records the approved Move/Wait+Say compatibility, explicit free-place versus named-prop links, finite world-aligned moving-prop pursuit, future-only paused regroup/reorder, scene v1.9 plan/action-ID migration, runtime group barrier, and three verification gates. Added #83–#84 for the flat-queue compatibility and pursuit tuning trade-offs. No implementation code or tests changed. |
|
||||
|
||||
| 2026-11-02 | Revised Phase 17a's Set Down release rule for the reported Set Down → Move collision: only the releasing rig receives a contact exception, held until its collision shapes clear the prop by 24 px for two physics frames. The exception also applies to that rig's movement sweep, and the prop retains its authored collision role with supports and other actors. Added #85; `test_phase17a_prop_roles.gd` passes 57 runtime checks. |
|
||||
|
||||
| 2026-11-02 | Added reserved-pickup collision protection: the pickup target remains exempt only for its reserving rig through approach and reach, then returns to normal contact on pickup or clearance after cancellation/failure. The existing locomotion sweep honors the same target-specific exemption. Added #86; `test_phase17a_prop_roles.gd` passes 68 focused runtime checks. |
|
||||
|
||||
_End of Log_
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Editor Visual Style Guide
|
||||
|
||||
**Scope:** Establish a practical visual and interaction direction for the Prop Editor and Stickman Editor, using the Sandbox Stage UI as a product reference.
|
||||
**Status:** Guidance for future editor design and implementation. It documents current patterns and gaps; it does not assert that the editors already share the Stage theme or components.
|
||||
**Status:** Guidance for current and future editor UI. Prop and Stickman share `EditorVisualTheme` for editor chrome and selected reusable controls; Stage-only layouts and components remain Stage-specific unless explicitly generalized.
|
||||
|
||||
## 1. Product direction
|
||||
|
||||
@@ -22,9 +22,12 @@ The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-f
|
||||
| --- | --- | --- |
|
||||
| Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. |
|
||||
| Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. |
|
||||
| Stage dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) styles child-facing dialogs hosted by the Stage overlay. | Reuse its visual contract. Its overlay host and lifecycle are Stage-specific unless deliberately generalized. |
|
||||
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json); the controller creates a local inspector Theme. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep values token-driven and scoped. Current Prop colors and target sizes differ from Stage tokens. |
|
||||
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) coordinates ten BodyPartPanels and the Whole Stickman preview. The scene currently uses fixed top-bar offsets and a three-column workspace. | The canvas/preview model is established, but it has no apparent SandboxTheme or PropTheme integration. Harmonization is future work. |
|
||||
| Stage action plans | [Phase 20b](../specs/phase_20b_action_plans_and_direct_authoring.md) defines the DIRECT Step Stack, action markers, target capture, and action picker. | Use these as Stage-specific references. Reuse their general feedback principles in editor workflows only when the interaction fits. |
|
||||
| Stage object names and overlap chooser | [Phase 20a](../specs/phase_20a_stage_identities_and_names_ui_ux.md) defines the name-and-Rename inspector header and the anchored **Objects here** chooser. | Reuse the rename interaction for other renameable objects; reuse the chooser shell and state tokens for similar non-modal object pickers. Follow the click-versus-drag and transform-handle priority in §4.6. Keep object-specific naming rules with the owning feature. |
|
||||
| Stage and editor dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) draws the shared confirmation card over the active scene while retaining native dialog signals. The style reads `popups.confirmation` from SandboxTheme. | Use this shared card for Prop and Stickman confirmation, recovery, and short action dialogs. Native file picker content remains native. |
|
||||
| Shared editor chrome | [EditorVisualTheme](../../scripts/shared/editor_ui/editor_visual_theme.gd) loads validated tokens from [editor_visual_theme.json](../../editor_visual_theme.json) and builds each editor's local control Theme. `EditorSelectionDropdown` and `EditorContextMenu` supply shared selection and canvas-menu styling. | Use shared semantic tokens and controls for matching editor workflows. Keep editor-specific document behavior in its owner. |
|
||||
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json) for material, geometry, and canvas defaults; editor chrome comes from `EditorVisualTheme`. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep Prop artwork/material values and canvas contrast in `PropTheme`; use shared editor tokens for chrome, inspector states, and selection dropdown styling. |
|
||||
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) uses `EditorVisualTheme` for chrome and coordinates ten BodyPartPanels in a two-column Overview beside the Whole Stickman preview, plus a Focus view for one large panel. | Overview columns and panels expand to the workspace size, retaining a 230 × 160 px minimum and scrolling when space is short. Keep the splitter draggable, preserve panel state, and show the part title inside its panel in both views. |
|
||||
| Child-facing dialogs | [Dialog Design Principles](../specs/dialog_design_principles.md) documents the rounded, labeled-action pattern and stable resizing behavior. | Apply these rules to editor dialogs, adapting ownership and host behavior to each editor. |
|
||||
|
||||
The Prop Editor user guide at [prop_editor_user_guide.md](prop_editor_user_guide.md) describes its current controls and layout. Use it to avoid documenting proposed changes as existing behavior.
|
||||
@@ -41,7 +44,7 @@ Use color to reinforce meaning consistently. Keep labels, shapes, icons, or stat
|
||||
| Raised surface | global.surface_color #252B38FA | Use for toolbars, inspector shells, and cards. Distinguish adjacent surfaces by value, border, or spacing. |
|
||||
| Primary text | global.text_color #F5F7FA | Use for titles, control names, and important values. |
|
||||
| Secondary text | global.muted_text_color #AAB2C0 | Use for helper text and secondary status; keep readable at the chosen size. |
|
||||
| EDIT accent | mode_colors.edit #22C6FF | Suitable for active edit mode, selection accents, and editor guidance. It is not the current PropTheme accent. |
|
||||
| EDIT accent | mode_colors.edit #22C6FF | Shared editor accent for active modes, selection accents, and editor guidance through `EditorVisualTheme`. |
|
||||
| DIRECT accent | mode_colors.direct #FFB300 | Reserved for the Stage's directing mode. Do not use as a generic warning color in editors. |
|
||||
| PLAY accent | mode_colors.play #33DD77 | Reserved for Stage playback state. |
|
||||
| Primary action | Stage dialog/action blue #087DBA | Use for the single most important forward action in a dialog. |
|
||||
@@ -51,16 +54,14 @@ Use color to reinforce meaning consistently. Keep labels, shapes, icons, or stat
|
||||
|
||||
These values are current Stage reference values, not a mandate to paste literal hex values into editor controls. Resolve colors through validated theme accessors. If an editor needs a warmer or quieter local accent, define a semantic editor token and document its mapping to Stage meaning.
|
||||
|
||||
### 3.2 Current token differences to resolve during harmonization
|
||||
### 3.2 Shared chrome and intentional editor differences
|
||||
|
||||
The current palettes are related but not at parity:
|
||||
Both standalone editors now use `EditorVisualTheme` for shared chrome roles, sizing, and local control Themes. Keep these domain-specific values in their existing owner:
|
||||
|
||||
- PropTheme uses ui.colors.accent **#4FA3FF**, while SandboxTheme EDIT uses **#22C6FF**.
|
||||
- PropTheme defaults its button and row minimum heights to **36 px** and its mode button minimum height to **56 px**. Stage action targets are **48 px**, and child-facing UI guidance calls for at least **44 × 44 logical pixels**.
|
||||
- PropTheme's canvas is #1F1F1F; its inspector token is #262C39. The Stage uses #171B24F2 / #252B38FA for global background and surface.
|
||||
- The older Stickman Editor has no apparent editor theme integration. Its scene uses 32 px menu/top bars, a 28 px status bar, 4 px preview corners, and a 14 px preview title; body-part headings are around 13–14 px. It defines preview surface and borders directly in the scene.
|
||||
- `PropTheme` retains material tints, primitive defaults, outline widths, and the Prop canvas surface/grid. Keep its neutral charcoal canvas distinct from the blue-tinted editor panels. Stickman canvas grid and selection colors remain in `EditorVisualTheme.stickman_canvas` tokens.
|
||||
- `EditorVisualTheme` owns common editor surfaces, text, EDIT accent, focus, action roles, menu sizes, and minimum targets. `SandboxTheme` continues to own Stage-specific panels and dialog tokens.
|
||||
|
||||
Treat these as migration work, not evidence that the editors already match the Stage. Before implementation, decide whether to harmonize the accent, minimum targets, border/focus states, and surface roles. Preserve canvas contrast where it serves drawing readability.
|
||||
Preserve these boundaries when extending the UI: share semantic chrome roles where workflows match, and keep canvas, material, and runtime-domain colors local to their owner.
|
||||
|
||||
### 3.3 Type, spacing, and shape
|
||||
|
||||
@@ -70,6 +71,7 @@ Use these Stage examples as starting points, then tune them for the denser edito
|
||||
- Stage inspector shell: **12 px** padding, **8 px** header gap, **12 px** section gap, **10 px** corner radius.
|
||||
- Stage choice row: **56 px** minimum height; Stage toggle row: **64 px** minimum, increasing when helper text is visible.
|
||||
- Large child-facing dialogs: title about **28–30 px**, body about **20 px**, button about **20 px**, **2 px** borders, **12–18 px** corner radii, and generous footer spacing.
|
||||
- For short numeric settings such as a wait duration, keep the value directly editable, show its unit, and offer a large touch-size stepper. Optional quick picks should update the same value. Keep typed input, stepping, quick picks, and bounds synchronized; choose an increment that suits the setting.
|
||||
- Stage generally uses dark surfaces with a restrained border. The dialog guide's stronger borders and rounder controls are for modal emphasis, not a requirement to put every editor widget inside a thick outline.
|
||||
|
||||
For compact inspectors, shorten the hierarchy only after checking it at the intended window size. Prefer a clear label and value over shrinking type until it becomes difficult to read. Use container spacing and alignment rather than per-control position nudges.
|
||||
@@ -96,9 +98,10 @@ For drawing handles, keep a contrasting backing/halo where the canvas can contai
|
||||
### 4.1 Use the right pattern for the job
|
||||
|
||||
- **In-stage selected-object inspector:** follow StageInspectorPanel, choice groups, and toggle rows in the [Stage UI spec](../specs/stage_panel_ui_system.md). Its title/Close header, fixed header with scrolling body, and safe-rect fitting are specific to a floating selection card.
|
||||
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Reuse spacing, hierarchy, colors, and control states; do not force the floating Stage card structure into this column.
|
||||
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Separate sections with rounded cards, readable headings, colorful section icons, and concise helper copy; keep related values grouped in compact tiles. Reuse Stage's hierarchy and state clarity, but do not force the floating Stage card structure into this column.
|
||||
- **Temporary targeting step:** the Stage capture bar is for a short-lived click-on-stage task. An editor can borrow the prompt-plus-visible-Cancel contract when it has a similar temporary canvas capture step, but should not copy the bar just because it is visible in Stage.
|
||||
- **Modal dialog:** follow [Dialog Design Principles](../specs/dialog_design_principles.md). Keep a labeled **Cancel**, **Done**, or **Back** action; use one visually clear primary action; preserve the clicked control's anchor when content grows; cap height and scroll content on short viewports.
|
||||
- **Anchored object chooser:** use the non-modal chooser pattern in section 4.6 for resolving overlapping canvas targets. Keep it visually distinct from a centered dialog and from a persistent inspector.
|
||||
- **Canvas tools:** keep mode and document controls outside the draw area where possible. Put contextual handles and temporary prompts near the object only if they do not obscure the task.
|
||||
|
||||
### 4.2 Reuse through tokens and small components
|
||||
@@ -111,7 +114,7 @@ For drawing handles, keep a contrasting backing/halo where the canvas can contai
|
||||
6. Keep canvas-specific rendering tokens separate from chrome tokens. Grid, artwork, shape fill, selection halo, vertex handles, and preview ghosts have different contrast jobs.
|
||||
7. Do not serialize UI state unless it is part of the authored document. Tool preference, document content, preview-only state, and persistence should be deliberately separated.
|
||||
|
||||
The current code already demonstrates local theme ownership in Prop Editor (_ui_theme) and Stage token validation/cache behavior in SandboxTheme. This guide recommends extending those strengths; it does not claim the editor themes are currently unified.
|
||||
Both standalone editors build their chrome Theme from `EditorVisualTheme`; Prop also uses `PropTheme` for prop-specific art and material defaults. Stage remains the reference for fitting, dialog hierarchy, and child-facing language, but its in-stage inspector layout is not a default editor-inspector component.
|
||||
|
||||
### 4.3 Responsive workspace behavior
|
||||
|
||||
@@ -120,7 +123,50 @@ The current code already demonstrates local theme ownership in Prop Editor (_ui_
|
||||
- Use container-driven rows (FlowContainer, HBoxContainer, VBoxContainer) instead of fixed offsets for control groups that can wrap or grow with theme fonts.
|
||||
- Keep top actions and mode controls reachable at smaller window sizes. Wrap or stack controls only when necessary, and preserve their order and mode meaning.
|
||||
- Keep toolbars, status bars, and dialogs inside safe margins. A dynamic dialog should grow from a stable anchor and scroll rather than jump or recenter after each choice.
|
||||
- For Stickman Editor, the current scene uses fixed top bar offsets and a three-column layout. A responsive pass should specifically validate narrower windows, custom fonts, longer translations, and panel resizing before claiming parity with Prop Editor's split workspace.
|
||||
- Stickman Overview uses two equal, flexible columns of five body-part drawing panels beside the Whole Stickman preview. Let both columns fill the Overview pane and share extra width; let the five rows share extra height while preserving a 230 × 160 px minimum panel size. Scroll the Overview when the viewport is shorter than its minimum content size.
|
||||
- Keep the Overview/preview splitter draggable. On narrow windows, use the existing Focus view to give one body-part panel the available space; keep Whole Stickman access available through its dedicated control. Switching views must preserve each panel instance, authored geometry, selection, and view transform. In Focus, use the title inside the active panel as the only canvas heading; do not repeat it above the panel.
|
||||
- Validate the layout at wide and narrow windows, with raised fonts and resized panels, before claiming responsive behavior.
|
||||
|
||||
### 4.4 Shared confirmation dialog style
|
||||
|
||||
Use the Sandbox Stage confirmation card for child-facing confirmation, recovery, and short action dialogs in both standalone editors. The shared implementation is `StageDialogStyler`, with color, sizing, and spacing values from `SandboxTheme.popups.confirmation`.
|
||||
|
||||
- Draw a dim backdrop and a centered, rounded dark card. Use the themed card width (560 px target), 22 px inset, 18 px corners, and 2 px border; fit the card inside the safe viewport.
|
||||
- Put a contextual icon badge and a left-aligned 28 px title in the header. Put the message in a left-aligned 20 px body. Wrap longer copy and scroll the body if the viewport is short.
|
||||
- Order actions as primary, destructive, then **Cancel**. Use Stage blue for Save, Restore, and other forward actions; use the danger treatment for Discard, Start Over, and other destructive choices; use the neutral surface for Cancel. Make action labels at least 52 px high and 112 px wide, with 12 px gaps.
|
||||
- Keep the visible **Cancel** action even when Escape is supported. Do not show a native title-bar **×**. Focus the primary action when present; for a destructive-only prompt, focus Cancel.
|
||||
- Keep the native `AcceptDialog` or `ConfirmationDialog` as the signal source and open its shared overlay through `StageDialogStyler`. This keeps confirmation, custom-action, cancellation, keyboard, and safe-viewport behavior consistent.
|
||||
- Apply this pattern to Prop recovery, unsaved changes before New/Close/Stage return, off-origin save choices, anchor rename, and error acknowledgement; apply it to Stickman Start Over, unsaved changes, grid setup, and error acknowledgement. File pickers keep their native file-selection interface.
|
||||
|
||||
Treat the card as one shared product pattern. Do not create editor-local confirmation palettes or button-role variants.
|
||||
|
||||
### 4.5 Renameable object inspector header
|
||||
|
||||
When a selected object has a user-editable name, show that name once in the inspector header beside the object's type icon. Put a clearly labeled **Rename** button beside it; the title itself should not be an undisclosed rename hotspot. Truncate a long title with an ellipsis and expose the full value as a tooltip. Keep the header and Rename action visible when the inspector body is collapsed.
|
||||
|
||||
Activating **Rename** reveals a prefilled, single-line field anchored directly below the header. Provide a prominent **Done** action and a neutral **Cancel** action, both with child-sized targets. Enter commits and Escape cancels. Keep validation beside the field, explain the problem in plain language, and offer a concrete suggestion when possible. A failed rename keeps the draft visible. Cancel restores the inspector's previous expanded or collapsed state; a successful rename updates the header and any visible object labels.
|
||||
|
||||
Use this interaction for other renameable objects when their inspector can support the same compact header and inline edit. Keep validation, uniqueness, and persistence rules in the owning feature; this visual pattern does not prescribe domain rules. Store shared header, edit, button, focus, and spacing values in the owning UI theme rather than inventing local styling for each object type.
|
||||
|
||||
### 4.6 Anchored object chooser
|
||||
|
||||
Use an anchored chooser when a click lands on multiple real canvas targets and the child must choose which one to act on. The Stage's **Objects here** popup is the implementation reference. It is a temporary, non-modal popup near the click, not an inspector or a centered confirmation dialog.
|
||||
|
||||
- Resolve selection clicks and movement gestures together. When the already-selected object is one of the overlapping targets, check its rotate and resize handles first on pointer press; a handle gesture starts that transform immediately. A press on the selected object's visible body becomes a drag after movement exceeds the screen-space threshold (8 logical pixels in Stage). If released before the threshold, open the chooser and preserve the prior selection. Measure movement in screen space so zoom does not change the gesture; other canvas workflows can tune the threshold while keeping this ordering.
|
||||
|
||||
- Give the popup a dark raised shell with a clear 2 px border, a 12–16 px corner radius, a restrained local shadow, and 12–16 px of inner space. Keep the stage visible; do not dim the canvas behind this short selection task.
|
||||
- Use full-width rows at least 44 px high, preferably around 56 px, with about 8 px between them. Keep the candidate's icon and name together. The selected candidate keeps a teal fill, cyan outline, and a visible **Selected** label; hover and keyboard focus each remain easy to distinguish from selection.
|
||||
- Keep a rounded, neutral **Cancel** button in a separate footer, at least 48 px high. Escape may also cancel. An outside click closes the chooser and is consumed so it cannot select the stage underneath.
|
||||
- Keep the clicked art visible where possible, fit the popup to safe viewport margins, and cap the list height so longer candidate lists scroll. Keep pointer hover and keyboard focus behavior equivalent when they preview a candidate.
|
||||
- Define shell, row, selected, hover, focus, badge, spacing, and Cancel values as theme tokens. The Stage implementation uses `SandboxTheme.popups.anchored_chooser`; other workflows should reuse the visual contract through their own appropriate theme owner.
|
||||
|
||||
### 4.7 Stage action planning and target capture
|
||||
|
||||
These are Stage-specific references, not a new inspector layout for the standalone editors. The [Phase 20b spec](../specs/phase_20b_action_plans_and_direct_authoring.md) owns the exact action behavior and component placement.
|
||||
|
||||
**Temporary target capture:** Keep one short, action-specific prompt and a visible **Cancel** together in a stable capture bar. Dim and block the underlying inspector while the stage is waiting for a target. Escape and right-click may act as shortcuts, but the visible Cancel remains available. Cancel discards the draft, restores the prior selection and focus, and leaves the plan and dirty state unchanged. Show a cursor marker and action-specific feedback: highlight only valid Set Down surfaces, keep Move feedback grounded at the actor's walking level, and preserve Throw's ballistic preview. Do not show a movement-looking trajectory while choosing a Pick Up prop.
|
||||
|
||||
**Action markers:** Use one readable icon-and-verb pill for each action. When actions run together, keep their pills separate and stack them so each remains identifiable. Anchor **Pick Up** at its prop, **Say** and **Wait** by the actor, and **Move** at its destination. Let pill color reinforce the action type while the label remains the source of meaning. Do not repeat the step number in the pill when its route flag already carries that number.
|
||||
|
||||
## 5. Editor flows and state maps
|
||||
|
||||
@@ -195,7 +241,7 @@ For either editor, the expected child-facing sequence is:
|
||||
|
||||
1. State the action in the title and one short sentence.
|
||||
2. Show the effect or consequence when it may be surprising.
|
||||
3. Present one primary action, any needed destructive action, and a visible **Cancel**.
|
||||
3. Present one primary action, any needed destructive action, and a visible **Cancel**. If the only forward-looking choice is destructive (such as Start Over), keep it in the danger role and focus Cancel initially.
|
||||
4. Keep focus in the dialog, preserve the current anchor during resize, and return focus to the invoking control after close where practical.
|
||||
5. On success, close the dialog and update the affected editor state once. On cancel, leave the document unchanged.
|
||||
|
||||
@@ -208,6 +254,11 @@ This is a target interaction contract for editor dialogs. Existing controls shou
|
||||
- Support mouse and touch-size targets; target at least **44 × 44 logical pixels** for child-facing actions. Prefer Stage's **48 px** action target where the layout allows.
|
||||
- Keep keyboard navigation visible and complete: Tab reaches meaningful controls, Enter/Space activates them, Escape cancels temporary tasks, and focus does not disappear behind a selected style.
|
||||
- Keep right-click and keyboard shortcuts as optional accelerators. A visible menu item or button should remain available for important operations.
|
||||
- Keep the top-level File/Edit/View menu titles plain and use familiar emoji beside readable action labels inside their dropdowns. Match the Sandbox Stage menu scale: 22 px menu-bar labels and 26 px popup labels. Group file actions as New/Open/Recent, then Save/Save As, with separators before a distinct Rename or Start Over action where present. Use the restrained charcoal popup surface for menu-bar dropdowns; reserve the raised Studio List surface for canvas right-click menus.
|
||||
- Style single-choice editor pickers as a dark field with the chosen label and chevron. Popup rows keep readable text and may include a meaningful domain icon, as in Prop material and shape choices; do not show radio/check glyphs or an empty indicator lane. The current choice gets a full teal surface and cyan outline. Keep hover and keyboard focus visible, and give the field and rows at least 44 logical pixels of height. Use the shared `EditorSelectionDropdown` control for Prop and Stickman editor pickers.
|
||||
- Standardize Prop and Stickman canvas context menus on the **Studio List** pattern: a compact vertical list on a raised editor surface, with a subtle border, rounded corners, readable 20 px labels, and 48 px rows. Resolve surface, border, text, hover, EDIT accent, focus, and destructive colors through `EditorVisualTheme` tokens rather than local color literals.
|
||||
- Group related actions with spacing and separators, and keep each menu's actions specific to the current mode and hit target. Hover uses a lighter surface with a slim EDIT-cyan cue; keyboard focus has a distinct, high-contrast focus outline. Open with no focused row so mouse users do not mistake an action for a selection; show the focus outline after Up/Down navigation, and clear it when the pointer takes over. Give destructive actions explicit labels, separate them from regular actions, and place them last when practical; do not use color as their only cue.
|
||||
- Keep labels as the source of meaning and use icons sparingly and consistently. Open the menu near the pointer, keep the clicked point or shape edge visible, and clamp the menu within the viewport.
|
||||
- For dragging, preview changes continuously, show which handle is active, commit on release, and provide a clear cancel path when the operation is consequential or multi-step.
|
||||
- Avoid hit-test surprises: decorative labels/icons ignore mouse input; a large row toggle has one activation target; a canvas guide is visual only.
|
||||
- Preserve the world point under the cursor during zoom. Keep selected gizmos and handles visible above artwork and grid.
|
||||
@@ -228,7 +279,7 @@ Prefer concise, concrete verbs and familiar terms:
|
||||
| “Choose where this prop attaches.” | Internal terms such as “anchor payload” |
|
||||
| “Unsaved changes. Save before starting a new prop?” | Generic “Are you sure?” without naming the consequence |
|
||||
|
||||
Emoji can help distinguish common actions, as used by Prop Editor menus, but should be an optional cue rather than the only meaning. Keep the text label understandable if an emoji font is unavailable. Use the same wording for the same action in menu, dialog, toolbar, tooltip, and user guide.
|
||||
Emoji can help distinguish common actions, as used by both editor menus, but should be an optional cue rather than the only meaning. Keep the text label understandable if an emoji font is unavailable. Use the same wording for the same action in menu, dialog, toolbar, tooltip, and user guide.
|
||||
|
||||
### 6.3 Icons and emoji
|
||||
|
||||
@@ -236,7 +287,8 @@ Emoji can help distinguish common actions, as used by Prop Editor menus, but sho
|
||||
- Keep the text label as the source of meaning. Provide a tooltip or accessible name for icon-only canvas tools.
|
||||
- Use icons consistently for the same action across menus, toolbars, empty states, and dialogs. Avoid changing icon meaning between editors.
|
||||
- Avoid using an emoji's color as the only state cue; fonts and platforms may render it differently.
|
||||
- The Prop Editor already uses emoji in File and mode labels. The Stickman Editor currently uses plain menu labels. Treat a visual icon pass as a recommendation that should be applied consistently, not as a current shared convention.
|
||||
- Both editors use icons beside readable menu action labels, with separators between distinct groups. Keep top-level File/Edit/View titles plain; place icons on actions and use the same symbol for the same operation across editors.
|
||||
- In the Prop inspector, use colorful, flat icons for section headings, material choices, 2D shape choices, and artwork tags. Keep text labels authoritative. Show tag selection with the selected surface and border; do not add a separate check badge. Shape icons must depict planar 2D geometry.
|
||||
|
||||
### 6.4 Empty, loading, and error states
|
||||
|
||||
@@ -249,7 +301,7 @@ The current editors have different levels of state guidance. The following table
|
||||
| Prop load/recovery | Load failures use an error dialog; autosave recovery offers Restore or Discard. | Name the file/action in the message, preserve the current artwork on failure, and keep Restore/Discard outcomes distinct. Loading feedback should not imply that the document is already saved. |
|
||||
| Stickman panel before drawing | The ten body-part panels are the main workspace; the panel script owns drawing and selection behavior. | Give each blank panel a concise, low-contrast drawing cue if discovery testing shows it is needed. Remove it on first shape so it does not compete with artwork. |
|
||||
| Stickman load/clear | Load and save use file dialogs; Clear has a confirmation dialog; error dialogs report failures. | Keep the current document intact on a failed load. State the consequence in Clear confirmation and offer a visible cancel path. |
|
||||
| Save / dirty state | Prop Editor has status/title and autosave behavior; Stickman Editor status bar reports cursor coordinates and snap state. | Make save status easy to find in both editors. Do not treat autosave as a user-visible successful save unless the file was actually saved to the chosen destination. |
|
||||
| Save / dirty state | Prop Editor has status/title and autosave behavior; Stickman Editor's status bar reports cursor coordinates, snap state, and save state. | Keep save status easy to find in both editors. Do not treat autosave as a user-visible successful save unless the file was actually saved to the chosen destination. |
|
||||
|
||||
Do not add spinners or blocking overlays for synchronous work without evidence that the operation takes long enough to need them. If an operation becomes asynchronous, keep progress near the task and preserve access to cancellation where safe.
|
||||
|
||||
@@ -258,7 +310,10 @@ Do not add spinners or blocking overlays for synchronous work without evidence t
|
||||
- Use motion to show cause and effect: a brief selection flash, a panel entering, or a successful action cue. Keep it short and do not delay the next input.
|
||||
- Do not animate every hover or value change. Motion should help the child understand an edit, not decorate idle controls.
|
||||
- Keep persistent mode and selection state visible after any transition ends.
|
||||
- For a secondary chooser that slides out beside an inspector, align the panel tops and briefly dim the parent to show that the chooser is temporary. Keep a visible **Cancel**; Escape and an outside click should reverse the same transition. On close, restore the parent and return focus to the control that opened the chooser.
|
||||
- For card reordering, keep bold up/down buttons stationary and animate the affected cards exchanging places after activation. Keep the selected card selected and visually in front, preserve scroll and focus, and ignore repeat input until the swap finishes.
|
||||
- Respect a reduced-motion preference for nonessential transitions and flashes. Provide an equally clear static state.
|
||||
- When reduced motion is requested, show the chooser or reordered cards directly in their final state without the transition.
|
||||
- The current code has short visual effects such as the Prop canvas's new-point flash and Stage completion effects. A unified reduced-motion preference is not established by the referenced theme/specs; treat it as a future product-level decision.
|
||||
|
||||
## 7. Theme and implementation checklist
|
||||
@@ -317,14 +372,13 @@ This is recommended implementation order, not a claim that every current editor
|
||||
|
||||
### Recommended harmonization order
|
||||
|
||||
1. **Agree on token ownership and names.** Define shared semantic roles for background, surface, text, muted text, EDIT accent, focus, primary/destructive action, spacing, radii, borders, and minimum targets. Keep canvas-specific colors separate.
|
||||
2. **Resolve token drift.** Decide whether Prop's #4FA3FF accent and 36 px button/row minima should move toward the Stage reference, or remain intentional editor overrides. Record the choice and add an accessibility rationale for target sizes.
|
||||
3. **Bring Stickman chrome into token ownership.** Move its scene-level colors, font sizes, spacing, and bar sizing into a validated theme source while preserving the panel/preview composition that supports drawing.
|
||||
4. **Improve responsive layout.** Replace brittle fixed offsets where font or window growth can clip controls; establish minimum column widths and wrapping behavior for both editors.
|
||||
5. **Unify states and dialogs.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Reuse dialog styling only after its host/lifecycle works in the editor.
|
||||
6. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
|
||||
1. **Maintain token ownership.** `EditorVisualTheme` owns shared standalone-editor chrome; `PropTheme` owns prop-domain and canvas defaults; `SandboxTheme` owns Stage-specific UI. Document any intentional override at the token or component that owns it.
|
||||
2. **Audit remaining overrides.** Check for editor chrome values that still bypass `EditorVisualTheme`; preserve the Prop canvas surface/material palette and Stickman canvas drawing tokens where they serve artwork contrast.
|
||||
3. **Document responsive limits.** Record minimum usable widths, toolbar wrapping, how the two Stickman Overview columns share space beside the preview, and when Focus or scrolling takes over.
|
||||
4. **Audit remaining state gaps.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Both editors use the shared `StageDialogStyler` card for confirmation, recovery, and short action dialogs.
|
||||
5. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
|
||||
|
||||
This order is a recommendation. It does not select a new shared-theme class or authorize a source migration; choose the theme architecture when implementation scope is defined.
|
||||
This order is a maintenance checklist for the current shared chrome and editor-specific drawing patterns; it does not propose replacing the established theme architecture.
|
||||
|
||||
### Visual QA checklist
|
||||
|
||||
@@ -344,8 +398,8 @@ This order is a recommendation. It does not select a new shared-theme class or a
|
||||
To make this guide a stronger long-term design reference, add these pieces as the editor UI evolves:
|
||||
|
||||
1. **Annotated screenshots at canonical window sizes.** Include each editor's initial screen, every mode, selection/reshape states, Attach preview, and one modal. Mark which visuals are current and which are target.
|
||||
2. **A shared token inventory.** Decide semantic names and authoritative ownership for surfaces, accents, focus, destructive actions, canvas grid, selection halo, typography, spacing, and minimum target sizes. Reconcile SandboxTheme, PropTheme, and Stickman Editor scene-level values in a planned migration.
|
||||
3. **Responsive breakpoints and minimum workspace sizes.** Record minimum usable canvas/inspector widths, when controls wrap, how the Attach preview stacks, and how Stickman Editor's three columns adapt.
|
||||
2. **A theme ownership map.** Document how `SandboxTheme`, `EditorVisualTheme`, and `PropTheme` divide Stage UI, shared editor chrome, and Prop domain/canvas values. Note intentional overrides and identify any remaining editor chrome that bypasses its shared token.
|
||||
3. **Responsive breakpoints and minimum workspace sizes.** Record minimum usable canvas/inspector widths, when controls wrap, how the Attach preview stacks, and how the two Stickman body-panel columns share space with the Whole Stickman preview.
|
||||
4. **Accessibility and contrast acceptance checks.** Specify contrast targets, keyboard tab order, focus visibility, touch target checks, color-vision-safe cues, and high-font-size behavior. The current references provide useful states and target sizes but do not establish a measured accessibility audit.
|
||||
5. **Motion and sound rules.** Define when transitions, selection flashes, save feedback, and error feedback animate or use sound; keep them brief, informative, and respectful of reduced-motion preferences.
|
||||
6. **Content and localization rules.** Include label length budgets, wrapping/truncation behavior, tooltip standards, pluralization, and translation expansion checks.
|
||||
@@ -363,5 +417,6 @@ To make this guide a stronger long-term design reference, add these pieces as th
|
||||
- [Prop canvas](../../scripts/editors/prop/prop_canvas.gd) — mode behavior, canvas gestures, selection handles, and context menus.
|
||||
- [Stickman Editor controller](../../scripts/editors/stickman/stickman_editor.gd), [Stickman Editor scene](../../scenes/editors/stickman/stickman_editor.tscn), [BodyPartPanel](../../scripts/editors/stickman/body_part_panel.gd), and [Whole Stickman preview](../../scripts/editors/stickman/whole_stickman_preview.gd).
|
||||
- [Stage dialog style helper](../../scripts/shared/stage_ui/stage_dialog_styler.gd) and [Stage capture bar](../../scripts/editors/stage/stage_use_target_capture_bar.gd).
|
||||
- [Phase 20b action plans and DIRECT authoring](../specs/phase_20b_action_plans_and_direct_authoring.md) — Stage-specific action pills, capture feedback, and plan editing.
|
||||
- [Prop Editor UX specification](../specs/phase_9c_prop_editor_ux.md) — current approved Prop Editor mode, Attach preview, marker, and layout behavior.
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Action Reactions — Product Design Brief
|
||||
|
||||
> **Status:** Accepted product decisions captured from the design conversation; not a final architecture or implementation specification.
|
||||
> **Audience:** UI/UX, architecture, and implementation.
|
||||
> **Purpose:** Preserve the agreed child-facing behavior and identify the remaining product and technical questions before implementation.
|
||||
|
||||
## Goal
|
||||
|
||||
Let a child connect a specific moment in an authored action to a purposeful response from one or more characters. A reaction can contain ordered steps, combine compatible actions such as walking and speaking, and itself become the source of another reaction. The same scene events can also be observed by stage-level handlers such as sound or lighting cues.
|
||||
|
||||
The child is directing a planned scene. Reaction links are intentional links to authored action instances, not broad reusable rules such as “whenever Milo walks.”
|
||||
|
||||
## Accepted child flow
|
||||
|
||||
1. In **DIRECT**, the child selects a character. The Director inspector appears only while a character is selected. Clicking outside dismisses it; there is no close button. This is an intentional departure from the shared Stage inspector shell’s fixed Close control. The Phase 20a **EDIT** naming inspector is a separate workflow and retains the shared shell's labeled Close control.
|
||||
2. The inspector keeps the three quick actions **Move**, **Say**, and **Wait** visible.
|
||||
3. An authored action card can be opened to reveal its management choices. From a specific action, the child chooses **Make someone react to this…**.
|
||||
4. The **When** choice is contextual to that action. The child does not browse one long list of generic events.
|
||||
5. The child chooses who responds from a visual, searchable character roster, with on-stage picking available where useful. Each additional responder has a separate **Then** lane.
|
||||
6. **Then** is an ordered set of numbered steps. Actions placed together in one step start together; the next step waits until the current step’s actions finish.
|
||||
7. Reactions to reactions use the same flow. They appear as separate linked cards, not as a nested graph. The source action shows that it starts reactions; browsing all reactions supports search and grouping by their **When** event.
|
||||
|
||||
Numbered steps and paired actions should fit the shared Stage inspector visual system, not introduce a parallel popup style. The shell should retain its safe placement and accessible, clearly labeled controls. The agreed outside-click dismissal is a deliberate exception to the shell’s usual Close button.
|
||||
|
||||
## Stage character names
|
||||
|
||||
- A placed character can be renamed from its inspector when selected in **Edit** mode. This is a new stickman inspector; the current Edit-mode inspector UI is for props and scenery.
|
||||
- The name belongs to that character instance in the scene. Renaming it does not rename or modify the reusable stickman asset.
|
||||
- Character and prop instance names share one scene-wide, case-insensitive uniqueness rule. Placing or duplicating characters that share an asset default name assigns a distinct suffix, such as **Milo**, **Milo 2**, and **Milo 3**.
|
||||
- Action and reaction cards, event descriptions, and responder choices use the current stage-character name.
|
||||
- A character’s saved identity is independent of its display name, so renaming it does not retarget its actions or reactions.
|
||||
|
||||
## Stage prop names
|
||||
|
||||
- A placed prop can be renamed from its selected-prop inspector in **Edit** mode using a **Name on stage** field. The name belongs to the scene instance and does not modify the reusable prop asset.
|
||||
- Duplicate props receive a distinct default name, such as **Blue Door** and **Blue Door 2**. Renaming an instance does not change its saved identity or retarget existing links.
|
||||
- Target choices and action/reaction descriptions show the instance name so a child can distinguish props of the same asset. Where helpful, target pickers should also show a thumbnail or highlight the exact prop on stage.
|
||||
- A Move To action can be described as **Walk to Blue Door** only when it is linked to that specific prop instance. A free-position target remains a place, optionally described as **Place 2 · by Blue Door**. Do not infer prop identity from coordinate proximity.
|
||||
|
||||
## Choosing overlapping stage objects
|
||||
|
||||
- A normal click on one object selects it immediately. A click that hits multiple objects opens **Objects here** and lists only objects whose visible geometry is actually under the click; an object merely nearby or inside another object's bounding-box corner is not a candidate.
|
||||
- Keep the previous selection and inspector as-is while the chooser is open. Hovering or keyboard-focusing a row previews that exact candidate on the stage without committing selection.
|
||||
- Choosing a row commits that object and opens its inspector. Cancel, Escape, or outside dismissal closes the chooser and keeps the previous selection. The click that dismisses the popup must not also select an object behind it.
|
||||
- Rows use the current scene instance name and a clear character/prop icon. The accompanying approved mockup is [Objects here overlap picker](action_reactions_mockups/06_edit_overlap_picker.png).
|
||||
|
||||
## DIRECT mode action previews
|
||||
|
||||
- A selected character’s route is labeled with its authored movement type, such as **Walk** or **Run**. The destination marker identifies the named target, such as **Blue Door**. Do not use a generic “planned walk” label; show only movement types the editor supports.
|
||||
- A standalone **Say** step uses a compact speech-bubble marker in the plan overview. In Step Spotlight, show a yellow-bordered pill containing the speech glyph and **Say**, without a step number or leader. Place it just above the Say step’s predicted ground start point, with a small gap; when Say follows a Move, align it above that Move’s endpoint flag. Keep the dialogue text in the inspector. A focused **Wait** step uses a purple-bordered pill containing the hourglass glyph and **Wait**, without a step number or leader, just above its predicted ground start point. A focused Wait+Say step stacks Wait above Say. For a concurrent Move+Say step, show stacked Move and **Say** action pills near the first third of the grounded route, using the same small action glyphs and cyan/yellow colors as the inspector; do not also draw a separate speech marker for that Say. Say may accompany every other registered action type, while Say+Say, primary+primary, and any third action remain unavailable. In Step Spotlight, show each Together action that can share the actor’s space as two stacked, icon-bearing pills with its primary action above plain **Say**, using each action’s color; fit the pair as one group against the character name, inspector, and viewport. Keep Move+Say near the route’s first third, Wait and other physical actions near their predicted start, and Throw Prop beside the hands or upper torso. Keep Pick Up over its target prop and Say by the character so the target stays clear. The normal plan overview remains compact. These cues signal actions without displaying bubble counts or multiple dialogue bubbles.
|
||||
- Tapping a numbered movement flag selects its existing plan step and reveals the stacked **Edit action** and **Erase action** controls; selecting alone never moves the destination, and flags are not draggable by default. **Edit action** opens the destination editor, where choosing a new place or exact named prop enters capture with a clear prompt and visible **Cancel**. The old flag stays faintly visible while the candidate route and flag follow the cursor. Choosing a new point or exact named prop commits the change; **Cancel** or **Esc** restores the original destination.
|
||||
## Action moments
|
||||
|
||||
Only moments relevant to the selected authored action are shown. “Starts” is omitted from Drop and Set Down labels because the start timing is implied.
|
||||
|
||||
| Authored action or event | Reaction moment |
|
||||
| --- | --- |
|
||||
| **Move To** | When the character begins walking, or when they reach that action’s named endpoint. The endpoint is specific to that Move To action; there is no generic “walk finishes” choice. |
|
||||
| **Say** | When that exact line begins (default), or after that line finishes. The line-start event occurs when the bubble/line appears, so a reaction can start while the speaker is still walking. |
|
||||
| **Wait** | When that exact wait finishes (default), or when it begins. A “finished waiting” reaction only fires if the wait completes. |
|
||||
| **Pick Up** | When the prop is successfully picked up and reaches the character’s hand. |
|
||||
| **Use** | When the character successfully uses the selected prop/use spot. |
|
||||
| **Drop** | When the character begins the authored Drop action. |
|
||||
| **Set Down** | When the character begins the authored Set Down action. |
|
||||
| **Throw action** | A separate event for when a character throws a prop. This is distinct from a prop touching a character. The event fires when the character begins the authored Throw action. |
|
||||
| **Moving prop touches character** | A separate scene event for a moving prop touching a character, such as “When a prop touches Ava.” It is not tied back to, or tracked as, the authored Throw action that may have launched it. Fire once when contact begins, not every physics frame; if contact ends and a new touch occurs, that is a new event. |
|
||||
| **Ragdoll** | **When Milo falls.** No separate settled/lying-still moment for now. Landing, bouncing, and prop contact are distinct physical events. |
|
||||
| **Recover** | When Milo is back on his feet, after the Recover action completes. |
|
||||
|
||||
## Reaction priority and action flow
|
||||
|
||||
- A reaction begins as soon as its selected source moment occurs. It does not wait for the source character’s other action in the same step to finish. For example, Milo can **Walk to Red Door + Say “Help!”** together; Ava’s reaction starts when that exact Say line appears, while Milo keeps walking.
|
||||
- A reaction takes priority for its responder. If Ava is doing an incompatible action, that action pauses. Compatible actions may continue together, such as adding speech while a walk continues.
|
||||
- Ava completes all numbered steps in the reaction before returning to her paused authored plan. Actions grouped in one step start together; the step ends when all its actions finish. Ava resumes her paused action and then continues her original queue.
|
||||
- A **Follow Milo** action tracks Milo’s live position. In the agreed example, Ava follows while Milo walks, and the follow ends when Milo reaches the door and Ava catches up.
|
||||
- If a new reaction for Ava triggers while she is already reacting, the new reaction takes over and cancels the unfinished steps of the current reaction. Completed actions are not undone. The original authored plan remains paused and resumes after the new reaction finishes. The saved earlier reaction remains available for later plays; there is no queue of superseded reactions.
|
||||
- The user does not want extra explanatory UI copy for the basic priority/resume behavior.
|
||||
- Do not show loop warnings for ordinary authored reaction chains. Links refer to specific authored actions, so a finite chain remains finite unless the child deliberately authors more links. The moving-prop contact event is independently repeatable per distinct contact, as described above.
|
||||
|
||||
## Event listeners beyond characters
|
||||
|
||||
One scene event can start a linked character reaction and notify external handlers from the same occurrence. Handlers are not limited to characters: they may drive stage-wide effects such as a sound or lighting cue. For example, Milo’s specific “Help!” line can start Ava’s reaction and notify a stage handler.
|
||||
|
||||
## Editing and deletion
|
||||
|
||||
- Editing the destination of an existing Move To action keeps reactions attached to that authored action and updates the displayed trigger location.
|
||||
- Deleting an action with dependent reactions warns that reactions will also be removed. Do not enumerate all affected reactions. Deleting it removes dangling reactions, including follow-up reactions downstream. If there are no dependent reactions, use a normal confirmation to delete the action.
|
||||
- If editing an action removes the moment a reaction depends on (for example, changing a Walk to a Say), use the same confirmation style as deleting an action with reactions. Explain that reactions will also be removed; on confirmation, delete the dangling reactions and downstream reactions. Canceling keeps the original action and reactions. Never silently retarget a reaction to an unrelated moment.
|
||||
|
||||
## Existing visual guidance and intentional departures
|
||||
|
||||
- Use the established Stage inspector visual system and its shared theme tokens for the inspector shell and controls.
|
||||
- The no-close-button, outside-click dismissal behavior is an intentional departure from the Stage panel system’s fixed Close button, chosen by the user for this inspector.
|
||||
- The user chose a sparse reaction flow and simple delete warning. Do not add an itemized dependent-reaction list to the delete confirmation.
|
||||
- A persistent actor-specific inspector context and actor-named reopen affordance were proposed during UI/UX review; their final layout is not part of the decisions captured here.
|
||||
|
||||
## Architecture follow-up before implementation
|
||||
|
||||
The product decisions require an architecture specification for:
|
||||
|
||||
- Stable authored action identity across save/load, so links remain attached to the exact action rather than row position or action type.
|
||||
- Stable stage-character and prop-instance identities independent of display names, so renames do not retarget actions or reactions and duplicate instances remain distinct.
|
||||
- A Move To target linked to a prop instance must retain that identity separately from its approach position; free-position targets remain distinct from prop-linked targets.
|
||||
- Event routing that can notify both reactions and external/stage handlers from one event, including the moving-prop contact event.
|
||||
- Grouped concurrent actions, multi-step Then execution, reaction priority, interruption and resumption, replacement of an active reaction, and live actor following.
|
||||
- Distinguishing authored Drop and Set Down events from incidental prop release during ragdoll or other runtime behavior.
|
||||
|
||||
These are implementation constraints to carry into the architecture phase; this brief does not claim the current runtime already supports them.
|
||||
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,56 @@
|
||||
# Phase 20 — Action Plans and Reactions
|
||||
|
||||
This roadmap keeps the proposed Phase 20 slices together so they are available when each slice is scoped. The slice boundaries are proposals and can be refined before their specs are written.
|
||||
|
||||
## Source of product decisions
|
||||
|
||||
- [Action and reaction design brief](action_reactions_design_brief.md) — accepted user-facing decisions and architecture requirements.
|
||||
- [Action visualization mockups](action_reactions_mockups/action_visuals_contact_sheet.png) — current DIRECT-mode visual direction.
|
||||
|
||||
Do not duplicate detailed decisions here. Add new decisions to the design brief and update this roadmap only when the slice boundaries change.
|
||||
|
||||
## Proposed slices
|
||||
|
||||
### Phase 20a — Stage identities and names
|
||||
|
||||
**Status:** User-verified on `feature/phase-20-action-reactions`; automated checks, production interaction run, UIUX visual review, and user manual checks passed. The branch remains unmerged. See the [Phase 20a implementation and verification spec](../specs/phase_20a_stage_identities_and_names.md) and [UI/UX spec](../specs/phase_20a_stage_identities_and_names_ui_ux.md).
|
||||
|
||||
- Rename placed characters and props from their selected Edit-mode inspectors.
|
||||
- Give duplicate instances distinct scene names while keeping those display names separate from reusable assets.
|
||||
- Add stable character and prop instance identities that survive save/load and renaming.
|
||||
- Make character and prop names unique across the scene, ignoring case.
|
||||
- For overlapping clicks, show only objects whose visible geometry is hit; preview candidates from the chooser and preserve the prior selection until a row is chosen. The approved mockup is `action_reactions_mockups/06_edit_overlap_picker.png`.
|
||||
- Make those identities available to later action targets and reaction responder choices.
|
||||
|
||||
### Phase 20b — Action plans and DIRECT authoring
|
||||
|
||||
**Status:** Implementation delivered; the user verified manual scenarios 1–7. Broader production-interaction and UI/UX-review acceptance evidence remains unrecorded. The branch remains unmerged. See the [Phase 20b implementation and verification spec](../specs/phase_20b_action_plans_and_direct_authoring.md) for the delivery record.
|
||||
|
||||
- Author each character's ordered action plan, including actions grouped to run together.
|
||||
- Author movement destinations as either a free place or a specific named prop instance.
|
||||
- Support selecting a route marker, deliberately changing its destination, and the agreed DIRECT preview labels and speech marker.
|
||||
- Establish the action identity and serialization needed for event links in later slices.
|
||||
|
||||
### Phase 20c — Event-specific reaction authoring
|
||||
|
||||
- Let a child choose a specific authored action event and choose one or more responding characters.
|
||||
- Author ordered **Then** steps, including actions that start together and actions such as **Follow**.
|
||||
- Apply the agreed warnings and cleanup when deleting an action or editing away its reaction trigger.
|
||||
|
||||
### Phase 20d — Reaction runtime and priority
|
||||
|
||||
- Run reactions at their selected event moments, with reaction priority over the responder's authored plan.
|
||||
- Pause and resume compatible or interrupted actions as agreed; replace an active reaction when a higher-priority reaction triggers.
|
||||
- Execute multi-step reactions and live following, with saved action and reaction links remaining stable.
|
||||
|
||||
### Phase 20e — Prop events and stage-wide handlers
|
||||
|
||||
- Add the authored Throw-start event and the separate moving-prop-touches-character event.
|
||||
- Route one event occurrence to both character reactions and external stage handlers, such as sound or lighting cues.
|
||||
- Keep contact events distinct from the authored Throw action that may have caused them.
|
||||
|
||||
## Workflow
|
||||
|
||||
For each slice, prepare the relevant UI/UX and architecture specs and review them with the user. Before handing a slice back for user verification, pass three gates: automated behavior/persistence assertions, production-path interaction checks, and rendered visual review by UIUX against the style guide and approved mockups. Capture the actual GUI states reached by the interactions; a headless pass, debug print, or screenshot file alone is not proof of the whole flow. Fix findings and rerun the affected checks before sign-off. If an environment prevents a required check, report the specific limitation and the remaining reproducible user check.
|
||||
|
||||
Apply the same gates to 20a through 20e. The detailed 20a scenarios are in [its verification plan](../specs/phase_20a_stage_identities_and_names.md#verification-plan); later slice specs should define their own observable scenarios and captures for action authoring, event links, reaction timing, and prop/stage handlers.
|
||||
@@ -21,12 +21,18 @@ Use this guide when creating or revising child-facing dialogs, popups, and short
|
||||
## Theme and implementation
|
||||
|
||||
- Put reusable colors, sizes, spacing, radii, and border widths in `SandboxTheme` or `PropTheme` tokens. Apply local stylebox overrides instead of mutating a shared theme resource.
|
||||
- For Sandbox Stage modal windows, use `StageDialogStyler` in `scripts/shared/stage_ui/stage_dialog_styler.gd` for the shared shell, typography, and primary, secondary, or destructive button roles. Custom Control-based dialogs can reuse its `panel_style()` and `style_button()` helpers.
|
||||
- Render the shared modal surface as a Control in the stage UI CanvasLayer. Keep the native `AcceptDialog` or `ConfirmationDialog` hidden as the signal source; a full-screen native Window can replace the stage with a blank surface on some render paths.
|
||||
- Use `StageDialogStyler` in `scripts/shared/stage_ui/stage_dialog_styler.gd` for Sandbox Stage and standalone Prop/Stickman confirmation, recovery, and short action dialogs. It owns the shared shell, typography, and primary, secondary, or destructive button roles through `SandboxTheme.popups.confirmation`. Custom Control-based dialogs can reuse its `panel_style()` and `style_button()` helpers.
|
||||
- Render the shared modal surface as a Control in the active scene's UI CanvasLayer. Keep the native `AcceptDialog` or `ConfirmationDialog` hidden as the signal source; a full-screen native Window can replace the active scene with a blank surface on some render paths. Native `FileDialog` contents remain native.
|
||||
- Keep larger text from clipping: use containers that can expand or wrap, and size the dialog after its visible content has been laid out.
|
||||
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
|
||||
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
|
||||
|
||||
## Anchored object chooser
|
||||
|
||||
An anchored chooser resolves a temporary canvas choice, such as picking between objects under one click. It is non-modal and remains visually attached to the stage context; it does not use the dim backdrop or centered card used by a confirmation dialog. Follow the rounded shell, row states, selected badge, footer Cancel, safe-viewport fit, and outside-click behavior in [Editor Visual Style Guide, section 4.6](../guides/editor_visual_style_guide.md#46-anchored-object-chooser). The current Stage implementation is **Objects here** and its tokens live in `SandboxTheme.popups.anchored_chooser`.
|
||||
|
||||
Keep **Cancel** visible even when Escape is supported. Outside dismissal consumes that click so the stage beneath the popup does not receive it. Keep previews transient until the child activates a candidate.
|
||||
|
||||
## Stable position during dynamic resizing
|
||||
|
||||
- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer.
|
||||
|
||||
@@ -98,7 +98,7 @@ StickmanEditor : Control (document, save/load, dirty, active part, view choice)
|
||||
|
||||
There is exactly one `BodyPartPanel` instance per part and one `WholeStickmanPreview` instance. The preview remains a permanent sibling of the body-part workspace in `WorkspaceRow`; it is not duplicated or serialized for a view switch. The controller records each panel's original Overview parent and index. Entering Focus reparents the active panel into `FocusPanelHost`; leaving Focus restores it at the original index. Changing focused part restores the outgoing panel before moving the incoming panel. The controller's existing panel dictionary and signal connections remain stable.
|
||||
|
||||
`StickmanEditor` owns `enum WorkspaceView { OVERVIEW, FOCUS }`, `_active_part_name: String`, `_view_choice_was_manual: bool`, and the pending view/part request. `BodyPartPanel` owns its shapes, selected shape, zoom, and pan; the preview owns its assembly transform and zoom/pan. The part picker changes `_active_part_name` without calling `BodyPartPanel.select()`, which currently selects the topmost shape and would overwrite the child's shape selection. Clicking a panel updates the active part through the existing selection signal. The active part gets a text label and visible border cue in Overview and Focus.
|
||||
`StickmanEditor` owns `enum WorkspaceView { OVERVIEW, FOCUS }`, `_active_part_name: String`, `_view_choice_was_manual: bool`, and the pending view/part request. `BodyPartPanel` owns its shapes, selected shape, zoom, and pan; the preview owns its assembly transform and zoom/pan. The part picker changes `_active_part_name` without calling `BodyPartPanel.select()`, which currently selects the topmost shape and would overwrite the child's shape selection. Clicking a panel updates the active part through the existing selection signal. The active part gets its title inside the panel and a visible border cue in Overview and Focus; Focus does not add a duplicate heading above the canvas.
|
||||
|
||||
Suggested typed additions:
|
||||
|
||||
|
||||
@@ -137,9 +137,9 @@ Add `set_down_prop` to `ActionRegistry`, `ActionEditor`, stickman action popup,
|
||||
|
||||
The interactor adds SET_DOWN_APPROACHING and SET_DOWN_REACHING (or equivalent explicit substates), with one shared nonmutating solver for authoring preview and PLAY validation. It reuses the queued held-prop preview prefix from Throw to determine the prop, grip, hand, and predicted actor pose. The prefix evaluator must also consume earlier Set Down actions as release actions and account for Drop/Throw; it may return `known=false` when a rule or earlier action makes the held prop uncertain. Do not move or reserve a live prop during preview. When unknown, show the neutral marker and **Prop depends on earlier actions**, and assess grounded approach/reach where possible without pretending to know exact occupancy. As with the existing Throw preview, cache physics shape checks in `_physics_process()` and draw the latest result in `_process()`; do not perform fresh direct-space queries from every pointer draw.
|
||||
|
||||
For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. Do not add an implicit owner-collision grace period or permit release inside the torso. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop.
|
||||
For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop.
|
||||
|
||||
During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; ragdoll aborts Set Down and uses the existing forced-release path with the authored released profile. STOP restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor.
|
||||
During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. For a pickup, when the actor reserves its target, add an owner-specific contact exception between that target and the reserving rig's animated and ragdoll collision bodies. Keep it while `interaction_reserved_by` names that rig, regardless of distance or elapsed time; the owner's movement sweeps also skip only that target. On successful pickup, the `HELD` role clears the exception with the collision profile. On cancel or failure, release the reservation but retain an exit grace until the actor's collision shapes clear the prop by a 24 px query margin for two consecutive physics frames. For Set Down, add the same owner-specific exception after release and keep it until those shapes clear by that margin. Neither behavior changes authored collision flags or contact with the support, other props, or other stickmen; normal contacts resume after clearance. These protections are owner-specific and clearance-based, not inherited from the support; pickup protection lasts for the full reservation so it cannot expire while the actor is far away. Drop, Throw, and ragdoll forced release keep their authored contact behavior. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; STOP clears temporary exceptions and restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor.
|
||||
|
||||
## Stage UI, selection, and theme
|
||||
|
||||
@@ -151,6 +151,6 @@ Use `SandboxTheme.control_theme()` on the card and popup. Read dimensions, fonts
|
||||
|
||||
## Regression checklist
|
||||
|
||||
For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, and support contact, followed by Godot headless syntax validation. A small runtime scene can check that a support-only table catches a released pot while a stickman passes through, that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release.
|
||||
For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, support contact, Set Down owner-collision grace, and reserved-pickup protection, followed by Godot headless syntax validation. A runtime Set Down followed immediately by Move should confirm that the releasing stickman can clear the prop without knocking it away, the temporary exception is removed after clearance, a different stickman can still contact it, and the prop still rests on its support. A reserved pickup should stay protected through a long approach, clear on successful pickup, and transition to exit grace on cancellation/failure. Also verify that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release.
|
||||
|
||||
The approved table/pot, open-air Set Down, invalid-target feedback, overlap chooser, card readability, old-scene replay, and movement-blocking behavior are useful visual/runtime regression scenarios.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Phase 20a — Stage identities, names, and overlap selection
|
||||
|
||||
**Status:** User-verified; identity, interaction, and UIUX visual gates passed. Committed and pushed on the Phase 20 feature branch; no merge has been made.
|
||||
|
||||
**UI authority:** [Phase 20a UI/UX](phase_20a_stage_identities_and_names_ui_ux.md). The [Phase 20 design brief](../plans/action_reactions_design_brief.md) records the product decisions. This slice supplies stable identities and readable names for placed characters and props, and makes overlapping object selection match the approved mockup. Action plans, reaction links, and runtime event routing belong to Phases 20b–20e.
|
||||
|
||||
## Current system and boundaries
|
||||
|
||||
- `StageSceneAdapter.collect()` assigns `file_id` from each object's type and current World order on every save. Existing rule encoding maps `Object.get_instance_id()` through those temporary IDs. Keep that conversion intact. A `file_id` is not persistent object identity.
|
||||
- `PropBlock.stage_object_id` already persists and targets prop actions. `StickmanRig` lacks the equivalent. Neither placed type has an instance display name. The selected-prop card currently shows the asset library name; there is no EDIT character card.
|
||||
- `StageSelection.hit_test_all()` currently tests World AABBs and returns candidates in front-to-back order. `_begin_click_select()` and the scenery click path select the frontmost object before opening **Objects here**. `StageModalViews` builds an anchored `PopupPanel` whose labels use asset/node names. The same popup also resolves multiple props during DIRECT Use-target capture.
|
||||
- Selection remains in `StageSelection`; Stage inspectors and `StageGizmos` render it. The new chooser preview must remain separate from that selected state.
|
||||
|
||||
## Identity and name contract
|
||||
|
||||
Persist these two **object entry** fields for every `type == "stickman"` or `type == "prop"` in `.scene`:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"stage_object_id": "stickman_172...",
|
||||
"stage_name": "Milo 2",
|
||||
"stk_path": "res://content/stickmen/milo.stk"
|
||||
}
|
||||
```
|
||||
|
||||
`stage_object_id` is an opaque, nonempty, scene-unique string. It is assigned once per placed instance and does not change on rename, move, save, load, or asset repair. A duplicated placed instance receives a new ID. Keep existing prop IDs exactly, including values already referenced by queue/rule prop actions. A `stage_name` is a child-facing scene label, independent of `Node.name`, the `.stk` name, the `.prop` library name, and the asset path. Its trimmed, nonempty value is unique across **both** characters and props using a case-insensitive comparison. A capitalization-only rename of the same object is valid.
|
||||
|
||||
`file_id` remains the per-save key for the current `StageSceneIO.encode_rules()/decode_rules()` runtime-ID remap. Do not replace it in Phase 20a. Existing prop action payloads keep using `target_prop_id == stage_object_id`. Later phases may use the common persistent IDs for actor, responder, and movement target references without using display names as keys.
|
||||
|
||||
### Typed live APIs
|
||||
|
||||
| Class / file | Contract and responsibility |
|
||||
| --- | --- |
|
||||
| `StickmanRig` — `scripts/characters/stickman/stickman_rig.gd` | Add `var stage_object_id: String = ""` and `var stage_name: String = ""`. These are placed-instance state; the stickman factory does not write them to reusable `.stk` content. |
|
||||
| `PropBlock` — `scripts/props/prop_block.gd` | Retain `stage_object_id`; add `var stage_name: String = ""`. Prop library templates remain unchanged. |
|
||||
| `MissingStickmanPlaceholder` / `MissingPropPlaceholder` | Read both fields from `original_entry`, retain them while the asset is absent, and write their current values back over the cloned entry during collection. Asset repair must carry both values to the restored node. |
|
||||
| `StageObjectIdentity` — proposed `scripts/stage/stage_object_identity.gd`, `class_name StageObjectIdentity extends RefCounted` | Scene-scoped allocation and lookup, not an autoload. Typed public functions: `func get_id(node: Node2D) -> String`, `func get_name(node: Node2D) -> String`, `func assign_new(node: Node2D, base_name: String, world: Node2D) -> void`, `func next_free_name(base_name: String, world: Node2D, exclude: Node2D = null) -> String`, `func validate_name(requested: String, world: Node2D, exclude: Node2D = null) -> Dictionary`, and `func rename(node: Node2D, requested: String, world: Node2D) -> Dictionary`. The result shape is `{ "ok": bool, "name": String, "suggestion": String, "reason": StringName }`, with `reason` `&""`, `&"blank"`, or `&"duplicate"`. Only named characters and props participate in the name search. |
|
||||
| `SandboxStage` — `scripts/editors/stage/sandbox_stage.gd` | Own one identity helper for the active World. Call it after a real placement, before placement feedback; never assign IDs to ghosts. Handle rename requests, mark the document dirty on success, refresh the selected inspector and visible chooser labels, and preserve selection. Use the same helper when an existing placement is duplicated. |
|
||||
|
||||
Additional typed touchpoints:
|
||||
|
||||
| Existing or new class | Phase 20a interface |
|
||||
| --- | --- |
|
||||
| `class_name StageSceneIO extends RefCounted` | `const FILE_VERSION: String = "1.8"`; keep `static func read_file(path: String) -> Dictionary` and `_validate_root(data: Dictionary) -> Dictionary` as the non-destructive migration/validation boundary. |
|
||||
| `class_name StageSceneAdapter extends RefCounted` | Keep `func collect(...) -> Dictionary` and `func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary`; include both object fields in each direction. |
|
||||
| `class_name StageSelection extends RefCounted` | Keep `func hit_test_all(world_pos: Vector2) -> Array[Node2D]`; add a private typed visible-hit helper, such as `func _visible_hit(node: Node2D, world_pos: Vector2, screen_tolerance_px: float) -> bool`. |
|
||||
| `class_name StageGizmos extends Node2D` | Add `func set_candidate_preview(node: Node2D) -> void`; a null node clears the preview. The candidate field is transient and distinct from selected targets and ordinary hover. |
|
||||
| `class_name StageModalViews extends RefCounted` | Keep `signal object_selected(node: Node2D)`; add `signal candidate_preview_changed(node: Node2D)` and `signal objects_here_dismissed()`. `show_objects_here(...) -> void` retains the candidate list and prior-selection list. |
|
||||
| `class_name StagePropSettingsView extends Control` | Add `signal rename_requested(node: Node2D, requested_name: String)`; show the stage name once in the shared header beside Rename; keep its existing prop role/spot signals and shared shell. The prop body has no duplicate static name row. |
|
||||
| New `class_name StageCharacterSettingsView extends Control` in `scripts/editors/stage/stage_character_settings_view.gd` | `signal rename_requested(node: Node2D, requested_name: String)`; `func show_character(rig: StickmanRig, safe_rect: Rect2) -> void`; `func hide_card(reset_selection: bool = false) -> void`. Own the compact name-and-Rename header and inline naming presentation only. |
|
||||
|
||||
`StageObjectIdentity` should inspect the current World when allocating or validating. Scene sizes are small, and a scan avoids a second mutable registry that can drift after deletion, load, or replacement. The existing prop ID generator may be moved behind `assign_new`; it must check both named types and missing placeholders for collisions. Name suffixing chooses the smallest available positive suffix at or above 2 without renaming existing instances. A terminal ` <number>` suffix from a duplicated instance is interpreted as the same base, so duplicating **Milo 2** among **Milo** and **Milo 2** offers **Milo 3**, rather than **Milo 2 2**. Deleting **Milo 2** can make that suffix available to a future placement. Compare with `to_lower()` after trimming outer spaces; preserve the child's chosen spelling and internal spaces.
|
||||
|
||||
Use the Stickman Library entry's `name` as a new character's base name; that entry is derived from the `.stk` `stickman_name`, falling back to the `.stk` basename. Use the Prop Library's `name`/`label`, falling back to the asset basename or **Prop**, as a new prop's base name. If the chosen base is empty after trimming, use **Character** or **Prop**. Repeated placement and duplication allocate IDs and names from the current scene, without a naming dialog. An asset rename never silently changes already placed `stage_name` values.
|
||||
|
||||
### Save, migration, and validation
|
||||
|
||||
Advance `StageSceneIO.FILE_VERSION` from `1.7` to `1.8`. Extend `StageSceneAdapter.collect()/apply()` to write and restore both fields for real and missing objects. Assign restored values before `_finalize_placed_node()` can generate a fresh prop ID. Save and autosave use the same adapter, so both retain identity. Existing `file_id` generation and rule mapping continue unchanged.
|
||||
|
||||
For scene versions `1.0`–`1.7`, preserve all current migrations, then fill the two new fields in object order. Keep every existing nonempty prop `stage_object_id`. For each legacy character, derive a deterministic ID from its pre-migration `file_id` (for example `stickman:<file_id>`) and add a deterministic suffix only if it collides with another persistent ID. Do this once at the `1.8` migration boundary; after saving, the stored ID is authoritative. Derive initial names from each asset's familiar name where available, then allocate scene-wide suffixes in document order. The migration's base-name resolver can read the existing Stickman/Prop Library metadata but must not instantiate or modify World nodes. An unavailable asset uses its saved path basename as the base and retains the resulting name and ID in its placeholder. With unchanged asset metadata, opening the same old file before its first save must yield the same identities and names each time.
|
||||
|
||||
The current migration contains version-specific Sky/Plain Background and wall-tint conditions. When adding `1.7` to accepted legacy versions, ensure it does **not** receive pre-1.7 background defaults or pre-1.6 wall-color rewrites. Add the new identity/name pass after the existing older-version transformations.
|
||||
|
||||
For a version `1.8` document, `StageSceneIO._validate_root()` requires nonempty string IDs and trimmed, nonempty string names for character and prop entries; rejects duplicate IDs across both types and case-insensitive duplicate names; and does not silently repair ambiguous current-format references. Validate before `StageSceneAdapter.apply()` clears the current World, preserving the existing non-destructive read boundary. The legacy migration allocates missing fields before this validation. A successful manual rename trims outer spaces, permits capitalization-only edits, and returns `blank` or `duplicate` with a suggested free name without altering the saved or live name. A successful rename marks the scene dirty once; Cancel and invalid input do not.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Asset[Reusable asset name] -->|placement base| Allocate[StageObjectIdentity]
|
||||
Allocate --> Live[Placed stage_object_id + stage_name]
|
||||
Live --> Adapter[StageSceneAdapter collect/apply]
|
||||
Adapter --> File[.scene v1.8 object entry]
|
||||
Live --> Card[EDIT inspector and Objects here labels]
|
||||
Live --> Later[20b–20e stable links]
|
||||
FileID[file_id per save] --> Rules[Existing rule ID remap]
|
||||
```
|
||||
|
||||
## EDIT inspector flow
|
||||
|
||||
Adopt the shared `StageInspectorPanel` shell, `SandboxTheme.stage_panels` type/spacing/button tokens, safe rectangle, and fixed header. Show the selected object's name once in the header beside a visible **Rename** button. The prop inspector uses a disclosure chevron: **▴** collapses the settings body into the same named header and **▾** expands it. The character inspector stays as a compact name-and-Rename bar because naming is its only EDIT setting. Both compact headers remain one line; long names use an ellipsis and keep the full text in a tooltip. Clicking empty stage space clears selection and removes the bar. Activating Rename opens a prefilled inline `LineEdit` with explicit Done/Cancel controls and nearby validation feedback. Do not make the title itself an undisclosed rename hotspot. `StagePanelChoiceGroup` and `StagePanelToggleRow` remain available for choice and switch content; keep the modal **Objects here** layout under `StageModalViews` and `SandboxTheme.control_theme()`, since its point-click popup behavior differs from the persistent inspector.
|
||||
|
||||
The view owns only a draft name and inline error presentation. It emits intent, such as `signal rename_requested(node: Node2D, requested_name: String)`. `SandboxStage` validates and mutates via `StageObjectIdentity`, then refreshes the view and labels. On blank/duplicate results, keep the field and focus in place and show the UI spec's explanation and suggestion. Enter requests Done; Escape cancels the draft; selection change cancels it. Multiple selection shows the approved helper instead of an editable field. The header and inspector anchor stay stable while the body grows. Do not use `Node.name` as a shortcut for stage names.
|
||||
|
||||
The placement ghost displays `next_free_name()` as **Click to place …**, and the actual placed node displays brief **… placed** feedback. Only actual placement commits an ID or name. Deletion releases a name naturally when the node leaves World; no tombstone/name counter is needed. Existing objects are never renumbered.
|
||||
|
||||
## Visible-geometry hit model
|
||||
|
||||
`StageSelection.hit_test_all(world_pos: Vector2) -> Array[Node2D]` remains the single source for both immediate click selection and **Objects here**. Use `get_world_aabb()` as a broad phase, expanded only by a small tolerance converted from screen pixels with camera zoom. For `PropBlock` and `StickmanRig`, run a second pass against the actual visible polygons and stroked lines in their transformed visual subtree. Use `Geometry2D.is_point_in_polygon()` for filled `Polygon2D` shapes; for `Line2D`, compare distance to each transformed segment against half its rendered width plus an approximately 6–8 logical-pixel screen tolerance. Respect node transforms, rotation, scale, `is_visible_in_tree()`, and effective opacity. If a custom prop is visibly composed from multiple shapes, inspect the shapes rather than treating its collision hull or union AABB as the visible hit. A transparent gap or a door outside the click's visible area is not a candidate. Missing-asset markers use their drawn marker rectangle. Keep existing AABB behavior for terrain/scenery/areas in this slice, so their selection remains covered by debt item #12.
|
||||
|
||||
Preserve front-to-back sorting after the exact filter. The small line tolerance keeps thin limbs selectable, but it should not pull in a visually separate neighboring object. Hover and click should use the same hit model where practical so the cue predicts the candidate list. Do not replace the precise visible hit with a physics point query: an animated `StickmanRig` has no comparable physics body, and a prop's collision hull can differ from its rendered shape.
|
||||
|
||||
## Overlap chooser and transform gesture precedence
|
||||
|
||||
```text
|
||||
EDIT left press → StageSelection.hit_test_all(point)
|
||||
0 hits: existing empty-click / box-select behavior
|
||||
1 hit: select immediately (existing drag handling remains)
|
||||
2+ hits: defer the choice until movement or release
|
||||
selected candidate + pointer moves ≥ 8 screen px → drag current selection, no chooser
|
||||
selected gizmo handle → rotate/resize immediately, no chooser
|
||||
pointer released before threshold → show Objects here, selection and inspector unchanged
|
||||
row hover/focus → transient candidate preview
|
||||
row click → validate candidate → select_only(candidate) → close
|
||||
Cancel/Escape/outside → clear preview → close, no selection write
|
||||
```
|
||||
|
||||
Use screen-space distance so the drag threshold feels the same at each camera zoom. The selected object under the press is the drag anchor even if it is behind another visible candidate; dragging it moves the current selection as usual. Keep rotate and prop resize handle hit tests ahead of overlap disambiguation. If no already-selected candidate is under the press, a multi-hit gesture remains a chooser click. After a chooser commit in EDIT, show the brief teaching toast **Drag to move · Click to choose**; do not show it for DIRECT Use or scenery selection.
|
||||
|
||||
Add `StageModalViews` signals `candidate_preview_changed(node: Node2D)` (`null` clears), `object_selected(node: Node2D)` (existing commit signal), and `objects_here_dismissed()`. Row hover and keyboard focus emit the same preview; pointer exit, Cancel, popup hide, and invalidated candidates clear it. The popup receives stage-name labels and a **Selected** badge for candidates in the previous selection. Rows stay at least 44 px high, preferably around 56 px, with bounded scrolling and a visible Cancel action.
|
||||
|
||||
`SandboxStage` owns `_objects_here_candidates: Array[Node2D]` for the one open chooser and routes preview to a new transient `StageGizmos.set_candidate_preview(node: Node2D)` API. This preview is separate from `_targets` and `_hovered`; it draws the EDIT-cyan visible outline/silhouette above overlap art and a nearby cyan-outlined rounded pill containing only the candidate's **name**. The pill appears while a chooser row is hovered or focused; do not add a “Preview:” prefix. The normal selected outline remains visible. Clear candidate preview when the chooser closes, selection changes, mode changes, or the candidate is deleted. Row commit checks the node is still valid, selectable, and in the stored candidate set before calling `select_only()` once. The chooser should snapshot the prior selection solely to display **Selected** badges; it need not restore a snapshot because opening and previewing never mutate `StageSelection`.
|
||||
|
||||
Give the popup a full-viewport transparent input guard below its anchored card, or an equivalent centralized consumed-event path. The guard closes on an outside press and consumes that press, so it cannot also run Stage click selection. Escape and Cancel call the same close path. `popup_hide` is a final cleanup path and should not commit selection. The EDIT click path and scenery overlap path must remove their eager `select_only(hit)` calls before opening the chooser. Shift and multi-select behavior can continue its existing direct-toggle path when no chooser is requested.
|
||||
|
||||
The DIRECT Use-target picker already filters `hit_test_all()` down to props and calls `_select_use_target_prop()` after row choice. Retain that prop-only filter and target-commit path. Store a chooser context (`&"edit_select"` or `&"direct_use_prop"`) in `SandboxStage` when opening the shared popup; route its existing `object_selected(node)` signal according to that context. This avoids mixing the EDIT selection commit with a DIRECT target commit or opening an EDIT inspector during capture. Both contexts use the same precise hit list and stage-name labels. Dismissing a DIRECT chooser leaves the pending target capture active and unchanged.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Child
|
||||
participant Stage as SandboxStage
|
||||
participant Hits as StageSelection
|
||||
participant Popup as StageModalViews
|
||||
participant Gizmos as StageGizmos
|
||||
Child->>Stage: Press shared visible point
|
||||
Stage->>Hits: hit_test_all(point)
|
||||
Hits-->>Stage: [Milo, Ava]
|
||||
alt Selected candidate under pointer, drag reaches 8 screen px
|
||||
Stage->>Gizmos: begin_translate_drag(selected candidate)
|
||||
Child->>Stage: Move and release
|
||||
Stage->>Gizmos: drag_to(point), end_drag()
|
||||
Stage-->>Child: Keep selection; no chooser
|
||||
else Pointer released before 8 screen px
|
||||
Stage->>Popup: show_objects_here(candidates, prior selection)
|
||||
Child->>Popup: Hover or focus Ava row
|
||||
Popup->>Stage: candidate_preview_changed(Ava)
|
||||
Stage->>Gizmos: set_candidate_preview(Ava)
|
||||
Child->>Popup: Click Ava row
|
||||
Popup->>Stage: object_selected(Ava)
|
||||
Stage->>Hits: select_only(Ava)
|
||||
Popup->>Stage: objects_here_dismissed()
|
||||
Stage->>Gizmos: set_candidate_preview(null)
|
||||
end
|
||||
Note over Stage,Gizmos: Rotate and resize handles take priority on press
|
||||
```
|
||||
|
||||
## Acceptance outcomes and observation path
|
||||
|
||||
1. Place two copies of a character and two copies of a prop with the same asset bases. The ghost announces the next free name; feedback confirms the placed name. Each object gets its own persistent ID; names are distinct across both types regardless of capitalization. Asset files and names do not change.
|
||||
2. Select one character or prop in EDIT. Its inspector header shows the stage name once beside Rename. The prop disclosure collapses and expands the body while preserving the named header; the character inspector stays compact. A valid rename updates the header and future chooser labels, marks the scene dirty, and keeps its ID. Blank or conflicting names stay in the field with a suggestion. Cancel, Escape, and selection changes abandon the draft. A multiple selection offers no name editor.
|
||||
3. Save, close, and reload. Observe the same names and IDs on both real objects and any missing-asset markers. A prior-version scene loads with deterministic generated character IDs and unique names; saving it writes v1.8. Existing prop targets and rule references still resolve through their respective IDs.
|
||||
4. Put Milo and Ava over one click point, with a Blue Door nearby but outside that clicked visible geometry. The chooser lists Milo and Ava only. Hover or focus Ava: the exact Ava preview and name pill appear while prior selection and inspector remain. Cancel, Escape, or an outside click clears preview and leaves prior selection intact; that outside click causes no second stage selection. Choosing Ava selects her once and opens her inspector.
|
||||
5. With an overlapping object selected, press its visible body and drag past 8 screen pixels: move the selected object or current selection without opening the chooser. Release before the threshold: open the chooser and preserve the previous selection. Rotate or resize from the selected gizmo handle: begin that transform immediately without opening the chooser.
|
||||
6. Click where only one visible object is hit: select immediately. Click inside an object's AABB but outside all visible character/prop art: it is not selected. Thin visible limbs remain selectable with the documented tolerance. In DIRECT Use target capture, multiple touching props still offer a prop-only chooser and choosing one advances that target capture; dismissing it leaves capture pending.
|
||||
|
||||
A repeatable manual review is: create **Milo**, **Milo 2**, and **Blue Door**; rename the first to **Max**; overlap Max and Milo 2; position Blue Door close to, but outside, the test point; save and reopen; then run the click, preview, Cancel, and commit observations above. Record IDs from the saved `.scene` before and after reload to confirm they remain byte-for-byte equal while `file_id` continues to serve the existing rule remap. The user reports completing the sign-off workflows for names and persistence, overlap selection and movement, and the DIRECT **Use…** picker; see the User verification record below.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Headless assertions, a production-path interaction run, and a render-capable visual review cover different failure classes; a successful launch or debug print alone does not satisfy a gate. The report below records the completed gates, including user verification at the end.
|
||||
|
||||
### 1. Automated behavior and persistence
|
||||
|
||||
- Add focused assertions for `StageObjectIdentity`: allocation for both types, cross-type case-insensitive conflicts, suffix selection, capitalization-only rename, blank/duplicate rejection with suggestions, duplicate placement receiving a fresh ID, and freeing a name when an object is deleted.
|
||||
- Exercise `StageSceneIO` migrations from each supported version `1.0`–`1.7` to `1.8`. Assert deterministic character IDs/names, exact retention of existing prop IDs, and rejection of duplicate/empty IDs and names in current-format documents before the live World is cleared.
|
||||
- Exercise production `StageSceneAdapter.collect()/apply()` round trips for real and missing-asset characters and props. Assert that rename, save/load, and asset repair preserve instance IDs and names while `file_id` continues to serve rule remapping.
|
||||
- Cover the hit model with visible polygon interiors, points outside polygon art but inside the AABB, thin limb lines, transforms, visibility, camera zoom, and front-to-back ordering. A nearby object outside the click's geometry must not enter the candidates.
|
||||
|
||||
### 2. Production-path interaction
|
||||
|
||||
- Run a GUI-capable Stage harness that instantiates `SandboxStage`, creates the overlap arrangement from acceptance outcome 4, and drives the same click, pointer, focus, keyboard, and button routes a child uses. Use Godot input-event injection or desktop UI automation where supported; do not substitute direct calls to `select_only()` or popup callbacks for the interactions being verified.
|
||||
- Assert selection and inspector state before opening the chooser, during row preview, after row commit, and after Cancel, Escape, and outside dismissal. Confirm candidate preview always clears on close, stale candidates cannot be committed, and the outside-dismissal press is consumed instead of selecting the stage object behind the popup.
|
||||
- Drive the name editor through Done, Enter, Cancel, Escape, invalid-name feedback, and selection changes. Assert document dirty state and saved values change only after a valid commit.
|
||||
- Exercise the shared DIRECT Use-target picker separately. Verify it remains prop-only, commits through the target-capture route, and leaves capture pending after dismissal.
|
||||
|
||||
### 3. Rendered visual and UIUX review
|
||||
|
||||
- Capture rendered PNGs from the same production-path flow in a render-capable GUI session. Include: prop inspector; character inspector using the colorful `break.stk` art; name editing with a duplicate-name message; overlap chooser with the previous selection visible; hover preview; keyboard-focus preview; Cancel/Escape/outside-dismissal result; and the nearby Blue Door omitted from candidates. Include a crowded chooser/scroll state, a long name in the collapsed prop header at 920×560, and a character near the top chrome to verify the rotation handle moves below the rectangle while retaining its 44 × 44 hit target.
|
||||
- Capture an ordered set of states (or a short recording where available) so UIUX can judge how the controls respond across the flow, not only isolated static layouts. Ask UIUX to compare the rendered pixels with `docs/guides/editor_visual_style_guide.md`, `docs/specs/stage_panel_ui_system.md`, the approved UI/UX spec, and `docs/plans/action_reactions_mockups/06_edit_overlap_picker.png`.
|
||||
- Have UIUX report concrete visual findings for hierarchy, theme-token use, target size, readable names, focus/hover/selection distinction, anchoring, and clipping. Address findings and recapture the affected states before considering visual review complete.
|
||||
- Inspect the actual rendered pixels. A capture file existing or the scene launching does not prove the visual result. Follow the same visual-capture conventions used by `tests/phase19_gui_visual_capture.gd` and `tests/phase19_depth_inspector_visual_capture.gd`.
|
||||
|
||||
The verification report should list the commands and scenarios run, assertion results, capture locations, UIUX findings and resolutions, and any unverified outcome with its specific environment limitation. Keep debug prints or temporary overlays as diagnostic aids; they are not substitutes for assertions or rendered review. If GUI input or rendering cannot be established after trying an available route, mark the affected outcomes unverified and leave a precise reproducible user check. The popup interaction can follow the existing test precedent in `tests/test_phase4b_popup_anchor.gd`.
|
||||
|
||||
## Architectural decisions and trade-offs
|
||||
|
||||
- **Separate persistent ID, display name, and save-local `file_id`:** preserves current prop targeting and old rule serialization while preparing exact references for later action/reaction work. It adds two string fields per named object.
|
||||
- **Scene-scoped helper over an autoload or name registry:** avoids stale indices after deletion and keeps preview scenes isolated. A World scan is sufficient for the expected child-authored scene size; index it later only if profiling shows a need.
|
||||
- **Visible-geometry narrow phase for characters/props:** prevents false chooser rows and aligns one-hit selection with the mockup. Drawing and hit extraction must agree on transform and visibility; the line tolerance protects touchability. Terrain/scenery precision remains a separate issue in debt #12.
|
||||
- **One chooser with explicit context:** reuses the established popup for EDIT and DIRECT while keeping each commit side effect explicit. The transient preview has no authority to mutate scene or selection state.
|
||||
|
||||
## Verification report
|
||||
|
||||
### Automated behavior and persistence
|
||||
|
||||
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_identity.gd` — **200 checks passed**. Covers identity/name allocation and validation, deterministic migrations from 1.0–1.7, current-format rejection, real and missing-object collection/apply, and a file save/read round trip.
|
||||
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_hit_testing.gd` — **12 checks passed**. Covers polygon interior and empty AABB space, front-to-back order, transformed props, line touch tolerance at two zooms, and hidden/transparent shapes.
|
||||
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_content_paths.gd` — **17 checks passed**. The old-path fixture now uses the pre-identity 1.7 schema plus its required background fields, then verifies migration, loading, and canonical asset paths.
|
||||
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase4b_stage.gd` — **53 assertions passed**. Restored the documented bare-Escape fallback from DIRECT to EDIT after the existing regression exposed its absence.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
### Production-path interaction and rendered review
|
||||
|
||||
- `tests/phase20a_gui_visual_capture.gd` ran in a render-capable Godot 4.7.1 session. **104 production-input assertions passed** and 25 captures were saved at 1280×720 and 920×560. Scenarios include keyboard-driven naming and dirty state, duplicate-name feedback, prop and character overlaps with Blue Door excluded outside the clicked geometry, chooser preview/commit/Cancel/Escape/outside dismissal, DIRECT Use target progression, crowded-list scrolling at a smaller viewport, long-name one-line inspector headers, a top-edge rotation handle, and an actual rotation drag through the Stage input path.
|
||||
- Captures are in `tests/phase20a_captures/`; key states include `prop_inspector.png`, `character_inspector.png`, `prop_inspector_collapsed.png`, `prop_inspector_collapsed_long_name_small_viewport.png`, `break_character_long_name_top_handle_small_viewport.png`, `character_duplicate_name_feedback.png`, `character_overlap_chooser.png`, `character_overlap_preview.png`, `character_overlap_committed.png`, `direct_use_chooser.png`, `direct_use_target_selected.png`, `placement_name_prompt.png`, `placement_feedback.png`, and both `crowded_chooser_*_small_viewport.png` states.
|
||||
- UIUX compared the current captures against `docs/guides/editor_visual_style_guide.md`, the amended shared Stage panel spec, this UI/UX spec, and the approved overlap mockup. **Visual review accepted.** The long prop title truncates to one line while keeping the full name in its tooltip; the Break art reads clearly inside the stronger outline; the compact rotate handle moves below the selection near the top chrome and remains operable through its 44 px hit target. The preview-name pill may move toward the upper-right at 920×560 when space is tight; the cyan candidate outline remains distinct.
|
||||
- The follow-up name-pill refinement uses `tests/phase20a_popup_style_preview.gd` to render the actual Stage chooser, overlapping characters, and selected **Blue Door** inspector without invoking any scene-recovery action. Its **14 assertions passed**. It refreshed `character_overlap_preview.png`, `overlap_hover_preview.png`, `overlap_candidate_preview.png`, and `overlap_chooser_popup_style.png` at 1280×720. The candidate label now contains the name alone inside a rounded cyan-outlined pill. UIUX reviewed `character_overlap_preview.png` and approved the pill's alignment, separation from the Selected badge, and spacing from the open inspector.
|
||||
- The same render-capable Stage harness also drives overlap presses, pointer motion, and releases through production input. **32 assertions passed**: a press waits; movement below 8 px still resolves as a click; release opens the chooser without changing selection; dragging the selected object moves it without opening the chooser; and rotate/resize handles start their transforms without opening it. This checks the gesture decision alongside the actual popup styling and layout.
|
||||
- A later full `tests/phase20a_gui_visual_capture.gd` attempt did not reach its first placement step because a pre-existing scene-recovery dialog was open. No recovery button was activated; the recovery copy was left intact. The isolated visual harness above verified the updated chooser and name pill.
|
||||
- An additional run of `tests/test_phase4_final_bugs.gd` ended with **16 failures of 80 checks** across action-editor/payload assertions, synthetic headless prop-drag input, overlay speech assertions, left-idle mirroring checks, and a source-text contract. This broader script is outside the Phase 20a acceptance set and those failures were not changed here. The new below-box rotation behavior itself passes the render-capable production-input check above.
|
||||
|
||||
The Windows Godot runs print a root certificate-store warning; the render-capable run also reported shader-cache warnings. Neither prevented execution or capture. Missing-asset warnings in the identity suite are expected because that test deliberately verifies editable placeholders.
|
||||
|
||||
### User verification
|
||||
|
||||
- The user reports manually testing the remaining sign-off workflows and that they look good: stage names and persistence, overlapping selection and movement, and the DIRECT **Use…** prop-target chooser. The user had also reviewed the EDIT-mode appearance.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Phase 20a — Stage identities and names UI/UX
|
||||
|
||||
**Status:** Updated from user feedback; current inspector and selection-cue captures received UIUX approval. The implementation and test report are in `phase_20a_stage_identities_and_names.md`.
|
||||
|
||||
## Current behavior
|
||||
|
||||
- EDIT has a prop inspector, but selected characters have no inspector or stage-name field.
|
||||
- The current **Objects here** popup is opened for multiple AABB candidates, after the frontmost object has already been selected. Its rows use a prop library name or a character's node name.
|
||||
- `StageSelection.hit_test_all()` currently treats the world-space AABB as the hit test, so empty corners of a rotated or irregular object's bounds can appear as candidates.
|
||||
|
||||
## Name controls
|
||||
|
||||
- Each placed character and prop receives a distinct scene display name automatically. The asset's familiar name is the base: for example, **Milo**, **Milo 2**, and **Blue Door**, **Blue Door 2**. Placing an object does not open a naming dialog or stop repeat placement. Existing instances keep their names when another instance is added or removed.
|
||||
- Character and prop names share one scene-wide, case-insensitive uniqueness rule.
|
||||
- Show the selected character or prop name once, in the inspector header beside its icon. Add a visible **Rename** button there; do not make the title text itself a hidden rename hotspot.
|
||||
- A selected prop's header also has a 48 px disclosure button. **▴** collapses the settings body into the same named one-line bar; **▾** expands it. Keep the name and **Rename** visible when collapsed. Do not replace the selected prop name with a generic **Settings** button.
|
||||
- A selected character stays as a compact name-and-**Rename** bar because naming is its only EDIT inspector setting. It has no disclosure control. Compact prop and character headers stay on one line; long names truncate with an ellipsis while the full name remains available as a tooltip. Clicking empty stage space clears the selection and removes the bar.
|
||||
- Activating **Rename** reveals a prefilled single-line editor with **Done** and **Cancel**. Enter commits; Escape cancels. Show duplicate/blank feedback under the editor. Keep the header anchored as the card grows. Changing selection cancels an uncommitted draft.
|
||||
- Trim outer spaces on commit. Compare the requested name case-insensitively against all other named character and prop instances. Blank or duplicate names remain in the field with a nearby kid-friendly explanation and a suggested free name. A capitalization-only change to the same object's current name is allowed. A successful rename updates its inspector header and chooser label without changing its instance identity.
|
||||
- Do not offer name editing while multiple objects are selected.
|
||||
|
||||
## Overlapping-object chooser
|
||||
|
||||
- On a normal EDIT click, retain the AABB check as a broad phase, then check the clicked point against the candidate's visible geometry. Only actual geometry hits are selectable or listed. Do not show a nearby object merely because it is within another object's bounds.
|
||||
- Apply visible-geometry filtering to both immediate single-object selection and chooser candidates so there is one consistent hit model.
|
||||
- When a normal EDIT press hits two or more objects, wait briefly to tell a click from a drag. If the pointer is released without moving past the 8 px screen-space drag threshold, open the anchored, scrolling **Objects here** popup without changing the current selection or inspector. If the current selection includes an object under the pointer and the child drags past the threshold, drag that selected object (or the whole current selection) and do not open the chooser. The selected object wins as the drag target even when another candidate is drawn in front.
|
||||
- Test gizmo handles before overlap selection. Pressing a selected object's rotate or resize handle starts that transform immediately, even when other objects are visible under the handle; do not open **Objects here** for a handle gesture.
|
||||
- Each row has a character/prop icon and its unique stage name. Mark any candidate that is already in the prior selection with a distinct **Selected** badge.
|
||||
- Hovering or keyboard-focusing a row previews that exact object with a bright EDIT-cyan outline or silhouette above overlapping art and a nearby cyan-outlined rounded pill containing only the candidate name, such as **Milo**. The name pill appears only while that row is hovered or focused; do not prepend “Preview:”. This transient cue does not update selection or inspector. Leaving the row clears it.
|
||||
- Clicking a row commits selection once and opens that object's inspector. **Cancel**, Escape, and outside dismissal clear the preview and preserve the previous selection, including a multi-object selection. The dismissal click must not also select the stage object beneath it.
|
||||
- After a child chooses an overlapping object in EDIT, briefly show **Drag to move · Click to choose**. This teaches that a drag manipulates the selected object while a click opens the chooser again.
|
||||
- Anchor the popup near the click while keeping the clicked art visible. Limit its height and scroll when many objects truly share the hit point. Use large rows, at least 44 px high and preferably around 56 px, with readable names.
|
||||
- Give the chooser a rounded dark shell with a visible 2 px border, 12–16 px corners, a local shadow, and 12 px content inset. Space the 56 px candidate rows by 8 px. Keep **Cancel** in its own rounded neutral footer button at least 48 px high. This is a non-modal picker: leave the stage undimmed, and consume outside clicks after dismissing it.
|
||||
- The shared popup also serves DIRECT prop target capture. Preserve the existing prop-only candidate filter and target-commit semantics there while using the same stage-name labels and exact hit results.
|
||||
- Make the regular selected-object outline a more pronounced light rectangle with a dark backing. Replace the large blue rotate ring with a compact curved-arrow rotate handle attached above the rectangle; retain an approximately 44 × 44 logical-pixel hit target and keep prop corner resize handles. The transient chooser preview remains EDIT cyan and visually distinct.
|
||||
- Keep the rotation handle reachable near the top chrome. If the 44 × 44 hit target would overlap the toolbar or mode pill, place the handle below the selected object's rectangle with its connector attached to the lower edge.
|
||||
|
||||
## Canvas feedback and child-facing copy
|
||||
|
||||
- During repeated placement, show the next automatic name beside the placement ghost, such as **Click to place Milo 2**. After placement, briefly show **Milo 2 placed** beside the object. Keep names off the canvas at rest to avoid visual clutter.
|
||||
- Retain the normal selected-object cue while a chooser preview is active; the candidate preview must be visually distinct and must not transfer selection.
|
||||
- Use the approved overlap mockup at `../plans/action_reactions_mockups/06_edit_overlap_picker.png` as the visual reference.
|
||||
|
||||
| State | Copy |
|
||||
|---|---|
|
||||
| Name in inspector | The stage name appears once in the header |
|
||||
| Start editing | **Rename** |
|
||||
| Save or discard edit | **Done** / **Cancel** |
|
||||
| Blank name | **Give this one a name.** |
|
||||
| Duplicate name | **Milo is already here. Try Milo 3.** |
|
||||
| Multiple selection | **Select one character or prop to change its name.** |
|
||||
| Placement prompt | **Click to place Milo 2** |
|
||||
| Placement feedback | **Milo 2 placed** |
|
||||
| Chooser title | **Objects here** · **Choose one** |
|
||||
| Previously selected row | **Selected** |
|
||||
| Candidate name pill | **Milo** (name only; no “Preview:” prefix) |
|
||||
| After choosing an overlapping object | **Drag to move · Click to choose** |
|
||||
| Dismiss chooser | **Cancel** |
|
||||
|
||||
## Theme and accessibility
|
||||
|
||||
- Reuse `StageInspectorPanel` and `SandboxTheme.stage_panels` for selected-object inspector shell, header Rename action, disclosure behavior, type, spacing, buttons, focus, and safe placement. Use `SandboxTheme.control_theme()` for the chooser. Extend `stage_panels` tokens only when the shared system lacks a needed value.
|
||||
- Use `SandboxTheme.popups.anchored_chooser` for the chooser shell, rows, selected badge, state borders, spacing, and Cancel button. Reuse the general name-and-Rename header and inline editing contract in the [Editor Visual Style Guide](../guides/editor_visual_style_guide.md#45-renameable-object-inspector-header) when adding renameable object inspectors. Object-specific validation remains owned by the identity system.
|
||||
- Use the EDIT cyan from `mode_colors.edit` for candidate preview and active EDIT feedback. Do not use color alone: preserve a clear outline/silhouette, focus state, and textual preview name.
|
||||
- Use a rounded dark fill with a cyan outline for the candidate name pill; keep its background, corner radius, border width, and padding in `SandboxTheme.overlays.gizmos.candidate_name_pill_*` tokens.
|
||||
- Keep controls large and keyboard-operable. Hover and keyboard focus produce the same candidate preview. Use explicit **Rename**, **Done**, and **Cancel** labels; the prop disclosure control exposes **Expand settings** / **Collapse settings** tooltips.
|
||||
|
||||
## Intentional distinctions
|
||||
|
||||
- The selected prop inspector uses a named collapse bar instead of **Close → Settings**. The selected character is already a one-line name bar. Clicking empty stage space removes either inspector by clearing selection. The separate Director/action inspector's outside-click behavior remains scoped to that workflow.
|
||||
- The chooser is a popup for resolving a point click, not a persistent side panel. It does not select the frontmost object before the child chooses.
|
||||
- Visible geometry improves on the current AABB-only candidate behavior. The AABB remains a fast broad phase; the visible hit check and a small screen-space tolerance for thin lines define the actual candidates.
|
||||
|
||||
## Visual verification
|
||||
|
||||
UIUX reviewed the earlier Final6 captures against the style guide, shared Stage panel spec, and approved overlap mockup. The chooser placement, short and crowded lists, naming flow, placement feedback, preview, dismissal, commit, and DIRECT Use capture were accepted. That review predates the current inspector/selection refinement.
|
||||
|
||||
The current revision strengthens the rectangular selection outline, replaces the large rotation ring with a compact curved-arrow handle, puts Rename beside the single name in each inspector header, and changes the prop inspector's Close → Settings state to a named disclosure bar. The character inspector stays compact because naming is its only setting. UIUX reviewed the regenerated captures and approved the one-line long-name header, the Break artwork outline, and the below-box rotate handle near the top chrome. At 920×560, the overlap preview-name pill may move toward the upper-right edge when the click point and inspector leave little room.
|
||||
|
||||
The follow-up candidate label now shows only the character name in a rounded cyan-outlined pill. UIUX reviewed the refreshed [character overlap preview](../../tests/phase20a_captures/character_overlap_preview.png) with the **Blue Door** inspector open and approved its alignment and separation from the chooser's **Selected** badge and the inspector.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Phase 20b — Action plans and DIRECT authoring
|
||||
|
||||
> **Status:** Implementation delivered; the user verified manual scenarios 1–7. The broader acceptance matrix still has unrecorded production-interaction and UI/UX-review evidence. The branch remains unmerged.
|
||||
> **Approval:** User approved the specifications in this conversation.
|
||||
> **Branch:** `feature/phase-20-action-reactions` in `C:\Godot4\stickman\.worktrees\phase-20-action-reactions`.
|
||||
> **Scope:** The [Phase 20 roadmap](../plans/phase_20_action_plans_and_reactions.md) defines 20b as ordered, optionally grouped character plans, explicit place/prop Move targets, DIRECT route authoring, and stable action identity. [Phase 20c–20e](../plans/phase_20_action_plans_and_reactions.md) retain reaction authoring/runtime and prop event handling.
|
||||
|
||||
## Sources and current boundary
|
||||
|
||||
Use the accepted [action/reaction design brief](../plans/action_reactions_design_brief.md), [Phase 20a identity contract](phase_20a_stage_identities_and_names.md), [Stage panel UI system](stage_panel_ui_system.md), [editor visual style guide](../guides/editor_visual_style_guide.md), and branch-local [Step Stack](../plans/action_reactions_mockups/phase_20b_step_stack.png) and [Add together](../plans/action_reactions_mockups/phase_20b_add_together_compatibility.png) mockups. The mockups show the selected direction; this spec settles data and interaction behavior where a static image cannot.
|
||||
|
||||
Today `.scene` v1.8 saves a flat `action_queue` per stickman. `StickmanActionRunner` runs one primary queue action at a time, with a replaceable speech overlay; its `int` action IDs are regenerated by `set_authored_queue()` and at PLAY start. `QueuePanel` and `ActionEditor` are separate popup workflows. `StageDirectorVisuals` draws and hits flat queue positions separately. `StickmanRig` and `PropBlock` now have persistent `stage_object_id` and scene-unique `stage_name`; `file_id` remains only the save-local rule remap. This spec replaces authored plan identity and scheduling without changing those Phase 20a identities or the existing generic rule system in 20b.
|
||||
|
||||
## Approved product decisions
|
||||
|
||||
1. The selected-character DIRECT inspector uses the **Step Stack**: numbered steps in order; all actions in a step begin together; the next step waits until every action in the current step has ended.
|
||||
2. The fixed **Move**, **Say**, and **Wait** shortcuts always append a *new* step, even when a card is selected. **+ Add together** is the explicit way to group.
|
||||
3. A 20b concurrent step contains one supported primary action plus at most one **Say**. Say can accompany every other registered action type, including Move, Wait, prop actions, Ragdoll, and Recover. Two primary actions, Say+Say, and any third action stay incompatible. The group picker shows only compatible choices; when the selected step is Say, it offers every other action type.
|
||||
4. A named-prop Move keeps the exact `stage_object_id` and a world-aligned approach offset. It pursues that prop for a bounded time during this Move, reaches only when both current approach point and prop are within reach, and fails safely if the prop vanishes, route blocks, or time expires. Rotation of a thrown/spinning prop never rotates the saved offset.
|
||||
5. In paused DIRECT, grouping and step reorder affect future steps only. Existing active-action replace/delete behavior remains. Completed/interrupted actions cannot be edited or moved, but their action rows still offer confirmed **Erase action** for the next run, as in [Phase 12](phase_12_live_director.md).
|
||||
6. Phase 20b creates no reaction editor or reaction execution. It persists action IDs for future links and exposes completion outcomes so a later event can distinguish a reached endpoint from failure.
|
||||
|
||||
## Child-facing Step Stack
|
||||
|
||||
```text
|
||||
Selected Milo in DIRECT stage route
|
||||
┌───────────────────────────────────┐ Milo ── Walk ── 🚩 1 Blue Door
|
||||
│ Milo │ 💬 1
|
||||
│ [ Move ] [ Say ] [ Wait ] │
|
||||
│ Plan [+ Add step] │
|
||||
│ ┌ Step 1 Together ─────── ↑ ↓ ┐ │
|
||||
│ │ Walk to Blue Door │ │
|
||||
│ │ Say “Let's go!” │ │
|
||||
│ │ [+ Add together] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ ┌ Step 2 ───────────────── ↑ ↓ ┐ │
|
||||
│ │ Wait 2 seconds │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
The inspector is anchored on the right for **one selected character** in DIRECT. Its right edge aligns with the clock above it, using the shared 12 px viewport inset so the orange DIRECT frame remains visible. Its width is capped at 380 px and shrinks only when the safe viewport is narrower. It uses the selected character's current stage name and icon as its header. Clicking empty stage space dismisses it and clears selection. There is no Close button, the explicit exception already accepted in the design brief. DIRECT selection does not rename the character; the separate EDIT naming inspector retains its own controls. A character switch resets plan scroll and expanded action; an in-place edit preserves the anchor and scroll as far as possible. No character selected means no Director inspector.
|
||||
|
||||
`StageInspectorPanel` provides the shell, fixed header, safe rectangle, focus, and scroll behavior. Extend it with a caller-owned **pinned content slot** between header and scroll body for the three quick actions and plan heading/add control; include the slot in its height budget so only the plan list scrolls. Keep the existing `body()` API and other inspector behavior. Use `set_close_visible(false)` and explicit outside-click handling in the Stage owner; do not add a duplicate shell. `StagePanelChoiceGroup` suits exclusive Walk/Run and Prop/Place choices. `StagePanelToggleRow` and `StagePanelSwitchVisual` are not needed because 20b has no on/off setting. `StageUseTargetCaptureBar` should be generalized or reused for destination capture; keep its prompt and visible Cancel together below the top toolbar/mode pill. `StageDialogStyler` styles action deletion confirmations. Anchored prop and overlap choices use `popups.anchored_chooser` and the 20a **Objects here** behavior.
|
||||
|
||||
The scroll body shows **Plan**, **+ Add step**, and ordered **Step 1**, **Step 2**, etc. Numbers derive from order and update after reorder. Each step title is plain, stationary text in a focusable card. Clicking anywhere on a card, including its title or controls, selects that step; a child control still performs its own action. The card's DIRECT amber border marks selection. Tab focus on an unselected card is shown by a separate white inset outline; the selected card uses its amber border as the focus cue. Enter or Space selects the focused card. The title has no tooltip or hover style. Selecting a different step clears the previous action focus and closes its Action options. Selecting an action summary selects its owning step and opens only that action's **Action options** inline. Long Say summaries stay on one line and end with an ellipsis; hovering the Say row reveals the full utterance in its tooltip, and **Edit action** opens the full speech composer. A step with two actions says **Together**. Actions with editable settings offer vertically stacked, full-width **Edit action**, **Change action** when a different type is valid, and **Erase action** controls. Parameter-free actions such as Ragdoll, Recover, and Drop Prop omit **Edit action**. **Edit action** changes that action's settings; its type is fixed in the plan editor. **Change action** opens the existing slide-out action picker, omits the current type, and shows a **Replace: [current summary]** context line. A type choice opens its normal editor; changes apply only on **Done** (or **Done speech** for Say). Choosing a parameter-free type adds it immediately, or replaces the selected action immediately while preserving its ID, with no empty editor dialog. Cancel/Escape/outside click in the picker closes without changing the plan, and Cancel/Escape in the editor leaves the plan unchanged. The replacement keeps its stable action ID and remains selected. For a Together action, choices are filtered against the other action so the resulting pair stays valid; when no different type can fit, hide **Change action**. Move destination changes are made through **Edit action**, which opens the place/prop destination editor. In paused DIRECT, a completed or interrupted action's options retain **Erase action** with confirmation while **Edit action** is unavailable. There is no **Make someone react to this…** control until 20c. Bold solid up/down arrows reorder a step earlier or later inside 48 × 48 px targets; each has a descriptive tooltip and is disabled at the first/last position or when runtime state forbids reordering. Moving one step swaps only the two neighboring cards with a brief ~200 ms ease-out slide. The moved card remains selected and is drawn in front while they move; the same stable action ID preserves selection/spotlight through the list rebuild. Reorder buttons are disabled during the slide, and the prior scroll position and keyboard focus are restored afterward. Drag reorder is optional and cannot be the only route. Empty state: **Choose an action to start Milo’s plan.**
|
||||
|
||||
**+ Add step** opens a left-side **Choose an action** drawer with every supported action shown as a 48 px icon-and-label choice and a visible **Cancel** button pinned below the list. Its top aligns with the inspector, and it grows below the inspector to show all choices when the safe viewport allows. On shorter viewports, only the action list scrolls; Cancel stays visible and the drawer stays above the Stage status bar. Choosing an action with settings opens that action's editor; Say opens the speech composer. Parameter-free actions (Ragdoll, Recover, and Drop Prop) are added immediately after selection. The selected action type stays fixed in the new plan editor. A step is appended only after its complete draft is committed. **+ Add together** appears only when the selected step has a compatible action to add, and its picker offers only compatible choices: a one-action Say step offers every other action, while a different one-action step offers Say. The same two-action limit prevents Say+Say and primary-action pairs. Move setup asks **How should Milo move?** (Walk/Run) and **Where should Milo go?** (**Choose a prop**/**Choose a place**). The prop roster uses current stage names, a preview/highlight, and optional **Pick on stage**. Overlap picking reuses the 20a geometry-filtered chooser, including preview, Cancel, Escape, and no click-through. During plan place/prop capture, show one cursor-following instruction, a fixed **✕ Cancel** button, the cursor crosshair, and only the grounded route preview with a **Walk**/**Run** label; hide the duplicate prompt panel and generic diagonal trajectory. Place capture asks **Choose a place for Milo.**; prop capture asks **Choose the prop Milo will walk to.** Editing a target asks **Choose a new place or prop for Milo.** Capture is complete only after valid target selection and **Done** where a draft needs confirmation. **Cancel**/Escape discards the draft, creates no empty step, and causes no dirty mark. An invalid landing point says **Choose open ground for Milo.** For other target capture, prop/actor/area/waypoint selection shows only the cursor marker; direct Move To and rule Move previews stay horizontal at the actor's ground level. Throw retains its ballistic arc, and Set Down retains its prop/reach preview.
|
||||
|
||||
The existing speech composer creates exactly one Say action in the chosen step. Commit is atomic at **Done speech**; cancel does not create a step or replace the old Say. Existing bubble styling and content stay inside that action. **Erase action** asks **Erase this action?** via the shared confirmation card; Cancel preserves it. Removing the last action removes its step and renumbers later cards. A type replacement through **Change action** keeps its action ID because it explicitly replaces that selected action; later reaction cleanup rules belong to 20c.
|
||||
|
||||
### Stage preview and capture
|
||||
|
||||
- Keep the stage canvas quiet outside directing. In EDIT, hide authored action-plan routes, flags, Walk/Run badges, Say/Wait markers, and DIRECT actor badges. Keep placement feedback, the grid, selection outlines, and transform handles. When one or more named visible stickmen or props are selected, show a rounded, screen-sized badge for each selected object using its `stage_name`; use the EDIT cyan outline, position from the object's visual bounds, and prefer above the object. If a badge would collide with another badge, a transform handle, Stage chrome, an inspector, or the viewport edge, move it to the nearest clear adjacent position and add a short tether when displaced. Never omit a selected object's badge. Long names elide at the theme maximum width; the full name remains available through the single-object inspector or by selecting that object alone. Badges are read-only and input-transparent; Rename remains in the inspector. While **Objects here** previews a candidate, hide these persistent EDIT badges so the chooser's existing preview-name pill stays distinct.
|
||||
- DIRECT uses **Step Spotlight** only when exactly one character is selected. With no actor selected or with a multi-selection, hide authored action-plan routes, flags, movement badges, and Say/Wait markers; keep the amber DIRECT frame and the existing direction hint. With one character selected, show that actor's stage name in an amber rounded, screen-sized badge centered above the head. Show the focused step's full route and its action markers; hide routes for other actors. If the plan has no steps, keep the name badge and show no route. On first selection, focus the first pending step, or the active/next pending step while paused. A character switch applies the same default.
|
||||
- Movement steps outside the focus remain as faint numbered destination flags without route lines or action labels. These ghost flags keep their 44 px hit areas. Selecting a step card or ghost flag moves the focus without changing or dirtying the plan. A flag hit still resolves through the existing `action_id`, scrolls its step into view, and opens its Action options. Flags are not draggable; **Edit action** is the deliberate retarget entry and opens the destination editor.
|
||||
- Draw the focused route with its authored **Walk** or **Run** label. Use a bright 4 px cyan dashed line with a 6 px dark contrast edge, 12 px dashes, and 7 px gaps. Place the rounded, bold action badge near the first third of the route, away from the center crossing. Keep the destination label separate: an endpoint linked to a prop shows that prop's current `stage_name`, such as **Blue Door**; a free destination uses only its numbered flag, without a generic **Place 2** label or an inferred nearby prop name.
|
||||
- Round Move action badges have a dark surface, 2 px action-tinted border, bold 18–20 px light text, and generous padding. The actor-name pill uses a dark surface and 2 px DIRECT amber border, bold 20 px text, 12 px corners, and a 260 px maximum width; long names elide while the inspector retains the full stage name. Badges are visual only and do not intercept stage input.
|
||||
- A focused Move+Say step shows two stacked action pills near the first third of the grounded route: the movement glyph and **Walk**/**Run** label above a speech glyph and **Say** label. Use the Move cyan and Say yellow action colors; this pair replaces the separate Say marker for that step. A focused standalone Say shows a yellow-bordered pill with a speech glyph and **Say**, without a step number or leader. A focused standalone Wait shows a purple-bordered pill with an hourglass glyph and **Wait**, also without a step number or leader; place it just above the action’s predicted ground start point with the same safe-viewport placement as Say. A focused Wait+Say step shows both pills stacked near that point, with Wait above Say. In Step Spotlight, show each primary-action+Say Together step as two stacked, icon-bearing pills with the primary action above plain **Say**; fit the pair as one group against the name badge, inspector, and safe viewport. Place Move+Say near the first third of the grounded route; Wait, Ragdoll, Recover, Drop Prop, Set Down Prop, and Use near the predicted start; Throw Prop beside the character’s hands or upper torso. Keep Pick Up over its target prop and place its Say cue by the character so the target stays clear. Keep dialogue text in the inspector. In the plan overview, paired Move+Say keeps its route stack and other Say pairings remain compact. Markers from nonfocused steps are hidden. When prior steps contain unpredictable physics, show a conditional/uncertain anchor using existing preview semantics rather than pretending an exact simulated future position.
|
||||
- During retarget capture keep the old route/flag faint, even if selection is temporarily cleared. Draw a candidate route and flag with a validity state and relevant label at the pointer or exact picked prop. The old destination remains the authored value until a valid commit; Cancel/Escape restores its exact prior kind, ID, offset, and coordinate and preserves selection.
|
||||
- Factor the route/marker layout into one preview projection used by both drawing and hit testing. Its entries carry `action_id`, `step_index`, world points, label, status, and hit shape. This prevents a drawn flag from selecting a different flat queue index after reorder.
|
||||
|
||||
Use `SandboxTheme` as the sole Stage visual source: `stage_panels` for shell/type/action/focus sizes, `mode_colors.direct` for selection, `popups.anchored_chooser` for rosters, `popups.confirmation` for deletion, and a small `director_plan` subtree for card/badge/route/flag/speech/old-target/candidate states. Theme both `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json`; use local stylebox overrides instead of mutating its cached `Theme`. Button targets are at least 44 × 44 logical pixels, preferably 48 px. Fit at wide and approximately 920×560 viewports below Stage chrome; the header and quick actions remain visible while eight or more steps scroll. Long stage names wrap or elide with full tooltip and do not cover actions. Focus/hover/selected/disabled/invalid states are visually distinct and described with text or icon as well as color. In-place picker growth keeps its anchor and only nudges within the safe viewport.
|
||||
|
||||
## Canonical document model and identity
|
||||
|
||||
One placed character owns an ordered `action_plan: Array[Dictionary]`. Each step is `{ "actions": Array[Dictionary] }`, with one or two nonempty actions; no persisted step ID or number. Each action has an opaque nonempty **string** `action_id` plus the action's canonical `type` and parameters. IDs are unique across authored plans in one `.scene`, independent of actor name, step/order, destination, and action type. They survive save/load, stage rename, edit, regroup, and step reorder. A duplicated *placed* character receives a new `stage_object_id` (20a) and fresh action IDs for all copied plan actions. Deletion retires IDs; a new action never reuses them in that scene. A replacement through Edit action keeps the selected ID; **+ Add step** or **+ Add together** allocates a new ID only on valid commit. Cancel never consumes a persisted ID or mutates the plan. Scene names are presentation values only and never link keys.
|
||||
|
||||
Use a scene-scoped `StageActionIdentity` (`RefCounted`, not autoload) to allocate/check IDs against real and missing character entries. A stable ID may be an opaque `action_<random/counter>` string; its textual shape is not part of the UI or event contract. Legacy v1.8 migration derives deterministic IDs from `stage_object_id` plus original flat index, with deterministic collision suffixes. A current-format file with duplicate/blank IDs is rejected before World clear, not silently repaired. A missing `.stk` placeholder preserves the entire plan and IDs in its original entry through save and asset repair.
|
||||
|
||||
### Target payloads
|
||||
|
||||
The canonical Move action uses one discriminated `destination` dictionary. Its position values are authored world coordinates, encoded with the existing `{x,y}` vector helper. The runtime `target: Vector2` used by present locomotion is derived, not a second authoritative serialized target.
|
||||
|
||||
```json
|
||||
{
|
||||
"action_id": "action_opaque_17",
|
||||
"type": "move_to",
|
||||
"style": "walk",
|
||||
"destination": {
|
||||
"kind": "prop",
|
||||
"target_prop_id": "stage_opaque_4",
|
||||
"approach_offset": { "x": -42.0, "y": 0.0 },
|
||||
"last_known_approach": { "x": 310.0, "y": 420.0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action_id": "action_opaque_18",
|
||||
"type": "move_to",
|
||||
"style": "run",
|
||||
"destination": { "kind": "place", "position": { "x": 510.0, "y": 420.0 } }
|
||||
}
|
||||
```
|
||||
|
||||
`target_prop_id` is the exact 20a `PropBlock.stage_object_id`, including missing-prop placeholders; `approach_offset` is a **world-aligned translation** from prop origin to the selected approach point. It does not rotate with the prop. `last_known_approach` is only an authored editor fallback for an unresolved/deleted prop link; PLAY motion does not rewrite it on save. It never turns the action into a free place or implies successful arrival. Prop rename changes displayed text but not this payload. A deleted target remains a repairable unresolved link labeled **Missing prop** and fails at PLAY if not retargeted. `kind: "place"` has no prop ID, even when its coordinate overlaps or is near a prop. Changing place commits a new destination dictionary on the same action ID. Validate finite vectors, known kinds, nonblank string prop IDs, and Walk/Run style; validate referenced prop IDs when present, while allowing an unresolved ID to preserve repairable authored intent. Never infer a prop link from coordinates. `ContentPaths.canonicalize()` remains on existing asset path fields; these identity and coordinate fields are not paths.
|
||||
|
||||
### Scene v1.9 boundary
|
||||
|
||||
`StageSceneIO.FILE_VERSION` becomes `"1.9"`. Current v1.9 character entries save `action_plan` and do not save a second authoritative `action_queue`. Add `encode_plan()`/`decode_plan()` or equivalent so IDs and destinations cannot be stripped by today's flat `_encode_flat_action()`/`_decode_flat_action()`. Preserve full speech payload normalization. Existing `event_rules`, their save-local `file_id` remap, background fields, and 20a stage identities/names stay intact. `StageSceneAdapter.collect()` writes the rig's canonical plan; `apply()` restores it after object spawn and identity assignment. Missing-character placeholders keep the canonical plan in `original_entry` so a save does not lose it.
|
||||
|
||||
For v1.0–v1.7, run the existing historical migrations, including the 20a identity/name pass, then migrate flat actions. For v1.8, retain its validated identities/names and migrate each valid flat `action_queue` entry to an ordered singleton step with a deterministic action ID; old `move_to.target` becomes a `place` destination. Existing malformed queue entries keep the present skip-and-warn behavior, while the current-format v1.9 shape is strictly validated. Preserve the precise old Sky/Plain and wall-tint version conditions: v1.8 must not receive older background defaults or wall rewrites. A migrated file saves as v1.9. `read_file()` migrates and validates the whole document before `StageSceneAdapter.apply()` clears World, including cross-actor action ID uniqueness, nonempty steps, compatibility, and destination shape.
|
||||
|
||||
The old `speak.keep_acting` overlay is a compatibility seam. Migration pairs a v1.8 overlay Say with its immediately following Move/Wait when that creates a compatible step, retaining order and approximate concurrent intent. Otherwise it makes a singleton Say with an explicit legacy-only nonblocking runtime flag to preserve old playback. The legacy flag is an exception to the new step barrier; new 20b authoring never creates it. Do not silently drop speech or reinterpret it as a prop/place target. Record paired and unpaired cases in migration tests. The new authored Say in a group remains active until its own bubble/line duration finishes, so its barrier is real.
|
||||
|
||||
## Runtime step execution
|
||||
|
||||
One runner owns the actor's canonical plan and an ephemeral execution cursor. At step start, snapshot both actions, mark both active, emit each `action_started` in plan order in the same physics tick, and start the primary track (Move/Wait) and optional Say track. The Say track's completion is the actual speech duration, not the instant its overlay is shown. Each action independently enters `PENDING`, `ACTIVE`, `SUCCEEDED`, `FAILED`, or `CANCELLED`; a step ends when all its actions have ended, then starts the next step on the next runner tick. Failure is an ended state and advances the plan; it is never reported as a reached endpoint. An empty plan completes immediately. STOP resets runtime status but does not change saved IDs or steps. Pausing freezes timers/pursuit and resumes them without consuming the bounded pursuit budget.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Pending
|
||||
Pending --> Active: step starts
|
||||
Active --> Succeeded: action succeeds
|
||||
Active --> Failed: missing target / blocked / timeout
|
||||
Active --> Cancelled: edit, delete, or STOP
|
||||
Succeeded --> [*]
|
||||
Failed --> [*]
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
Keep `StickmanRig` as the public façade. Add typed plan APIs and typed event signals while adapting existing flat/index callers deliberately; do not persist or regenerate the old session-only `int` IDs as link keys. `ActionRegistry` owns an explicit authored-step compatibility predicate, separate from `ReactiveRole`/`overlay_eligible`, which still govern existing generic rule injection. Current generic rules and reactive actions remain operational, but are not converted into reaction plans. For 20b, preserve their present priority behavior and do not design 20d interruption/resume here.
|
||||
|
||||
| Owner | Proposed typed contract and work |
|
||||
| --- | --- |
|
||||
| `StageActionPlan extends RefCounted` (`scripts/stage/stage_action_plan.gd`) | `static func validate_steps(steps: Array[Dictionary]) -> String`; `static func can_add_together(step: Dictionary, action_type: String) -> bool`; pure deep-copy edit/reorder helpers returning a new plan or error. It owns structure/compatibility, no nodes or UI. |
|
||||
| `StageActionIdentity extends RefCounted` (`scripts/stage/stage_action_identity.gd`) | `func allocate(world: Node2D) -> String`; `func validate_scene_ids(objects: Array) -> String`; scan current scene and placeholders rather than maintaining a second mutable registry. |
|
||||
| `StickmanRig extends Node2D` | `func get_action_plan() -> Array[Dictionary]`; `func set_action_plan(steps: Array[Dictionary]) -> bool`; `func find_action(action_id: String) -> Dictionary`; `signal planned_action_started(action_id: String, action: Dictionary, step_index: int)`; `signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)`. Existing `action_started/action_finished`, `arrived`, `get_queue()` and queue mutation callers need a tested compatibility path during migration. |
|
||||
| `StickmanActionRunner extends Node` | Execute current step with primary + Say tracks; track statuses by string action ID; expose `get_plan_view() -> Array[Dictionary]` with `step_index`, `action_id`, `action`, `status`; preserve `pause_simulation()`/`resume_simulation()` and active replace/delete semantics. A direct `get_queue_view()` can be a derived flat projection for legacy consumers during migration, never the persistence model. |
|
||||
| `StickmanLocomotion extends Node` | Add a lightweight `retarget_walk(target: Vector2) -> bool` for the current active Move: update `_walk_target_feet`/`NavigationAgent2D.target_position` without resetting gait, facing, pause, or settle history. Report reached versus blocked explicitly. |
|
||||
| `StageSceneIO extends RefCounted` / `StageSceneAdapter extends RefCounted` | v1.9 encode/decode/migrate/validate plan, actor IDs, prop links, and missing placeholders before live apply; preserve old rule remap and path canonicalization. |
|
||||
| `StageActionQueueController extends RefCounted` and `StageDirectorController extends Node` | Coordinate atomic draft/commit by string action ID, capture, step edits, pausable history guard, and plan UI intent. Convert delayed editor/capture references from index/session `int` to stable `String`; re-resolve against the live plan on commit. |
|
||||
| `StageDirectorVisuals extends Node2D` | Produce one route/marker projection shared by draw and hit. Hit result uses `action_id: String`, not a stale index. Keep rule visuals independent. |
|
||||
| `QueuePanel extends PopupPanel` / `ActionEditor extends PopupPanel` | Reuse action-type editing/composer behavior where it fits. The selected-character Step Stack becomes the primary DIRECT plan view; retire or adapt the flat queue popup without a second conflicting authoring source. Dialogs need their own focus/capture layout and should not be forced into `StageInspectorPanel`. |
|
||||
|
||||
Suggested event flow:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Step Stack / flag hit] --> Intent[StageActionQueueController by action_id]
|
||||
Intent --> Draft[Editor or destination capture]
|
||||
Draft -->|Done and valid| Plan[StageActionPlan commit]
|
||||
Draft -->|Cancel| UI
|
||||
Plan --> Rig[StickmanRig plan facade]
|
||||
Rig --> Draw[StageDirectorVisuals projection]
|
||||
Rig --> IO[StageSceneAdapter -> StageSceneIO v1.9]
|
||||
Rig --> Runner[StickmanActionRunner]
|
||||
Runner -->|started / ended with outcome| Stage[SandboxStage listeners]
|
||||
```
|
||||
|
||||
All UI and visual pointers re-resolve action ID at commit/hit; index is only a derived display position. Selection and opening Action options are read-only. `queue_changed`/plan-changed refresh happens after one valid transaction; mark the Stage document dirty exactly once per commit. Cancel, invalid input, selection, and hover never mark dirty.
|
||||
|
||||
### Bounded pursuit for named props
|
||||
|
||||
At this Move's start, resolve `target_prop_id` to the exact live `PropBlock`, including a missing placeholder check. Project `prop.global_position + approach_offset` onto a reachable terrain approach point, retaining the offset in world axes even while the rigid body spins. While the Move is active, sample the prop's translation before locomotion's movement tick and retarget only after both a meaningful target shift and a minimum interval (initial tuning: at least 16 world units **and** 0.1 seconds since the last retarget) so a rolling ball does not restart the walk animation or repath every tiny physics move. The implementation may tune thresholds from rendered/behavior checks but must retain a finite budget (initial candidate: 12 seconds of unpaused active pursuit). A settled prop simply leaves the latest destination in place; if it moves again before arrival, retarget while budget remains.
|
||||
|
||||
Success requires the actor to finish arrival at the **current** ground-projected approach point *and* the live prop to be within the interaction reach used for an ordinary grounded actor/prop approach. Arrival at a stale point, or directly under an airborne out-of-reach prop, is not success: continue waiting/retargeting until reachable or timeout. A vanished/unresolvable prop, blocked/unreachable path, invalid ground projection, or expired budget ends the Move as `FAILED`, advances the step when its other action ends, and emits no successful endpoint moment. The failure may show brief child-facing feedback and an action status, without a blocking popup during PLAY. Freeze the budget during pause; STOP cancels it. Ensure `StickmanRig.arrived`/legacy waypoint rules do not receive a false intermediate arrival from a retargeted prop Move; later action-specific endpoint events must derive from `planned_action_ended(..., SUCCEEDED)` for that exact ID. A free-place Move keeps the existing finite walk target and arrival behavior.
|
||||
|
||||
This Move is finite pursuit of *one prop for one destination action*. It ends when reached or failed. The future 20c **Follow** action is a different authored action with its own explicit stop condition and continues to track after catch-up; do not turn every prop-linked Move into an indefinite Follow.
|
||||
|
||||
### Paused DIRECT invariants
|
||||
|
||||
Step reorder and Add together are allowed only on wholly future steps. A step containing any `ACTIVE`, `SUCCEEDED`, `FAILED`, `CANCELLED`, or legacy interrupted action cannot be regrouped or reordered; disable those structural controls with a concise reason. Completed/interrupted action **contents** cannot be edited, retargeted, or moved during this run, but their rows retain **Erase action** with the normal confirmation, matching Phase 12. That deletion removes the authored action for the next run while leaving consumed runtime history and already-emitted outcomes untouched; if it empties a step, remove that step from the saved plan without replaying or renumbering the live execution cursor. Preserve the existing ability to replace/delete the active action while paused: cancel that action's live track, keep the unaffected concurrent action/status in the same step, re-resolve by stable ID, then run the replacement or finish the step as appropriate. Editing a pending action preserves its ID and cannot alter an already-started Say event. Future-step edits rebuild only future runtime snapshots. Resume uses the modified future plan; STOP/PLAY starts from the authored beginning.
|
||||
|
||||
## Acceptance and verification plan
|
||||
|
||||
The roadmap requires automated behavior/persistence assertions, production-path interaction, and rendered UIUX review against the style guide/mockups. The checks below define the acceptance scope; completion status is recorded above.
|
||||
|
||||
### 1. Automated behavior and persistence
|
||||
|
||||
1. Validate singleton steps and Say paired with every other registered action type; reject primary+primary, Say+Say, a third action, empty steps, duplicate/blank action IDs, malformed destination variants, nonfinite vectors, and unknown style. Confirm **+ Add together** appears on a standalone Say step and offers all other action types, while every other singleton offers Say. Confirm the picker omits choices that would create an invalid pair. Confirm all edit/reorder/regroup/retarget operations retain IDs, duplicate actor regenerates every copied action ID, and invalid/cancel creates no saved mutation.
|
||||
2. Load v1.0–v1.8 fixtures; assert Phase 20a IDs/names, background/wall migrations, deterministic legacy action IDs, fixed Move targets becoming places, and current `.scene` v1.9 save/reopen preserving every action, speech payload, prop target ID/offset, and existing generic rules. Reject malformed current v1.9 plans before the live World is cleared. Save/reload missing actor and missing/deleted prop references without silently turning them into coordinates.
|
||||
3. Run a small repeated step timing scenario: a plan with `Step 1 = Walk 180 units + Say 3 seconds`, `Step 2 = Wait 0.5 seconds`. Record physics timestamps for both Step 1 starts, Walk end, Say end, and Wait start. Both starts occur in the same runner tick; Wait starts only after `max(Walk end, Say end)`. Repeat with Wait+Say and Recover+Say, STOP/PLAY, and pause/resume. A failed Move still lets its Say finish before the next step.
|
||||
4. Run a repeatable moving-prop scenario on simple Ground: actor Move targets a named rigid ball with a fixed world-left offset; move and spin the ball during the action. Confirm destination updates with translation but not rotation, gait does not restart on retarget, and success occurs only after the actor reaches the latest approach and the ball is within reach. Repeat with an airborne ball over the actor (no early success), a removed target, a blocking prop/path, timeout, and pause (budget frozen). Assert no reached-endpoint event on failure or stale intermediate arrival.
|
||||
5. Check existing generic rule/queue callers and old `keep_acting` migration cases. A new grouped Say remains active to its real end. Existing rule `file_id` remaps, prop Use/Pick Up IDs, and `ContentPaths` behavior survive.
|
||||
|
||||
### 2. Production-path interaction
|
||||
|
||||
In the real Sandbox Stage, verify EDIT with no selection has no plan markers; selecting a stickman and a prop individually shows one cyan stage-name badge each; mixed multi-selection shows every selected named object badge without overlap or input interception; and the **Objects here** candidate preview suppresses persistent badges. Keep each selected object's badge anchor stable through translate/rotate/scale, release, camera pan, and zoom. Reposition only if that anchor becomes obstructed by the actor, another badge, a transform handle, Stage chrome, an inspector, or the safe viewport; keep unaffected multi-selection badges anchored. Plan routes and action markers stay hidden throughout EDIT. In DIRECT, verify an empty stage with no selection has no plan markers, and selecting exactly one actor restores Step Spotlight. Place the actor-name badge at least 24 screen pixels above the actual visible head shape, including headwear; when the top safe area prevents that placement, move the badge beside the head with a short tether and keep it inside the safe viewport. Select an empty-plan Milo, see the empty message and fixed quick actions, then dismiss on empty stage and reselect. Add Move by Walk/Run and exact prop/place selection; add Say to the selected step via **+ Add together**, then append Wait with its quick action. Select an action in Step 2, then click Step 1's plain title; verify the old action options close and its amber highlight clears. Verify that clicking blank card space and each child control selects its owning card while preserving that control's action. Hover over a step title and verify the text remains stationary with no tooltip. Tab to a card, verify the white inset focus outline, and use Enter/Space to select it. Verify **↑**/**↓** buttons reorder future steps, preserve action IDs, and remain disabled at boundaries or when runtime state forbids reordering. Observe Step 1 **Together** and Step 2 Wait, play and observe concurrent Walk+Say with the barrier before Wait. On a standalone Say step, open **+ Add together** and verify the drawer offers every other registered action; on any other singleton, verify it offers Say. Confirm the picker never offers Say+Say or primary+primary and the two-action card cap stays enforced. Use the reorder arrows and verify numbering, routes, action IDs, and saved order.
|
||||
|
||||
Select Ragdoll, Recover, or Drop Prop from the add/change picker and verify each commits immediately without opening an editor; replacement keeps the action ID, and parameter-free cards have no **Edit action** button.
|
||||
|
||||
Place two instances of the same prop asset, select one by current distinct stage name/visual preview, rename it in EDIT, and verify the Move card/flag update while the saved target ID stays fixed. Place a free destination beside that prop and verify it remains **Place N**. In DIRECT, select a multi-step character and verify only the focused route is bright, other actors' routes are hidden, and other movement steps retain faint numbered flags; click a step card and a ghost flag to switch focus without dirtying the scene. Check the rounded name/action badges, long-name elision, route contrast over sky/building/grass, and that marker labels stay away from route crossings. Tap one numbered flag: the correct existing action opens with no dirty mark or move. Open **Edit action**, inspect the old faint and candidate routes while retargeting, Cancel/Escape, then retarget to the other prop and to a free place; verify the same action ID and saved destination variant each time. Open **Change action** on a standalone Move, verify the slide-in picker lists other types, then Cancel and confirm the plan stays clean. Choose Wait, Cancel its editor, and confirm the Move and ID remain; repeat and commit, then verify Wait replaced Move under the same ID. In a Move+Say Together step, verify changing Move offers all other primary actions except Move, while Say has no **Change action** control; in Wait+Say, changing Wait offers all other primary actions except Wait. Check that Pick Up stays anchored to its prop while Say stays by the actor, and that Recover+Say pills stack without overlap. Check invalid terrain, overlap chooser Cancel, keyboard-only target selection, and speech composer Cancel. Pause during a grouped step: active replace/delete works; grouping/reorder offers only future steps. After one action completes or is interrupted, verify its options offer confirmed Erase but not Edit action; delete it, resume without replaying history, then Reset and confirm the deletion affects the next run.
|
||||
|
||||
### 3. Rendered captures and UIUX review
|
||||
|
||||
Capture the actual GUI states reached by those interactions, not an isolated screenshot generator: EDIT empty selection, selected actor/prop names, mixed multi-selection labels, badge position during a real drag, and overlap chooser preview; DIRECT empty selection, focused Step 1, clear name-to-head gap, and top-edge tether fallback; the Choose an action picker with scrolling and a pinned Cancel button; the Together picker for a standalone Say showing every other action and for another singleton offering Say, without compatibility helper copy; Walk+Say stacked action pills and a later Wait; a prop action+Say with Pick Up over its prop and Say by the actor; Recover+Say with distinct stacked action pills; exact prop roster/preview and 20a overlap chooser; free place near a prop; selected flag/options with stacked Edit, Change, and Erase controls; the Change action drawer for a standalone action and for a Together pair with only compatible replacement choices; Cancel from the drawer/editor and a committed replacement preserving the action ID; Move retarget old/candidate/invalid/Cancel through Edit action; focused standalone Say and Wait pills, stacked Wait+Say pills, and compact overview markers; paused active/future controls; and playback start/barrier frames. Include wide and approximately 920×560 viewports with a long character name and eight or more steps, visible header/quick buttons, body scrolling, bold 48×48 px reorder arrows, an animated adjacent-card swap with the moved card in front, persistent selected-card/action identity, preserved scroll/focus, plain stationary step titles with no tooltip, blank-card and child-control selection, keyboard focus outline, and safe capture bar placement. Compare against the branch-local mockups and `docs/guides/editor_visual_style_guide.md`; record any intentional deviation. An automated assertion, log line, or image file alone is not proof that the production interaction and visual state were reached.
|
||||
|
||||
## Implementation and verification record
|
||||
|
||||
### Delivered
|
||||
|
||||
- **Plan data and identity:** Scene v1.9 stores ordered `action_plan` steps with stable string `action_id` values. Move destinations explicitly identify a free `place` or a prop by its stable stage object ID and world-aligned approach offset. Legacy flat queues are migrated to plans.
|
||||
- **Step execution:** A step may contain one primary action and one Say. They start together; the next step waits until both finish. The runner records per-action outcomes, and a failed Move does not count as reaching its endpoint. Named-prop pursuit follows translation without rotating the saved offset, pauses its time budget with the simulation, and fails on a missing target, blocked or unreachable route, or expired pursuit budget.
|
||||
- **DIRECT authoring:** The inspector provides the ordered Step Stack, quick actions, compatible Add together choices, action editing/replacement, cancellation, and step reordering. Selection follows the same action through reorder; route spotlight and on-stage action markers follow the focused step.
|
||||
- **Target and prop feedback:** Stage target capture uses a visible Cancel and action-specific preview. Pickup selection uses friendly prop names and eligible-target feedback; Set Down highlights only valid support surfaces. Prop interaction safety protects an intended pickup target from its approaching actor and gives the releasing actor collision grace after Set Down.
|
||||
|
||||
### Verification evidence
|
||||
|
||||
- The automated action-plan suite recorded **45 checks, 0 failures** in [the final log](../../tests/phase20b_action_plan_test_final.log). Its checks cover plan validation and serialization, stable IDs, grouped-action timing/barriers, pause behavior, moving-prop retargeting, and missing-target failure. The test source is [test_phase20b_action_plan.gd](../../tests/test_phase20b_action_plan.gd).
|
||||
- Focused production-GUI runs recorded passing results for selected slices, including grouped authoring, action drawer, step spotlight/reorder, flag selection, and Move editing. Examples include [grouping](../../tests/phase20b_grouping_success_check.log), [Step Spotlight](../../tests/phase20b_step_spotlight_final.log), and [action drawer](../../tests/phase20b_action_drawer_full_height.log). These are targeted slice results, not a single run of every item in the broader GUI checklist above.
|
||||
- The user reported completing and passing the following seven manual scenarios:
|
||||
1. **Airborne target:** pursuing a prop above the actor does not count as arrival before it is reachable.
|
||||
2. **Blocked route:** a blocked Move does not pass through the obstacle or report success, and eventually fails.
|
||||
3. **Timeout and missing target:** both cases fail without an arrival result or a stalled plan.
|
||||
4. **Pause during pursuit:** pausing longer than the pursuit limit does not consume the budget; pursuit resumes toward the prop's current position. Fixture: [Test 4](../../content/stages/phase20b_test4_pause_during_pursuit.scene).
|
||||
5. **Save, reload, rename, and delete:** a prop Move retains its identity through rename and remains repairable after deletion; a nearby free destination remains a place target. Fixture: [Test 5](../../content/stages/phase20b_test5_target_identity.scene).
|
||||
6. **DIRECT editing and focus:** Move+Say grouping, adding Wait, selection/focus changes, reorder identity, and Change Action cancellation behave as expected. Fixture: [Test 6](../../content/stages/phase20b_test6_direct_editing_focus.scene).
|
||||
7. **Target picking and cancel:** Escape cancels place, Pick Up, and Set Down capture without committing the draft; the inspector and target feedback recover. Fixture: [Test 7](../../content/stages/phase20b_test7_target_picking_cancel.scene).
|
||||
|
||||
### Remaining boundary
|
||||
|
||||
The user-verified seven-case matrix and the cited automated/GUI slices do not establish that every detailed item in the broad acceptance matrix was independently exercised and captured. In particular, the complete production-interaction matrix and a recorded UI/UX review remain unrecorded as a single sign-off. Phase 20b does not include event-specific reaction authoring or reaction runtime; those remain in Phases 20c–20e.
|
||||
|
||||
## Decisions and trade-offs
|
||||
|
||||
- **Nested steps and string IDs:** A true step array makes order and barrier semantics explicit. Persistent opaque strings cost more migration and adaptation than flat session indices but keep later event links stable under edits and saves. Step numbers remain derived, avoiding unnecessary step identity.
|
||||
- **Explicit destination kind:** The discriminated payload prevents proximity inference and keeps prop rename separate from identity. A cached last-known approach supports repairable missing targets; it never acts as an implicit fallback success location.
|
||||
- **Finite live pursuit:** Retargeting by world-aligned offset supports moving/spinning props without creating a perpetual Follow. Throttling avoids gait resets/repath churn; a bounded budget means a continually moving prop eventually fails, so the plan cannot stall forever.
|
||||
- **One primary plus one Say:** A user update broadened the rule to every currently registered primary action type. The explicit pair preserves the runner's existing primary/speech split, prevents ambiguous primary+primary and Say+Say steps, and still defers a general parallel action scheduler until later phases need evidence for one.
|
||||
- **Shared Stage shell, specialized plan body:** Extend `StageInspectorPanel` for pinned quick actions and use shared theme/capture/dialog patterns. The plan card and route preview remain domain-specific; modal action editing keeps its own focus model.
|
||||
- **Legacy behavior:** Migrating flat queues is deterministic, while old nonblocking `keep_acting` may need a narrow compatibility flag. It must be visibly tested and must not leak into newly authored grouped steps.
|
||||
|
||||
> Historical note: The user approved this spec before implementation. The implementation and verification record above documents the current delivery and evidence status.
|
||||
@@ -1,10 +1,10 @@
|
||||
# Stage selection panel UI system — architecture specification
|
||||
|
||||
> **Status:** Approved for implementation. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. Modal dialogs, the Prop Editor, and the Stickman Editor are later candidates, outside this change.
|
||||
> **Status:** Approved baseline, amended by Phase 20a user feedback. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. The Phase 20a amendment below replaces the original selected-prop **Close → Settings** interaction for the prop and character naming inspectors. Other callers can retain Close when that fits their workflow. Modal dialogs, the Prop Editor, and the Stickman Editor are outside this change.
|
||||
|
||||
## Purpose and current reference
|
||||
|
||||
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon and Close button; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls.
|
||||
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls. Phase 20a adds a visible Rename action and replaces the prop card's Close/dismiss state with a disclosure chevron; the name remains visible in both expanded and collapsed states.
|
||||
|
||||
Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSettingsToggleRow` and its drawn switch implement one reusable control, but carry prop-specific names. `SandboxTheme` has `stage_prop_settings` tokens. `QueuePanel`, `RulePanel`, and action dialogs also create local style overrides, but they are popup workflows and do not join this first migration.
|
||||
|
||||
@@ -24,13 +24,15 @@ Targeting and placement steps in EDIT and DIRECT use a temporary capture bar, se
|
||||
|
||||
## Child-facing contract
|
||||
|
||||
- A selection card has a recognizable icon, the selected object's name, and a large Close button. Long names wrap to at most two lines and expose their full name in a tooltip; Close never overlaps the title.
|
||||
- A selection card has a recognizable icon and the selected object's name. Long names wrap to at most two lines and expose their full name in a tooltip; header actions never overlap the title.
|
||||
- When renaming is available, show a visible **Rename** button beside the name. Do not make the title text itself a hidden rename hotspot. Rename opens a prefilled inline field with visible **Done** and **Cancel** actions; validation feedback stays beside the field.
|
||||
- A caller may configure the final header button as a large Close action or, for a collapsible inspector, as a disclosure button. The disclosure uses **▾** to expand and **▴** to collapse, with **Expand settings** / **Collapse settings** tooltips. Collapsing keeps the icon, name, and Rename button visible on one line; long names use an ellipsis and keep the full value in the title tooltip.
|
||||
- Content uses the same heading, choice, switch, helper, spacing, corners, focus, hover, selected, and disabled styles. Each setting can still supply its own words, optional illustration, and reason for being unavailable.
|
||||
- Choice labels stay stable when selected. Selection is shown by fill and border; text does not gain a checkmark or change width.
|
||||
- A switch is clickable anywhere in its row. Showing or hiding help may change the row height, but the switch and ON/OFF label stay anchored at the top, so a second click at the same pointer position hits the same control.
|
||||
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and Close remain reachable. Focus and selected states remain distinct from hover.
|
||||
- After Close, a compact **Settings** button appears at the card's usual top-right position for the selected object. It reopens the card without requiring the child to deselect and reselect the object.
|
||||
- Keyboard users can Tab to choices, switches, and Close; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
|
||||
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and header actions remain reachable. Focus and selected states remain distinct from hover.
|
||||
- A Close-enabled, non-collapsible caller may use its existing dismissal/reopen behavior. Phase 20a's selected prop inspector collapses to its named header instead of showing a generic **Settings** button. The character inspector stays as a compact name-and-Rename bar because renaming is its only content. Clicking empty stage space clears selection and removes either bar.
|
||||
- Keyboard users can Tab to choices, switches, Rename, disclosure, and any Close action; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
|
||||
|
||||
## Component hierarchy
|
||||
|
||||
@@ -42,7 +44,8 @@ CanvasLayer (owned by SandboxStage)
|
||||
├─ Header : HBoxContainer
|
||||
│ ├─ Icon : Label
|
||||
│ ├─ Title : Label (selected item name)
|
||||
│ └─ Close : Button
|
||||
│ ├─ Rename : Button (optional)
|
||||
│ └─ Disclosure or Close : Button (caller-configured)
|
||||
└─ ScrollContainer (vertical only)
|
||||
└─ Body : VBoxContainer (caller adds sections and controls)
|
||||
├─ Section heading : Label
|
||||
@@ -58,7 +61,7 @@ The shell owns layout and presentation. The caller owns the body composition and
|
||||
|
||||
| Target | Public contract | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `func apply_theme(stage_theme: SandboxTheme) -> void`; `func set_header(title: String, icon_text: String = "") -> void`; `func body() -> VBoxContainer`; `func set_safe_rect(rect: Rect2) -> void`; `func request_fit() -> void`; `func reset_scroll() -> void` | Fixed header, scroll body, viewport fit, local theme. Emits Close; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
|
||||
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `signal rename_requested`; `signal collapsed_changed(collapsed: bool)`; `func set_rename_enabled(enabled: bool) -> void`; `func set_collapsible(enabled: bool, collapsed: bool = false) -> void`; `func set_close_visible(enabled: bool) -> void`; `func set_body_visible(enabled: bool) -> void`; `func set_collapsed(collapsed: bool, notify: bool = true) -> void`; `func set_title_single_line(enabled: bool) -> void`; plus `apply_theme`, `set_header`, `body`, `set_safe_rect`, `request_fit`, `reset_scroll` | Fixed header, optional Rename and Close/disclosure actions, scroll body, viewport fit, local theme. Compact headers use one-line ellipsis with a full-name tooltip. Emits intent; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
|
||||
| `scripts/shared/stage_ui/stage_panel_choice_group.gd`, `class_name StagePanelChoiceGroup extends VBoxContainer` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func clear_no_signal() -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Exclusive choices with stable captions and native focus/activation. `clear_no_signal()` supports migrated prop draw order. Repressing the selected option leaves it selected. An optional cue is decorative, not clickable. |
|
||||
| `scripts/shared/stage_ui/stage_panel_toggle_row.gd`, `class_name StagePanelToggleRow extends Button` | `signal preferred_height_changed`; inherited `signal toggled(toggled_on: bool)`; `func configure(caption: String) -> void`; `func set_help_text(text: String) -> void`; `func set_disabled_state(value: bool, reason: String = "") -> void`; `func set_checked_no_signal(value: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | One focusable full-row toggle, large drawn switch, ON/OFF label, help layout. Stores `_normal_help` and `_disabled_reason` separately; displayed help derives from `disabled`. Re-enabling restores normal help and never leaves a stale disabled reason. Child labels and switch ignore pointer events. |
|
||||
| `scripts/shared/stage_ui/stage_panel_switch_visual.gd`, `class_name StagePanelSwitchVisual extends Control` | `func set_state(enabled: bool, unavailable: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Vector switch drawing only. No input or business signals. Reuses the current 64 × 34 track and 26 px knob. |
|
||||
@@ -77,11 +80,15 @@ flowchart LR
|
||||
Stage --> Save[Save placement, reconcile, mark dirty]
|
||||
Stage --> Refresh[Refresh controls without signals]
|
||||
Refresh --> Fit[Panel request_fit, deferred once]
|
||||
Close[Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
|
||||
Rename[Rename pressed] --> RenameSignal[StageInspectorPanel.rename_requested]
|
||||
RenameSignal --> Caller[Caller opens inline name editor]
|
||||
Disclosure[Disclosure pressed] --> CollapseSignal[StageInspectorPanel.collapsed_changed]
|
||||
CollapseSignal --> Fit[Refit named header or expanded body]
|
||||
Close[Optional Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
|
||||
CloseSignal --> Stage
|
||||
```
|
||||
|
||||
Signal payloads are `StringName` for a choice ID and `bool` for a toggle. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. The panel emits `close_requested`; `SandboxStage` records the currently selected object as dismissed and keeps its card hidden through unrelated refreshes. `SandboxStage` shows a compact Settings button at the panel anchor while dismissed. A different selection or pressing Settings clears that dismissal. Closing does not undo a saved setting.
|
||||
Choice payloads are `StringName` IDs and toggle payloads are `bool`. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. Rename and disclosure signals carry presentation intent only. A caller that exposes Close may retain its own dismissal/reopen policy; a collapsible inspector keeps its selected name in the header and uses `collapsed_changed` to synchronize any caller state. Collapsing does not change selection or undo a saved setting.
|
||||
|
||||
### Example prop adapter
|
||||
|
||||
@@ -125,8 +132,8 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
||||
2. Switch and ON/OFF remain at a fixed top alignment while help grows below the caption. Control height comes from wrapped content and a minimum, never from vertically centering the switch in a changing row.
|
||||
3. Selected, hovered, focused, and disabled styles each have a visible state. Focus remains visible over selected fill. Disabled rows retain readable text and show their reason in the row. If no reason exists, the caller must provide one before disabling a child-facing setting.
|
||||
4. Choice state is apparent through fill and border; its label stays unchanged. The toggle also has ON/OFF text and knob position, so it does not depend on color alone.
|
||||
5. The title can wrap to two lines; Close reserves its width. Full title is available as tooltip. The shell recomputes height after wrapping and keeps Close outside the scroll area.
|
||||
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape follows the Stage's existing panel dismissal convention. Focus moves into the card when opened from keyboard and returns to the originating Stage control on Close when one exists.
|
||||
5. The title can wrap to two lines; visible Rename and disclosure/Close actions reserve their widths. Full title is available as tooltip. The shell recomputes height after wrapping and keeps all header controls outside the scroll area.
|
||||
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape collapses an expanded collapsible card; a Close-enabled non-collapsible caller may use its existing dismissal convention. Focus moves into the card when opened from keyboard.
|
||||
|
||||
## Migration sequence
|
||||
|
||||
@@ -137,10 +144,10 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The prop card retains the current selected-name header, fixed Close, no text checkmarks, front/behind cues, role semantics, and saved scene format. Legacy draw order still shows no selected depth until a child chooses one.
|
||||
1. The prop card retains the selected-name header, no text checkmarks, front/behind cues, role semantics, and saved scene format. Under the Phase 20a amendment, it shows Rename and a disclosure chevron; the name remains visible when collapsed. Legacy draw order still shows no selected depth until a child chooses one.
|
||||
2. At the same viewport and theme, the migrated prop card's width, spacing, typography, colors, switch size, and selected states match the current card. Long prop names and wrapped labels cannot collide with Close or the switch.
|
||||
3. Switching a row On and Off at the same pointer position works twice without moving the switch. Help appears beneath the caption. A disabled reason disappears on re-enable and normal help returns.
|
||||
4. On short viewports, every body control is reachable by scroll while header and Close stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. After Close, the card stays hidden while that same object remains selected, including after role or viewport refreshes; a compact Settings button remains available at the card anchor to reopen it. Changing selection shows the new selection's card.
|
||||
4. On short viewports, every body control is reachable by scroll while the header actions stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. Collapsing keeps the selected prop name and Rename action visible; expanding restores the body. Changing selection shows the new selection's card.
|
||||
5. Pointer, touch, and keyboard activation each change a setting once. Disabled controls cannot mutate a role, and focus remains visible on both selected and neutral choices.
|
||||
6. The next Stage inspector can create a shell, a choice group, and a toggle row using only `SandboxTheme` and shared APIs, with no copied panel geometry or stylebox code.
|
||||
|
||||
@@ -153,6 +160,6 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
||||
|
||||
## Decision and next adopter
|
||||
|
||||
1. **Panel dismissal — decided:** Closing a selection card keeps it dismissed while the same object remains selected. Changing selection or pressing the compact Settings button shows it again. The implementation tracks this in `SandboxStage`, separate from the shared shell.
|
||||
1. **Panel visibility — amended by Phase 20a:** For the placed-prop inspector, the child collapses/expands the body with a disclosure while the named header stays visible. Character naming uses a compact header only. Callers with a different workflow may retain Close and own any dismissal/reopen behavior in `SandboxStage`.
|
||||
2. **Next adopter — recommended:** Apply the shared kit to the next in-stage selected-object inspector as it is built. Existing queue/rule popups have different focus and sizing behavior and can be evaluated in a later pass.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"background": "#171B24F2",
|
||||
"surface": "#252B38FA",
|
||||
"surface_hover": "#30394A",
|
||||
"selected_surface": "#103B4D",
|
||||
"text": "#F5F7FA",
|
||||
"text_muted": "#AAB2C0",
|
||||
"edit": "#22C6FF",
|
||||
@@ -24,6 +25,8 @@
|
||||
"section": 22,
|
||||
"title": 26,
|
||||
"menu": 20,
|
||||
"menu_bar": 22,
|
||||
"menu_popup": 26,
|
||||
"status": 16,
|
||||
"mode": 24,
|
||||
"prop_name": 20,
|
||||
@@ -32,7 +35,13 @@
|
||||
"spacing": { "small": 8, "medium": 12, "large": 16 },
|
||||
"radii": { "control": 8, "panel": 10 },
|
||||
"borders": { "normal": 1, "focus": 2 },
|
||||
"targets": { "button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56 }
|
||||
"targets": {
|
||||
"button_min_height": 44,
|
||||
"row_min_height": 44,
|
||||
"mode_button_min_height": 56,
|
||||
"context_menu_row_height": 48,
|
||||
"context_menu_min_width": 208
|
||||
}
|
||||
},
|
||||
"editors": { "prop": {}, "stickman": {} },
|
||||
"stickman_canvas": {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"dialogs": {
|
||||
"queue_size": [680, 520],
|
||||
"rule_size": [720, 560],
|
||||
"action_size": [560, 390],
|
||||
"action_size": [560, 460],
|
||||
"confirm_size": [560, 300],
|
||||
"font_size": 22,
|
||||
"message_font_size": 20
|
||||
@@ -114,6 +114,58 @@
|
||||
"padding": 12
|
||||
},
|
||||
"action_menu": { "font_size": 24 },
|
||||
"anchored_chooser": {
|
||||
"preferred_width": 360,
|
||||
"edge_inset": 8,
|
||||
"safe_top": 120,
|
||||
"safe_bottom": 48,
|
||||
"content_inset": 12,
|
||||
"heading_font_size": 20,
|
||||
"row_height": 56,
|
||||
"row_gap": 8,
|
||||
"header_gap": 8,
|
||||
"footer_gap": 8,
|
||||
"max_rows_height": 320,
|
||||
"minimum_rows_height": 116,
|
||||
"row_font_size": 20,
|
||||
"row_text_color": "#F5F7FA",
|
||||
"row_surface": "#171B24",
|
||||
"row_border_color": "#FFFFFF55",
|
||||
"selected_surface": "#12394A",
|
||||
"hover_surface": "#203849",
|
||||
"accent_color": "#22C6FF",
|
||||
"focus_color": "#8BD9FF",
|
||||
"corner_radius": 14,
|
||||
"border_width": 2,
|
||||
"bg_color": "#202633FA",
|
||||
"border_color": "#667386",
|
||||
"padding": 0,
|
||||
"shadow_size": 6,
|
||||
"shadow_color": "#00000080",
|
||||
"shadow_offset": [0, 4],
|
||||
"row_corner_radius": 12,
|
||||
"row_border_width": 1,
|
||||
"selected_border_width": 2,
|
||||
"hover_border_width": 2,
|
||||
"focus_border_width": 3,
|
||||
"row_padding_x": 12,
|
||||
"row_padding_y": 6,
|
||||
"badge_corner_radius": 8,
|
||||
"badge_surface": "#12394A",
|
||||
"badge_text_color": "#F5F7FA",
|
||||
"badge_font_size": 16,
|
||||
"badge_padding_x": 8,
|
||||
"badge_padding_y": 2,
|
||||
"cancel_width": 120,
|
||||
"cancel_height": 48,
|
||||
"cancel_corner_radius": 10,
|
||||
"cancel_border_width": 2,
|
||||
"cancel_font_size": 20,
|
||||
"cancel_surface": "#293242",
|
||||
"cancel_hover_surface": "#354154",
|
||||
"cancel_border_color": "#718096",
|
||||
"cancel_text_color": "#F5F7FA"
|
||||
},
|
||||
"dialog": {
|
||||
"surface_color_size": [560, 640],
|
||||
"surface_color_preview_size": [420, 140],
|
||||
@@ -159,9 +211,13 @@
|
||||
"choice_font_size": 18,
|
||||
"choice_min_width": 104,
|
||||
"choice_corner_radius": 12,
|
||||
"choice_content_inset_x": 8,
|
||||
"choice_content_inset_y": 6,
|
||||
"compact_choice_height": 50,
|
||||
"compact_choice_preview_height": 22,
|
||||
"compact_choice_font_size": 16,
|
||||
"compact_choice_content_inset": 4,
|
||||
"compact_choice_content_separation": 2,
|
||||
"compact_grid_gap": 6,
|
||||
"compact_layout_max_width": 600,
|
||||
"compact_layout_max_height": 460,
|
||||
@@ -233,6 +289,7 @@
|
||||
"stage_panels": {
|
||||
"shell": {
|
||||
"preferred_width": 380,
|
||||
"max_width": 380,
|
||||
"edge_inset": 12,
|
||||
"padding": 12,
|
||||
"header_gap": 8,
|
||||
@@ -324,13 +381,74 @@
|
||||
"completed_path_color": "#959EAD",
|
||||
"rule_connector_color": "#AAB2C0",
|
||||
"rule_action_color": "#7E57C2",
|
||||
"director_plan": {
|
||||
"route_color": "#8BD9FF",
|
||||
"run_color": "#FFB300",
|
||||
"say_color": "#FFD84D",
|
||||
"wait_color": "#B69CFF",
|
||||
"completed_color": "#959EAD",
|
||||
"failed_color": "#F05B57",
|
||||
"candidate_valid_color": "#4DE666",
|
||||
"candidate_invalid_color": "#FF626B",
|
||||
"flag_size_px": 44,
|
||||
"flag_number_size_px": 18,
|
||||
"label_bg_color": "#101720E8",
|
||||
"label_border_alpha": 0.8,
|
||||
"spotlight_route_width_px": 4,
|
||||
"spotlight_route_shadow_width_px": 6,
|
||||
"spotlight_dash_length_px": 12,
|
||||
"spotlight_dash_gap_px": 7,
|
||||
"spotlight_route_alpha": 0.95,
|
||||
"unfocused_step_alpha": 0.24,
|
||||
"action_badge_font_size_px": 18,
|
||||
"action_badge_padding_px": [10, 6],
|
||||
"action_badge_radius_px": 11,
|
||||
"action_badge_border_width_px": 2,
|
||||
"action_badge_icon_slot_width_px": 22,
|
||||
"action_badge_icon_text_gap_px": 6,
|
||||
"action_badge_icon_scale": 0.72,
|
||||
"action_badge_stack_gap_px": 4,
|
||||
"action_badge_route_gap_px": 6,
|
||||
"actor_name_font_size_px": 20,
|
||||
"actor_name_max_width_px": 260,
|
||||
"actor_name_padding_px": [12, 7],
|
||||
"actor_name_radius_px": 12,
|
||||
"actor_name_border_width_px": 2,
|
||||
"actor_name_offset_px": 26,
|
||||
"route_badge_fraction": 0.32,
|
||||
"actor_name_border_color": "#FFB300",
|
||||
"action_badge_text_color": "#F5F7FA",
|
||||
"route_shadow_color": "#101720D9",
|
||||
"action_row_move_surface": "#17313D",
|
||||
"action_row_say_surface": "#2C281C",
|
||||
"action_row_wait_surface": "#29243A",
|
||||
"action_row_other_surface": "#202632",
|
||||
"action_row_icon_surface": "#252B38",
|
||||
"action_row_text_color": "#F5F7FA",
|
||||
"action_row_duration_color": "#AAB2C0",
|
||||
"action_row_options_color": "#8E9AAA",
|
||||
"action_row_font_size_px": 18,
|
||||
"action_row_duration_font_size_px": 14,
|
||||
"action_row_icon_font_size_px": 20,
|
||||
"action_row_height_px": 48,
|
||||
"action_row_icon_size_px": 32,
|
||||
"action_row_rail_width_px": 4,
|
||||
"action_row_options_width_px": 24,
|
||||
"action_row_options_font_size_px": 18,
|
||||
"action_row_radius_px": 8,
|
||||
"action_row_selected_border_width_px": 2
|
||||
},
|
||||
"valid": { "color": "#4DE66680" },
|
||||
"same_type": { "color": "#FFFFFF26" },
|
||||
"invalid": { "color": "#FF4D4D80" },
|
||||
"set_down": {
|
||||
"unknown_color": "#C5CAD4",
|
||||
"reach_color": "#FFB300",
|
||||
"ghost_alpha": 0.36
|
||||
"ghost_alpha": 0.36,
|
||||
"surface_line_width_px": 5,
|
||||
"surface_hover_line_width_px": 7,
|
||||
"surface_alpha": 0.75,
|
||||
"surface_hover_alpha": 0.95
|
||||
},
|
||||
"use_preview": {
|
||||
"valid_color": "#42D887",
|
||||
@@ -344,7 +462,14 @@
|
||||
"selection_color": "#F5F7FAE6",
|
||||
"hover_color": "#FFE66DCC",
|
||||
"rotate_color": "#4DB9FFE6",
|
||||
"backing_extra_width": 3.0
|
||||
"backing_extra_width": 3.0,
|
||||
"candidate_name_pill_bg_color": "#182432F2",
|
||||
"candidate_name_pill_corner_radius": 8.0,
|
||||
"candidate_name_pill_border_width": 3.0,
|
||||
"candidate_name_pill_padding_x": 12.0,
|
||||
"candidate_name_pill_padding_y": 8.0,
|
||||
"selected_name_pill_font_size_px": 20.0,
|
||||
"selected_name_pill_max_width_px": 260.0
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
|
||||
@@ -159,3 +159,6 @@ layout_mode = 2
|
||||
text = ""
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
|
||||
layer = 100
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"]
|
||||
[ext_resource type="Script" path="res://scripts/shared/editor_ui/editor_context_menu.gd" id="2_context_menu_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
|
||||
bg_color = Color(0.12, 0.12, 0.12, 1)
|
||||
@@ -14,6 +15,7 @@ border_color = Color(0.25, 0.25, 0.25, 1)
|
||||
custom_minimum_size = Vector2(230, 160)
|
||||
offset_right = 230.0
|
||||
offset_bottom = 160.0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
script = ExtResource("1_panel_script")
|
||||
|
||||
@@ -38,9 +40,9 @@ mouse_filter = 1
|
||||
clip_contents = true
|
||||
theme_override_styles/panel = SubResource("StyleBox_drawing")
|
||||
|
||||
[node name="ContextMenu" type="PopupMenu" parent="."]
|
||||
[node name="ContextMenu" type="PopupPanel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
item_count = 0
|
||||
script = ExtResource("2_context_menu_script")
|
||||
|
||||
[node name="ColorPickerPopup" type="PopupPanel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://6wqo0sp4eij6"]
|
||||
[gd_scene load_steps=4 format=3 uid="uid://6wqo0sp4eij6"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dlacmxk257ro" path="res://scripts/editors/stickman/stickman_editor.gd" id="1_editor_script"]
|
||||
[ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/editors/stickman/whole_stickman_preview.gd" id="2_preview_script"]
|
||||
[ext_resource type="Script" path="res://scripts/shared/editor_ui/editor_selection_dropdown.gd" id="3_selection_dropdown_script"]
|
||||
|
||||
[node name="StickmanEditor" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -60,9 +61,10 @@ layout_mode = 2
|
||||
text = "Focus on Part"
|
||||
toggle_mode = true
|
||||
|
||||
[node name="PartPicker" type="OptionButton" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
[node name="PartPicker" type="MenuButton" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
script = ExtResource("3_selection_dropdown_script")
|
||||
|
||||
[node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
@@ -115,16 +117,22 @@ vertical_scroll_mode = 1
|
||||
[node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
|
||||
@@ -138,12 +146,6 @@ grow_vertical = 2
|
||||
visible = false
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="FocusPartLabel" type="Label" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Head"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
@@ -219,9 +221,9 @@ filters = PackedStringArray("*.stk ; Stickman Files")
|
||||
|
||||
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Start Over"
|
||||
title = "Start Over?"
|
||||
ok_button_text = "Start Over"
|
||||
dialog_text = "Start over with a blank stickman?"
|
||||
dialog_text = "This will clear your current stickman. Start over?"
|
||||
|
||||
[node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
@@ -254,3 +256,6 @@ max_value = 100.0
|
||||
value = 15.0
|
||||
step = 1.0
|
||||
rounded = true
|
||||
|
||||
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
|
||||
layer = 100
|
||||
|
||||
@@ -3,12 +3,16 @@ extends Node2D
|
||||
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_stk_path: String = ""
|
||||
var stage_object_id: String = ""
|
||||
var stage_name: String = ""
|
||||
|
||||
const MARKER_SIZE := Vector2(260.0, 52.0)
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
original_entry = entry.duplicate(true)
|
||||
missing_stk_path = String(entry.get("stk_path", ""))
|
||||
stage_object_id = String(entry.get("stage_object_id", ""))
|
||||
stage_name = String(entry.get("stage_name", ""))
|
||||
queue_redraw()
|
||||
|
||||
func get_area_rect() -> Rect2:
|
||||
|
||||
@@ -5,11 +5,18 @@ extends Node
|
||||
## locomotion and signals requests back to the facade.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd")
|
||||
const STAGE_ACTION_IDENTITY := preload("res://scripts/stage/stage_action_identity.gd")
|
||||
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const PROP_INTERACTION_REACH: float = 110.0
|
||||
const PROP_PURSUIT_MIN_SHIFT: float = 16.0
|
||||
const PROP_PURSUIT_RETARGET_INTERVAL: float = 0.1
|
||||
const PROP_PURSUIT_BUDGET: float = 12.0
|
||||
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/speech/stickman_speech_bubble.gd")
|
||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||
|
||||
enum RunnerState { IDLE, EXECUTING }
|
||||
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
|
||||
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION, GROUP_BARRIER, PURSUIT_WAIT }
|
||||
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
|
||||
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
@@ -20,8 +27,12 @@ signal speech_finished
|
||||
signal ragdoll_requested(enabled: bool)
|
||||
signal recovery_requested
|
||||
signal active_action_cancelled(action: Dictionary)
|
||||
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
|
||||
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
|
||||
signal plan_changed
|
||||
|
||||
var _authored_queue: Array[Dictionary] = []
|
||||
var _authored_steps: Array[Dictionary] = []
|
||||
var action_queue: Array[Dictionary]:
|
||||
get:
|
||||
return _authored_queue.duplicate(true)
|
||||
@@ -43,7 +54,26 @@ var _speech_bubble: Node2D = null
|
||||
var _speech_preview_suppressed: bool = false
|
||||
var _action_ids: Array[int] = []
|
||||
var _next_action_id: int = 1
|
||||
var _stable_to_numeric_ids: Dictionary = {}
|
||||
var _stable_status: Dictionary = {}
|
||||
var _current_action_id: int = -1
|
||||
var _current_stable_action_id: String = ""
|
||||
var _current_step_index: int = -1
|
||||
var _active_step_entry: Dictionary = {}
|
||||
var _group_say_action_id: String = ""
|
||||
var _group_say_numeric_id: int = -1
|
||||
var _group_say_step_index: int = -1
|
||||
var _group_say_action: Dictionary = {}
|
||||
var _group_say_barrier: bool = false
|
||||
var _group_primary_done: bool = false
|
||||
var _parallel_active_numeric_ids: Dictionary = {}
|
||||
var _pursuit_action_id: String = ""
|
||||
var _pursuit_prop_id: String = ""
|
||||
var _pursuit_offset: Vector2 = Vector2.ZERO
|
||||
var _pursuit_last_prop_position: Vector2 = Vector2.ZERO
|
||||
var _pursuit_approach: Vector2 = Vector2.INF
|
||||
var _pursuit_elapsed: float = 0.0
|
||||
var _pursuit_retarget_elapsed: float = 0.0
|
||||
var _completed_ids: Dictionary = {}
|
||||
var _interrupted_ids: Dictionary = {}
|
||||
var _paused: bool = false
|
||||
@@ -70,8 +100,14 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
_advance_to_next_action()
|
||||
ActionPhase.WALKING:
|
||||
if _locomotion != null:
|
||||
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
|
||||
_finish_action()
|
||||
if _locomotion.consume_walk_failed():
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
elif _locomotion.consume_walk_completed():
|
||||
if _pursuit_action_id == _current_stable_action_id:
|
||||
_check_prop_pursuit_arrival()
|
||||
else:
|
||||
_finish_action(&"succeeded")
|
||||
ActionPhase.SPEAKING:
|
||||
if _primary_speech.is_empty():
|
||||
_finish_action()
|
||||
@@ -79,6 +115,11 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_finish_action()
|
||||
ActionPhase.GROUP_BARRIER:
|
||||
if not _group_say_barrier or _group_say_action_id.is_empty():
|
||||
_complete_active_step()
|
||||
ActionPhase.PURSUIT_WAIT:
|
||||
pass
|
||||
ActionPhase.RAGDOLLING:
|
||||
if ragdoll_at_rest:
|
||||
_finish_action()
|
||||
@@ -91,6 +132,48 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
_finish_action()
|
||||
|
||||
|
||||
func pre_movement(delta: float) -> void:
|
||||
if _paused or _pursuit_action_id.is_empty() or _action_phase not in [ActionPhase.WALKING, ActionPhase.PURSUIT_WAIT]:
|
||||
return
|
||||
_pursuit_elapsed += delta
|
||||
_pursuit_retarget_elapsed += delta
|
||||
if _pursuit_elapsed >= PROP_PURSUIT_BUDGET:
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(false)
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
|
||||
if prop == null:
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(false)
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
var prop_position: Vector2 = prop.global_position
|
||||
if prop_position.distance_to(_pursuit_last_prop_position) < PROP_PURSUIT_MIN_SHIFT or _pursuit_retarget_elapsed < PROP_PURSUIT_RETARGET_INTERVAL:
|
||||
return
|
||||
var projected: Vector2 = _locomotion.project_target_to_navigation(prop_position + _pursuit_offset) if _locomotion != null else Vector2.INF
|
||||
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
|
||||
var retargeted: bool = false
|
||||
if projected.is_finite() and _locomotion != null:
|
||||
retargeted = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
|
||||
if not projected.is_finite() or not retargeted:
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(false)
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
_pursuit_last_prop_position = prop_position
|
||||
_pursuit_approach = projected
|
||||
_pursuit_retarget_elapsed = 0.0
|
||||
_action_phase = ActionPhase.WALKING
|
||||
|
||||
|
||||
func is_prop_pursuit_active() -> bool:
|
||||
return not _pursuit_action_id.is_empty()
|
||||
|
||||
|
||||
func speak(text: String, duration: float) -> void:
|
||||
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
|
||||
|
||||
@@ -134,111 +217,286 @@ func sync_facing(profile: int) -> void:
|
||||
func set_authored_queue(actions: Array[Dictionary]) -> bool:
|
||||
if _session_started:
|
||||
return false
|
||||
_authored_queue = actions.duplicate(true)
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
var steps: Array[Dictionary] = []
|
||||
for source_action: Dictionary in actions:
|
||||
var action: Dictionary = source_action.duplicate(true)
|
||||
action["action_id"] = _allocate_stable_action_id()
|
||||
if ACTION_REGISTRY.is_move_action(action) and action.get("target", null) is Vector2:
|
||||
action["destination"] = {"kind": "place", "position": action["target"]}
|
||||
action.erase("target")
|
||||
steps.append({"actions": [action]})
|
||||
_authored_steps = steps
|
||||
_sync_legacy_queue()
|
||||
queue_changed.emit()
|
||||
plan_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func get_action_plan() -> Array[Dictionary]:
|
||||
return _authored_steps.duplicate(true)
|
||||
|
||||
|
||||
func set_action_plan(steps: Array[Dictionary]) -> bool:
|
||||
if _session_started or not STAGE_ACTION_PLAN.validate_steps(steps).is_empty():
|
||||
return false
|
||||
_authored_steps = steps.duplicate(true)
|
||||
_sync_legacy_queue()
|
||||
queue_changed.emit()
|
||||
plan_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func find_action(action_id: String) -> Dictionary:
|
||||
return STAGE_ACTION_PLAN.find_action(_authored_steps, action_id)
|
||||
|
||||
|
||||
func get_plan_view() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for step_index: int in _authored_steps.size():
|
||||
var actions_value: Variant = _authored_steps[step_index].get("actions", [])
|
||||
if not actions_value is Array:
|
||||
continue
|
||||
for raw_action: Variant in actions_value:
|
||||
if not raw_action is Dictionary:
|
||||
continue
|
||||
var action: Dictionary = raw_action as Dictionary
|
||||
var action_id: String = String(action.get("action_id", ""))
|
||||
result.append({
|
||||
"step_index": step_index,
|
||||
"action_id": action_id,
|
||||
"action": action.duplicate(true),
|
||||
"status": _status_for_stable_id(action_id),
|
||||
"numeric_id": _numeric_for_stable_id(action_id),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func add_plan_step(action: Dictionary) -> String:
|
||||
if _session_started and not _paused:
|
||||
return ""
|
||||
var created: Dictionary = _prepare_new_plan_action(action)
|
||||
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_step(_authored_steps, created)
|
||||
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
|
||||
|
||||
|
||||
func add_plan_action_together(step_index: int, action: Dictionary) -> String:
|
||||
if _session_started and not _paused:
|
||||
return ""
|
||||
if _session_started and _paused and not _plan_step_is_future(step_index):
|
||||
return ""
|
||||
var created: Dictionary = _prepare_new_plan_action(action)
|
||||
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_together(_authored_steps, step_index, created)
|
||||
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
|
||||
|
||||
|
||||
func replace_plan_action(action_id: String, action: Dictionary) -> bool:
|
||||
if _session_started and not _paused:
|
||||
return false
|
||||
var status: StringName = _status_for_stable_id(action_id)
|
||||
if _session_started and _paused and status in [&"succeeded", &"failed", &"cancelled"]:
|
||||
return false
|
||||
var next: Array[Dictionary] = STAGE_ACTION_PLAN.replace_action(_authored_steps, action_id, action)
|
||||
if next.is_empty() and not _authored_steps.is_empty():
|
||||
return false
|
||||
if _session_started and _paused and status == &"active":
|
||||
_cancel_stable_action(action_id)
|
||||
_stable_status[action_id] = &"pending"
|
||||
return _commit_plan_edit(next)
|
||||
|
||||
|
||||
func remove_plan_action(action_id: String) -> bool:
|
||||
if _session_started and not _paused:
|
||||
return false
|
||||
var location: Dictionary = find_action(action_id)
|
||||
if location.is_empty():
|
||||
return false
|
||||
if _status_for_stable_id(action_id) == &"active":
|
||||
_cancel_stable_action(action_id)
|
||||
var next: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(_authored_steps, action_id)
|
||||
_stable_status.erase(action_id)
|
||||
return _commit_plan_edit(next, true)
|
||||
|
||||
|
||||
func move_plan_step(from_index: int, to_index: int) -> bool:
|
||||
if _session_started and not _paused:
|
||||
return false
|
||||
if _session_started and _paused and (not _plan_step_is_future(from_index) or not _plan_step_is_future(to_index)):
|
||||
return false
|
||||
var next: Array[Dictionary] = STAGE_ACTION_PLAN.move_step(_authored_steps, from_index, to_index)
|
||||
return _commit_plan_edit(next) if from_index != to_index else true
|
||||
|
||||
|
||||
func can_add_plan_action_together(step_index: int, action_type: String) -> bool:
|
||||
if _session_started and _paused and not _plan_step_is_future(step_index):
|
||||
return false
|
||||
if _session_started and not _paused:
|
||||
return false
|
||||
return step_index >= 0 and step_index < _authored_steps.size() and STAGE_ACTION_PLAN.can_add_together(_authored_steps[step_index], action_type)
|
||||
|
||||
|
||||
func can_reorder_plan_step(step_index: int) -> bool:
|
||||
if step_index < 0 or step_index >= _authored_steps.size():
|
||||
return false
|
||||
if _session_started and not _paused:
|
||||
return false
|
||||
return not _session_started or _plan_step_is_future(step_index)
|
||||
|
||||
|
||||
func _prepare_new_plan_action(source_action: Dictionary) -> Dictionary:
|
||||
var action: Dictionary = source_action.duplicate(true)
|
||||
action["action_id"] = _allocate_stable_action_id()
|
||||
if ACTION_REGISTRY.is_move_action(action) and not action.has("destination") and action.get("target", null) is Vector2:
|
||||
action["destination"] = {"kind": "place", "position": action["target"]}
|
||||
action.erase("target")
|
||||
return action
|
||||
|
||||
|
||||
func _commit_plan_edit(next: Array[Dictionary], allow_empty: bool = false) -> bool:
|
||||
if next.is_empty() and not allow_empty:
|
||||
return false
|
||||
if not STAGE_ACTION_PLAN.validate_steps(next).is_empty():
|
||||
return false
|
||||
_authored_steps = next.duplicate(true)
|
||||
_sync_legacy_queue()
|
||||
if _session_started:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
plan_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func _sync_legacy_queue() -> void:
|
||||
_authored_queue = STAGE_ACTION_PLAN.flatten(_authored_steps)
|
||||
_action_ids.clear()
|
||||
for action: Dictionary in _authored_queue:
|
||||
var stable_id: String = String(action.get("action_id", ""))
|
||||
if not _stable_to_numeric_ids.has(stable_id):
|
||||
_stable_to_numeric_ids[stable_id] = _allocate_action_id()
|
||||
_action_ids.append(int(_stable_to_numeric_ids[stable_id]))
|
||||
|
||||
|
||||
func _allocate_stable_action_id() -> String:
|
||||
var identity := STAGE_ACTION_IDENTITY.new() as StageActionIdentity
|
||||
var world: Node2D = _rig.get_parent() as Node2D if _rig != null else null
|
||||
return identity.allocate(world)
|
||||
|
||||
|
||||
func _numeric_for_stable_id(action_id: String) -> int:
|
||||
if not _stable_to_numeric_ids.has(action_id):
|
||||
_stable_to_numeric_ids[action_id] = _allocate_action_id()
|
||||
return int(_stable_to_numeric_ids[action_id])
|
||||
|
||||
|
||||
func _status_for_stable_id(action_id: String) -> StringName:
|
||||
if _stable_status.has(action_id):
|
||||
return StringName(String(_stable_status[action_id]))
|
||||
return &"pending" if _session_started else &"not_started"
|
||||
|
||||
|
||||
func _plan_step_is_future(step_index: int) -> bool:
|
||||
if step_index < 0 or step_index >= _authored_steps.size():
|
||||
return false
|
||||
var actions: Array = _authored_steps[step_index].get("actions", [])
|
||||
for raw_action: Variant in actions:
|
||||
if not raw_action is Dictionary:
|
||||
return false
|
||||
if _status_for_stable_id(String((raw_action as Dictionary).get("action_id", ""))) != &"pending":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _cancel_stable_action(action_id: String) -> void:
|
||||
if action_id == _current_stable_action_id:
|
||||
_cancel_current_primary(true)
|
||||
_stable_status[action_id] = &"cancelled"
|
||||
elif action_id == _group_say_action_id:
|
||||
_overlay_speech.clear()
|
||||
_parallel_active_numeric_ids.erase(_group_say_numeric_id)
|
||||
_stable_status[action_id] = &"cancelled"
|
||||
planned_action_ended.emit(action_id, &"cancelled", _group_say_step_index)
|
||||
_group_say_action_id = ""
|
||||
_group_say_numeric_id = -1
|
||||
_group_say_action.clear()
|
||||
if _group_say_barrier:
|
||||
_group_say_barrier = false
|
||||
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
insert_action(action_queue.size(), action)
|
||||
|
||||
|
||||
func clear_queue() -> void:
|
||||
_authored_queue.clear()
|
||||
_action_ids.clear()
|
||||
if not _current_run_item.is_empty():
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_current_stable_action_id = ""
|
||||
_group_say_action_id = ""
|
||||
_group_say_action.clear()
|
||||
_overlay_speech.clear()
|
||||
_authored_steps.clear()
|
||||
_sync_legacy_queue()
|
||||
_run_queue.clear()
|
||||
_run_cursor = 0
|
||||
if _session_started:
|
||||
_runner_state = RunnerState.IDLE
|
||||
queue_changed.emit()
|
||||
plan_changed.emit()
|
||||
|
||||
|
||||
func get_queue() -> Array[Dictionary]:
|
||||
return action_queue.duplicate(true)
|
||||
return _authored_queue.duplicate(true)
|
||||
|
||||
|
||||
func remove_action(index: int) -> void:
|
||||
if index < 0 or index >= action_queue.size():
|
||||
if index < 0 or index >= _action_ids.size():
|
||||
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
|
||||
return
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue.remove_at(index)
|
||||
if index < _action_ids.size():
|
||||
_action_ids.remove_at(index)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
var stable_id: String = _stable_for_numeric_id(_action_ids[index])
|
||||
if not stable_id.is_empty():
|
||||
remove_plan_action(stable_id)
|
||||
|
||||
|
||||
func insert_action(index: int, action: Dictionary) -> void:
|
||||
if _session_started and not _paused:
|
||||
return
|
||||
var target: int = clampi(index, 0, _authored_queue.size())
|
||||
if _session_started and _paused:
|
||||
if target <= _paused_history_floor():
|
||||
return
|
||||
var action_id: int = _allocate_action_id()
|
||||
_authored_queue.insert(target, action.duplicate(true))
|
||||
_action_ids.insert(target, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
var step_target: int = _step_boundary_for_flat_index(target)
|
||||
if _session_started and _paused and not _plan_step_is_future(step_target):
|
||||
return
|
||||
var created: Dictionary = _prepare_new_plan_action(action)
|
||||
var next: Array[Dictionary] = _authored_steps.duplicate(true)
|
||||
next.insert(clampi(step_target, 0, next.size()), {"actions": [created]})
|
||||
_commit_plan_edit(next)
|
||||
|
||||
|
||||
func replace_action(index: int, action: Dictionary) -> bool:
|
||||
if index < 0 or index >= _authored_queue.size():
|
||||
return false
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED):
|
||||
return false
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue[index] = action.duplicate(true)
|
||||
if _session_started and _paused:
|
||||
_completed_ids.erase(action_id)
|
||||
_interrupted_ids.erase(action_id)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
var stable_id: String = String(_authored_queue[index].get("action_id", ""))
|
||||
var replacement: Dictionary = action.duplicate(true)
|
||||
if ACTION_REGISTRY.is_move_action(replacement) and replacement.get("target", null) is Vector2:
|
||||
replacement["destination"] = {"kind": "place", "position": replacement["target"]}
|
||||
replacement.erase("target")
|
||||
return replace_plan_action(stable_id, replacement)
|
||||
|
||||
|
||||
func move_action(from_index: int, to_index: int) -> bool:
|
||||
if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size():
|
||||
return false
|
||||
if _session_started and _paused:
|
||||
var id: int = _action_ids[from_index]
|
||||
if _status_for_action_id(id) != QueueStatus.PENDING:
|
||||
return false
|
||||
if to_index <= _paused_history_floor():
|
||||
return false
|
||||
if from_index == to_index:
|
||||
return true
|
||||
var action: Dictionary = _authored_queue.pop_at(from_index)
|
||||
var action_id: int = _action_ids.pop_at(from_index)
|
||||
_authored_queue.insert(to_index, action)
|
||||
_action_ids.insert(to_index, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
var source_id: String = String(_authored_queue[from_index].get("action_id", ""))
|
||||
var target_id: String = String(_authored_queue[to_index].get("action_id", ""))
|
||||
var source: Dictionary = find_action(source_id)
|
||||
var target: Dictionary = find_action(target_id)
|
||||
if source.is_empty() or target.is_empty():
|
||||
return false
|
||||
return move_plan_step(int(source["step_index"]), int(target["step_index"]))
|
||||
|
||||
|
||||
func get_queue_view() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id})
|
||||
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id, "stable_action_id": String(_authored_queue[index].get("action_id", "")), "step_index": int(_authored_queue[index].get("step_index", -1))})
|
||||
return result
|
||||
|
||||
|
||||
@@ -266,7 +524,10 @@ func clear_pause_state() -> void:
|
||||
_paused = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_stable_status.clear()
|
||||
_parallel_active_numeric_ids.clear()
|
||||
_current_action_id = -1
|
||||
_current_stable_action_id = ""
|
||||
_session_started = false
|
||||
|
||||
|
||||
@@ -360,10 +621,17 @@ func stop_queue() -> void:
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
_current_stable_action_id = ""
|
||||
_current_step_index = -1
|
||||
_active_step_entry.clear()
|
||||
_group_say_action_id = ""
|
||||
_group_say_action.clear()
|
||||
_parallel_active_numeric_ids.clear()
|
||||
_paused = false
|
||||
_session_started = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_stable_status.clear()
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
@@ -381,8 +649,12 @@ func _advance_to_next_action() -> void:
|
||||
_current_index = -1
|
||||
queue_finished.emit()
|
||||
return
|
||||
_current_run_item = _run_queue[_run_cursor]
|
||||
var next_item: Dictionary = _run_queue[_run_cursor]
|
||||
_run_cursor += 1
|
||||
if next_item.has("actions"):
|
||||
_start_plan_step(next_item)
|
||||
return
|
||||
_current_run_item = next_item
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
_current_action_id = int(_current_run_item.get("action_id", -1))
|
||||
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
|
||||
@@ -418,6 +690,121 @@ func _advance_to_next_action() -> void:
|
||||
_begin_action(action, legacy_speed_override)
|
||||
|
||||
|
||||
func _start_plan_step(step_entry: Dictionary) -> void:
|
||||
_active_step_entry = step_entry.duplicate(true)
|
||||
_current_step_index = int(step_entry.get("step_index", -1))
|
||||
_group_primary_done = false
|
||||
_group_say_barrier = false
|
||||
_group_say_action_id = ""
|
||||
_group_say_numeric_id = -1
|
||||
_group_say_action.clear()
|
||||
var raw_actions: Array = step_entry.get("actions", [])
|
||||
var primary_action: Dictionary = {}
|
||||
var primary_id: String = ""
|
||||
var primary_numeric_id: int = -1
|
||||
var speech_started: bool = false
|
||||
for action_index: int in raw_actions.size():
|
||||
var raw_action: Variant = raw_actions[action_index]
|
||||
if not raw_action is Dictionary:
|
||||
continue
|
||||
var authored_action: Dictionary = raw_action as Dictionary
|
||||
var action_id: String = String(authored_action.get("action_id", ""))
|
||||
var numeric_id: int = _numeric_for_stable_id(action_id)
|
||||
var runtime_action: Dictionary = _runtime_action_for_plan(authored_action)
|
||||
_stable_status[action_id] = &"active"
|
||||
_parallel_active_numeric_ids[numeric_id] = true
|
||||
var flat_index: int = _flat_index_for_action(action_id)
|
||||
planned_action_started.emit(action_id, runtime_action.duplicate(true), _current_step_index)
|
||||
action_started.emit(runtime_action.duplicate(true), flat_index)
|
||||
if String(authored_action.get("type", "")) == "speak" and raw_actions.size() == 2:
|
||||
speech_started = true
|
||||
speak_overlay_action(runtime_action)
|
||||
if bool(authored_action.get("legacy_nonblocking", false)):
|
||||
_stable_status[action_id] = &"succeeded"
|
||||
_completed_ids[numeric_id] = true
|
||||
_parallel_active_numeric_ids.erase(numeric_id)
|
||||
planned_action_ended.emit(action_id, &"succeeded", _current_step_index)
|
||||
action_finished.emit(runtime_action.duplicate(true), flat_index)
|
||||
else:
|
||||
_group_say_action_id = action_id
|
||||
_group_say_numeric_id = numeric_id
|
||||
_group_say_step_index = _current_step_index
|
||||
_group_say_action = runtime_action.duplicate(true)
|
||||
_group_say_barrier = true
|
||||
elif primary_action.is_empty():
|
||||
primary_action = runtime_action
|
||||
primary_id = action_id
|
||||
primary_numeric_id = numeric_id
|
||||
if primary_action.is_empty():
|
||||
_complete_active_step()
|
||||
return
|
||||
_current_run_item = {
|
||||
"action": primary_action.duplicate(true),
|
||||
"queue_index": _flat_index_for_action(primary_id),
|
||||
"action_id": primary_numeric_id,
|
||||
"stable_action_id": primary_id,
|
||||
"step_index": _current_step_index,
|
||||
"reactive": false,
|
||||
}
|
||||
_current_action_id = primary_numeric_id
|
||||
_current_stable_action_id = primary_id
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
var action: Dictionary = primary_action.duplicate(true)
|
||||
if ACTION_REGISTRY.is_move_action(action):
|
||||
action["type"] = ACTION_REGISTRY.MOVE_TO
|
||||
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
|
||||
action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK
|
||||
_begin_action(action)
|
||||
if not speech_started and String(primary_action.get("type", "")) == "speak":
|
||||
# Singleton Say is executed by the primary speech state, not an overlay.
|
||||
_group_say_action_id = ""
|
||||
|
||||
|
||||
func _runtime_action_for_plan(action: Dictionary) -> Dictionary:
|
||||
var runtime_action: Dictionary = action.duplicate(true)
|
||||
if ACTION_REGISTRY.is_move_action(runtime_action):
|
||||
var destination_value: Variant = runtime_action.get("destination", {})
|
||||
if destination_value is Dictionary:
|
||||
var destination: Dictionary = destination_value
|
||||
var point_value: Variant = destination.get("position", destination.get("last_known_approach", Vector2.ZERO))
|
||||
runtime_action["target"] = point_value
|
||||
return runtime_action
|
||||
|
||||
|
||||
func _complete_active_step() -> void:
|
||||
_active_step_entry.clear()
|
||||
_current_step_index = -1
|
||||
_group_primary_done = false
|
||||
_group_say_barrier = false
|
||||
_action_phase = ActionPhase.NONE
|
||||
|
||||
|
||||
func _flat_index_for_action(action_id: String) -> int:
|
||||
for index: int in _authored_queue.size():
|
||||
if String(_authored_queue[index].get("action_id", "")) == action_id:
|
||||
return index
|
||||
return -1
|
||||
|
||||
|
||||
func _stable_for_numeric_id(numeric_id: int) -> String:
|
||||
for stable_id: Variant in _stable_to_numeric_ids:
|
||||
if int(_stable_to_numeric_ids[stable_id]) == numeric_id:
|
||||
return String(stable_id)
|
||||
return ""
|
||||
|
||||
|
||||
func _step_boundary_for_flat_index(flat_index: int) -> int:
|
||||
var cursor: int = 0
|
||||
for step_index: int in _authored_steps.size():
|
||||
var actions: Array = _authored_steps[step_index].get("actions", [])
|
||||
if flat_index <= cursor:
|
||||
return step_index
|
||||
if flat_index < cursor + actions.size():
|
||||
return step_index + 1
|
||||
cursor += actions.size()
|
||||
return _authored_steps.size()
|
||||
|
||||
|
||||
func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void:
|
||||
match String(action.get("type", "")):
|
||||
"move_to":
|
||||
@@ -425,8 +812,15 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
|
||||
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
|
||||
if style.is_empty():
|
||||
style = ACTION_REGISTRY.WALK
|
||||
if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override):
|
||||
_finish_action()
|
||||
var destination_value: Variant = action.get("destination", {})
|
||||
var move_started: bool = false
|
||||
if destination_value is Dictionary and String((destination_value as Dictionary).get("kind", "")) == "prop":
|
||||
move_started = _start_prop_pursuit(action, destination_value as Dictionary, style, legacy_speed_override)
|
||||
elif _locomotion != null:
|
||||
move_started = _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override)
|
||||
if not move_started:
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
"speak":
|
||||
if bool(action.get("keep_acting", false)):
|
||||
speak_overlay_action(action)
|
||||
@@ -445,34 +839,142 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
|
||||
recovery_requested.emit()
|
||||
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop":
|
||||
if _rig == null or not _rig.start_prop_action(action):
|
||||
_finish_action()
|
||||
_finish_action(&"failed")
|
||||
else:
|
||||
_prop_action_finished = false
|
||||
_action_phase = ActionPhase.PROP_INTERACTION
|
||||
_:
|
||||
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
|
||||
_finish_action()
|
||||
_finish_action(&"failed")
|
||||
|
||||
|
||||
func _finish_action() -> void:
|
||||
func _start_prop_pursuit(action: Dictionary, destination: Dictionary, style: String, speed_override: float) -> bool:
|
||||
var prop_id_value: Variant = destination.get("target_prop_id", null)
|
||||
var offset_value: Variant = destination.get("approach_offset", null)
|
||||
if not prop_id_value is String or not offset_value is Vector2 or not (offset_value as Vector2).is_finite():
|
||||
return false
|
||||
var prop: PropBlock = _resolve_pursuit_prop(String(prop_id_value))
|
||||
if prop == null or _locomotion == null:
|
||||
return false
|
||||
var approach: Vector2 = prop.global_position + (offset_value as Vector2)
|
||||
var projected: Vector2 = _locomotion.project_target_to_navigation(approach)
|
||||
if not projected.is_finite():
|
||||
return false
|
||||
_pursuit_action_id = String(action.get("action_id", _current_stable_action_id))
|
||||
_pursuit_prop_id = String(prop_id_value)
|
||||
_pursuit_offset = offset_value as Vector2
|
||||
_pursuit_last_prop_position = prop.global_position
|
||||
_pursuit_approach = projected
|
||||
_pursuit_elapsed = 0.0
|
||||
_pursuit_retarget_elapsed = 0.0
|
||||
return _locomotion.move_to(projected, style, speed_override)
|
||||
|
||||
|
||||
func _resolve_pursuit_prop(prop_id: String) -> PropBlock:
|
||||
if prop_id.is_empty() or _rig == null or not is_instance_valid(_rig):
|
||||
return null
|
||||
var world: Node = _rig.get_parent()
|
||||
if world == null:
|
||||
return null
|
||||
for child: Node in world.get_children():
|
||||
if child is PropBlock and not child.is_queued_for_deletion() and String((child as PropBlock).stage_object_id) == prop_id:
|
||||
return child as PropBlock
|
||||
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == prop_id:
|
||||
return null
|
||||
return null
|
||||
|
||||
|
||||
func _check_prop_pursuit_arrival() -> void:
|
||||
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
|
||||
if prop == null or _locomotion == null or _rig == null:
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
var projected: Vector2 = _locomotion.project_target_to_navigation(prop.global_position + _pursuit_offset)
|
||||
if not projected.is_finite():
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
var actor_feet: Vector2 = _rig.global_position - _locomotion.get_foot_offset()
|
||||
if actor_feet.distance_to(projected) <= 28.0 and actor_feet.distance_to(prop.global_position) <= PROP_INTERACTION_REACH:
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"succeeded")
|
||||
return
|
||||
if actor_feet.distance_to(projected) > 28.0:
|
||||
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
|
||||
var restarted: bool = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
|
||||
if restarted:
|
||||
_pursuit_approach = projected
|
||||
_pursuit_last_prop_position = prop.global_position
|
||||
_pursuit_retarget_elapsed = 0.0
|
||||
_action_phase = ActionPhase.WALKING
|
||||
return
|
||||
_end_prop_pursuit()
|
||||
_finish_action(&"failed")
|
||||
return
|
||||
_locomotion.cancel_walk(false)
|
||||
_pursuit_approach = projected
|
||||
_pursuit_last_prop_position = prop.global_position
|
||||
_pursuit_retarget_elapsed = 0.0
|
||||
_action_phase = ActionPhase.PURSUIT_WAIT
|
||||
|
||||
|
||||
func _end_prop_pursuit() -> void:
|
||||
_pursuit_action_id = ""
|
||||
_pursuit_prop_id = ""
|
||||
_pursuit_offset = Vector2.ZERO
|
||||
_pursuit_last_prop_position = Vector2.ZERO
|
||||
_pursuit_approach = Vector2.INF
|
||||
_pursuit_elapsed = 0.0
|
||||
_pursuit_retarget_elapsed = 0.0
|
||||
|
||||
|
||||
func _finish_action(outcome: StringName = &"succeeded") -> void:
|
||||
var finished: Dictionary = _current_run_item.get("action", {})
|
||||
var index: int = int(_current_run_item.get("queue_index", -1))
|
||||
var action_id: int = int(_current_run_item.get("action_id", -1))
|
||||
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
|
||||
if stable_id == _pursuit_action_id:
|
||||
_end_prop_pursuit()
|
||||
if action_id >= 0:
|
||||
_completed_ids[action_id] = true
|
||||
index = _index_for_action_id(action_id) if action_id >= 0 else -1
|
||||
if outcome == &"cancelled":
|
||||
_interrupted_ids[action_id] = true
|
||||
else:
|
||||
_completed_ids[action_id] = true
|
||||
if not stable_id.is_empty():
|
||||
_stable_status[stable_id] = outcome
|
||||
_parallel_active_numeric_ids.erase(action_id)
|
||||
planned_action_ended.emit(stable_id, outcome, int(_current_run_item.get("step_index", _current_step_index)))
|
||||
index = _flat_index_for_action(stable_id) if not stable_id.is_empty() else (_index_for_action_id(action_id) if action_id >= 0 else -1)
|
||||
_action_phase = ActionPhase.NONE
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
_current_stable_action_id = ""
|
||||
if not _active_step_entry.is_empty():
|
||||
_group_primary_done = true
|
||||
if _group_say_barrier and not _group_say_action_id.is_empty():
|
||||
_action_phase = ActionPhase.GROUP_BARRIER
|
||||
else:
|
||||
_complete_active_step()
|
||||
|
||||
|
||||
func _cancel_current_primary(notify: bool = false) -> void:
|
||||
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
|
||||
var step_index: int = int(_current_run_item.get("step_index", _current_step_index))
|
||||
var numeric_id: int = int(_current_run_item.get("action_id", _current_action_id))
|
||||
if stable_id == _pursuit_action_id:
|
||||
_end_prop_pursuit()
|
||||
if notify and not _current_run_item.is_empty():
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
active_action_cancelled.emit(action.duplicate(true))
|
||||
if not stable_id.is_empty():
|
||||
_stable_status[stable_id] = &"cancelled"
|
||||
_parallel_active_numeric_ids.erase(numeric_id)
|
||||
_interrupted_ids[numeric_id] = true
|
||||
planned_action_ended.emit(stable_id, &"cancelled", step_index)
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(not _is_blocked())
|
||||
if _rig != null:
|
||||
@@ -484,6 +986,7 @@ func _cancel_current_primary(notify: bool = false) -> void:
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
_current_stable_action_id = ""
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
@@ -502,13 +1005,25 @@ func _start_session_runtime() -> void:
|
||||
_session_started = true
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
_stable_status.clear()
|
||||
_parallel_active_numeric_ids.clear()
|
||||
for action: Dictionary in _authored_queue:
|
||||
_stable_status[String(action.get("action_id", ""))] = &"pending"
|
||||
|
||||
|
||||
func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary:
|
||||
return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false}
|
||||
func _runtime_step_item(step_index: int, step: Dictionary) -> Dictionary:
|
||||
var actions: Array[Dictionary] = []
|
||||
var stable_ids: Array[String] = []
|
||||
var numeric_ids: Array[int] = []
|
||||
for raw_action: Variant in step.get("actions", []):
|
||||
if not raw_action is Dictionary:
|
||||
continue
|
||||
var action: Dictionary = raw_action as Dictionary
|
||||
var stable_id: String = String(action.get("action_id", ""))
|
||||
actions.append(action.duplicate(true))
|
||||
stable_ids.append(stable_id)
|
||||
numeric_ids.append(_numeric_for_stable_id(stable_id))
|
||||
return {"step_index": step_index, "actions": actions, "stable_ids": stable_ids, "numeric_ids": numeric_ids, "reactive": false}
|
||||
|
||||
|
||||
func _rebuild_pending_runtime_queue() -> void:
|
||||
@@ -518,11 +1033,19 @@ func _rebuild_pending_runtime_queue() -> void:
|
||||
var runtime_item: Dictionary = _run_queue[runtime_index]
|
||||
if bool(runtime_item.get("reactive", false)):
|
||||
retained_reactive.append(runtime_item.duplicate(true))
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED:
|
||||
rebuilt.append(_runtime_item(action_id, _authored_queue[index], index))
|
||||
for step_index: int in _authored_steps.size():
|
||||
var step: Dictionary = _authored_steps[step_index]
|
||||
var all_pending: bool = true
|
||||
for raw_action: Variant in step.get("actions", []):
|
||||
if not raw_action is Dictionary:
|
||||
all_pending = false
|
||||
break
|
||||
var status: StringName = _status_for_stable_id(String((raw_action as Dictionary).get("action_id", "")))
|
||||
if status not in [&"pending", &"not_started"]:
|
||||
all_pending = false
|
||||
break
|
||||
if all_pending:
|
||||
rebuilt.append(_runtime_step_item(step_index, step))
|
||||
_run_queue = retained_reactive + rebuilt
|
||||
_run_cursor = 0
|
||||
if not _run_queue.is_empty() and _session_started:
|
||||
@@ -545,15 +1068,14 @@ func _index_for_action_id(action_id: int) -> int:
|
||||
func _status_for_action_id(action_id: int) -> int:
|
||||
if action_id < 0:
|
||||
return QueueStatus.NOT_STARTED
|
||||
if _completed_ids.has(action_id):
|
||||
return QueueStatus.COMPLETED
|
||||
if _interrupted_ids.has(action_id):
|
||||
return QueueStatus.INTERRUPTED
|
||||
if action_id == _current_action_id:
|
||||
return QueueStatus.ACTIVE
|
||||
if _session_started:
|
||||
return QueueStatus.PENDING
|
||||
return QueueStatus.NOT_STARTED
|
||||
var stable_id: String = _stable_for_numeric_id(action_id)
|
||||
var status: StringName = _status_for_stable_id(stable_id) if not stable_id.is_empty() else &"not_started"
|
||||
match status:
|
||||
&"succeeded", &"failed": return QueueStatus.COMPLETED
|
||||
&"cancelled": return QueueStatus.INTERRUPTED
|
||||
&"active": return QueueStatus.ACTIVE
|
||||
&"pending": return QueueStatus.PENDING
|
||||
_: return QueueStatus.NOT_STARTED
|
||||
|
||||
|
||||
func _is_blocked() -> bool:
|
||||
@@ -565,6 +1087,7 @@ func _update_speech(delta: float) -> void:
|
||||
_overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta
|
||||
if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)):
|
||||
_overlay_speech.clear()
|
||||
_finish_group_speech()
|
||||
speech_finished.emit()
|
||||
elif not _primary_speech.is_empty() and not _is_blocked():
|
||||
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
|
||||
@@ -578,6 +1101,25 @@ func _update_speech(delta: float) -> void:
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _finish_group_speech() -> void:
|
||||
if _group_say_action_id.is_empty():
|
||||
return
|
||||
var action_id: String = _group_say_action_id
|
||||
var numeric_id: int = _group_say_numeric_id
|
||||
var step_index: int = _group_say_step_index
|
||||
var action: Dictionary = _group_say_action.duplicate(true)
|
||||
_stable_status[action_id] = &"succeeded"
|
||||
_completed_ids[numeric_id] = true
|
||||
_parallel_active_numeric_ids.erase(numeric_id)
|
||||
planned_action_ended.emit(action_id, &"succeeded", step_index)
|
||||
action_finished.emit(action, _flat_index_for_action(action_id))
|
||||
_group_say_action_id = ""
|
||||
_group_say_numeric_id = -1
|
||||
_group_say_step_index = -1
|
||||
_group_say_action.clear()
|
||||
_group_say_barrier = false
|
||||
|
||||
|
||||
func _show_visible_speech() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
|
||||
@@ -161,6 +161,7 @@ var collision_lengths: Dictionary = {}
|
||||
var collision_proxy_velocities: Dictionary = {}
|
||||
var _collision_proxy_positions: Dictionary = {}
|
||||
var _last_sweep_hit_tick: Dictionary = {}
|
||||
var _owner_collision_grace_props: Dictionary = {}
|
||||
var _actor_prop_debug_last_sample_frame: int = -1
|
||||
var _actor_prop_debug_last_hit_tick: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
@@ -196,6 +197,7 @@ func setup_collision_bodies() -> void:
|
||||
collision_proxy_velocities.clear()
|
||||
_collision_proxy_positions.clear()
|
||||
_last_sweep_hit_tick.clear()
|
||||
_owner_collision_grace_props.clear()
|
||||
_collision_visuals.clear()
|
||||
reaction_bones.clear()
|
||||
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
|
||||
@@ -470,6 +472,34 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
|
||||
_walk_latch_probe_frames = 0
|
||||
return true
|
||||
|
||||
|
||||
func project_target_to_navigation(target: Vector2) -> Vector2:
|
||||
if _nav_agent == null or not target.is_finite():
|
||||
return Vector2.INF
|
||||
var navigation_map: RID = _nav_agent.get_navigation_map()
|
||||
if not navigation_map.is_valid() or NavigationServer2D.map_get_iteration_id(navigation_map) == 0:
|
||||
return Vector2.INF
|
||||
var projected: Vector2 = NavigationServer2D.map_get_closest_point(navigation_map, target)
|
||||
return projected if projected.is_finite() else Vector2.INF
|
||||
|
||||
|
||||
func retarget_walk(target: Vector2) -> bool:
|
||||
if _rig == null or _nav_agent == null or not target.is_finite() or (not _walking and not _settling):
|
||||
return false
|
||||
_walk_target_feet = target
|
||||
_nav_agent.target_position = target
|
||||
if _settling:
|
||||
_settling = false
|
||||
_walk_done = false
|
||||
_arrival_emitted = false
|
||||
_walk_settle_elapsed = 0.0
|
||||
_walk_settle_start_pose.clear()
|
||||
_walk_settle_end_pose.clear()
|
||||
_walking = true
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
|
||||
_rig._anim_player.play(_active_animation_name)
|
||||
return true
|
||||
|
||||
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
@@ -624,6 +654,15 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if own_proxy != null and is_instance_valid(own_proxy):
|
||||
excluded.append(own_proxy.get_rid())
|
||||
for prop_id: Variant in _owner_collision_grace_props.keys():
|
||||
var prop_ref: WeakRef = _owner_collision_grace_props.get(prop_id) as WeakRef
|
||||
var grace_prop: PropBlock = prop_ref.get_ref() as PropBlock if prop_ref != null else null
|
||||
if grace_prop == null or not is_instance_valid(grace_prop) or not grace_prop.is_owner_collision_grace_active_for(_rig):
|
||||
_owner_collision_grace_props.erase(prop_id)
|
||||
continue
|
||||
var grace_prop_rid: RID = grace_prop.get_rid()
|
||||
if grace_prop_rid.is_valid() and not excluded.has(grace_prop_rid):
|
||||
excluded.append(grace_prop_rid)
|
||||
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
|
||||
if held != null and is_instance_valid(held):
|
||||
excluded.append(held.get_rid())
|
||||
@@ -779,6 +818,18 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||
return {"fraction": safe_fraction}
|
||||
|
||||
|
||||
func register_owner_collision_grace_prop(prop: PropBlock) -> void:
|
||||
if prop == null or not is_instance_valid(prop):
|
||||
return
|
||||
_owner_collision_grace_props[prop.get_instance_id()] = weakref(prop)
|
||||
|
||||
|
||||
func unregister_owner_collision_grace_prop(prop: PropBlock) -> void:
|
||||
if prop == null:
|
||||
return
|
||||
_owner_collision_grace_props.erase(prop.get_instance_id())
|
||||
|
||||
|
||||
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
|
||||
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
|
||||
# the `collider` object key used by ray/shape intersection queries.
|
||||
|
||||
@@ -955,6 +955,7 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
|
||||
_hand = int(picked["hand"])
|
||||
_held_prop = prop
|
||||
_held_prop.interaction_reserved_by = _rig.get_instance_id()
|
||||
_held_prop.begin_owner_collision_grace(_rig, true)
|
||||
_active_action = action.duplicate(true)
|
||||
_approach_replans = 0
|
||||
var marker := _hand_marker()
|
||||
@@ -1028,7 +1029,7 @@ func _begin_set_down(action: Dictionary) -> bool:
|
||||
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
|
||||
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
|
||||
var target_feet := _set_down_approach_root - foot_offset
|
||||
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
|
||||
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.WALK)):
|
||||
_active_action.clear()
|
||||
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
|
||||
return false
|
||||
@@ -1930,12 +1931,18 @@ func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vec
|
||||
release_velocity = Vector2.ZERO
|
||||
if not is_finite(release_angular):
|
||||
release_angular = 0.0
|
||||
if reason == "scene_reset":
|
||||
prop.clear_owner_collision_grace()
|
||||
elif _rig != null and is_instance_valid(_rig) and prop.interaction_reserved_by == _rig.get_instance_id():
|
||||
prop.release_owner_collision_grace(_rig)
|
||||
prop.interaction_owner_id = 0
|
||||
prop.interaction_reserved_by = 0
|
||||
var target_role := PropBlock.RuntimeRole.RELEASED
|
||||
if reason == "scene_reset":
|
||||
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
|
||||
prop.apply_runtime_role(target_role)
|
||||
if reason == "set_down" and _rig != null and is_instance_valid(_rig):
|
||||
prop.begin_owner_collision_grace(_rig)
|
||||
prop.sleeping = false
|
||||
prop.linear_velocity = release_velocity
|
||||
prop.angular_velocity = release_angular
|
||||
@@ -1983,6 +1990,7 @@ func _clear_reservation() -> void:
|
||||
if _held_prop != null and is_instance_valid(_held_prop):
|
||||
if _held_prop.interaction_reserved_by == _rig.get_instance_id():
|
||||
_held_prop.interaction_reserved_by = 0
|
||||
_held_prop.release_owner_collision_grace(_rig)
|
||||
_held_prop = null
|
||||
_held_anchor.clear()
|
||||
_active_action.clear()
|
||||
|
||||
@@ -182,6 +182,9 @@ signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
signal queue_finished # queue ran to completion (not on stop)
|
||||
signal queue_changed # any queue mutation
|
||||
signal action_plan_changed
|
||||
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
|
||||
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
|
||||
signal speech_finished # bubble auto-hid after speak()
|
||||
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
|
||||
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
|
||||
@@ -215,6 +218,8 @@ var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _pending_action_queue: Array[Dictionary] = []
|
||||
var _pending_action_plan: Array[Dictionary] = []
|
||||
var _has_pending_action_plan: bool = false
|
||||
var action_queue: Array[Dictionary]:
|
||||
get:
|
||||
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
|
||||
@@ -226,6 +231,10 @@ var action_queue: Array[Dictionary]:
|
||||
|
||||
## Original .stk asset path, assigned by StageSpawner for scene persistence.
|
||||
var source_stk_path: String = ""
|
||||
## Persistent per-stage-instance identity and display name. Reusable .stk
|
||||
## assets never serialize these fields.
|
||||
var stage_object_id: String = ""
|
||||
var stage_name: String = ""
|
||||
|
||||
## Authored nonvisual subsystem children. They establish explicit ownership
|
||||
## boundaries while this facade preserves the long-standing public API.
|
||||
@@ -258,7 +267,10 @@ func _ready() -> void:
|
||||
push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.")
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.configure(self)
|
||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void:
|
||||
if _action_runner_component == null or not _action_runner_component.is_prop_pursuit_active():
|
||||
arrived.emit(target)
|
||||
)
|
||||
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.configure(self, _locomotion_component)
|
||||
@@ -271,11 +283,17 @@ func _ready() -> void:
|
||||
_prop_interactor_component.connect("use_failed", _on_prop_use_failed)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
|
||||
_action_runner_component.action_queue = _pending_action_queue
|
||||
if _has_pending_action_plan:
|
||||
_action_runner_component.set_action_plan(_pending_action_plan)
|
||||
else:
|
||||
_action_runner_component.action_queue = _pending_action_queue
|
||||
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
|
||||
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
|
||||
_action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit())
|
||||
_action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit())
|
||||
_action_runner_component.plan_changed.connect(func() -> void: action_plan_changed.emit())
|
||||
_action_runner_component.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void: planned_action_started.emit(action_id, action, step_index))
|
||||
_action_runner_component.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void: planned_action_ended.emit(action_id, outcome, step_index))
|
||||
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
||||
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
||||
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
@@ -332,6 +350,8 @@ func _physics_process(delta: float) -> void:
|
||||
_sample_ragdoll_velocity_baselines()
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.pre_movement(delta)
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.tick_movement(delta, int(state))
|
||||
if _prop_interactor_component != null:
|
||||
@@ -943,6 +963,56 @@ func get_queue() -> Array[Dictionary]:
|
||||
return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true)
|
||||
|
||||
|
||||
func get_action_plan() -> Array[Dictionary]:
|
||||
if _action_runner_component != null:
|
||||
return _action_runner_component.get_action_plan()
|
||||
return _pending_action_plan.duplicate(true)
|
||||
|
||||
|
||||
func set_action_plan(steps: Array[Dictionary]) -> bool:
|
||||
if _action_runner_component != null:
|
||||
return _action_runner_component.set_action_plan(steps)
|
||||
_pending_action_plan = steps.duplicate(true)
|
||||
_has_pending_action_plan = true
|
||||
return true
|
||||
|
||||
|
||||
func find_action(action_id: String) -> Dictionary:
|
||||
return _action_runner_component.find_action(action_id) if _action_runner_component != null else {}
|
||||
|
||||
|
||||
func get_plan_view() -> Array[Dictionary]:
|
||||
return _action_runner_component.get_plan_view() if _action_runner_component != null else []
|
||||
|
||||
|
||||
func add_action_step(action: Dictionary) -> String:
|
||||
return _action_runner_component.add_plan_step(action) if _action_runner_component != null else ""
|
||||
|
||||
|
||||
func add_action_together(step_index: int, action: Dictionary) -> String:
|
||||
return _action_runner_component.add_plan_action_together(step_index, action) if _action_runner_component != null else ""
|
||||
|
||||
|
||||
func replace_planned_action(action_id: String, action: Dictionary) -> bool:
|
||||
return _action_runner_component.replace_plan_action(action_id, action) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func remove_planned_action(action_id: String) -> bool:
|
||||
return _action_runner_component.remove_plan_action(action_id) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func move_plan_step(from_index: int, to_index: int) -> bool:
|
||||
return _action_runner_component.move_plan_step(from_index, to_index) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func can_add_action_together(step_index: int, action_type: String) -> bool:
|
||||
return _action_runner_component.can_add_plan_action_together(step_index, action_type) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func can_reorder_plan_step(step_index: int) -> bool:
|
||||
return _action_runner_component.can_reorder_plan_step(step_index) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func remove_action(index: int) -> void:
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.remove_action(index)
|
||||
|
||||
@@ -76,6 +76,7 @@ const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
const PropAnchorOverlayScript := preload("res://scripts/props/prop_anchor_overlay.gd")
|
||||
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
|
||||
const WholeStickmanPreviewScript := preload("res://scripts/editors/stickman/whole_stickman_preview.gd")
|
||||
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
|
||||
|
||||
const SELECTION_CORE_COLOR := Color.WHITE
|
||||
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
|
||||
@@ -122,7 +123,7 @@ var _grid_size: int = 15
|
||||
var _snap_enabled: bool = false
|
||||
var _has_clipboard: bool = false
|
||||
var _create_point_armed: bool = false
|
||||
var _context_menu: PopupMenu
|
||||
var _context_menu: EditorContextMenu
|
||||
var _edit_mode: EditMode = EditMode.TRANSFORM
|
||||
var _anchor_overlay: RefCounted
|
||||
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
|
||||
@@ -189,7 +190,7 @@ func _ready() -> void:
|
||||
_anchor_overlay = PropAnchorOverlayScript.new()
|
||||
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
|
||||
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
|
||||
_context_menu = PopupMenu.new()
|
||||
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
|
||||
_context_menu.name = "ContextMenu"
|
||||
add_child(_context_menu)
|
||||
if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed):
|
||||
@@ -983,7 +984,12 @@ func set_has_clipboard(state: bool) -> void:
|
||||
## Sets the font size of the right-click context menu (Round 4, theme-driven).
|
||||
func set_context_menu_font_size(size: int) -> void:
|
||||
if _context_menu != null:
|
||||
_context_menu.add_theme_font_size_override("font_size", size)
|
||||
_context_menu.set_font_size(size)
|
||||
|
||||
|
||||
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
|
||||
if _context_menu != null:
|
||||
_context_menu.configure(visual_theme, editor)
|
||||
|
||||
|
||||
func is_cursor_over_drawing(global_pos: Vector2) -> bool:
|
||||
@@ -1335,8 +1341,8 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
|
||||
if context_shape >= 0 and context_vertex >= 0:
|
||||
_context_menu.set_meta("context_shape", context_shape)
|
||||
_context_menu.set_meta("context_vertex", context_vertex)
|
||||
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
_context_menu.add_destructive_item("Remove Point", MENU_REMOVE_POINT)
|
||||
_context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
|
||||
return
|
||||
|
||||
# Segment proximity -> Create Point (selects the shape first).
|
||||
@@ -1348,7 +1354,7 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
|
||||
queue_redraw()
|
||||
shape_selected.emit(seg_shape)
|
||||
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(seg_shape))
|
||||
return
|
||||
|
||||
# Interior body: select only, no menu.
|
||||
@@ -1381,17 +1387,18 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
|
||||
_context_menu.set_meta("click_position", world_pos)
|
||||
queue_redraw()
|
||||
shape_selected.emit(hit_shape)
|
||||
_context_menu.add_item("Copy", MENU_COPY)
|
||||
_context_menu.add_item("Copy Shape", MENU_COPY)
|
||||
if _has_clipboard:
|
||||
_context_menu.add_item("Paste", MENU_PASTE)
|
||||
_context_menu.add_item("Erase", MENU_DELETE)
|
||||
_context_menu.add_item("Paste Shape", MENU_PASTE)
|
||||
_context_menu.add_separator()
|
||||
_context_menu.add_item("Flip Left-Right", MENU_MIRROR_X)
|
||||
_context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y)
|
||||
_context_menu.add_separator()
|
||||
_context_menu.add_item("Bring Forward", MENU_BRING_FORWARD)
|
||||
_context_menu.add_item("Send Back", MENU_SEND_BACK)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
_context_menu.add_separator()
|
||||
_context_menu.add_destructive_item("Erase Shape", MENU_DELETE)
|
||||
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(hit_shape))
|
||||
return
|
||||
|
||||
# Empty canvas -> add primitives.
|
||||
@@ -1403,8 +1410,8 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
|
||||
_context_menu.add_item("Add Circle", MENU_ADD_CIRCLE)
|
||||
if _has_clipboard:
|
||||
_context_menu.add_separator()
|
||||
_context_menu.add_item("Paste", MENU_PASTE)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
_context_menu.add_item("Paste Shape", MENU_PASTE)
|
||||
_context_menu.popup_at(mb.global_position)
|
||||
|
||||
|
||||
func _handle_left_press(world_pos: Vector2) -> void:
|
||||
@@ -1857,6 +1864,19 @@ func _screen_to_world(pos: Vector2) -> Vector2:
|
||||
return (pos - _pan_offset) / _zoom
|
||||
|
||||
|
||||
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
|
||||
var screen_pos := global_position + _pan_offset + world_pos * _zoom
|
||||
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
|
||||
|
||||
|
||||
func _global_shape_target_rect(shape_idx: int) -> Rect2:
|
||||
if shape_idx < 0 or shape_idx >= shapes.size():
|
||||
return Rect2()
|
||||
var world_bounds: Rect2 = _shape_bbox(shapes[shape_idx])
|
||||
var screen_position := global_position + _pan_offset + world_bounds.position * _zoom
|
||||
return Rect2(screen_position, world_bounds.size * _zoom).grow(4.0)
|
||||
|
||||
|
||||
func _snap_to_grid(pos: Vector2) -> Vector2:
|
||||
var gs := float(_grid_size)
|
||||
if gs <= 0.0:
|
||||
|
||||
@@ -13,6 +13,8 @@ extends Control
|
||||
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
|
||||
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
const SandboxThemeScript := preload("res://scripts/stage/sandbox_theme.gd")
|
||||
const StageDialogStylerScript := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
|
||||
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
|
||||
@@ -20,6 +22,7 @@ const ThumbnailCacheScript := preload("res://scripts/shared/thumbnails/thumbnail
|
||||
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
|
||||
const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn")
|
||||
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
|
||||
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
|
||||
|
||||
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
|
||||
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
|
||||
@@ -116,6 +119,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
|
||||
var _theme: PropTheme
|
||||
var _editor_visual_theme: EditorVisualTheme
|
||||
var _dialog_theme: SandboxTheme
|
||||
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
|
||||
var _current_path: String = ""
|
||||
var _dirty: bool = false
|
||||
@@ -128,11 +132,14 @@ var _thumb_cache: ThumbnailCache
|
||||
var _ui_theme: Theme
|
||||
|
||||
# Inspector controls
|
||||
var _preset_option: OptionButton
|
||||
var _material_section: VBoxContainer
|
||||
var _preset_option: PropInspectorDropdown
|
||||
var _material_section: PanelContainer
|
||||
var _mass_label: Label
|
||||
var _mass_value: Label
|
||||
var _friction_label: Label
|
||||
var _friction_value: Label
|
||||
var _bounce_label: Label
|
||||
var _bounce_value: Label
|
||||
var _fill_button: ColorPickerButton
|
||||
var _outline_button: ColorPickerButton
|
||||
var _width_spin: SpinBox
|
||||
@@ -143,14 +150,14 @@ var _vertex_label: Label
|
||||
var _delete_button: Button
|
||||
var _create_point_button: Button
|
||||
var _create_point_hint: Label
|
||||
var _shape_section: VBoxContainer
|
||||
var _primitive_option: OptionButton
|
||||
var _shape_section: PanelContainer
|
||||
var _primitive_option: PropInspectorDropdown
|
||||
var _params_box: VBoxContainer
|
||||
var _param_spins: Dictionary = {}
|
||||
var _add_button: Button
|
||||
var _updating_inspector: bool = false
|
||||
var _tag_checks: Dictionary = {}
|
||||
var _tag_section: VBoxContainer
|
||||
var _tag_section: PanelContainer
|
||||
var _stage_authoring: bool = false
|
||||
var _stage_return_pending: bool = false
|
||||
var _stage_place_button: Button
|
||||
@@ -177,7 +184,7 @@ var _points_button: Button
|
||||
var _attach_button: Button
|
||||
|
||||
# Add-shape inspector section (hidden in Reshape mode, Round 3)
|
||||
var _add_section: VBoxContainer
|
||||
var _add_section: PanelContainer
|
||||
var _anchor_section: VBoxContainer
|
||||
var _anchor_list: ItemList
|
||||
var _anchor_role: OptionButton
|
||||
@@ -218,6 +225,8 @@ func _ready() -> void:
|
||||
get_tree().auto_accept_quit = false
|
||||
_editor_visual_theme = EditorVisualThemeScript.new()
|
||||
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
|
||||
_dialog_theme = SandboxThemeScript.new()
|
||||
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
|
||||
_theme = PropThemeScript.new()
|
||||
_theme.load_from(PropThemeScript.DEFAULT_PATH)
|
||||
_thumb_cache = ThumbnailCacheScript.new()
|
||||
@@ -254,6 +263,7 @@ func _ready() -> void:
|
||||
_canvas.set_grid_size(_theme.grid_size())
|
||||
_canvas.set_snap_enabled(false)
|
||||
_canvas.set_has_clipboard(false)
|
||||
_canvas.set_context_menu_visual_theme(_editor_visual_theme, &"prop")
|
||||
_canvas.set_context_menu_font_size(_theme.font_context_menu())
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
var origin_marker_color: Color = _theme.text_muted_color()
|
||||
@@ -503,26 +513,26 @@ func _setup_menu_bar() -> void:
|
||||
var file_menu := PopupMenu.new()
|
||||
file_menu.name = "FileMenu"
|
||||
file_menu.add_item("🆕 New", MENU_NEW)
|
||||
file_menu.add_item("💾 Save", MENU_SAVE)
|
||||
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
|
||||
file_menu.add_item("📂 Load...", MENU_LOAD)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("📂 Load…", MENU_LOAD)
|
||||
var recent_popup := PopupMenu.new()
|
||||
recent_popup.name = "RecentProps"
|
||||
recent_popup.id_pressed.connect(_on_recent_prop_selected)
|
||||
file_menu.add_child(recent_popup)
|
||||
file_menu.add_submenu_item("🕑 Recent", "RecentProps")
|
||||
file_menu.add_submenu_item("🕑 Open Recent", "RecentProps")
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("💾 Save", MENU_SAVE)
|
||||
file_menu.add_item("📄 Save As…", MENU_SAVE_AS)
|
||||
file_menu.id_pressed.connect(_on_menu_id_pressed)
|
||||
file_menu.about_to_popup.connect(_update_recent_menu)
|
||||
_menu_bar.add_child(file_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File")
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
|
||||
|
||||
var view_menu := PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)
|
||||
view_menu.add_item("🔍 Reset Views", MENU_RESET_VIEWS)
|
||||
view_menu.id_pressed.connect(_on_menu_id_pressed)
|
||||
_menu_bar.add_child(view_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View")
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -617,25 +627,31 @@ func _setup_dialogs() -> void:
|
||||
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
|
||||
_error_dialog.get_ok_button().text = "Got it"
|
||||
StageDialogStylerScript.apply(
|
||||
_error_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_error_dialog.get_ok_button()
|
||||
)
|
||||
_build_exit_dialog()
|
||||
_rename_anchor_dialog = ConfirmationDialog.new()
|
||||
_rename_anchor_dialog.title = "Rename Anchor"
|
||||
_rename_anchor_dialog.ok_button_text = "Rename"
|
||||
_rename_anchor_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_rename_anchor_dialog.set_meta(StageDialogStylerScript.ICON_META, "📝")
|
||||
_rename_anchor_edit = LineEdit.new()
|
||||
_rename_anchor_edit.placeholder_text = "Anchor name"
|
||||
_rename_anchor_dialog.add_child(_rename_anchor_edit)
|
||||
add_child(_rename_anchor_dialog)
|
||||
StageDialogStylerScript.apply(
|
||||
_rename_anchor_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_rename_anchor_dialog.get_ok_button()
|
||||
)
|
||||
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
|
||||
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
|
||||
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
|
||||
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
|
||||
|
||||
func _on_rename_anchor_text_submitted(_text: String) -> void:
|
||||
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
|
||||
if _rename_anchor_dialog == null or not StageDialogStylerScript.is_open(_rename_anchor_dialog):
|
||||
return
|
||||
var ok_button := _rename_anchor_dialog.get_ok_button()
|
||||
if not ok_button.disabled:
|
||||
@@ -886,30 +902,31 @@ func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
|
||||
|
||||
func _build_exit_dialog() -> void:
|
||||
_exit_dialog = ConfirmationDialog.new()
|
||||
_exit_dialog.title = "⚠️ Unsaved Changes"
|
||||
_exit_dialog.title = "Unsaved Changes"
|
||||
_exit_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
|
||||
_exit_dialog.ok_button_text = "💾 Save"
|
||||
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
|
||||
_exit_dialog.get_cancel_button().text = "❌ Cancel"
|
||||
_exit_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
|
||||
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_exit_dialog.ok_button_text = "Save"
|
||||
var discard_button: Button = _exit_dialog.add_button("Discard", false, "discard")
|
||||
_exit_dialog.get_cancel_button().text = "Cancel"
|
||||
add_child(_exit_dialog)
|
||||
StageDialogStylerScript.apply(
|
||||
_exit_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_exit_dialog.get_ok_button(), [discard_button]
|
||||
)
|
||||
_exit_dialog.confirmed.connect(_on_exit_save)
|
||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||
_exit_dialog.canceled.connect(_on_exit_cancel)
|
||||
_off_origin_save_dialog = ConfirmationDialog.new()
|
||||
_off_origin_save_dialog.title = "🎯 Center Prop?"
|
||||
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
|
||||
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
|
||||
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
|
||||
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
|
||||
_off_origin_save_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_off_origin_save_dialog.ok_button_text = "Center & Save"
|
||||
var save_anyway_button: Button = _off_origin_save_dialog.add_button("Save Anyway", false, "save_anyway")
|
||||
_off_origin_save_dialog.get_cancel_button().text = "Cancel"
|
||||
add_child(_off_origin_save_dialog)
|
||||
StageDialogStylerScript.apply(
|
||||
_off_origin_save_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_off_origin_save_dialog.get_ok_button(), [], [save_anyway_button]
|
||||
)
|
||||
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
|
||||
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
|
||||
if action == &"save_anyway": await _execute_pending_save(false))
|
||||
@@ -931,7 +948,7 @@ func _request_close() -> void:
|
||||
_pending_quit = true
|
||||
_pending_new = false
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
|
||||
_exit_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_exit_dialog)
|
||||
|
||||
|
||||
func _request_new() -> void:
|
||||
@@ -942,7 +959,7 @@ func _request_new() -> void:
|
||||
_pending_new = true
|
||||
_pending_quit = false
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?"
|
||||
_exit_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_exit_dialog)
|
||||
|
||||
|
||||
func _request_stage_return() -> void:
|
||||
@@ -958,11 +975,11 @@ func _request_stage_return() -> void:
|
||||
_pending_new = false
|
||||
_pending_quit = false
|
||||
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
|
||||
_exit_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_exit_dialog)
|
||||
|
||||
|
||||
func _on_exit_save() -> void:
|
||||
_exit_dialog.hide()
|
||||
StageDialogStylerScript.hide(_exit_dialog)
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered()
|
||||
return
|
||||
@@ -971,7 +988,7 @@ func _on_exit_save() -> void:
|
||||
|
||||
func _on_exit_custom_action(action: StringName) -> void:
|
||||
if action == &"discard":
|
||||
_exit_dialog.hide()
|
||||
StageDialogStylerScript.hide(_exit_dialog)
|
||||
if not _stage_authoring:
|
||||
_clear_autosave()
|
||||
if _stage_return_pending:
|
||||
@@ -1046,47 +1063,60 @@ func _on_menu_id_pressed(id: int) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_inspector() -> void:
|
||||
var header_size: int = _theme.font_header()
|
||||
var row_height: int = _theme.row_min_height()
|
||||
_inspector_list.add_theme_constant_override("separation", 10)
|
||||
_inspector_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
# Material preset section
|
||||
_material_section = VBoxContainer.new()
|
||||
_material_section.add_theme_constant_override("separation", 6)
|
||||
_material_section = _make_inspector_card()
|
||||
_inspector_list.add_child(_material_section)
|
||||
var material_title := Label.new()
|
||||
material_title.text = "Material"
|
||||
material_title.add_theme_font_size_override("font_size", header_size)
|
||||
_material_section.add_child(material_title)
|
||||
var material_content: VBoxContainer = _inspector_card_content(_material_section)
|
||||
material_content.add_child(_make_inspector_header("Material", PropInspectorIconsScript.section("material")))
|
||||
|
||||
_preset_option = OptionButton.new()
|
||||
for label: String in PRESET_LABELS:
|
||||
_preset_option.add_item(label)
|
||||
_preset_option = PropInspectorDropdown.new()
|
||||
for index: int in PRESET_LABELS.size():
|
||||
_preset_option.add_icon_item(
|
||||
PropInspectorIconsScript.material(index, _theme.material_tint(index, "fill")),
|
||||
PRESET_LABELS[index]
|
||||
)
|
||||
_preset_option.selected = _material_preset
|
||||
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_preset_option.configure(
|
||||
_ui_theme,
|
||||
_theme.inspector_background_color(),
|
||||
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
|
||||
_theme.border_color(),
|
||||
_theme.accent_color(),
|
||||
_editor_visual_theme.chrome_color(&"focus", &"prop"),
|
||||
_theme.text_color(),
|
||||
_theme.font_body()
|
||||
)
|
||||
_preset_option.item_selected.connect(_on_preset_selected)
|
||||
_material_section.add_child(_preset_option)
|
||||
material_content.add_child(_preset_option)
|
||||
|
||||
_mass_label = Label.new()
|
||||
_friction_label = Label.new()
|
||||
_bounce_label = Label.new()
|
||||
_material_section.add_child(_mass_label)
|
||||
_material_section.add_child(_friction_label)
|
||||
_material_section.add_child(_bounce_label)
|
||||
var stats := HBoxContainer.new()
|
||||
stats.add_theme_constant_override("separation", 6)
|
||||
material_content.add_child(stats)
|
||||
var mass_tile: Array[Label] = _make_stat_tile(stats, "Mass")
|
||||
_mass_label = mass_tile[0]
|
||||
_mass_value = mass_tile[1]
|
||||
var grip_tile: Array[Label] = _make_stat_tile(stats, "Surface grip")
|
||||
_friction_label = grip_tile[0]
|
||||
_friction_value = grip_tile[1]
|
||||
var bounce_tile: Array[Label] = _make_stat_tile(stats, "Bounce")
|
||||
_bounce_label = bounce_tile[0]
|
||||
_bounce_value = bounce_tile[1]
|
||||
_update_physics_labels()
|
||||
|
||||
# Selected-shape section
|
||||
_shape_section = VBoxContainer.new()
|
||||
_shape_section.add_theme_constant_override("separation", 6)
|
||||
_shape_section = _make_inspector_card()
|
||||
_inspector_list.add_child(_shape_section)
|
||||
|
||||
var shape_title := Label.new()
|
||||
shape_title.text = "Selected Shape"
|
||||
shape_title.add_theme_font_size_override("font_size", header_size)
|
||||
_shape_section.add_child(shape_title)
|
||||
var shape_content: VBoxContainer = _inspector_card_content(_shape_section)
|
||||
shape_content.add_child(_make_inspector_header("Selected Shape", PropInspectorIconsScript.section("selected_shape")))
|
||||
|
||||
var fill_row := HBoxContainer.new()
|
||||
fill_row.custom_minimum_size.y = float(row_height)
|
||||
_shape_section.add_child(fill_row)
|
||||
shape_content.add_child(fill_row)
|
||||
var fill_label := Label.new()
|
||||
fill_label.text = "Fill:"
|
||||
fill_label.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
@@ -1099,7 +1129,7 @@ func _build_inspector() -> void:
|
||||
|
||||
var outline_row := HBoxContainer.new()
|
||||
outline_row.custom_minimum_size.y = float(row_height)
|
||||
_shape_section.add_child(outline_row)
|
||||
shape_content.add_child(outline_row)
|
||||
var outline_label := Label.new()
|
||||
outline_label.text = "Outline:"
|
||||
outline_label.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
@@ -1112,7 +1142,7 @@ func _build_inspector() -> void:
|
||||
|
||||
var width_row := HBoxContainer.new()
|
||||
width_row.custom_minimum_size.y = float(row_height)
|
||||
_shape_section.add_child(width_row)
|
||||
shape_content.add_child(width_row)
|
||||
var width_label := Label.new()
|
||||
width_label.text = "Width:"
|
||||
width_label.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
@@ -1128,7 +1158,7 @@ func _build_inspector() -> void:
|
||||
|
||||
_radius_row = HBoxContainer.new()
|
||||
_radius_row.custom_minimum_size.y = float(row_height)
|
||||
_shape_section.add_child(_radius_row)
|
||||
shape_content.add_child(_radius_row)
|
||||
var radius_label := Label.new()
|
||||
radius_label.text = "Radius:"
|
||||
radius_label.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
@@ -1145,10 +1175,10 @@ func _build_inspector() -> void:
|
||||
_reshaped_note = Label.new()
|
||||
_reshaped_note.text = "✏️ This shape was reshaped"
|
||||
_reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
_shape_section.add_child(_reshaped_note)
|
||||
shape_content.add_child(_reshaped_note)
|
||||
|
||||
_vertex_label = Label.new()
|
||||
_shape_section.add_child(_vertex_label)
|
||||
shape_content.add_child(_vertex_label)
|
||||
|
||||
_create_point_button = Button.new()
|
||||
_create_point_button.text = "📍 Create Point"
|
||||
@@ -1156,46 +1186,55 @@ func _build_inspector() -> void:
|
||||
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
|
||||
_create_point_button.pressed.connect(_on_create_point_pressed)
|
||||
_shape_section.add_child(_create_point_button)
|
||||
shape_content.add_child(_create_point_button)
|
||||
_create_point_hint = Label.new()
|
||||
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
_shape_section.add_child(_create_point_hint)
|
||||
shape_content.add_child(_create_point_hint)
|
||||
|
||||
_delete_button = Button.new()
|
||||
_delete_button.text = "Erase Shape"
|
||||
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
|
||||
_delete_button.pressed.connect(_on_delete_shape_pressed)
|
||||
_shape_section.add_child(_delete_button)
|
||||
shape_content.add_child(_delete_button)
|
||||
|
||||
# Add-shape section (hidden in Reshape mode, Round 3)
|
||||
_add_section = VBoxContainer.new()
|
||||
_add_section.add_theme_constant_override("separation", 8)
|
||||
_add_section = _make_inspector_card()
|
||||
_inspector_list.add_child(_add_section)
|
||||
var add_content: VBoxContainer = _inspector_card_content(_add_section)
|
||||
add_content.add_child(_make_inspector_header("Add Shape", PropInspectorIconsScript.section("add_shape")))
|
||||
|
||||
var add_title := Label.new()
|
||||
add_title.text = "Add Shape"
|
||||
add_title.add_theme_font_size_override("font_size", header_size)
|
||||
_add_section.add_child(add_title)
|
||||
|
||||
_primitive_option = OptionButton.new()
|
||||
for label: String in PRIMITIVE_LABELS:
|
||||
_primitive_option.add_item(label)
|
||||
_primitive_option = PropInspectorDropdown.new()
|
||||
for index: int in PRIMITIVE_LABELS.size():
|
||||
_primitive_option.add_icon_item(
|
||||
PropInspectorIconsScript.shape(PRIMITIVE_IDS[index], _primitive_icon_color(PRIMITIVE_IDS[index])),
|
||||
PRIMITIVE_LABELS[index]
|
||||
)
|
||||
_primitive_option.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_primitive_option.configure(
|
||||
_ui_theme,
|
||||
_theme.inspector_background_color(),
|
||||
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
|
||||
_theme.border_color(),
|
||||
_theme.accent_color(),
|
||||
_editor_visual_theme.chrome_color(&"focus", &"prop"),
|
||||
_theme.text_color(),
|
||||
_theme.font_body()
|
||||
)
|
||||
_primitive_option.item_selected.connect(_on_primitive_selected)
|
||||
_add_section.add_child(_primitive_option)
|
||||
add_content.add_child(_primitive_option)
|
||||
|
||||
_params_box = VBoxContainer.new()
|
||||
_params_box.add_theme_constant_override("separation", 4)
|
||||
_add_section.add_child(_params_box)
|
||||
add_content.add_child(_params_box)
|
||||
|
||||
_add_button = Button.new()
|
||||
_add_button.text = "Add a Shape"
|
||||
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
|
||||
_add_button.pressed.connect(_on_add_pressed)
|
||||
_add_section.add_child(_add_button)
|
||||
add_content.add_child(_add_button)
|
||||
|
||||
_apply_control_theme(_inspector_list)
|
||||
_rebuild_primitive_params()
|
||||
@@ -1204,6 +1243,117 @@ func _build_inspector() -> void:
|
||||
_refresh_tag_controls()
|
||||
|
||||
|
||||
func _make_inspector_card() -> PanelContainer:
|
||||
var card := PanelContainer.new()
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = _theme.inspector_background_color().darkened(0.07)
|
||||
style.border_color = _theme.border_color()
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(10)
|
||||
style.content_margin_left = 10.0
|
||||
style.content_margin_top = 10.0
|
||||
style.content_margin_right = 10.0
|
||||
style.content_margin_bottom = 10.0
|
||||
card.add_theme_stylebox_override("panel", style)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
card.add_child(content)
|
||||
return card
|
||||
|
||||
|
||||
func _inspector_card_content(card: PanelContainer) -> VBoxContainer:
|
||||
return card.get_child(0) as VBoxContainer
|
||||
|
||||
|
||||
func _make_inspector_header(title: String, icon: Texture2D) -> HBoxContainer:
|
||||
var header := HBoxContainer.new()
|
||||
header.add_theme_constant_override("separation", 8)
|
||||
var icon_view := TextureRect.new()
|
||||
icon_view.texture = icon
|
||||
icon_view.custom_minimum_size = Vector2(30.0, 30.0)
|
||||
icon_view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon_view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(icon_view)
|
||||
var label := Label.new()
|
||||
label.text = title
|
||||
label.add_theme_font_size_override("font_size", _theme.font_header())
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
header.add_child(label)
|
||||
return header
|
||||
|
||||
|
||||
func _make_stat_tile(parent: Control, caption_text: String) -> Array[Label]:
|
||||
var tile := PanelContainer.new()
|
||||
tile.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = _editor_visual_theme.chrome_color(&"background", &"prop").darkened(0.02)
|
||||
style.border_color = _theme.border_color()
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(8)
|
||||
style.content_margin_left = 7.0
|
||||
style.content_margin_top = 7.0
|
||||
style.content_margin_right = 7.0
|
||||
style.content_margin_bottom = 7.0
|
||||
tile.add_theme_stylebox_override("panel", style)
|
||||
parent.add_child(tile)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 1)
|
||||
tile.add_child(content)
|
||||
var caption := Label.new()
|
||||
caption.text = caption_text
|
||||
caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
caption.add_theme_font_size_override("font_size", 14)
|
||||
caption.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
content.add_child(caption)
|
||||
var value := Label.new()
|
||||
value.add_theme_font_size_override("font_size", 22)
|
||||
content.add_child(value)
|
||||
return [caption, value]
|
||||
|
||||
|
||||
func _style_tag_button(button: Button) -> void:
|
||||
var text_color: Color = _theme.text_color()
|
||||
var surface: Color = _editor_visual_theme.chrome_color(&"background", &"prop")
|
||||
var border: Color = _theme.border_color()
|
||||
var accent: Color = _theme.accent_color()
|
||||
button.add_theme_stylebox_override("normal", _make_tag_style(surface, border, 1))
|
||||
button.add_theme_stylebox_override("hover", _make_tag_style(surface.lightened(0.12), accent, 1))
|
||||
button.add_theme_stylebox_override("pressed", _make_tag_style(_editor_visual_theme.chrome_color(&"selected_surface", &"prop"), accent, 2))
|
||||
button.add_theme_stylebox_override("focus", _make_tag_style(Color(0.0, 0.0, 0.0, 0.0), _editor_visual_theme.chrome_color(&"focus", &"prop"), 2))
|
||||
button.add_theme_color_override("font_color", text_color)
|
||||
button.add_theme_color_override("font_hover_color", text_color)
|
||||
button.add_theme_color_override("font_pressed_color", text_color)
|
||||
button.add_theme_color_override("icon_normal_color", Color.WHITE)
|
||||
button.add_theme_color_override("icon_hover_color", Color.WHITE)
|
||||
button.add_theme_color_override("icon_pressed_color", Color.WHITE)
|
||||
|
||||
|
||||
func _make_tag_style(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = fill
|
||||
style.border_color = border
|
||||
style.set_border_width_all(border_width)
|
||||
style.set_corner_radius_all(8)
|
||||
style.content_margin_left = 7.0
|
||||
style.content_margin_top = 5.0
|
||||
style.content_margin_right = 7.0
|
||||
style.content_margin_bottom = 5.0
|
||||
return style
|
||||
|
||||
|
||||
func _primitive_icon_color(primitive_id: String) -> Color:
|
||||
match primitive_id:
|
||||
"box": return Color("#70C8F0")
|
||||
"plank": return Color("#F3B65D")
|
||||
"triangle": return Color("#66C681")
|
||||
"line": return Color("#DDE7F5")
|
||||
"circle": return Color("#C9A3FF")
|
||||
return _theme.accent_color()
|
||||
|
||||
|
||||
func _build_stage_authoring_controls() -> void:
|
||||
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
|
||||
if top_bar == null:
|
||||
@@ -1244,29 +1394,30 @@ func _apply_authoring_context() -> void:
|
||||
|
||||
|
||||
func _build_tag_inspector() -> void:
|
||||
_tag_section = VBoxContainer.new()
|
||||
_tag_section.add_theme_constant_override("separation", 4)
|
||||
_tag_section = _make_inspector_card()
|
||||
_inspector_list.add_child(_tag_section)
|
||||
var title := Label.new()
|
||||
title.text = "Artwork Tags"
|
||||
title.add_theme_font_size_override("font_size", _theme.font_header())
|
||||
_tag_section.add_child(title)
|
||||
var tag_content: VBoxContainer = _inspector_card_content(_tag_section)
|
||||
tag_content.add_child(_make_inspector_header("Artwork Tags", PropInspectorIconsScript.section("tags")))
|
||||
var hint := Label.new()
|
||||
hint.text = "Choose a few so this art is easy to find."
|
||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_tag_section.add_child(hint)
|
||||
hint.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
tag_content.add_child(hint)
|
||||
var rows := FlowContainer.new()
|
||||
rows.add_theme_constant_override("h_separation", 6)
|
||||
rows.add_theme_constant_override("v_separation", 4)
|
||||
_tag_section.add_child(rows)
|
||||
rows.add_theme_constant_override("v_separation", 6)
|
||||
tag_content.add_child(rows)
|
||||
for index: int in ART_TAG_IDS.size():
|
||||
var tag_id: String = ART_TAG_IDS[index]
|
||||
var check := CheckBox.new()
|
||||
check.text = ART_TAG_LABELS[index]
|
||||
check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
|
||||
rows.add_child(check)
|
||||
_tag_checks[tag_id] = check
|
||||
var tag_button := Button.new()
|
||||
tag_button.text = ART_TAG_LABELS[index]
|
||||
tag_button.toggle_mode = true
|
||||
tag_button.custom_minimum_size = Vector2(122.0, 50.0)
|
||||
tag_button.icon = PropInspectorIconsScript.tag(tag_id)
|
||||
tag_button.toggled.connect(_on_art_tag_toggled.bind(tag_id))
|
||||
_style_tag_button(tag_button)
|
||||
rows.add_child(tag_button)
|
||||
_tag_checks[tag_id] = tag_button
|
||||
|
||||
|
||||
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
|
||||
@@ -1285,9 +1436,9 @@ func _refresh_tag_controls() -> void:
|
||||
return
|
||||
_updating_inspector = true
|
||||
for tag_id: String in ART_TAG_IDS:
|
||||
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
|
||||
if check != null:
|
||||
check.set_pressed_no_signal(_tags.has(tag_id))
|
||||
var tag_button: Button = _tag_checks.get(tag_id) as Button
|
||||
if tag_button != null:
|
||||
tag_button.set_pressed_no_signal(_tags.has(tag_id))
|
||||
_updating_inspector = false
|
||||
|
||||
func _build_anchor_inspector() -> void:
|
||||
@@ -1397,13 +1548,17 @@ func _on_rename_anchor_pressed() -> void:
|
||||
_rename_anchor_source_id = _selected_anchor_id
|
||||
_rename_anchor_edit.text = _rename_anchor_source_id
|
||||
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
|
||||
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
|
||||
StageDialogStylerScript.popup(_rename_anchor_dialog)
|
||||
_rename_anchor_edit.call_deferred("grab_focus")
|
||||
_rename_anchor_edit.call_deferred("select_all")
|
||||
|
||||
func _update_rename_anchor_confirmation(value: String) -> void:
|
||||
if _rename_anchor_dialog != null:
|
||||
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
|
||||
var native_button: Button = _rename_anchor_dialog.get_ok_button()
|
||||
native_button.disabled = value.strip_edges().is_empty()
|
||||
var visible_button: Button = StageDialogStylerScript.visual_button(native_button)
|
||||
if visible_button != native_button:
|
||||
visible_button.disabled = native_button.disabled
|
||||
|
||||
func _commit_anchor_rename() -> void:
|
||||
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
|
||||
@@ -1463,10 +1618,10 @@ func _apply_control_theme(node: Node) -> void:
|
||||
|
||||
func _update_physics_labels() -> void:
|
||||
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
|
||||
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
|
||||
_friction_label.text = "Surface grip: %.2f" % mat.friction
|
||||
_mass_value.text = "%.1f" % PropBlockScript.mass_for(_material_preset)
|
||||
_friction_value.text = "%.2f" % mat.friction
|
||||
_friction_label.tooltip_text = "Higher values make sliding harder."
|
||||
_bounce_label.text = "Bounce: %.2f" % mat.bounce
|
||||
_bounce_value.text = "%.2f" % mat.bounce
|
||||
|
||||
|
||||
func _on_preset_selected(index: int) -> void:
|
||||
@@ -1786,14 +1941,16 @@ func _check_autosave_recovery() -> void:
|
||||
|
||||
var dialog := ConfirmationDialog.new()
|
||||
dialog.title = "Restore Prop?"
|
||||
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
|
||||
dialog.ok_button_text = "🔄 Restore"
|
||||
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
|
||||
dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
|
||||
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
dialog.set_meta(StageDialogStylerScript.ICON_META, "🔄")
|
||||
dialog.dialog_text = "An unsaved prop was recovered. Restore this work?"
|
||||
dialog.ok_button_text = "Restore"
|
||||
var discard_button: Button = dialog.add_button("Discard", false, "discard")
|
||||
dialog.get_cancel_button().text = "Cancel"
|
||||
add_child(dialog)
|
||||
StageDialogStylerScript.apply(
|
||||
dialog, _dialog_theme, _dialog_theme.font(),
|
||||
dialog.get_ok_button(), [discard_button]
|
||||
)
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_apply_json_data(raw, true)
|
||||
_current_path = source_path
|
||||
@@ -1803,7 +1960,8 @@ func _check_autosave_recovery() -> void:
|
||||
if action == &"discard":
|
||||
_clear_autosave()
|
||||
dialog.queue_free())
|
||||
dialog.popup_centered()
|
||||
dialog.canceled.connect(func() -> void: dialog.queue_free())
|
||||
StageDialogStylerScript.popup(dialog)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1879,7 +2037,7 @@ func _begin_save_request(path: String, with_thumbnail: bool) -> void:
|
||||
_pending_save_path = path
|
||||
_pending_save_with_thumbnail = with_thumbnail
|
||||
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
|
||||
_off_origin_save_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_off_origin_save_dialog)
|
||||
return
|
||||
await _execute_pending_save(false)
|
||||
|
||||
@@ -1944,4 +2102,4 @@ func _on_load_file_selected(path: String) -> void:
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
_error_dialog.dialog_text = message
|
||||
_error_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_error_dialog)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class_name PropInspectorDropdown
|
||||
extends EditorSelectionDropdown
|
||||
## Prop-specific icon adapter for the shared editor selection dropdown.
|
||||
|
||||
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
|
||||
|
||||
|
||||
func add_icon_item(texture: Texture2D, label: String) -> void:
|
||||
add_styled_item(label, texture, PropInspectorIconsScript.popup_item(texture))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnnujoeli7cwk
|
||||
@@ -0,0 +1,95 @@
|
||||
class_name PropInspectorIcons
|
||||
extends RefCounted
|
||||
## Small flat 2D glyphs for the Prop Editor inspector.
|
||||
## SVG is rasterized at runtime so colors can follow the editor and material tokens.
|
||||
|
||||
const ICON_SIZE := 24
|
||||
|
||||
static func material(preset: int, tint: Color) -> Texture2D:
|
||||
if preset == 0:
|
||||
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="#252B38" stroke="#C4D1E5" stroke-width="4"/>'))
|
||||
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="%s" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="3"/>' % _hex(tint)))
|
||||
|
||||
|
||||
static func shape(shape_id: String, tint: Color = Color("#22C6FF")) -> Texture2D:
|
||||
var color := _hex(tint)
|
||||
var content := ""
|
||||
match shape_id:
|
||||
"box":
|
||||
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
|
||||
"plank":
|
||||
content = '<rect x="5" y="16" width="38" height="16" rx="2" fill="%s"/>' % color
|
||||
"triangle":
|
||||
content = '<polygon points="24,6 44,41 4,41" fill="%s"/>' % color
|
||||
"line":
|
||||
content = '<path d="M8 38 L40 10" fill="none" stroke="%s" stroke-width="6" stroke-linecap="round"/>' % color
|
||||
"circle":
|
||||
content = '<circle cx="24" cy="24" r="16" fill="%s"/>' % color
|
||||
_:
|
||||
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
|
||||
return _texture(_svg(content))
|
||||
|
||||
|
||||
static func section(section_id: String) -> Texture2D:
|
||||
var content := ""
|
||||
match section_id:
|
||||
"material":
|
||||
content = '<rect x="7" y="9" width="14" height="14" rx="3" fill="#F3B65D"/><rect x="27" y="9" width="14" height="14" rx="3" fill="#E76D66"/><rect x="7" y="27" width="14" height="14" rx="3" fill="#66C681"/><rect x="27" y="27" width="14" height="14" rx="3" fill="#70BFE8"/>'
|
||||
"selected_shape":
|
||||
content = '<path d="M9 32 L16 11 L38 15 L35 37 Z" fill="#DDE7F5" stroke="#22C6FF" stroke-width="3" stroke-linejoin="round"/><circle cx="9" cy="32" r="3" fill="#FFFFFF"/><circle cx="16" cy="11" r="3" fill="#FFFFFF"/><circle cx="38" cy="15" r="3" fill="#FFFFFF"/><circle cx="35" cy="37" r="3" fill="#FFFFFF"/>'
|
||||
"add_shape":
|
||||
content = '<rect x="7" y="7" width="34" height="34" fill="none" stroke="#22C6FF" stroke-width="5"/><path d="M24 15 V33 M15 24 H33" stroke="#22C6FF" stroke-width="4" stroke-linecap="round"/>'
|
||||
"tags":
|
||||
content = '<path d="M7 17 L17 7 H34 L42 15 V32 L25 42 L7 24 Z" fill="#FFB84D"/><circle cx="31" cy="16" r="3.5" fill="#252B38"/>'
|
||||
return _texture(_svg(content))
|
||||
|
||||
|
||||
static func tag(tag_id: String) -> Texture2D:
|
||||
var color := "#66C681"
|
||||
var body := ""
|
||||
match tag_id:
|
||||
"nature":
|
||||
color = "#66C681"
|
||||
body = '<path d="M9 35 C9 18 21 8 39 8 C39 26 29 39 13 39 Z" fill="%s"/><path d="M12 38 L33 15" stroke="#E8FFF0" stroke-width="3" stroke-linecap="round"/>' % color
|
||||
"buildings":
|
||||
color = "#F3A653"
|
||||
body = '<path d="M10 40 V10 H29 V18 H38 V40 Z" fill="%s"/><path d="M16 16 H21 M16 23 H21 M16 30 H21 M31 24 H35 M31 31 H35" stroke="#3C2A22" stroke-width="3"/>' % color
|
||||
"walls":
|
||||
color = "#F36B5B"
|
||||
body = '<path d="M5 10 H21 V19 H5 Z M24 10 H42 V19 H24 Z M14 22 H31 V31 H14 Z M4 34 H20 V42 H4 Z M23 34 H42 V42 H23 Z" fill="%s"/><path d="M22 10 V19 M13 22 V31 M31 34 V42" stroke="#733A37" stroke-width="2"/>' % color
|
||||
"roads":
|
||||
color = "#9BAEC9"
|
||||
body = '<path d="M16 7 H32 L42 41 H6 Z" fill="%s"/><path d="M24 12 V19 M24 25 V31 M24 36 V40" stroke="#283247" stroke-width="3" stroke-linecap="round"/>' % color
|
||||
"objects":
|
||||
color = "#E8A96D"
|
||||
body = '<rect x="7" y="11" width="34" height="26" fill="none" stroke="%s" stroke-width="5"/>' % color
|
||||
return _texture(_svg(body))
|
||||
|
||||
|
||||
static func popup_item(texture: Texture2D, size: int = 44) -> Texture2D:
|
||||
if texture == null:
|
||||
return null
|
||||
var source: Image = texture.get_image()
|
||||
var canvas := Image.create(size, size, false, Image.FORMAT_RGBA8)
|
||||
var draw_size: Vector2i = Vector2i(mini(source.get_width(), size), mini(source.get_height(), size))
|
||||
var source_rect := Rect2i(Vector2i.ZERO, draw_size)
|
||||
var destination := Vector2i((size - draw_size.x) / 2, (size - draw_size.y) / 2)
|
||||
canvas.blit_rect(source, source_rect, destination)
|
||||
return ImageTexture.create_from_image(canvas)
|
||||
|
||||
|
||||
static func _svg(content: String) -> String:
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 48 48">%s</svg>' % [ICON_SIZE, ICON_SIZE, content]
|
||||
|
||||
|
||||
static func _texture(svg: String) -> Texture2D:
|
||||
var image := Image.new()
|
||||
var error: Error = image.load_svg_from_string(svg)
|
||||
if error != OK:
|
||||
push_warning("Prop Inspector icon could not be rasterized (%s)." % error_string(error))
|
||||
return null
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
|
||||
static func _hex(color: Color) -> String:
|
||||
return "#" + color.to_html(false)
|
||||
@@ -0,0 +1 @@
|
||||
uid://5ac0640i4bj7
|
||||
@@ -37,6 +37,7 @@ var _row_font_size: int = 0
|
||||
var _title_font_size: int = 0
|
||||
var _title_font: Font = null
|
||||
var _use_target_summary_resolver: Callable = Callable()
|
||||
var _prop_target_name_resolver: Callable = Callable()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -62,6 +63,11 @@ func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func set_prop_target_name_resolver(resolver: Callable) -> void:
|
||||
_prop_target_name_resolver = resolver
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _list == null:
|
||||
return
|
||||
@@ -151,7 +157,7 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
|
||||
|
||||
var summary := Label.new()
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var action_summary: String = ACTION_REGISTRY.summarize(action)
|
||||
var action_summary: String = ACTION_REGISTRY.summarize(action, _prop_target_name_resolver)
|
||||
if String(action.get("type", "")) == "use_prop" and _use_target_summary_resolver.is_valid():
|
||||
action_summary = String(_use_target_summary_resolver.call(
|
||||
String(action.get("target_prop_id", "")),
|
||||
|
||||
@@ -283,7 +283,7 @@ func set_down_target(position: Vector2) -> void:
|
||||
|
||||
func show_action_editor() -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.popup_centered()
|
||||
_action_editor.resume_editor()
|
||||
|
||||
|
||||
func request_action_target_capture() -> void:
|
||||
@@ -304,7 +304,7 @@ func request_action_target_capture() -> void:
|
||||
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
|
||||
_emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
|
||||
"pick_up_prop":
|
||||
_emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
|
||||
_emit_capture_request(&"pickup_prop", "Choose a prop to pick up", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
|
||||
"use_prop":
|
||||
_emit_capture_request(&"use_prop", "Click a prop, then choose its Use spot", Callable(self, "set_use_target_from_capture"), Callable(self, "cancel_target_capture"))
|
||||
"throw_prop":
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://fqbgu2edqtpd
|
||||
@@ -27,7 +27,7 @@ signal reset_requested
|
||||
|
||||
const TOP_BAR_HEIGHT: float = 64.0
|
||||
const STATUS_BAR_HEIGHT: float = 38.0
|
||||
const COMPACT_TOP_BAR_WIDTH: float = 900.0
|
||||
const COMPACT_TOP_BAR_WIDTH: float = 1100.0
|
||||
|
||||
var _stage_theme: SandboxTheme
|
||||
var _current_mode: int = 0
|
||||
@@ -39,6 +39,7 @@ var _accent_direct: Color = Color("#FFB300")
|
||||
var _accent_play: Color = Color("#33DD77")
|
||||
var _status_pill_font_size: int = 16
|
||||
var _tooltip_font_size: int = 18
|
||||
var _direct_hint_requested: bool = false
|
||||
|
||||
var _mode_buttons: Dictionary = {}
|
||||
var _palette_buttons: Dictionary = {}
|
||||
@@ -58,6 +59,7 @@ var _pause_button: Button
|
||||
var _resume_button: Button
|
||||
var _reset_button: Button
|
||||
var _tooltip: Label
|
||||
var _action_tooltip_avoid_rect: Rect2 = Rect2()
|
||||
var _grid_check: CheckBox
|
||||
var _snap_check: CheckBox
|
||||
var _grid_size_spin: SpinBox
|
||||
@@ -293,21 +295,76 @@ func set_action_tooltip(visible: bool, text_value: String = "") -> void:
|
||||
_tooltip.visible = visible
|
||||
if not visible:
|
||||
return
|
||||
_tooltip.text = text_value
|
||||
_tooltip.text = _capture_tooltip_text(text_value)
|
||||
var style: StyleBox = _tooltip.get_theme_stylebox("normal")
|
||||
var extra: Vector2 = style.get_minimum_size() if style != null else Vector2.ZERO
|
||||
_tooltip.size = _tooltip.get_minimum_size() + extra
|
||||
var required_size: Vector2 = _tooltip.get_minimum_size() + extra
|
||||
var tooltip_width: float = clampf(required_size.x, 320.0, 420.0)
|
||||
_tooltip.size = Vector2(tooltip_width, maxf(required_size.y, 50.0))
|
||||
var mouse: Vector2 = get_viewport().get_mouse_position()
|
||||
var size: Vector2 = _tooltip.size
|
||||
var position: Vector2 = mouse + Vector2(20.0, -20.0)
|
||||
var viewport_size: Vector2 = get_viewport().get_visible_rect().size
|
||||
if position.x + size.x > viewport_size.x:
|
||||
position.x = mouse.x - size.x - 20.0
|
||||
if position.y + size.y > viewport_size.y:
|
||||
position.y = mouse.y - size.y - 20.0
|
||||
if position.y < 0.0:
|
||||
position.y = mouse.y + 20.0
|
||||
_tooltip.position = position
|
||||
var avoid_rects: Array[Rect2] = []
|
||||
if _action_tooltip_avoid_rect.has_area():
|
||||
avoid_rects.append(_action_tooltip_avoid_rect)
|
||||
if _mode_badge != null and _mode_badge.visible:
|
||||
avoid_rects.append(_mode_badge.get_global_rect().grow(6.0))
|
||||
if _timestamp_badge != null and _timestamp_badge.visible:
|
||||
avoid_rects.append(_timestamp_badge.get_global_rect().grow(6.0))
|
||||
if _palette_dock != null and _palette_dock.visible:
|
||||
avoid_rects.append(_palette_dock.get_global_rect().grow(6.0))
|
||||
_tooltip.position = _find_action_tooltip_position(mouse, size, viewport_size, avoid_rects)
|
||||
|
||||
|
||||
func _find_action_tooltip_position(mouse: Vector2, size: Vector2, viewport_size: Vector2, avoid_rects: Array[Rect2]) -> Vector2:
|
||||
var min_x: float = 8.0
|
||||
var max_x: float = maxf(min_x, viewport_size.x - size.x - 8.0)
|
||||
var min_y: float = TOP_BAR_HEIGHT + 8.0
|
||||
var max_y: float = maxf(min_y, viewport_size.y - size.y - 8.0)
|
||||
var candidates: Array[Vector2] = [
|
||||
mouse + Vector2(20.0, -20.0),
|
||||
mouse + Vector2(-size.x - 20.0, -20.0),
|
||||
mouse + Vector2(20.0, 20.0),
|
||||
mouse + Vector2(-size.x - 20.0, 20.0),
|
||||
]
|
||||
for avoid_rect: Rect2 in avoid_rects:
|
||||
candidates.append(Vector2(avoid_rect.position.x - size.x - 12.0, avoid_rect.get_center().y - size.y * 0.5))
|
||||
candidates.append(Vector2(avoid_rect.end.x + 12.0, avoid_rect.get_center().y - size.y * 0.5))
|
||||
candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.position.y - size.y - 12.0))
|
||||
candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.end.y + 12.0))
|
||||
candidates.append(Vector2(min_x, min_y))
|
||||
candidates.append(Vector2(max_x, min_y))
|
||||
candidates.append(Vector2(min_x, max_y))
|
||||
candidates.append(Vector2(max_x, max_y))
|
||||
for candidate: Vector2 in candidates:
|
||||
var position: Vector2 = Vector2(clampf(candidate.x, min_x, max_x), clampf(candidate.y, min_y, max_y))
|
||||
var tooltip_rect: Rect2 = Rect2(position, size)
|
||||
var overlaps: bool = false
|
||||
for avoid_rect: Rect2 in avoid_rects:
|
||||
if tooltip_rect.intersects(avoid_rect):
|
||||
overlaps = true
|
||||
break
|
||||
if not overlaps:
|
||||
return position
|
||||
return Vector2(min_x, min_y)
|
||||
|
||||
|
||||
func set_action_tooltip_avoid_rect(rect: Rect2) -> void:
|
||||
_action_tooltip_avoid_rect = rect
|
||||
|
||||
|
||||
func _capture_tooltip_text(text_value: String) -> String:
|
||||
var task: String = text_value.strip_edges()
|
||||
for old_copy: String in ["(Esc or RMB to cancel)", "(Esc or right-click to cancel)", "Esc cancels."]:
|
||||
task = task.replace(old_copy, "").strip_edges()
|
||||
while task.ends_with("\n"):
|
||||
task = task.substr(0, task.length() - 1).strip_edges()
|
||||
var cancel_hint: String = "Press Escape or right-click to cancel"
|
||||
if task.is_empty():
|
||||
return cancel_hint
|
||||
if task.contains(cancel_hint):
|
||||
return task
|
||||
return "%s\n%s" % [task, cancel_hint]
|
||||
|
||||
|
||||
func set_edit_controls_visible(visible: bool) -> void:
|
||||
@@ -326,8 +383,9 @@ func set_edit_controls_visible(visible: bool) -> void:
|
||||
|
||||
|
||||
func set_direct_controls_visible(visible: bool) -> void:
|
||||
_direct_hint_requested = visible
|
||||
if _direct_hint_label != null:
|
||||
_direct_hint_label.visible = visible
|
||||
_direct_hint_label.visible = visible and get_viewport().get_visible_rect().size.x > COMPACT_TOP_BAR_WIDTH
|
||||
|
||||
|
||||
func is_stage_frame_position(viewport_pos: Vector2) -> bool:
|
||||
@@ -426,17 +484,19 @@ func _build_file_menu(parent: HBoxContainer) -> void:
|
||||
var file_popup: PopupMenu = _file_menu.get_popup()
|
||||
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64)
|
||||
file_popup.add_theme_font_size_override("font_size", popup_font_size)
|
||||
file_popup.add_item("New", 0)
|
||||
file_popup.add_item("Open…", 1)
|
||||
file_popup.add_item("🆕 New", 0)
|
||||
file_popup.add_item("📂 Open…", 1)
|
||||
var recent_popup := PopupMenu.new()
|
||||
recent_popup.name = "OpenRecent"
|
||||
recent_popup.add_theme_font_size_override("font_size", popup_font_size)
|
||||
recent_popup.id_pressed.connect(_on_recent_scene_requested)
|
||||
file_popup.add_child(recent_popup)
|
||||
file_popup.add_submenu_item("Open Recent", "OpenRecent")
|
||||
file_popup.add_item("Save", 2)
|
||||
file_popup.add_item("Save As…", 3)
|
||||
file_popup.add_item("Rename Scene…", 4)
|
||||
file_popup.add_submenu_item("🕑 Open Recent", "OpenRecent")
|
||||
file_popup.add_separator()
|
||||
file_popup.add_item("💾 Save", 2)
|
||||
file_popup.add_item("📄 Save As…", 3)
|
||||
file_popup.add_separator()
|
||||
file_popup.add_item("✏️ Rename Scene…", 4)
|
||||
file_popup.id_pressed.connect(_on_file_menu_requested)
|
||||
file_popup.about_to_popup.connect(_on_recent_menu_requested)
|
||||
parent.add_child(_file_menu)
|
||||
@@ -504,6 +564,8 @@ func _apply_responsive_top_bar_layout() -> void:
|
||||
return
|
||||
var viewport_width: float = get_viewport().get_visible_rect().size.x
|
||||
var compact: bool = viewport_width <= COMPACT_TOP_BAR_WIDTH
|
||||
if _direct_hint_label != null:
|
||||
_direct_hint_label.visible = _direct_hint_requested and not compact
|
||||
if compact:
|
||||
_file_menu.custom_minimum_size = Vector2(64.0, 48.0)
|
||||
_file_menu.add_theme_font_size_override("font_size", 18)
|
||||
@@ -654,7 +716,7 @@ func _build_timestamp_badge() -> void:
|
||||
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_timestamp_badge.offset_left = -220.0
|
||||
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
|
||||
_timestamp_badge.offset_right = -12.0
|
||||
_timestamp_badge.offset_right = -_theme_number("stage_panels.shell.edge_inset", 12.0, 0.0, 48.0)
|
||||
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = _theme_color("chrome.timestamp_badge.bg_color", Color("#171B24F2"))
|
||||
@@ -681,6 +743,8 @@ func _build_action_tooltip() -> void:
|
||||
_tooltip.name = "ActionTooltip"
|
||||
_tooltip.visible = false
|
||||
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_tooltip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_tooltip.max_lines_visible = 3
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
|
||||
style.border_color = _accent_direct
|
||||
|
||||
@@ -75,7 +75,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo
|
||||
_action_popup.add_separator()
|
||||
_action_popup.add_item("⚡ When...", int(action_ids.get("when", 5)))
|
||||
_action_popup.add_separator()
|
||||
_action_popup.add_item("📋 Edit Queue…", int(action_ids.get("edit_queue", 7)))
|
||||
_action_popup.add_item("📋 Step Stack…", int(action_ids.get("edit_queue", 7)))
|
||||
_action_popup.add_item("⚡ Edit Rules…", int(action_ids.get("edit_rules", 8)))
|
||||
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
|
||||
_action_popup.about_to_popup.connect(_on_action_popup_about_to_popup)
|
||||
@@ -308,7 +308,7 @@ func refresh_action_popup_items(rules: Array[Dictionary]) -> void:
|
||||
if _action_popup == null:
|
||||
return
|
||||
var valid: bool = context_rig != null and is_instance_valid(context_rig)
|
||||
_disable_popup_item(_action_popup, 7, not valid or context_rig.get_queue().is_empty())
|
||||
_disable_popup_item(_action_popup, 7, not valid)
|
||||
_disable_popup_item(_action_popup, 8, not valid or not rig_has_rules(context_rig, rules))
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ func refresh_rig_context_items(rig: StickmanRig, popup: PopupMenu, rules: Array[
|
||||
if popup == null:
|
||||
return
|
||||
var valid: bool = rig != null and is_instance_valid(rig)
|
||||
_disable_popup_item(popup, 0, not valid or rig.get_queue().is_empty())
|
||||
_disable_popup_item(popup, 0, not valid)
|
||||
_disable_popup_item(popup, 1, not valid or not rig_has_rules(rig, rules))
|
||||
|
||||
|
||||
@@ -455,10 +455,13 @@ func update_action_overlay(
|
||||
if awaiting:
|
||||
var origin: Vector2 = action_origin(capture_kind, set_down_rig, throw_rig, throw_cache, _action_queue.panel_rig if _action_queue != null else null)
|
||||
var target: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.ZERO
|
||||
var movement_target_capture: bool = capture_kind == "position" or pending_walk_target or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)
|
||||
if snap_enabled and (capture_kind in ["position", "throw", "set_down"] or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)):
|
||||
if grid_size > 0.0:
|
||||
target = Vector2(roundf(target.x / grid_size) * grid_size, roundf(target.y / grid_size) * grid_size)
|
||||
if capture_kind == "set_down":
|
||||
if capture_kind in ["plan_place", "plan_prop"]:
|
||||
_placement_overlay.set_action_marker(target)
|
||||
elif capture_kind == "set_down":
|
||||
var known: bool = bool(set_down_cache.get("known", false))
|
||||
var valid: bool = bool(set_down_cache.get("valid", false))
|
||||
if known:
|
||||
@@ -487,11 +490,15 @@ func update_action_overlay(
|
||||
throw_preview["start_position"], target, throw_preview["landing_position"], target_reachable,
|
||||
trajectory_points, effective_target, collision_position, preview_planned, preview_uncertain
|
||||
)
|
||||
elif movement_target_capture:
|
||||
_placement_overlay.set_grounded_action_trajectory(origin, target, is_target_valid(target))
|
||||
else:
|
||||
_placement_overlay.set_action_trajectory(origin, target, is_target_valid(target))
|
||||
# Object/actor/rule target selection needs a cursor marker, not a
|
||||
# movement-looking line from the actor to the hovered target.
|
||||
_placement_overlay.set_action_marker(target)
|
||||
elif direct_move_target.is_finite() and context_rig != null and is_instance_valid(context_rig):
|
||||
var origin: Vector2 = context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||
_placement_overlay.set_action_trajectory(origin, direct_move_target, true)
|
||||
_placement_overlay.set_grounded_action_trajectory(origin, direct_move_target, true)
|
||||
else:
|
||||
_placement_overlay.clear_action()
|
||||
if _chrome != null:
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
class_name StageInspectorCaptureOverlay
|
||||
extends Control
|
||||
## Keeps an active world-target choice visible beside the inspector while
|
||||
## dimming and blocking the inspector controls underneath it.
|
||||
|
||||
signal capture_action_requested(action_id: StringName)
|
||||
|
||||
const CAPTURE_STRIP_HEIGHT: float = 72.0
|
||||
const CAPTURE_MULTI_ACTION_STRIP_HEIGHT: float = 124.0
|
||||
const CAPTURE_BUTTON_HEIGHT: float = 48.0
|
||||
const CAPTURE_CANCEL_WIDTH: float = 84.0
|
||||
const CAPTURE_ACTION_WIDTH: float = 112.0
|
||||
|
||||
var _stage_theme: SandboxTheme
|
||||
var _inspector: Control
|
||||
var _inspector_scrim: Panel
|
||||
var _capture_strip: PanelContainer
|
||||
var _content_column: VBoxContainer
|
||||
var _title_row: HBoxContainer
|
||||
var _caption: Label
|
||||
var _caption_base_font_size: int = 17
|
||||
var _actions_row: HBoxContainer
|
||||
var _action_buttons: Array[Button] = []
|
||||
var _active_signature: String = ""
|
||||
var _focus_before_capture: Control
|
||||
|
||||
|
||||
func configure(stage_theme: SandboxTheme) -> void:
|
||||
_stage_theme = stage_theme
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "StageInspectorCaptureOverlay"
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
z_index = 120
|
||||
visible = false
|
||||
_build_controls()
|
||||
get_viewport().size_changed.connect(_layout_capture_overlay)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_layout_capture_overlay()
|
||||
|
||||
|
||||
func show_capture(inspector: Control, prompt: String, actions: Array[Dictionary]) -> bool:
|
||||
if inspector == null or not is_instance_valid(inspector) or not inspector.is_visible_in_tree():
|
||||
hide_capture()
|
||||
return false
|
||||
var normalized_actions: Array[Dictionary] = actions.duplicate(true)
|
||||
if normalized_actions.is_empty():
|
||||
normalized_actions.append({"id": "cancel_capture", "label": "Cancel", "primary": true})
|
||||
_set_stacked_actions(normalized_actions.size() > 1)
|
||||
var action_signature: Array[String] = []
|
||||
for action: Dictionary in normalized_actions:
|
||||
action_signature.append("%s:%s" % [String(action.get("id", "")), String(action.get("label", ""))])
|
||||
var signature: String = "%d|%s|%s" % [inspector.get_instance_id(), prompt, ";".join(action_signature)]
|
||||
if _active_signature != signature:
|
||||
if _active_signature.is_empty():
|
||||
_focus_before_capture = get_viewport().gui_get_focus_owner()
|
||||
_active_signature = signature
|
||||
_inspector = inspector
|
||||
_caption.text = prompt
|
||||
_rebuild_actions(normalized_actions)
|
||||
visible = true
|
||||
if not _action_buttons.is_empty():
|
||||
_action_buttons[_action_buttons.size() - 1].grab_focus()
|
||||
_layout_capture_overlay()
|
||||
return true
|
||||
|
||||
|
||||
func hide_capture() -> void:
|
||||
if _active_signature.is_empty():
|
||||
return
|
||||
_active_signature = ""
|
||||
_inspector = null
|
||||
visible = false
|
||||
for button: Button in _action_buttons:
|
||||
button.queue_free()
|
||||
_action_buttons.clear()
|
||||
if _focus_before_capture != null and is_instance_valid(_focus_before_capture) and _focus_before_capture.is_visible_in_tree():
|
||||
_focus_before_capture.grab_focus()
|
||||
_focus_before_capture = null
|
||||
|
||||
|
||||
func has_capture() -> bool:
|
||||
return not _active_signature.is_empty()
|
||||
|
||||
|
||||
func get_inspector_rect() -> Rect2:
|
||||
if not has_capture() or _inspector == null or not is_instance_valid(_inspector):
|
||||
return Rect2()
|
||||
return _inspector.get_global_rect()
|
||||
|
||||
|
||||
func _build_controls() -> void:
|
||||
_inspector_scrim = Panel.new()
|
||||
_inspector_scrim.name = "InspectorInputBlocker"
|
||||
_inspector_scrim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_inspector_scrim.focus_mode = Control.FOCUS_NONE
|
||||
_inspector_scrim.gui_input.connect(_on_capture_area_gui_input)
|
||||
var scrim_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
scrim_style.bg_color = Color(0.015, 0.02, 0.035, 0.56)
|
||||
scrim_style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32))
|
||||
_inspector_scrim.add_theme_stylebox_override("panel", scrim_style)
|
||||
add_child(_inspector_scrim)
|
||||
|
||||
_capture_strip = PanelContainer.new()
|
||||
_capture_strip.name = "CaptureCancelStrip"
|
||||
_capture_strip.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_capture_strip.gui_input.connect(_on_capture_area_gui_input)
|
||||
_capture_strip.add_theme_stylebox_override("panel", _strip_style())
|
||||
add_child(_capture_strip)
|
||||
|
||||
var margin: MarginContainer = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_right", 10)
|
||||
margin.add_theme_constant_override("margin_top", 7)
|
||||
margin.add_theme_constant_override("margin_bottom", 7)
|
||||
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_capture_strip.add_child(margin)
|
||||
|
||||
_content_column = VBoxContainer.new()
|
||||
_content_column.name = "CaptureContent"
|
||||
_content_column.add_theme_constant_override("separation", 4)
|
||||
_content_column.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.add_child(_content_column)
|
||||
|
||||
_title_row = HBoxContainer.new()
|
||||
_title_row.name = "CaptureTitleRow"
|
||||
_title_row.add_theme_constant_override("separation", 8)
|
||||
_title_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_title_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content_column.add_child(_title_row)
|
||||
|
||||
var marker: Label = Label.new()
|
||||
marker.text = "⊕"
|
||||
marker.add_theme_font_size_override("font_size", 25)
|
||||
marker.add_theme_color_override("font_color", Color("#22C6FF"))
|
||||
marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_title_row.add_child(marker)
|
||||
|
||||
_caption = Label.new()
|
||||
_caption.name = "CapturePrompt"
|
||||
_caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_caption.text = "Choosing a target"
|
||||
_caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_caption.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_caption.clip_text = true
|
||||
_caption.custom_minimum_size.y = 48.0
|
||||
_caption_base_font_size = _integer("stage_panels.type.body_size", 17, 14, 28)
|
||||
_caption.add_theme_font_size_override("font_size", _caption_base_font_size)
|
||||
_caption.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||
_caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_BEGIN
|
||||
_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_title_row.add_child(_caption)
|
||||
|
||||
_actions_row = HBoxContainer.new()
|
||||
_actions_row.name = "CaptureActions"
|
||||
_actions_row.add_theme_constant_override("separation", 6)
|
||||
_actions_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
_actions_row.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
_actions_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_title_row.add_child(_actions_row)
|
||||
|
||||
|
||||
func _set_stacked_actions(stacked: bool) -> void:
|
||||
if _actions_row == null or _content_column == null or _title_row == null:
|
||||
return
|
||||
var target_parent: Node = _content_column if stacked else _title_row
|
||||
if _actions_row.get_parent() != target_parent:
|
||||
_actions_row.reparent(target_parent)
|
||||
_actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stacked else Control.SIZE_SHRINK_END
|
||||
_actions_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
|
||||
|
||||
func _rebuild_actions(actions: Array[Dictionary]) -> void:
|
||||
for button: Button in _action_buttons:
|
||||
button.queue_free()
|
||||
_action_buttons.clear()
|
||||
for action: Dictionary in actions:
|
||||
var action_id: StringName = StringName(String(action.get("id", "")))
|
||||
if action_id.is_empty():
|
||||
continue
|
||||
var button: Button = Button.new()
|
||||
button.name = "Capture_%s" % String(action_id)
|
||||
button.text = String(action.get("label", "Cancel"))
|
||||
button.tooltip_text = button.text
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
button.custom_minimum_size = Vector2(CAPTURE_CANCEL_WIDTH if action_id == &"cancel_capture" else CAPTURE_ACTION_WIDTH, CAPTURE_BUTTON_HEIGHT)
|
||||
button.add_theme_font_size_override("font_size", _integer("stage_panels.action.label_size", 16, 14, 24))
|
||||
_style_action_button(button, action_id == &"cancel_capture")
|
||||
button.pressed.connect(_on_action_pressed.bind(action_id))
|
||||
_actions_row.add_child(button)
|
||||
_action_buttons.append(button)
|
||||
_trap_action_focus()
|
||||
|
||||
|
||||
func _trap_action_focus() -> void:
|
||||
for index: int in _action_buttons.size():
|
||||
var button: Button = _action_buttons[index]
|
||||
button.set_focus_neighbor(SIDE_TOP, button.get_path())
|
||||
button.set_focus_neighbor(SIDE_BOTTOM, button.get_path())
|
||||
button.set_focus_neighbor(SIDE_LEFT, _action_buttons[maxi(0, index - 1)].get_path())
|
||||
button.set_focus_neighbor(SIDE_RIGHT, _action_buttons[mini(_action_buttons.size() - 1, index + 1)].get_path())
|
||||
|
||||
|
||||
func _on_action_pressed(action_id: StringName) -> void:
|
||||
capture_action_requested.emit(action_id)
|
||||
|
||||
|
||||
func _on_capture_area_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mouse_button: InputEventMouseButton = event as InputEventMouseButton
|
||||
if mouse_button.pressed and mouse_button.button_index == MOUSE_BUTTON_RIGHT:
|
||||
capture_action_requested.emit(&"cancel_capture")
|
||||
accept_event()
|
||||
|
||||
|
||||
func _layout_capture_overlay() -> void:
|
||||
if not has_capture() or _inspector == null or not is_instance_valid(_inspector) or not _inspector.is_visible_in_tree():
|
||||
hide_capture()
|
||||
return
|
||||
var viewport_rect: Rect2 = get_viewport().get_visible_rect()
|
||||
var inspector_rect: Rect2 = _inspector.get_global_rect().intersection(viewport_rect)
|
||||
if not inspector_rect.has_area():
|
||||
hide_capture()
|
||||
return
|
||||
var caption_font_size: int = mini(_caption_base_font_size, 16) if inspector_rect.size.x < 340.0 else _caption_base_font_size
|
||||
_caption.add_theme_font_size_override("font_size", caption_font_size)
|
||||
_inspector_scrim.position = inspector_rect.position
|
||||
_inspector_scrim.size = inspector_rect.size
|
||||
_capture_strip.position = inspector_rect.position
|
||||
var strip_height: float = CAPTURE_MULTI_ACTION_STRIP_HEIGHT if _actions_row.get_parent() == _content_column else CAPTURE_STRIP_HEIGHT
|
||||
_capture_strip.size = Vector2(inspector_rect.size.x, strip_height)
|
||||
_capture_strip.custom_minimum_size = Vector2(inspector_rect.size.x, strip_height)
|
||||
|
||||
|
||||
func _strip_style() -> StyleBoxFlat:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = _color("stage_panels.shell.surface_color", Color("#202634"))
|
||||
style.border_color = _color("mode_colors.direct", Color("#FFB300"))
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32))
|
||||
style.content_margin_left = 8.0
|
||||
style.content_margin_right = 8.0
|
||||
style.content_margin_top = 4.0
|
||||
style.content_margin_bottom = 4.0
|
||||
return style
|
||||
|
||||
|
||||
func _style_action_button(button: Button, is_cancel: bool) -> void:
|
||||
var normal: Color = Color("#B83A43") if is_cancel else _color("stage_panels.action.surface_color", Color("#171B24"))
|
||||
var hover: Color = Color("#CF4B54") if is_cancel else _color("stage_panels.action.hover_surface", Color("#303A4B"))
|
||||
var pressed: Color = Color("#96323A") if is_cancel else _color("stage_panels.action.selected_surface", Color("#12394A"))
|
||||
var border: Color = Color("#FF8790") if is_cancel else _color("stage_panels.choice.border_color", Color("#FFFFFF55"))
|
||||
button.add_theme_stylebox_override("normal", _button_style(normal, border, 2))
|
||||
button.add_theme_stylebox_override("hover", _button_style(hover, border.lightened(0.12), 2))
|
||||
button.add_theme_stylebox_override("pressed", _button_style(pressed, border, 2))
|
||||
button.add_theme_stylebox_override("focus", _button_style(normal, Color("#8BD9FF"), 3))
|
||||
button.add_theme_color_override("font_color", Color("#FFFFFF"))
|
||||
button.add_theme_color_override("font_hover_color", Color("#FFFFFF"))
|
||||
button.add_theme_color_override("font_pressed_color", Color("#FFFFFF"))
|
||||
button.add_theme_color_override("font_focus_color", Color("#FFFFFF"))
|
||||
|
||||
|
||||
func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
style.border_color = border
|
||||
style.set_border_width_all(border_width)
|
||||
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
|
||||
style.content_margin_left = 10.0
|
||||
style.content_margin_right = 10.0
|
||||
style.content_margin_top = 5.0
|
||||
style.content_margin_bottom = 5.0
|
||||
return style
|
||||
|
||||
|
||||
func _color(path: String, fallback: Color) -> Color:
|
||||
return _stage_theme.color(path, fallback) if _stage_theme != null else fallback
|
||||
|
||||
|
||||
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||
return _stage_theme.integer(path, fallback, minimum, maximum) if _stage_theme != null else fallback
|
||||
@@ -0,0 +1 @@
|
||||
uid://71weltw84wr
|
||||
@@ -9,11 +9,13 @@ const SPEECH_COMPOSER_SCENE := preload("res://scenes/editors/stage/speech_compos
|
||||
const SPEECH_STYLE_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_style_toolbar.gd")
|
||||
const SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_toolbar.gd")
|
||||
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
|
||||
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const MISSING_PROP_PLACEHOLDER_SCRIPT := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const MISSING_STICKMAN_PLACEHOLDER_SCRIPT := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
|
||||
signal object_selected(node: Node2D)
|
||||
signal candidate_preview_changed(node: Node2D)
|
||||
signal objects_here_dismissed
|
||||
|
||||
var speak_dialog: AcceptDialog
|
||||
var speak_edit: LineEdit
|
||||
@@ -35,12 +37,15 @@ var waypoint_context: PopupMenu
|
||||
var confirm_dialog: ConfirmationDialog
|
||||
var queue_panel: QueuePanel
|
||||
var action_editor: ActionEditor
|
||||
var action_editor_backdrop: ColorRect
|
||||
var speech_composer: Control
|
||||
var speech_style_toolbar: Control
|
||||
var speech_show_toolbar: Control
|
||||
var speech_recovery_dialog: ConfirmationDialog
|
||||
var speech_destination_dialog: ConfirmationDialog
|
||||
var _objects_here_popup: PopupPanel
|
||||
var _objects_here_guard: Control
|
||||
var _objects_here_preview: Node2D
|
||||
var _speak_box: VBoxContainer
|
||||
var _sandbox_theme: SandboxTheme
|
||||
var _ui_font: Font
|
||||
@@ -177,7 +182,7 @@ func build_context_and_editor_views(
|
||||
action_size: Vector2i
|
||||
) -> void:
|
||||
rig_context_popup = PopupMenu.new()
|
||||
rig_context_popup.add_item("📋 Edit Queue…", 0)
|
||||
rig_context_popup.add_item("📋 Step Stack…", 0)
|
||||
rig_context_popup.add_item("⚡ Edit Rules…", 1)
|
||||
parent.add_child(rig_context_popup)
|
||||
|
||||
@@ -198,11 +203,29 @@ func build_context_and_editor_views(
|
||||
queue_panel.apply_font(font, emoji_font, font_sizes)
|
||||
queue_panel.min_size = queue_size
|
||||
|
||||
action_editor_backdrop = ColorRect.new()
|
||||
action_editor_backdrop.name = "ActionEditorBackdrop"
|
||||
action_editor_backdrop.color = _sandbox_theme.color("popups.confirmation.backdrop_color", Color("#080D15B8")) if _sandbox_theme != null else Color("#080D15B8")
|
||||
action_editor_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
action_editor_backdrop.z_index = 100
|
||||
action_editor_backdrop.z_as_relative = false
|
||||
action_editor_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
action_editor_backdrop.visible = false
|
||||
parent.add_child(action_editor_backdrop)
|
||||
|
||||
action_editor = ACTION_EDITOR_SCENE.instantiate() as ActionEditor
|
||||
parent.add_child(action_editor)
|
||||
action_editor.apply_font(font, emoji_font, font_sizes)
|
||||
action_editor.apply_theme(_sandbox_theme)
|
||||
action_editor.min_size = action_size
|
||||
action_editor.set_preferred_size(action_size)
|
||||
action_editor.about_to_popup.connect(func() -> void:
|
||||
if is_instance_valid(action_editor_backdrop):
|
||||
action_editor_backdrop.show()
|
||||
)
|
||||
action_editor.popup_hide.connect(func() -> void:
|
||||
if is_instance_valid(action_editor_backdrop):
|
||||
action_editor_backdrop.hide()
|
||||
)
|
||||
|
||||
speech_composer = SPEECH_COMPOSER_SCENE.instantiate() as Control
|
||||
parent.add_child(speech_composer)
|
||||
@@ -237,12 +260,43 @@ func show_objects_here(
|
||||
candidates: Array[Node2D],
|
||||
selected_nodes: Array[Node2D],
|
||||
viewport_size: Vector2,
|
||||
pointer_position: Vector2
|
||||
pointer_position: Vector2,
|
||||
avoid_rects: Array[Rect2] = []
|
||||
) -> void:
|
||||
if parent == null or candidates.size() < 2:
|
||||
return
|
||||
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||
_objects_here_popup.queue_free()
|
||||
_objects_here_popup.hide()
|
||||
var content_inset: int = _objects_here_integer("content_inset", 12, 8, 24)
|
||||
var edge_inset: int = _objects_here_integer("edge_inset", 8, 0, 32)
|
||||
var safe_top: int = _objects_here_integer("safe_top", 120, 0, 240)
|
||||
var safe_bottom: int = _objects_here_integer("safe_bottom", 48, 0, 160)
|
||||
var preferred_width: int = _objects_here_integer("preferred_width", 360, 220, 720)
|
||||
var popup_width: int = mini(preferred_width, maxi(1, int(viewport_size.x) - edge_inset * 2))
|
||||
var content_width: int = maxi(1, popup_width - content_inset * 2)
|
||||
var row_height: int = _objects_here_integer("row_height", 56, 44, 96)
|
||||
var row_gap: int = _objects_here_integer("row_gap", 8, 2, 24)
|
||||
var header_gap: int = _objects_here_integer("header_gap", 8, 4, 24)
|
||||
var footer_gap: int = _objects_here_integer("footer_gap", 8, 4, 24)
|
||||
var cancel_height: int = _objects_here_integer("cancel_height", 48, 44, 96)
|
||||
var heading_font_size: int = _objects_here_integer("heading_font_size", 20, 16, 32)
|
||||
var fixed_content_height: float = float(heading_font_size + header_gap + cancel_height + footer_gap + content_inset * 2)
|
||||
var row_content_height: float = float(candidates.size() * row_height + maxi(0, candidates.size() - 1) * row_gap)
|
||||
var safe_popup_height: float = maxf(180.0, viewport_size.y - float(safe_top + safe_bottom))
|
||||
var minimum_rows_height: float = float(_objects_here_integer("minimum_rows_height", 116, 56, 240))
|
||||
var max_rows_height: float = minf(
|
||||
float(_objects_here_integer("max_rows_height", 320, 116, 640)),
|
||||
maxf(minimum_rows_height, safe_popup_height - fixed_content_height)
|
||||
)
|
||||
var rows_height: float = minf(max_rows_height, row_content_height)
|
||||
var popup_height: float = minf(safe_popup_height, fixed_content_height + rows_height)
|
||||
var guard: Control = Control.new()
|
||||
guard.name = "ObjectsHereInputGuard"
|
||||
guard.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
guard.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
guard.gui_input.connect(_on_objects_here_guard_input.bind(guard))
|
||||
parent.add_child(guard)
|
||||
_objects_here_guard = guard
|
||||
var popup: PopupPanel = PopupPanel.new()
|
||||
popup.name = "ObjectsHere"
|
||||
popup.exclusive = false
|
||||
@@ -250,67 +304,369 @@ func show_objects_here(
|
||||
popup.unfocusable = false
|
||||
if _sandbox_theme != null:
|
||||
popup.theme = _sandbox_theme.control_theme()
|
||||
var shell: StyleBoxFlat = _sandbox_theme.stylebox("popups.anchored_chooser") if _sandbox_theme != null else StyleBoxFlat.new()
|
||||
shell.set_corner_radius_all(_objects_here_integer("corner_radius", 14, 8, 24))
|
||||
shell.set_border_width_all(_objects_here_integer("border_width", 2, 1, 4))
|
||||
shell.bg_color = _objects_here_color("popups.anchored_chooser.bg_color", Color("#202633FA"))
|
||||
shell.border_color = _objects_here_color("popups.anchored_chooser.border_color", Color("#667386"))
|
||||
shell.shadow_size = _objects_here_integer("shadow_size", 6, 0, 24)
|
||||
shell.shadow_color = _objects_here_color("popups.anchored_chooser.shadow_color", Color("#00000080"))
|
||||
shell.shadow_offset = _objects_here_vector2("shadow_offset", Vector2(0.0, 4.0))
|
||||
# The child MarginContainer owns the content inset so the shell adds one clean gutter.
|
||||
shell.content_margin_left = 0.0
|
||||
shell.content_margin_right = 0.0
|
||||
shell.content_margin_top = 0.0
|
||||
shell.content_margin_bottom = 0.0
|
||||
popup.add_theme_stylebox_override("panel", shell)
|
||||
parent.add_child(popup)
|
||||
var margin: MarginContainer = MarginContainer.new()
|
||||
margin.name = "ObjectsHereMargin"
|
||||
for side: String in ["left", "right", "top", "bottom"]:
|
||||
margin.add_theme_constant_override("margin_" + side, 8)
|
||||
margin.add_theme_constant_override("margin_" + side, content_inset)
|
||||
popup.add_child(margin)
|
||||
var column: VBoxContainer = VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 4)
|
||||
column.name = "ObjectsHereColumn"
|
||||
column.add_theme_constant_override("separation", 0)
|
||||
margin.add_child(column)
|
||||
var heading: Label = Label.new()
|
||||
heading.text = "Objects here · Choose an object"
|
||||
heading.name = "ObjectsHereHeading"
|
||||
heading.text = "Objects here · Choose one"
|
||||
if _ui_font != null:
|
||||
heading.add_theme_font_override("font", _ui_font)
|
||||
heading.add_theme_font_size_override("font_size", heading_font_size)
|
||||
heading.add_theme_color_override("font_color", _objects_here_color("global.text_color", Color("#F5F7FA")))
|
||||
column.add_child(heading)
|
||||
var header_spacer: Control = Control.new()
|
||||
header_spacer.custom_minimum_size.y = float(header_gap)
|
||||
header_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
column.add_child(header_spacer)
|
||||
var scroll: ScrollContainer = ScrollContainer.new()
|
||||
scroll.name = "ObjectsHereScroll"
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.custom_minimum_size = Vector2(300.0, minf(360.0, float(candidates.size()) * 48.0))
|
||||
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED if row_content_height <= rows_height else ScrollContainer.SCROLL_MODE_AUTO
|
||||
scroll.custom_minimum_size = Vector2(float(content_width), rows_height)
|
||||
column.add_child(scroll)
|
||||
var rows: VBoxContainer = VBoxContainer.new()
|
||||
rows.name = "ObjectsHereRows"
|
||||
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
rows.add_theme_constant_override("separation", row_gap)
|
||||
scroll.add_child(rows)
|
||||
for node: Node2D in candidates:
|
||||
for candidate_index: int in candidates.size():
|
||||
var node: Node2D = candidates[candidate_index]
|
||||
if node == null or not is_instance_valid(node):
|
||||
continue
|
||||
var button: Button = Button.new()
|
||||
var selected_mark: String = "✓ " if selected_nodes.has(node) else ""
|
||||
button.text = selected_mark + _object_here_label(node)
|
||||
button.custom_minimum_size = Vector2(300.0, 44.0)
|
||||
button.name = "ObjectCandidate_%d" % candidate_index
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
button.custom_minimum_size = Vector2(float(content_width), float(row_height))
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_style_objects_here_candidate(button, selected_nodes.has(node))
|
||||
var row_content: HBoxContainer = HBoxContainer.new()
|
||||
row_content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var row_padding_x: float = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||
var row_padding_y: float = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||
row_content.offset_left = row_padding_x
|
||||
row_content.offset_top = row_padding_y
|
||||
row_content.offset_right = -row_padding_x
|
||||
row_content.offset_bottom = -row_padding_y
|
||||
row_content.add_theme_constant_override("separation", 8)
|
||||
row_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
button.add_child(row_content)
|
||||
var name_label: Label = Label.new()
|
||||
name_label.text = _object_here_label(node)
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
if _ui_font != null:
|
||||
name_label.add_theme_font_override("font", _ui_font)
|
||||
name_label.add_theme_font_size_override("font_size", _objects_here_font_size())
|
||||
row_content.add_child(name_label)
|
||||
if selected_nodes.has(node):
|
||||
row_content.add_child(_make_selected_candidate_badge())
|
||||
button.pressed.connect(_on_objects_here_selected.bind(node))
|
||||
button.mouse_entered.connect(_set_candidate_preview.bind(node))
|
||||
button.mouse_exited.connect(_clear_row_preview.bind(button, node))
|
||||
button.focus_entered.connect(_set_candidate_preview.bind(node))
|
||||
button.focus_exited.connect(_clear_row_preview.bind(button, node))
|
||||
rows.add_child(button)
|
||||
var popup_size: Vector2i = Vector2i(
|
||||
mini(360, maxi(260, int(viewport_size.x) - 16)),
|
||||
mini(420, maxi(120, int(viewport_size.y) - 16))
|
||||
var footer_spacer: Control = Control.new()
|
||||
footer_spacer.custom_minimum_size.y = float(footer_gap)
|
||||
footer_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
column.add_child(footer_spacer)
|
||||
var cancel: Button = Button.new()
|
||||
cancel.name = "CancelObjectsHere"
|
||||
cancel.text = "Cancel"
|
||||
cancel.custom_minimum_size = Vector2(
|
||||
minf(float(content_width), float(_objects_here_integer("cancel_width", 120, 88, 240))),
|
||||
float(cancel_height)
|
||||
)
|
||||
cancel.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
_style_objects_here_cancel(cancel)
|
||||
cancel.pressed.connect(_dismiss_objects_here)
|
||||
column.add_child(cancel)
|
||||
var popup_size: Vector2i = Vector2i(popup_width, int(round(popup_height)))
|
||||
popup.size = popup_size
|
||||
var position: Vector2i = Vector2i(pointer_position + Vector2(12.0, 12.0))
|
||||
position.x = clampi(position.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
|
||||
position.y = clampi(position.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
|
||||
var position: Vector2i = _objects_here_popup_position(pointer_position, viewport_size, popup_size, avoid_rects, edge_inset, safe_top, safe_bottom)
|
||||
_objects_here_popup = popup
|
||||
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
|
||||
popup.close_requested.connect(_dismiss_objects_here)
|
||||
popup.window_input.connect(_on_objects_here_window_input.bind(popup))
|
||||
popup.position = position
|
||||
popup.popup()
|
||||
popup.size_changed.connect(_fit_objects_here_popup_after_resize.bind(
|
||||
popup,
|
||||
pointer_position,
|
||||
viewport_size,
|
||||
avoid_rects.duplicate(),
|
||||
edge_inset,
|
||||
safe_top,
|
||||
safe_bottom
|
||||
))
|
||||
_fit_objects_here_popup_after_resize(popup, pointer_position, viewport_size, avoid_rects, edge_inset, safe_top, safe_bottom)
|
||||
cancel.call_deferred("grab_focus")
|
||||
|
||||
|
||||
func _objects_here_popup_position(
|
||||
pointer_position: Vector2,
|
||||
viewport_size: Vector2,
|
||||
popup_size: Vector2i,
|
||||
avoid_rects: Array[Rect2],
|
||||
edge_inset: int = 8,
|
||||
safe_top: int = 120,
|
||||
safe_bottom: int = 48
|
||||
) -> Vector2i:
|
||||
const POINTER_GAP: float = 12.0
|
||||
var maximum_x: int = maxi(edge_inset, int(viewport_size.x) - popup_size.x - edge_inset)
|
||||
var minimum_y: int = mini(safe_top, maxi(edge_inset, int(viewport_size.y) - popup_size.y - safe_bottom))
|
||||
var maximum_y: int = maxi(minimum_y, int(viewport_size.y) - popup_size.y - safe_bottom)
|
||||
var candidate_positions: Array[Vector2] = [
|
||||
pointer_position + Vector2(POINTER_GAP, POINTER_GAP),
|
||||
Vector2(pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.y + POINTER_GAP),
|
||||
Vector2(pointer_position.x + POINTER_GAP, pointer_position.y - float(popup_size.y) - POINTER_GAP),
|
||||
pointer_position - Vector2(float(popup_size.x) + POINTER_GAP, float(popup_size.y) + POINTER_GAP),
|
||||
]
|
||||
var x_edges: Array[float] = [float(edge_inset), float(maximum_x), pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.x + POINTER_GAP]
|
||||
var y_edges: Array[float] = [float(minimum_y), float(maximum_y), pointer_position.y - float(popup_size.y) - POINTER_GAP, pointer_position.y + POINTER_GAP]
|
||||
for avoid_rect: Rect2 in avoid_rects:
|
||||
if not avoid_rect.has_area():
|
||||
continue
|
||||
var centered_y: float = pointer_position.y - float(popup_size.y) * 0.5
|
||||
var centered_x: float = pointer_position.x - float(popup_size.x) * 0.5
|
||||
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, centered_y))
|
||||
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, centered_y))
|
||||
candidate_positions.append(Vector2(centered_x, avoid_rect.position.y - float(popup_size.y) - POINTER_GAP))
|
||||
candidate_positions.append(Vector2(centered_x, avoid_rect.end.y + POINTER_GAP))
|
||||
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, safe_top))
|
||||
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, safe_top))
|
||||
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, float(maximum_y)))
|
||||
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, float(maximum_y)))
|
||||
x_edges.append(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP)
|
||||
x_edges.append(avoid_rect.end.x + POINTER_GAP)
|
||||
y_edges.append(avoid_rect.position.y - float(popup_size.y) - POINTER_GAP)
|
||||
y_edges.append(avoid_rect.end.y + POINTER_GAP)
|
||||
for candidate_x: float in x_edges:
|
||||
for candidate_y: float in y_edges:
|
||||
candidate_positions.append(Vector2(candidate_x, candidate_y))
|
||||
var pointer_safety_rect: Rect2 = Rect2(pointer_position - Vector2(28.0, 28.0), Vector2(56.0, 56.0))
|
||||
var best_position: Vector2i = Vector2i(edge_inset, edge_inset)
|
||||
var best_overlap_score: float = INF
|
||||
for candidate: Vector2 in candidate_positions:
|
||||
var clamped_position: Vector2i = Vector2i(
|
||||
clampf(candidate.x, float(edge_inset), float(maximum_x)),
|
||||
clampf(candidate.y, float(minimum_y), float(maximum_y))
|
||||
)
|
||||
var popup_rect: Rect2 = Rect2(Vector2(clamped_position), Vector2(popup_size))
|
||||
var pointer_overlap_area: float = popup_rect.intersection(pointer_safety_rect).get_area()
|
||||
var avoid_overlap_area: float = 0.0
|
||||
for avoid_rect: Rect2 in avoid_rects:
|
||||
if avoid_rect.has_area():
|
||||
avoid_overlap_area += popup_rect.intersection(avoid_rect).get_area()
|
||||
var nearest_point: Vector2 = Vector2(
|
||||
clampf(pointer_position.x, popup_rect.position.x, popup_rect.end.x),
|
||||
clampf(pointer_position.y, popup_rect.position.y, popup_rect.end.y)
|
||||
)
|
||||
var overlap_score: float = pointer_overlap_area * 1000000.0 + avoid_overlap_area * 1000000.0 + nearest_point.distance_to(pointer_position)
|
||||
if overlap_score < best_overlap_score:
|
||||
best_overlap_score = overlap_score
|
||||
best_position = clamped_position
|
||||
return best_position
|
||||
|
||||
|
||||
func _fit_objects_here_popup_after_resize(
|
||||
popup: PopupPanel,
|
||||
pointer_position: Vector2,
|
||||
viewport_size: Vector2,
|
||||
avoid_rects: Array[Rect2],
|
||||
edge_inset: int,
|
||||
safe_top: int,
|
||||
safe_bottom: int
|
||||
) -> void:
|
||||
if popup == null or not is_instance_valid(popup) or not popup.visible:
|
||||
return
|
||||
var actual_size: Vector2i = popup.size
|
||||
var safe_position: Vector2i = _objects_here_popup_position(
|
||||
pointer_position,
|
||||
viewport_size,
|
||||
actual_size,
|
||||
avoid_rects,
|
||||
edge_inset,
|
||||
safe_top,
|
||||
safe_bottom
|
||||
)
|
||||
if popup.position != safe_position:
|
||||
popup.position = safe_position
|
||||
|
||||
|
||||
func _style_objects_here_candidate(button: Button, selected: bool) -> void:
|
||||
if button == null:
|
||||
return
|
||||
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
|
||||
var surface: Color = _objects_here_color("row_surface", Color("#171B24"))
|
||||
var selected_surface: Color = _objects_here_color("selected_surface", Color("#12394A"))
|
||||
var hover_surface: Color = _objects_here_color("hover_surface", Color("#203849"))
|
||||
if _sandbox_theme != null:
|
||||
accent = _sandbox_theme.color("mode_colors.edit", accent)
|
||||
button.add_theme_stylebox_override("normal", _objects_here_button_style(
|
||||
selected_surface if selected else surface,
|
||||
accent if selected else _objects_here_color("row_border_color", Color("#FFFFFF55")),
|
||||
_objects_here_integer("selected_border_width", 2, 1, 4) if selected else _objects_here_integer("row_border_width", 1, 0, 4),
|
||||
_objects_here_integer("row_corner_radius", 12, 4, 24)
|
||||
))
|
||||
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover_surface, accent, _objects_here_integer("hover_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||
button.add_theme_stylebox_override("pressed", _objects_here_button_style(selected_surface, accent, _objects_here_integer("selected_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||
button.add_theme_stylebox_override("focus", _objects_here_button_style(Color(0.0, 0.0, 0.0, 0.0), _objects_here_color("focus_color", accent), _objects_here_integer("focus_border_width", 3, 2, 5), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||
var text_color: Color = _objects_here_color("row_text_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_color", text_color)
|
||||
button.add_theme_color_override("font_hover_color", text_color)
|
||||
button.add_theme_color_override("font_pressed_color", text_color)
|
||||
button.add_theme_color_override("font_focus_color", text_color)
|
||||
button.add_theme_font_size_override("font_size", _objects_here_font_size())
|
||||
if _ui_font != null:
|
||||
button.add_theme_font_override("font", _ui_font)
|
||||
|
||||
|
||||
func _objects_here_button_style(background: Color, border: Color, border_width: int, corner_radius: int) -> StyleBoxFlat:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
style.border_color = border
|
||||
style.set_border_width_all(border_width)
|
||||
style.set_corner_radius_all(corner_radius)
|
||||
style.content_margin_left = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||
style.content_margin_right = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||
style.content_margin_top = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||
style.content_margin_bottom = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||
return style
|
||||
|
||||
|
||||
func _style_objects_here_cancel(button: Button) -> void:
|
||||
if button == null:
|
||||
return
|
||||
var normal: Color = _objects_here_color("cancel_surface", Color("#293242"))
|
||||
var hover: Color = _objects_here_color("cancel_hover_surface", Color("#354154"))
|
||||
var border: Color = _objects_here_color("cancel_border_color", Color("#718096"))
|
||||
var text_color: Color = _objects_here_color("cancel_text_color", Color("#F5F7FA"))
|
||||
var radius: int = _objects_here_integer("cancel_corner_radius", 10, 4, 24)
|
||||
var border_width: int = _objects_here_integer("cancel_border_width", 2, 1, 4)
|
||||
button.add_theme_stylebox_override("normal", _objects_here_button_style(normal, border, border_width, radius))
|
||||
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover, _objects_here_color("focus_color", border), border_width, radius))
|
||||
button.add_theme_stylebox_override("pressed", _objects_here_button_style(hover, border, border_width, radius))
|
||||
button.add_theme_stylebox_override("focus", _objects_here_button_style(normal, _objects_here_color("focus_color", border), _objects_here_integer("focus_border_width", 3, 2, 5), radius))
|
||||
button.add_theme_color_override("font_color", text_color)
|
||||
button.add_theme_color_override("font_hover_color", text_color)
|
||||
button.add_theme_color_override("font_pressed_color", text_color)
|
||||
button.add_theme_color_override("font_focus_color", text_color)
|
||||
button.add_theme_font_size_override("font_size", _objects_here_integer("cancel_font_size", 20, 16, 32))
|
||||
if _ui_font != null:
|
||||
button.add_theme_font_override("font", _ui_font)
|
||||
|
||||
|
||||
func _objects_here_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||
if _sandbox_theme == null:
|
||||
return clampi(fallback, minimum, maximum)
|
||||
return _sandbox_theme.integer("popups.anchored_chooser." + key, fallback, minimum, maximum)
|
||||
|
||||
|
||||
func _objects_here_color(path: String, fallback: Color) -> Color:
|
||||
if _sandbox_theme == null:
|
||||
return fallback
|
||||
var resolved_path: String = path if path.contains(".") else "popups.anchored_chooser." + path
|
||||
return _sandbox_theme.color(resolved_path, fallback)
|
||||
|
||||
|
||||
func _objects_here_vector2(key: String, fallback: Vector2) -> Vector2:
|
||||
if _sandbox_theme == null:
|
||||
return fallback
|
||||
var raw: Variant = _sandbox_theme.value("popups.anchored_chooser." + key, null)
|
||||
if raw is Array and (raw as Array).size() == 2:
|
||||
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
|
||||
return fallback
|
||||
|
||||
|
||||
func _make_selected_candidate_badge() -> PanelContainer:
|
||||
var badge: PanelContainer = PanelContainer.new()
|
||||
badge.name = "SelectedCandidateBadge"
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
|
||||
if _sandbox_theme != null:
|
||||
accent = _sandbox_theme.color("mode_colors.edit", accent)
|
||||
var badge_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
badge_style.bg_color = _objects_here_color("badge_surface", Color("#12394A"))
|
||||
badge_style.border_color = accent
|
||||
badge_style.set_border_width_all(1)
|
||||
badge_style.set_corner_radius_all(_objects_here_integer("badge_corner_radius", 8, 4, 20))
|
||||
badge_style.content_margin_left = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
|
||||
badge_style.content_margin_right = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
|
||||
badge_style.content_margin_top = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
|
||||
badge_style.content_margin_bottom = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
|
||||
badge.add_theme_stylebox_override("panel", badge_style)
|
||||
var label: Label = Label.new()
|
||||
label.text = "Selected"
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", _objects_here_integer("badge_font_size", 16, 12, 24))
|
||||
label.add_theme_color_override("font_color", _objects_here_color("badge_text_color", Color.WHITE))
|
||||
if _ui_font != null:
|
||||
label.add_theme_font_override("font", _ui_font)
|
||||
badge.add_child(label)
|
||||
return badge
|
||||
|
||||
|
||||
func _objects_here_font_size() -> int:
|
||||
return _objects_here_integer("row_font_size", 20, 14, 40)
|
||||
|
||||
|
||||
func get_objects_here_popup() -> PopupPanel:
|
||||
return _objects_here_popup
|
||||
|
||||
|
||||
func get_objects_here_screen_rect() -> Rect2:
|
||||
if _objects_here_popup == null or not is_instance_valid(_objects_here_popup) or not _objects_here_popup.visible:
|
||||
return Rect2()
|
||||
return Rect2(Vector2(_objects_here_popup.position), Vector2(_objects_here_popup.size))
|
||||
|
||||
|
||||
func dismiss_objects_here() -> void:
|
||||
_dismiss_objects_here()
|
||||
|
||||
|
||||
func _object_here_label(node: Node2D) -> String:
|
||||
if node is PropBlock:
|
||||
var prop: PropBlock = node as PropBlock
|
||||
var depth: String = "Behind characters" if String(prop.stage_roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else "In front of characters"
|
||||
var entry: Dictionary = PROP_LIBRARY_SCRIPT.get_entry(prop.spawn_id)
|
||||
var prop_name: String = String(entry.get("name", entry.get("label", prop.spawn_id)))
|
||||
return "📦 %s · %s" % [prop_name, depth]
|
||||
return "📦 %s" % String(node.get("stage_name"))
|
||||
if node is TerrainBlock:
|
||||
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
|
||||
if node is StickmanRig:
|
||||
return "🧍 %s · Stickman" % node.name
|
||||
return "🧍 %s" % String(node.get("stage_name"))
|
||||
if node.get_script() == MISSING_PROP_PLACEHOLDER_SCRIPT:
|
||||
return "📦 %s" % String(node.get("stage_name"))
|
||||
if node.get_script() == MISSING_STICKMAN_PLACEHOLDER_SCRIPT:
|
||||
return "🧍 %s" % String(node.get("stage_name"))
|
||||
return "🎯 %s" % node.name
|
||||
|
||||
|
||||
func _on_objects_here_selected(node: Node2D) -> void:
|
||||
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
|
||||
_dismiss_objects_here()
|
||||
return
|
||||
object_selected.emit(node)
|
||||
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||
_objects_here_popup.hide()
|
||||
@@ -319,10 +675,57 @@ func _on_objects_here_selected(node: Node2D) -> void:
|
||||
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
|
||||
if _objects_here_popup == popup:
|
||||
_objects_here_popup = null
|
||||
candidate_preview_changed.emit(null)
|
||||
_objects_here_preview = null
|
||||
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
|
||||
_objects_here_guard.queue_free()
|
||||
_objects_here_guard = null
|
||||
objects_here_dismissed.emit()
|
||||
if is_instance_valid(popup):
|
||||
popup.queue_free()
|
||||
|
||||
|
||||
func _set_candidate_preview(node: Node2D) -> void:
|
||||
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
|
||||
return
|
||||
_objects_here_preview = node
|
||||
candidate_preview_changed.emit(node)
|
||||
|
||||
|
||||
func _clear_row_preview(button: Button, node: Node2D) -> void:
|
||||
if button != null and is_instance_valid(button) and button.has_focus():
|
||||
return
|
||||
if _objects_here_preview == node:
|
||||
_objects_here_preview = null
|
||||
candidate_preview_changed.emit(null)
|
||||
|
||||
|
||||
func _dismiss_objects_here() -> void:
|
||||
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||
_objects_here_popup.hide()
|
||||
else:
|
||||
_on_objects_here_popup_closed(null)
|
||||
|
||||
|
||||
func _on_objects_here_guard_input(event: InputEvent, guard: Control) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
||||
guard.get_viewport().set_input_as_handled()
|
||||
_dismiss_objects_here()
|
||||
elif event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
guard.get_viewport().set_input_as_handled()
|
||||
_dismiss_objects_here()
|
||||
elif guard != null and is_instance_valid(guard):
|
||||
guard.get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _on_objects_here_window_input(event: InputEvent, _popup: PopupPanel) -> void:
|
||||
if not (event is InputEventKey) or not (event as InputEventKey).pressed or (event as InputEventKey).keycode != KEY_ESCAPE:
|
||||
return
|
||||
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
|
||||
_objects_here_guard.get_viewport().set_input_as_handled()
|
||||
_dismiss_objects_here()
|
||||
|
||||
|
||||
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
||||
if _sandbox_theme == null:
|
||||
return fallback
|
||||
|
||||
@@ -16,13 +16,11 @@ signal spot_move_requested(spot_id: String)
|
||||
signal spot_rename_requested(spot_id: String, name: String)
|
||||
signal spot_erase_requested(spot_id: String)
|
||||
signal spot_selected(spot_id: String)
|
||||
signal close_requested
|
||||
|
||||
signal rename_requested(node: Node2D, requested_name: String)
|
||||
var _stage_theme: SandboxTheme
|
||||
var _top_bar_height: float = 64.0
|
||||
var _status_bar_height: float = 38.0
|
||||
var _card: StageInspectorPanel
|
||||
var _reopen_button: Button
|
||||
var _fields: VBoxContainer
|
||||
var _notice: Label
|
||||
var _depth_group: StagePanelChoiceGroup
|
||||
@@ -32,7 +30,14 @@ var _controls: Dictionary = {}
|
||||
var _spot_rows: VBoxContainer
|
||||
var _spot_empty_label: Label
|
||||
var _active_selection_key: String = ""
|
||||
var _dismissed_selection_key: String = ""
|
||||
var _name_node: Node2D
|
||||
var _name_section: VBoxContainer
|
||||
var _name_edit: LineEdit
|
||||
var _name_done_button: Button
|
||||
var _name_cancel_button: Button
|
||||
var _name_feedback: Label
|
||||
var _name_editing: bool = false
|
||||
var _restore_collapsed_after_edit: bool = false
|
||||
|
||||
|
||||
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
|
||||
@@ -62,27 +67,28 @@ func show_prop(
|
||||
hide_card()
|
||||
return
|
||||
_update_selection_key(selection)
|
||||
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
||||
_card.visible = not dismissed
|
||||
_reopen_button.visible = dismissed
|
||||
if dismissed:
|
||||
return
|
||||
_card.visible = true
|
||||
_card.set_safe_rect(safe_rect)
|
||||
_notice.visible = false
|
||||
_fields.visible = true
|
||||
_card.set_header(title, "📦")
|
||||
_card.set_rename_enabled(true)
|
||||
if _name_node != prop:
|
||||
_name_node = prop
|
||||
_set_name_editing(false)
|
||||
_name_edit.text = prop.stage_name
|
||||
_name_feedback.visible = false
|
||||
_sync_roles(prop, roles)
|
||||
_sync_spots(prop.get_use_spots(), selected_spot_id)
|
||||
|
||||
|
||||
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
||||
_set_name_editing(false)
|
||||
_name_feedback.visible = false
|
||||
_update_selection_key(selection)
|
||||
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
||||
_card.visible = not dismissed
|
||||
_reopen_button.visible = dismissed
|
||||
if dismissed:
|
||||
return
|
||||
_card.visible = true
|
||||
_card.set_safe_rect(safe_rect)
|
||||
_card.set_rename_enabled(false)
|
||||
_notice.visible = true
|
||||
_fields.visible = false
|
||||
_card.set_header("Props", "📦")
|
||||
@@ -90,43 +96,25 @@ func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
||||
|
||||
|
||||
func hide_card(reset_selection: bool = false) -> void:
|
||||
if reset_selection:
|
||||
_set_name_editing(false)
|
||||
_name_feedback.visible = false
|
||||
if reset_selection and not _active_selection_key.is_empty():
|
||||
_update_selection_key([])
|
||||
if _card != null:
|
||||
_card.visible = false
|
||||
if _reopen_button != null:
|
||||
_reopen_button.visible = false
|
||||
|
||||
|
||||
func set_dismissed(dismissed: bool) -> void:
|
||||
if _active_selection_key.is_empty():
|
||||
return
|
||||
_dismissed_selection_key = _active_selection_key if dismissed else ""
|
||||
|
||||
|
||||
func update_layout(safe_rect: Rect2) -> void:
|
||||
if _card == null:
|
||||
return
|
||||
_card.set_safe_rect(safe_rect)
|
||||
if _reopen_button == null:
|
||||
return
|
||||
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
||||
var button_width: float = minf(148.0, maxf(1.0, safe_rect.size.x - inset * 2.0))
|
||||
var button_height: float = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
|
||||
_reopen_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||
_reopen_button.custom_minimum_size.x = button_width
|
||||
_reopen_button.position = Vector2(safe_rect.end.x - inset - button_width, safe_rect.position.y + inset)
|
||||
_reopen_button.size = Vector2(button_width, button_height)
|
||||
|
||||
|
||||
func get_card() -> Control:
|
||||
return _card
|
||||
|
||||
|
||||
func get_reopen_button() -> Button:
|
||||
return _reopen_button
|
||||
|
||||
|
||||
func get_fields() -> VBoxContainer:
|
||||
return _fields
|
||||
|
||||
@@ -151,10 +139,6 @@ func get_active_selection_key() -> String:
|
||||
return _active_selection_key
|
||||
|
||||
|
||||
func get_dismissed_selection_key() -> String:
|
||||
return _dismissed_selection_key
|
||||
|
||||
|
||||
func focus_card() -> void:
|
||||
_focus_card()
|
||||
|
||||
@@ -165,12 +149,15 @@ func _build() -> void:
|
||||
_card.visible = false
|
||||
add_child(_card)
|
||||
_card.apply_theme(_stage_theme)
|
||||
_card.close_requested.connect(_on_card_close_requested)
|
||||
_card.set_collapsible(true)
|
||||
_card.set_rename_enabled(false)
|
||||
_card.rename_requested.connect(_start_name_edit)
|
||||
_card.collapsed_changed.connect(_on_card_collapsed_changed)
|
||||
var body: VBoxContainer = _card.body()
|
||||
body.add_theme_constant_override("separation", 10)
|
||||
|
||||
_notice = Label.new()
|
||||
_notice.text = "Select one prop to change its settings."
|
||||
_notice.text = "Select one character or prop to change its name."
|
||||
_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_notice.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 20, 14, 40))
|
||||
_notice.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
|
||||
@@ -182,6 +169,7 @@ func _build() -> void:
|
||||
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
|
||||
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
body.add_child(_fields)
|
||||
_build_name_section(_fields)
|
||||
_add_heading(_fields, "Where it appears")
|
||||
_depth_group = _make_choice_group("depth")
|
||||
_fields.add_child(_depth_group)
|
||||
@@ -198,15 +186,114 @@ func _build() -> void:
|
||||
_add_wobble_section()
|
||||
_add_use_spots_section()
|
||||
|
||||
_reopen_button = Button.new()
|
||||
_reopen_button.name = "ReopenPropSettings"
|
||||
_reopen_button.text = "Settings"
|
||||
_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||
_reopen_button.focus_mode = Control.FOCUS_ALL
|
||||
_reopen_button.visible = false
|
||||
_reopen_button.pressed.connect(_on_reopen_pressed)
|
||||
_card.style_action_button(_reopen_button, true)
|
||||
add_child(_reopen_button)
|
||||
func _build_name_section(parent: VBoxContainer) -> void:
|
||||
_name_section = VBoxContainer.new()
|
||||
_name_section.name = "StageNameEditor"
|
||||
_name_section.add_theme_constant_override("separation", 6)
|
||||
_name_section.visible = false
|
||||
parent.add_child(_name_section)
|
||||
_name_edit = LineEdit.new()
|
||||
_name_edit.name = "StageNameEdit"
|
||||
_name_edit.placeholder_text = "Character or prop name"
|
||||
_name_edit.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
|
||||
_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_name_edit.visible = false
|
||||
_name_edit.text_submitted.connect(func(_value: String) -> void: _submit_name_edit())
|
||||
_name_edit.gui_input.connect(_on_name_edit_gui_input)
|
||||
_apply_ui_font(_name_edit)
|
||||
_name_section.add_child(_name_edit)
|
||||
_name_feedback = Label.new()
|
||||
_name_feedback.name = "StageNameFeedback"
|
||||
_name_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_name_feedback.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||
_name_feedback.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||
_name_feedback.visible = false
|
||||
_apply_ui_font(_name_feedback)
|
||||
_name_section.add_child(_name_feedback)
|
||||
var actions: HBoxContainer = HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 8)
|
||||
_name_section.add_child(actions)
|
||||
_name_done_button = Button.new()
|
||||
_name_done_button.name = "DoneStageName"
|
||||
_name_done_button.text = "Done"
|
||||
_name_done_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||
_name_done_button.visible = false
|
||||
_name_done_button.pressed.connect(_submit_name_edit)
|
||||
_card.style_primary_action_button(_name_done_button)
|
||||
actions.add_child(_name_done_button)
|
||||
_name_cancel_button = Button.new()
|
||||
_name_cancel_button.name = "CancelStageName"
|
||||
_name_cancel_button.text = "Cancel"
|
||||
_name_cancel_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||
_name_cancel_button.visible = false
|
||||
_name_cancel_button.pressed.connect(_cancel_name_edit)
|
||||
_card.style_action_button(_name_cancel_button, false)
|
||||
actions.add_child(_name_cancel_button)
|
||||
|
||||
|
||||
func show_name_result(result: Dictionary) -> void:
|
||||
if bool(result.get("ok", false)):
|
||||
_name_edit.text = String(result.get("name", ""))
|
||||
_name_feedback.visible = false
|
||||
_set_name_editing(false)
|
||||
else:
|
||||
var reason: StringName = StringName(String(result.get("reason", "")))
|
||||
var suggestion: String = String(result.get("suggestion", ""))
|
||||
if reason == &"blank":
|
||||
_name_feedback.text = "Give this one a name. Try %s." % suggestion
|
||||
else:
|
||||
_name_feedback.text = "%s is already here. Try %s." % [String(result.get("name", "")), suggestion]
|
||||
_name_feedback.visible = true
|
||||
_card.request_fit()
|
||||
if not bool(result.get("ok", false)):
|
||||
_name_edit.call_deferred("grab_focus")
|
||||
_name_edit.call_deferred("select_all")
|
||||
|
||||
|
||||
func _start_name_edit() -> void:
|
||||
if _name_node == null or not is_instance_valid(_name_node):
|
||||
return
|
||||
_restore_collapsed_after_edit = _card.is_collapsed()
|
||||
_card.set_collapsed(false)
|
||||
_name_edit.text = String(_name_node.get("stage_name"))
|
||||
_name_feedback.visible = false
|
||||
_set_name_editing(true)
|
||||
_name_edit.call_deferred("grab_focus")
|
||||
_name_edit.call_deferred("select_all")
|
||||
_card.request_fit()
|
||||
|
||||
|
||||
func _set_name_editing(editing: bool) -> void:
|
||||
_name_editing = editing
|
||||
if _name_section == null:
|
||||
return
|
||||
_name_section.visible = editing
|
||||
_name_edit.visible = editing
|
||||
_name_done_button.visible = editing
|
||||
_name_cancel_button.visible = editing
|
||||
if not editing and _restore_collapsed_after_edit and _card != null:
|
||||
_restore_collapsed_after_edit = false
|
||||
_card.set_collapsed(true)
|
||||
|
||||
|
||||
func _submit_name_edit() -> void:
|
||||
if _name_node == null or not is_instance_valid(_name_node):
|
||||
return
|
||||
rename_requested.emit(_name_node, _name_edit.text)
|
||||
|
||||
|
||||
func _cancel_name_edit() -> void:
|
||||
if _name_node != null and is_instance_valid(_name_node):
|
||||
_name_edit.text = String(_name_node.get("stage_name"))
|
||||
_name_feedback.visible = false
|
||||
_set_name_editing(false)
|
||||
_card.request_fit()
|
||||
|
||||
|
||||
func _on_name_edit_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
_cancel_name_edit()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _add_use_spots_section() -> void:
|
||||
@@ -449,7 +536,12 @@ func _update_selection_key(selection: Array[Node2D]) -> void:
|
||||
if selection_key == _active_selection_key:
|
||||
return
|
||||
_active_selection_key = selection_key
|
||||
_dismissed_selection_key = ""
|
||||
if _name_editing:
|
||||
_name_feedback.visible = false
|
||||
_restore_collapsed_after_edit = false
|
||||
_set_name_editing(false)
|
||||
if _card != null:
|
||||
_card.set_collapsed(false, false)
|
||||
_card.reset_scroll()
|
||||
|
||||
|
||||
@@ -481,17 +573,11 @@ func _on_row_height_changed() -> void:
|
||||
_card.request_fit()
|
||||
|
||||
|
||||
func _on_card_close_requested() -> void:
|
||||
close_requested.emit()
|
||||
|
||||
|
||||
func _on_reopen_pressed() -> void:
|
||||
var should_focus: bool = _reopen_button.has_focus()
|
||||
set_dismissed(false)
|
||||
_card.visible = true
|
||||
_reopen_button.visible = false
|
||||
if should_focus:
|
||||
call_deferred("_focus_card")
|
||||
func _on_card_collapsed_changed(collapsed: bool) -> void:
|
||||
if collapsed and _name_editing:
|
||||
_name_feedback.visible = false
|
||||
_restore_collapsed_after_edit = false
|
||||
_set_name_editing(false)
|
||||
|
||||
|
||||
func _focus_card() -> void:
|
||||
@@ -502,7 +588,7 @@ func _focus_card() -> void:
|
||||
func _on_viewport_resized() -> void:
|
||||
var viewport: Rect2 = get_viewport_rect()
|
||||
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
||||
var top_offset: float = minf(_top_bar_height + 116.0, maxf(12.0, viewport.size.y - 220.0))
|
||||
var top_offset: float = minf(_top_bar_height + 120.0, maxf(12.0, viewport.size.y - 220.0))
|
||||
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - _status_bar_height - 12.0)
|
||||
var safe_rect := Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
|
||||
update_layout(safe_rect)
|
||||
|
||||
@@ -87,10 +87,12 @@ func collect(
|
||||
entry["rotation"] = rotation_degrees
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
|
||||
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
||||
entry["stage_name"] = String(stage_node.get("stage_name"))
|
||||
elif type == "prop":
|
||||
var prop: PropBlock = stage_node as PropBlock
|
||||
entry["asset_id"] = prop.spawn_id
|
||||
entry["stage_object_id"] = prop.stage_object_id
|
||||
entry["stage_name"] = prop.stage_name
|
||||
entry["roles"] = prop.stage_roles.duplicate(true)
|
||||
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
|
||||
entry["next_use_spot_number"] = prop.next_use_spot_number
|
||||
@@ -109,13 +111,17 @@ func collect(
|
||||
entry["type"] = "stickman"
|
||||
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
|
||||
entry["rotation"] = rotation_degrees
|
||||
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
||||
entry["stage_name"] = String(stage_node.get("stage_name"))
|
||||
else:
|
||||
var rig: StickmanRig = stage_node as StickmanRig
|
||||
entry["stk_path"] = rig.source_stk_path
|
||||
entry["stage_object_id"] = rig.stage_object_id
|
||||
entry["stage_name"] = rig.stage_name
|
||||
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
|
||||
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
|
||||
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
||||
entry["action_queue"] = queue_result["actions"]
|
||||
var plan_result: Dictionary = STAGE_SCENE_IO.encode_plan(rig.get_action_plan())
|
||||
entry["action_plan"] = plan_result["steps"]
|
||||
if type == "stickman" and entry.has("stk_path"):
|
||||
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
|
||||
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
|
||||
@@ -248,6 +254,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
|
||||
if node is PropBlock:
|
||||
var prop: PropBlock = node as PropBlock
|
||||
prop.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||
prop.stage_name = String(entry.get("stage_name", ""))
|
||||
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false)))
|
||||
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
|
||||
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
|
||||
@@ -270,22 +277,23 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
|
||||
else:
|
||||
warnings.append("Invalid prop material preset")
|
||||
if node is StickmanRig:
|
||||
var rig: StickmanRig = node as StickmanRig
|
||||
rig.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||
rig.stage_name = String(entry.get("stage_name", ""))
|
||||
var facing_names: Array = StickmanRig.FacingProfile.keys()
|
||||
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
|
||||
if facing_index >= 0:
|
||||
(node as StickmanRig).set_facing_profile(facing_index)
|
||||
var raw_queue: Variant = entry.get("action_queue", [])
|
||||
if raw_queue is Array:
|
||||
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
|
||||
for warning: String in queue.get("warnings", []):
|
||||
var character_name: String = String(entry.get("stk_path", "")).get_file()
|
||||
if character_name.is_empty():
|
||||
character_name = file_id
|
||||
warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning])
|
||||
for action: Dictionary in queue["actions"]:
|
||||
(node as StickmanRig).queue_action(action)
|
||||
var raw_plan: Variant = entry.get("action_plan", [])
|
||||
if raw_plan is Array:
|
||||
var decoded_plan: Dictionary = STAGE_SCENE_IO.decode_plan(raw_plan as Array)
|
||||
if bool(decoded_plan.get("ok", false)):
|
||||
if not (node as StickmanRig).set_action_plan(decoded_plan.get("steps", [])):
|
||||
warnings.append("%s: the action plan could not be restored." % String(entry.get("stage_name", file_id)))
|
||||
else:
|
||||
warnings.append("%s: %s" % [String(entry.get("stage_name", file_id)), String(decoded_plan.get("error", "Invalid action plan."))])
|
||||
else:
|
||||
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
|
||||
warnings.append("%s: invalid action plan." % String(entry.get("stage_name", file_id)))
|
||||
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
|
||||
loaded_nodes.append(node)
|
||||
if node is TerrainBlock:
|
||||
|
||||
@@ -4,7 +4,7 @@ extends RefCounted
|
||||
|
||||
const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||
|
||||
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
|
||||
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, PLAN_PLACE, PLAN_PROP, PICKUP_PROP, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
|
||||
|
||||
var kind: int = Kind.NONE
|
||||
var hint: String = ""
|
||||
@@ -31,6 +31,14 @@ var use_target_preview_prefix: Array[Dictionary] = []
|
||||
var use_target_prefix_known: bool = false
|
||||
|
||||
|
||||
static func normalize_plan_destination(value: Variant) -> Dictionary:
|
||||
if value is Vector2:
|
||||
return {"kind": "place", "position": value}
|
||||
if value is Dictionary:
|
||||
return (value as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func is_stage_custom_capture() -> bool:
|
||||
return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT]
|
||||
|
||||
@@ -152,10 +160,40 @@ func resolve_target(world_pos: Vector2, selection: StageSelection, director_visu
|
||||
var prop_hit: Node2D = selection.hit_test(world_pos)
|
||||
if prop_hit is PropBlock:
|
||||
value = prop_hit
|
||||
Kind.PICKUP_PROP:
|
||||
var pickup_hit: PropBlock = null
|
||||
var ineligible_hit: PropBlock = null
|
||||
for candidate: Node2D in selection.hit_test_all(world_pos):
|
||||
if candidate is PropBlock and (candidate as PropBlock).can_be_picked_up():
|
||||
pickup_hit = candidate as PropBlock
|
||||
break
|
||||
elif candidate is PropBlock and ineligible_hit == null:
|
||||
ineligible_hit = candidate as PropBlock
|
||||
if pickup_hit == null:
|
||||
var reason: String = "Click a highlighted prop to pick it up."
|
||||
if ineligible_hit != null:
|
||||
reason = "This prop needs pickup enabled and a grip in Edit."
|
||||
return {"valid": false, "value": null, "reason": reason}
|
||||
value = pickup_hit
|
||||
Kind.STICKMAN:
|
||||
var stickman_hit: Node2D = selection.hit_test(world_pos)
|
||||
if stickman_hit is StickmanRig:
|
||||
value = stickman_hit
|
||||
Kind.PLAN_PROP:
|
||||
var plan_prop_hit: Node2D = selection.hit_test(world_pos)
|
||||
if plan_prop_hit is PropBlock:
|
||||
var prop: PropBlock = plan_prop_hit as PropBlock
|
||||
var approach: Vector2 = world_pos
|
||||
value = {
|
||||
"kind": "prop",
|
||||
"target_prop_id": prop.stage_object_id,
|
||||
"approach_offset": approach - prop.global_position,
|
||||
"last_known_approach": approach,
|
||||
}
|
||||
Kind.PLAN_PLACE:
|
||||
var place_hit: Node2D = selection.hit_test(world_pos)
|
||||
if place_hit == null or (not place_hit is PropBlock and not place_hit is StickmanRig and not place_hit is TriggerArea):
|
||||
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
|
||||
Kind.POSITION, Kind.THROW_POSITION:
|
||||
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
|
||||
Kind.SET_DOWN_POSITION:
|
||||
|
||||
@@ -302,11 +302,16 @@ func _draw_pill(center: Vector2, text_value: String, accent: Color) -> void:
|
||||
if text_value.is_empty():
|
||||
return
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var text_size: Vector2 = font.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16)
|
||||
var rect := Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0))
|
||||
var lines: PackedStringArray = text_value.split("\n", false)
|
||||
var text_width: float = 0.0
|
||||
for line: String in lines:
|
||||
text_width = maxf(text_width, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, 16).x)
|
||||
var rect_size: Vector2 = Vector2(text_width + _pill_padding * 2.0, maxf(26.0, float(lines.size()) * 18.0 + 8.0))
|
||||
var rect := Rect2(center - rect_size * 0.5, rect_size)
|
||||
draw_rect(rect, Color("#171B24"), true)
|
||||
draw_rect(rect, accent, false, 2.0)
|
||||
draw_string(font, rect.position + Vector2(10.0, 18.0), text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
|
||||
for index: int in lines.size():
|
||||
draw_string(font, rect.position + Vector2(10.0, 17.0 + index * 18.0), lines[index], HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
|
||||
|
||||
|
||||
func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void:
|
||||
|
||||
@@ -7,6 +7,7 @@ signal change_prop_requested
|
||||
signal capture_action_requested(action_id: StringName)
|
||||
|
||||
const MAX_BAR_WIDTH: float = 800.0
|
||||
const PLAN_CAPTURE_MAX_WIDTH: float = 440.0
|
||||
const SCENERY_CAPTURE_MAX_WIDTH: float = 900.0
|
||||
const VIEWPORT_WIDTH_RATIO: float = 0.68
|
||||
const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
|
||||
@@ -53,6 +54,8 @@ var _safe_left: float = 0.0
|
||||
var _safe_right: float = 0.0
|
||||
var _scenery_edit_active: bool = false
|
||||
var _scenery_capture_active: bool = false
|
||||
var _plan_capture_active: bool = false
|
||||
var _plan_target_cancel_only: bool = false
|
||||
|
||||
|
||||
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
|
||||
@@ -73,6 +76,9 @@ func _ready() -> void:
|
||||
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
|
||||
if _prompt == null:
|
||||
return
|
||||
_plan_capture_active = false
|
||||
_plan_target_cancel_only = false
|
||||
_prompt.visible = true
|
||||
_show_standard_capture_layout()
|
||||
_clear_custom_actions()
|
||||
if not prop_selected:
|
||||
@@ -88,10 +94,20 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) ->
|
||||
|
||||
|
||||
func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
|
||||
_plan_capture_active = true
|
||||
_plan_target_cancel_only = true
|
||||
_set_capture_actions(prompt, actions, false)
|
||||
|
||||
|
||||
func set_target_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
|
||||
_plan_capture_active = false
|
||||
_plan_target_cancel_only = false
|
||||
_set_capture_actions(prompt, actions, false)
|
||||
|
||||
|
||||
func set_scenery_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
|
||||
_plan_capture_active = false
|
||||
_plan_target_cancel_only = false
|
||||
_set_capture_actions(prompt, actions, true)
|
||||
|
||||
|
||||
@@ -100,6 +116,7 @@ func _set_capture_actions(prompt: String, actions: Array[Dictionary], scenery_ca
|
||||
return
|
||||
_show_standard_capture_layout()
|
||||
_prompt.text = prompt
|
||||
_prompt.visible = not _plan_target_cancel_only
|
||||
_change_prop_button.visible = false
|
||||
_cancel_button.visible = false
|
||||
_clear_custom_actions()
|
||||
@@ -160,6 +177,9 @@ func set_scenery_edit_state(selected_count: int) -> void:
|
||||
return
|
||||
_scenery_capture_active = false
|
||||
_scenery_edit_active = true
|
||||
_plan_capture_active = false
|
||||
_plan_target_cancel_only = false
|
||||
_prompt.visible = true
|
||||
_clear_custom_actions()
|
||||
if _active_layout.get_parent() == self:
|
||||
_active_layout.visible = false
|
||||
@@ -198,6 +218,9 @@ func clear_horizontal_safe_bounds() -> void:
|
||||
|
||||
func hide_capture() -> void:
|
||||
visible = false
|
||||
_plan_capture_active = false
|
||||
_plan_target_cancel_only = false
|
||||
_prompt.visible = true
|
||||
_show_standard_capture_layout()
|
||||
|
||||
|
||||
@@ -245,7 +268,10 @@ func apply_theme() -> void:
|
||||
if body_font != null:
|
||||
button.add_theme_font_override("font", body_font)
|
||||
button.add_theme_font_size_override("font_size", body_size)
|
||||
_style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done"))
|
||||
if _plan_target_cancel_only:
|
||||
_style_plan_target_cancel_button(button)
|
||||
else:
|
||||
_style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done"))
|
||||
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
|
||||
_change_prop_button.custom_minimum_size.y = button_height
|
||||
_cancel_button.custom_minimum_size.y = button_height
|
||||
@@ -628,6 +654,23 @@ func _style_button(button: Button, prominent: bool) -> void:
|
||||
button.add_theme_color_override("font_focus_color", text_color)
|
||||
|
||||
|
||||
func _style_plan_target_cancel_button(button: Button) -> void:
|
||||
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
|
||||
var surface: Color = _color("stage_panels.action.surface_color", Color("#171B24"))
|
||||
var hover: Color = _color("stage_panels.action.hover_surface", Color("#303A4B"))
|
||||
var pressed: Color = _color("stage_panels.action.selected_surface", Color("#12394A"))
|
||||
var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA"))
|
||||
button.add_theme_stylebox_override("normal", _button_style(surface, accent, 2))
|
||||
button.add_theme_stylebox_override("hover", _button_style(hover, accent.lightened(0.1), 2))
|
||||
button.add_theme_stylebox_override("pressed", _button_style(pressed, accent, 2))
|
||||
button.add_theme_stylebox_override("hover_pressed", _button_style(hover, accent.lightened(0.1), 2))
|
||||
button.add_theme_stylebox_override("focus", _button_style(surface, accent, 3))
|
||||
button.add_theme_color_override("font_color", text_color)
|
||||
button.add_theme_color_override("font_hover_color", text_color)
|
||||
button.add_theme_color_override("font_pressed_color", text_color)
|
||||
button.add_theme_color_override("font_focus_color", text_color)
|
||||
|
||||
|
||||
func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
@@ -651,6 +694,9 @@ func _layout_capture_bar() -> void:
|
||||
if _scenery_capture_active:
|
||||
_layout_scenery_capture_bar(viewport_size)
|
||||
return
|
||||
if _plan_target_cancel_only:
|
||||
_layout_plan_target_cancel_button(viewport_size)
|
||||
return
|
||||
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
|
||||
var safe_left: float = inset
|
||||
var safe_right: float = viewport_size.x - inset
|
||||
@@ -658,25 +704,59 @@ func _layout_capture_bar() -> void:
|
||||
safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset))
|
||||
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
|
||||
var available_width: float = maxf(1.0, safe_right - safe_left)
|
||||
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
|
||||
var maximum_width: float = PLAN_CAPTURE_MAX_WIDTH if _plan_capture_active else MAX_BAR_WIDTH
|
||||
var width: float = minf(maximum_width, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
|
||||
var narrow_compact: bool = width < NARROW_COMPACT_WIDTH
|
||||
custom_minimum_size.x = width
|
||||
if _plan_capture_active:
|
||||
custom_minimum_size.y = 0.0
|
||||
size.x = width
|
||||
clip_contents = true
|
||||
_prompt.add_theme_font_size_override("font_size", 18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34))
|
||||
_prompt.max_lines_visible = 2 if narrow_compact else 3
|
||||
_prompt.add_theme_font_size_override("font_size", 19 if _plan_capture_active else (18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34)))
|
||||
_prompt.max_lines_visible = 2 if _plan_capture_active or narrow_compact else 3
|
||||
_set_compact_panel_style(narrow_compact)
|
||||
if _plan_capture_active:
|
||||
_set_plan_capture_panel_style()
|
||||
var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left
|
||||
position = Vector2(left, _top_bar_height + 56.0)
|
||||
var many_actions: bool = _custom_action_buttons.size() > 3
|
||||
var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0)
|
||||
var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)
|
||||
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped)
|
||||
var stack_actions: bool = false if _plan_capture_active else (width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0))
|
||||
var wrapped: bool = false if _plan_capture_active else ((_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions))
|
||||
var compact_layout: bool = false if _plan_capture_active else (width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped)
|
||||
_set_layout(compact_layout, stack_actions, wrapped)
|
||||
_fit_custom_action_widths(width, wrapped)
|
||||
_apply_compact_action_labels(narrow_compact)
|
||||
call_deferred("_fit_height_to_content")
|
||||
|
||||
|
||||
func _layout_plan_target_cancel_button(viewport_size: Vector2) -> void:
|
||||
if _custom_action_buttons.is_empty():
|
||||
return
|
||||
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
|
||||
var safe_left: float = inset
|
||||
var safe_right: float = viewport_size.x - inset
|
||||
if _has_horizontal_safe_bounds:
|
||||
safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset))
|
||||
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
|
||||
var button: Button = _custom_action_buttons[0]
|
||||
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
|
||||
var available_width: float = maxf(1.0, safe_right - safe_left)
|
||||
var button_width: float = minf(148.0, available_width)
|
||||
button.custom_minimum_size = Vector2(button_width, button_height)
|
||||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
button.add_theme_font_size_override("font_size", 19)
|
||||
_prompt.visible = false
|
||||
_set_layout(false, false)
|
||||
_actions_row.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
_actions_row.add_theme_constant_override("separation", 0)
|
||||
custom_minimum_size = Vector2(button_width, 0.0)
|
||||
size = Vector2(button_width, button_height)
|
||||
position = Vector2(safe_left, _top_bar_height + 56.0)
|
||||
clip_contents = false
|
||||
_set_plan_capture_panel_style()
|
||||
call_deferred("_fit_height_to_content")
|
||||
|
||||
|
||||
func _layout_scenery_edit_capture_bar(viewport_size: Vector2) -> void:
|
||||
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
|
||||
var safe_left: float = inset
|
||||
@@ -871,14 +951,19 @@ func _fit_custom_action_widths(width: float, wrapped: bool) -> void:
|
||||
var button_width: float = ACTION_BUTTON_WIDTH
|
||||
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
|
||||
var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0))
|
||||
if _plan_capture_active:
|
||||
button_width = 124.0
|
||||
action_font_size = 18
|
||||
if width < NARROW_COMPACT_WIDTH:
|
||||
button_width = 0.0
|
||||
button_width = 124.0 if _plan_capture_active else 0.0
|
||||
action_font_size = 16
|
||||
if wrapped and not _custom_action_buttons.is_empty():
|
||||
var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12)
|
||||
button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size())))
|
||||
for button: Button in _custom_action_buttons:
|
||||
button.custom_minimum_size.x = button_width
|
||||
if _plan_capture_active:
|
||||
button.custom_minimum_size.y = 48.0
|
||||
button.add_theme_font_size_override("font_size", action_font_size)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width < NARROW_COMPACT_WIDTH else Control.SIZE_FILL
|
||||
|
||||
@@ -918,9 +1003,32 @@ func _set_compact_panel_style(compact: bool) -> void:
|
||||
panel_style.content_margin_bottom = 8.0 if compact else 14.0
|
||||
|
||||
|
||||
func _set_plan_capture_panel_style() -> void:
|
||||
if _plan_target_cancel_only:
|
||||
var cancel_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
cancel_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||
cancel_style.set_border_width_all(0)
|
||||
cancel_style.content_margin_left = 0.0
|
||||
cancel_style.content_margin_right = 0.0
|
||||
cancel_style.content_margin_top = 0.0
|
||||
cancel_style.content_margin_bottom = 0.0
|
||||
add_theme_stylebox_override("panel", cancel_style)
|
||||
return
|
||||
var panel_style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat
|
||||
if panel_style == null:
|
||||
return
|
||||
panel_style.border_width_left = 4
|
||||
panel_style.content_margin_left = 10.0
|
||||
panel_style.content_margin_right = 10.0
|
||||
panel_style.content_margin_top = 8.0
|
||||
panel_style.content_margin_bottom = 8.0
|
||||
|
||||
|
||||
func _fit_height_to_content() -> void:
|
||||
if not is_inside_tree() or not visible:
|
||||
return
|
||||
if _plan_capture_active:
|
||||
custom_minimum_size.y = 0.0
|
||||
var target_height: float = _active_layout.get_combined_minimum_size().y
|
||||
var panel_style: StyleBox = get_theme_stylebox("panel")
|
||||
if panel_style != null:
|
||||
|
||||
@@ -64,7 +64,7 @@ const ZOOM_STEP: float = 1.10
|
||||
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@onready var drawing_area: Control = %DrawingArea
|
||||
@onready var context_menu: PopupMenu = %ContextMenu
|
||||
@onready var context_menu: EditorContextMenu = %ContextMenu
|
||||
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
|
||||
@onready var color_picker: ColorPicker = %ColorPicker
|
||||
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
|
||||
@@ -127,6 +127,8 @@ func _ready() -> void:
|
||||
cancel_button.pressed.connect(_on_color_picker_cancel)
|
||||
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
|
||||
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
|
||||
if _editor_visual_theme != null:
|
||||
context_menu.configure(_editor_visual_theme, &"stickman")
|
||||
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
|
||||
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
|
||||
cancel_button.text = "Cancel"
|
||||
@@ -298,6 +300,8 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
|
||||
_hint_color = hint_color
|
||||
_button_target_height = button_target_height
|
||||
_editor_visual_theme = editor_visual_theme
|
||||
if context_menu != null and _editor_visual_theme != null:
|
||||
context_menu.configure(_editor_visual_theme, &"stickman")
|
||||
if drawing_area != null:
|
||||
drawing_area.queue_redraw()
|
||||
|
||||
@@ -421,6 +425,24 @@ func _screen_to_world(pos: Vector2) -> Vector2:
|
||||
return (pos - _pan_offset) / _zoom
|
||||
|
||||
|
||||
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
|
||||
var screen_pos := drawing_area.global_position + _pan_offset + world_pos * _zoom
|
||||
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
|
||||
|
||||
|
||||
func _global_shape_target_rect(sd: Dictionary) -> Rect2:
|
||||
var points: PackedVector2Array = sd.get("points", PackedVector2Array())
|
||||
if points.is_empty():
|
||||
return Rect2()
|
||||
var min_point := points[0]
|
||||
var max_point := points[0]
|
||||
for point: Vector2 in points:
|
||||
min_point = min_point.min(point)
|
||||
max_point = max_point.max(point)
|
||||
var screen_position := drawing_area.global_position + _pan_offset + min_point * _zoom
|
||||
return Rect2(screen_position, (max_point - min_point) * _zoom).grow(4.0)
|
||||
|
||||
|
||||
func _snap_to_grid(pos: Vector2) -> Vector2:
|
||||
var gs := float(_grid_size)
|
||||
if gs <= 0.0:
|
||||
@@ -638,9 +660,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
if hit_idx >= 0:
|
||||
_context_shape_idx = _selected_shape_idx
|
||||
context_menu.clear()
|
||||
context_menu.add_item("Remove Point", 6)
|
||||
context_menu.add_destructive_item("Remove Point", 6)
|
||||
context_menu.set_meta("vertex_index", hit_idx)
|
||||
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
|
||||
return
|
||||
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
@@ -651,9 +673,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
if hit_idx >= 0:
|
||||
_context_shape_idx = i
|
||||
context_menu.clear()
|
||||
context_menu.add_item("Remove Point", 6)
|
||||
context_menu.add_destructive_item("Remove Point", 6)
|
||||
context_menu.set_meta("vertex_index", hit_idx)
|
||||
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
|
||||
return
|
||||
|
||||
# Step 2: Check if right-click is over any shape (reverse order = front first).
|
||||
@@ -677,22 +699,22 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
context_menu.add_item("Create Point", 3)
|
||||
context_menu.add_separator()
|
||||
|
||||
context_menu.add_item("Color...", 4)
|
||||
context_menu.add_item("Change Color…", 4)
|
||||
context_menu.add_separator()
|
||||
context_menu.add_item("Send Back", 7)
|
||||
context_menu.add_item("Bring Forward", 8)
|
||||
context_menu.add_separator()
|
||||
context_menu.add_item("Copy", 9)
|
||||
context_menu.add_item("Copy Shape", 9)
|
||||
if _has_clipboard:
|
||||
context_menu.add_item("Paste", 10)
|
||||
context_menu.add_item("Paste Shape", 10)
|
||||
context_menu.add_separator()
|
||||
context_menu.add_item("Mirror X", 11)
|
||||
context_menu.add_item("Mirror Y", 12)
|
||||
context_menu.add_item("Flip Left-Right", 11)
|
||||
context_menu.add_item("Flip Up-Down", 12)
|
||||
context_menu.add_separator()
|
||||
context_menu.add_item("Delete", 5)
|
||||
context_menu.add_item("Bring Forward", 8)
|
||||
context_menu.add_item("Send Back", 7)
|
||||
context_menu.add_separator()
|
||||
context_menu.add_destructive_item("Erase Shape", 5)
|
||||
|
||||
context_menu.set_meta("click_position", world_pos)
|
||||
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
context_menu.popup_at(mb.global_position, _global_shape_target_rect(found_sd))
|
||||
return
|
||||
|
||||
# Step 3: No shape under mouse -> shape creation menu.
|
||||
@@ -702,9 +724,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
context_menu.add_item("Circle", 2)
|
||||
if _has_clipboard:
|
||||
context_menu.add_separator()
|
||||
context_menu.add_item("Paste", 10)
|
||||
context_menu.add_item("Paste Shape", 10)
|
||||
context_menu.set_meta("click_position", world_pos)
|
||||
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
context_menu.popup_at(mb.global_position)
|
||||
|
||||
|
||||
func _handle_left_press(world_pos: Vector2) -> void:
|
||||
|
||||
@@ -17,6 +17,8 @@ extends Control
|
||||
|
||||
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
|
||||
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
const SandboxThemeScript := preload("res://scripts/stage/sandbox_theme.gd")
|
||||
const StageDialogStylerScript := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
|
||||
enum WorkspaceView { OVERVIEW, FOCUS }
|
||||
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
|
||||
@@ -110,11 +112,10 @@ const WORKSPACE_GAP: float = 8.0
|
||||
@onready var _overview_scroll: ScrollContainer = %OverviewScroll
|
||||
@onready var _focus_host: VBoxContainer = %FocusHost
|
||||
@onready var _focus_panel_host: Control = %FocusPanelHost
|
||||
@onready var _focus_part_label: Label = %FocusPartLabel
|
||||
@onready var _workspace_row: HSplitContainer = %WorkspaceRow
|
||||
@onready var _overview_button: Button = %OverviewButton
|
||||
@onready var _focus_button: Button = %FocusButton
|
||||
@onready var _part_picker: OptionButton = %PartPicker
|
||||
@onready var _part_picker: EditorSelectionDropdown = %PartPicker
|
||||
@onready var _add_shape_button: Button = %AddShapeButton
|
||||
@onready var _color_button: Button = %ColorButton
|
||||
@onready var _whole_stickman_button: Button = %WholeStickmanButton
|
||||
@@ -132,6 +133,7 @@ const WORKSPACE_GAP: float = 8.0
|
||||
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
|
||||
|
||||
var _editor_visual_theme: EditorVisualTheme
|
||||
var _dialog_theme: SandboxTheme
|
||||
var _body_part_origins: Dictionary = {}
|
||||
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
|
||||
var _view_choice_was_manual: bool = false
|
||||
@@ -173,6 +175,8 @@ var _view_menu: PopupMenu
|
||||
func _ready() -> void:
|
||||
_editor_visual_theme = EditorVisualThemeScript.new()
|
||||
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
|
||||
_dialog_theme = SandboxThemeScript.new()
|
||||
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
|
||||
theme = _editor_visual_theme.control_theme(&"stickman")
|
||||
_setup_menu_bar()
|
||||
_setup_dialogs()
|
||||
@@ -188,6 +192,7 @@ func _ready() -> void:
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
|
||||
_editor_visual_theme.chrome_color(&"preview", &"stickman")
|
||||
)
|
||||
_whole_preview.set_context_menu_visual_theme(_editor_visual_theme, &"stickman")
|
||||
_stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
|
||||
|
||||
_load_settings()
|
||||
@@ -233,17 +238,18 @@ func _process(_delta: float) -> void:
|
||||
func _setup_menu_bar() -> void:
|
||||
var file_menu: PopupMenu = PopupMenu.new()
|
||||
file_menu.name = "FileMenu"
|
||||
file_menu.add_item("Save", 0)
|
||||
file_menu.add_item("Load", 1)
|
||||
file_menu.add_item("📂 Load…", 1)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("Start Over", 2)
|
||||
file_menu.add_item("💾 Save", 0)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("🆕 Start Over", 2)
|
||||
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
|
||||
_menu_bar.add_child(file_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
|
||||
|
||||
_edit_menu = PopupMenu.new()
|
||||
_edit_menu.name = "EditMenu"
|
||||
_edit_menu.add_item("Configure Grid...", 0)
|
||||
_edit_menu.add_item("⚙️ Configure Grid…", 0)
|
||||
_edit_menu.add_separator()
|
||||
_edit_menu.add_item(_snap_menu_label(), 1)
|
||||
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
|
||||
@@ -253,7 +259,7 @@ func _setup_menu_bar() -> void:
|
||||
|
||||
var view_menu: PopupMenu = PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("Reset Views", 0)
|
||||
view_menu.add_item("🔍 Reset Views", 0)
|
||||
view_menu.add_separator()
|
||||
view_menu.add_item(_guide_menu_label(), 1)
|
||||
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
|
||||
@@ -266,10 +272,6 @@ func _setup_menu_bar() -> void:
|
||||
func _setup_dialogs() -> void:
|
||||
_save_dialog.theme = theme
|
||||
_load_dialog.theme = theme
|
||||
_clear_confirm.theme = theme
|
||||
_unsaved_dialog.theme = theme
|
||||
_error_dialog.theme = theme
|
||||
_grid_config_dialog.theme = theme
|
||||
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
|
||||
_save_dialog.current_dir = content_dir
|
||||
_load_dialog.current_dir = content_dir
|
||||
@@ -280,17 +282,34 @@ func _setup_dialogs() -> void:
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_clear_confirm.confirmed.connect(_on_clear_confirmed)
|
||||
_clear_confirm.title = "Start Over?"
|
||||
_clear_confirm.dialog_text = "This will clear your current stickman. Start over?"
|
||||
_clear_confirm.ok_button_text = "Start Over"
|
||||
_clear_confirm.set_meta(StageDialogStylerScript.ICON_META, "🧹")
|
||||
_clear_confirm.get_cancel_button().text = "Cancel"
|
||||
_editor_visual_theme.style_button_role(_clear_confirm.get_ok_button(), &"destructive", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_clear_confirm.get_cancel_button(), &"secondary", &"stickman")
|
||||
var discard_button: Button = _unsaved_dialog.add_button("Discard Changes", false, "discard")
|
||||
StageDialogStylerScript.apply(
|
||||
_clear_confirm, _dialog_theme, _dialog_theme.font(),
|
||||
null, [_clear_confirm.get_ok_button()], [], false
|
||||
)
|
||||
_unsaved_dialog.title = "Unsaved Changes"
|
||||
_unsaved_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
|
||||
var discard_button: Button = _unsaved_dialog.add_button("Discard", false, "discard")
|
||||
_unsaved_dialog.get_cancel_button().text = "Cancel"
|
||||
_editor_visual_theme.style_button_role(_unsaved_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_unsaved_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_grid_config_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_grid_config_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_unsaved_dialog.ok_button_text = "Save"
|
||||
StageDialogStylerScript.apply(
|
||||
_unsaved_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_unsaved_dialog.get_ok_button(), [discard_button]
|
||||
)
|
||||
_error_dialog.get_ok_button().text = "Got it"
|
||||
StageDialogStylerScript.apply(
|
||||
_error_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_error_dialog.get_ok_button()
|
||||
)
|
||||
_grid_config_dialog.set_meta(StageDialogStylerScript.ICON_META, "📏")
|
||||
StageDialogStylerScript.apply(
|
||||
_grid_config_dialog, _dialog_theme, _dialog_theme.font(),
|
||||
_grid_config_dialog.get_ok_button()
|
||||
)
|
||||
_unsaved_dialog.confirmed.connect(_on_unsaved_save_pressed)
|
||||
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
|
||||
_unsaved_dialog.canceled.connect(_cancel_pending_document_action)
|
||||
@@ -303,14 +322,24 @@ func _setup_workspace_controls() -> void:
|
||||
_focus_button.button_group = _view_button_group
|
||||
_overview_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
|
||||
_part_picker.custom_minimum_size = Vector2(160.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
|
||||
_part_picker.custom_minimum_size = Vector2(220.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
|
||||
_part_picker.configure(
|
||||
theme,
|
||||
_editor_visual_theme.chrome_color(&"surface", &"stickman"),
|
||||
_editor_visual_theme.chrome_color(&"selected_surface", &"stickman"),
|
||||
_editor_visual_theme.chrome_color(&"border", &"stickman"),
|
||||
_editor_visual_theme.chrome_color(&"edit", &"stickman"),
|
||||
_editor_visual_theme.chrome_color(&"focus", &"stickman"),
|
||||
_editor_visual_theme.chrome_color(&"text", &"stickman"),
|
||||
_editor_visual_theme.chrome_px(&"menu_popup", &"stickman"),
|
||||
12
|
||||
)
|
||||
_add_shape_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_editor_visual_theme.style_button_role(_add_shape_button, &"primary", &"stickman")
|
||||
_color_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_editor_visual_theme.style_button_role(_color_button, &"secondary", &"stickman")
|
||||
_whole_stickman_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_focus_part_label.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"section", &"stickman"))
|
||||
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
_status_save_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
_status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
@@ -324,9 +353,9 @@ func _setup_workspace_controls() -> void:
|
||||
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
|
||||
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
|
||||
_add_shape_menu = PopupMenu.new()
|
||||
_add_shape_menu.add_item("Line", 0)
|
||||
_add_shape_menu.add_item("Rectangle", 1)
|
||||
_add_shape_menu.add_item("Circle", 2)
|
||||
_add_shape_menu.add_item("〰️ Line", 0)
|
||||
_add_shape_menu.add_item("▭ Rectangle", 1)
|
||||
_add_shape_menu.add_item("⚪ Circle", 2)
|
||||
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
|
||||
_add_shape_menu.theme = theme
|
||||
add_child(_add_shape_menu)
|
||||
@@ -526,8 +555,6 @@ func _update_active_part_presentation() -> void:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
if panel != null:
|
||||
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
|
||||
if is_instance_valid(_focus_part_label):
|
||||
_focus_part_label.text = BODY_PART_DISPLAY.get(_active_part_name, _active_part_name)
|
||||
|
||||
|
||||
func _refresh_workspace_controls() -> void:
|
||||
@@ -686,7 +713,7 @@ func _request_document_action(action: PendingDocumentAction) -> void:
|
||||
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
|
||||
PendingDocumentAction.QUIT:
|
||||
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
|
||||
_unsaved_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_unsaved_dialog)
|
||||
return
|
||||
_continue_document_action(action)
|
||||
|
||||
@@ -696,7 +723,7 @@ func _continue_document_action(action: PendingDocumentAction) -> void:
|
||||
PendingDocumentAction.LOAD:
|
||||
_load_dialog.popup_centered_ratio(0.6)
|
||||
PendingDocumentAction.CLEAR:
|
||||
_clear_confirm.popup_centered()
|
||||
StageDialogStylerScript.popup(_clear_confirm)
|
||||
PendingDocumentAction.QUIT:
|
||||
get_tree().quit()
|
||||
_pending_document_action = PendingDocumentAction.NONE
|
||||
@@ -763,7 +790,7 @@ func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Configure Grid...
|
||||
_grid_size_spin_box.value = float(_grid_size)
|
||||
_grid_config_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_grid_config_dialog)
|
||||
1: # Snap to Grid (checkable)
|
||||
_snap_enabled = not _snap_enabled
|
||||
if _edit_menu:
|
||||
@@ -808,7 +835,7 @@ func _update_guide_menu_item() -> void:
|
||||
|
||||
|
||||
func _guide_menu_label() -> String:
|
||||
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
|
||||
return "👁️ Hide Pose Guide" if _show_guide else "👁️ Show Pose Guide"
|
||||
|
||||
|
||||
func _on_grid_config_confirmed() -> void:
|
||||
@@ -862,7 +889,7 @@ func _on_clear_confirmed() -> void:
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
_error_dialog.dialog_text = message
|
||||
_error_dialog.popup_centered()
|
||||
StageDialogStylerScript.popup(_error_dialog)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handlers - body-part panels
|
||||
@@ -1198,7 +1225,7 @@ func _broadcast_settings() -> void:
|
||||
|
||||
|
||||
func _snap_menu_label() -> String:
|
||||
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
|
||||
return "🧲 [√] Snap to Grid" if _snap_enabled else "🧲 Snap to Grid"
|
||||
|
||||
|
||||
func _update_snap_status_label() -> void:
|
||||
|
||||
@@ -26,6 +26,7 @@ signal part_order_changed(part_name: String, new_order: Array[String])
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
||||
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
|
||||
const MIN_ZOOM: float = 0.3
|
||||
const MAX_ZOOM: float = 3.0
|
||||
const ZOOM_STEP: float = 1.10
|
||||
@@ -142,7 +143,7 @@ var _pan_start: Vector2 = Vector2.ZERO
|
||||
|
||||
# Phase 5: z-ordering
|
||||
var _part_order: Array[String] = []
|
||||
var _context_menu: PopupMenu
|
||||
var _context_menu: EditorContextMenu
|
||||
|
||||
# Phase 6: selection gizmos rendered on top
|
||||
var _selected_gizmo_bounds: Rect2 = Rect2()
|
||||
@@ -289,6 +290,11 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
|
||||
if _context_menu != null:
|
||||
_context_menu.configure(visual_theme, editor)
|
||||
|
||||
|
||||
## Returns the preview-space position of a guide joint (a master-space
|
||||
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
|
||||
## unknown joint names.
|
||||
@@ -334,7 +340,7 @@ func _ready() -> void:
|
||||
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
|
||||
preview_area.gui_input.connect(_on_preview_gui_input)
|
||||
|
||||
_context_menu = PopupMenu.new()
|
||||
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
|
||||
_context_menu.name = "PreviewContextMenu"
|
||||
add_child(_context_menu)
|
||||
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
|
||||
@@ -1024,12 +1030,14 @@ func _handle_right_click(mb: InputEventMouseButton) -> void:
|
||||
if rect.has_point(world_pos):
|
||||
_selected_part = part_name
|
||||
_context_menu.clear()
|
||||
_context_menu.add_item("Send Back", 0)
|
||||
_context_menu.add_item("Bring Forward", 1)
|
||||
_context_menu.add_item("Send Back", 0)
|
||||
_context_menu.add_separator()
|
||||
_context_menu.add_item("Mirror X", 2)
|
||||
_context_menu.add_item("Mirror Y", 3)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
_context_menu.add_item("Flip Left-Right", 2)
|
||||
_context_menu.add_item("Flip Up-Down", 3)
|
||||
var screen_position := preview_area.global_position + _pan_offset + rect.position * _zoom
|
||||
var target_rect := Rect2(screen_position, rect.size * _zoom).grow(4.0)
|
||||
_context_menu.popup_at(mb.global_position, target_rect)
|
||||
preview_area.queue_redraw()
|
||||
return
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ extends Node2D
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_prop_path: String = ""
|
||||
var stage_object_id: String = ""
|
||||
var stage_name: String = ""
|
||||
const MARKER_SIZE := Vector2(180.0, 54.0)
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
original_entry = entry.duplicate(true)
|
||||
missing_prop_path = String(entry.get("asset_id", ""))
|
||||
stage_object_id = String(entry.get("stage_object_id", ""))
|
||||
stage_name = String(entry.get("stage_name", ""))
|
||||
queue_redraw()
|
||||
|
||||
func get_area_rect() -> Rect2:
|
||||
|
||||
@@ -31,6 +31,7 @@ var spawn_id: String = ""
|
||||
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
|
||||
## spawn_id, this value is unique per instance and survives scene save/load.
|
||||
var stage_object_id: String = ""
|
||||
var stage_name: String = ""
|
||||
var interaction_anchors: Dictionary = {}
|
||||
var interaction_reserved_by: int = 0
|
||||
var interaction_owner_id: int = 0
|
||||
@@ -59,6 +60,8 @@ const MAX_CONTACTS_REPORTED := 32
|
||||
const DEFAULT_WOBBLE_DURATION: float = 0.48
|
||||
const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0
|
||||
const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0
|
||||
const OWNER_COLLISION_GRACE_CLEAR_MARGIN: float = 24.0
|
||||
const OWNER_COLLISION_GRACE_CLEAR_FRAMES: int = 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported properties
|
||||
@@ -128,6 +131,15 @@ var _wobble_center: Vector2 = Vector2.ZERO
|
||||
var _wobble_baselines: Dictionary = {}
|
||||
var _last_physics_velocity: Vector2 = Vector2.ZERO
|
||||
var _has_physics_velocity_sample: bool = false
|
||||
var _owner_collision_grace_actor_id: int = 0
|
||||
var _owner_collision_grace_actor_ref: WeakRef
|
||||
var _owner_collision_grace_locomotion_ref: WeakRef
|
||||
var _owner_collision_grace_body_refs: Array[WeakRef] = []
|
||||
var _owner_collision_grace_role: RuntimeRole = RuntimeRole.EDIT
|
||||
var _owner_collision_grace_while_reserved: bool = false
|
||||
var _owner_collision_grace_clear_frames: int = 0
|
||||
var _owner_collision_grace_started_frame: int = -1
|
||||
var _owner_collision_grace_last_sample_frame: int = -1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -177,12 +189,14 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_update_owner_collision_grace()
|
||||
if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]:
|
||||
_last_physics_velocity = linear_velocity
|
||||
_has_physics_velocity_sample = _last_physics_velocity.is_finite()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
clear_owner_collision_grace()
|
||||
cancel_wobble()
|
||||
|
||||
|
||||
@@ -319,6 +333,8 @@ func apply_stage_depth() -> void:
|
||||
|
||||
func apply_runtime_role(role: RuntimeRole) -> void:
|
||||
runtime_role = role
|
||||
if _owner_collision_grace_actor_id != 0 and (role != _owner_collision_grace_role or not is_actor_blocking()):
|
||||
clear_owner_collision_grace()
|
||||
match role:
|
||||
RuntimeRole.EDIT:
|
||||
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
@@ -368,6 +384,146 @@ func is_actor_blocking() -> bool:
|
||||
return bool(stage_roles.get("blocks_stickmen", false))
|
||||
|
||||
|
||||
## Temporarily ignores contacts with one stickman. Pickup reservations can keep
|
||||
## protection active until the reservation ends; Set Down uses exit clearance.
|
||||
## The prop's authored role and contacts with supports or other stickmen remain.
|
||||
func begin_owner_collision_grace(actor_rig: Node2D, while_reserved: bool = false) -> void:
|
||||
clear_owner_collision_grace()
|
||||
if actor_rig == null or not is_instance_valid(actor_rig) or not is_actor_blocking():
|
||||
return
|
||||
_owner_collision_grace_actor_id = actor_rig.get_instance_id()
|
||||
_owner_collision_grace_actor_ref = weakref(actor_rig)
|
||||
_owner_collision_grace_role = runtime_role
|
||||
_owner_collision_grace_while_reserved = while_reserved
|
||||
_owner_collision_grace_clear_frames = 0
|
||||
_owner_collision_grace_started_frame = Engine.get_physics_frames()
|
||||
_owner_collision_grace_last_sample_frame = -1
|
||||
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
|
||||
body.add_collision_exception_with(self)
|
||||
add_collision_exception_with(body)
|
||||
_owner_collision_grace_body_refs.append(weakref(body))
|
||||
if actor_rig.has_method("get_locomotion"):
|
||||
var locomotion: Node = actor_rig.call("get_locomotion") as Node
|
||||
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("register_owner_collision_grace_prop"):
|
||||
locomotion.call("register_owner_collision_grace_prop", self)
|
||||
_owner_collision_grace_locomotion_ref = weakref(locomotion)
|
||||
|
||||
|
||||
func release_owner_collision_grace(actor_rig: Node2D) -> void:
|
||||
if not is_owner_collision_grace_active_for(actor_rig):
|
||||
return
|
||||
_owner_collision_grace_while_reserved = false
|
||||
_owner_collision_grace_clear_frames = 0
|
||||
_owner_collision_grace_started_frame = Engine.get_physics_frames()
|
||||
_owner_collision_grace_last_sample_frame = -1
|
||||
|
||||
|
||||
func is_owner_collision_grace_active_for(actor: Node) -> bool:
|
||||
return (
|
||||
actor != null
|
||||
and is_instance_valid(actor)
|
||||
and _owner_collision_grace_actor_id != 0
|
||||
and actor.get_instance_id() == _owner_collision_grace_actor_id
|
||||
)
|
||||
|
||||
|
||||
func clear_owner_collision_grace() -> void:
|
||||
for body_ref: WeakRef in _owner_collision_grace_body_refs:
|
||||
var body: PhysicsBody2D = body_ref.get_ref() as PhysicsBody2D
|
||||
if body != null and is_instance_valid(body):
|
||||
body.remove_collision_exception_with(self)
|
||||
remove_collision_exception_with(body)
|
||||
_owner_collision_grace_body_refs.clear()
|
||||
var locomotion: Node = _owner_collision_grace_locomotion_ref.get_ref() as Node if _owner_collision_grace_locomotion_ref != null else null
|
||||
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("unregister_owner_collision_grace_prop"):
|
||||
locomotion.call("unregister_owner_collision_grace_prop", self)
|
||||
_owner_collision_grace_locomotion_ref = null
|
||||
_owner_collision_grace_actor_ref = null
|
||||
_owner_collision_grace_actor_id = 0
|
||||
_owner_collision_grace_role = RuntimeRole.EDIT
|
||||
_owner_collision_grace_while_reserved = false
|
||||
_owner_collision_grace_clear_frames = 0
|
||||
_owner_collision_grace_started_frame = -1
|
||||
_owner_collision_grace_last_sample_frame = -1
|
||||
|
||||
|
||||
func _update_owner_collision_grace() -> void:
|
||||
if _owner_collision_grace_actor_id == 0:
|
||||
return
|
||||
var actor: Node2D = _owner_collision_grace_actor_ref.get_ref() as Node2D if _owner_collision_grace_actor_ref != null else null
|
||||
if actor == null or not is_instance_valid(actor) or runtime_role != _owner_collision_grace_role or not is_actor_blocking():
|
||||
clear_owner_collision_grace()
|
||||
return
|
||||
if _owner_collision_grace_while_reserved:
|
||||
return
|
||||
var physics_frame: int = Engine.get_physics_frames()
|
||||
if physics_frame <= _owner_collision_grace_started_frame or physics_frame == _owner_collision_grace_last_sample_frame:
|
||||
return
|
||||
_owner_collision_grace_last_sample_frame = physics_frame
|
||||
if _actor_collision_shapes_overlap(actor):
|
||||
_owner_collision_grace_clear_frames = 0
|
||||
return
|
||||
_owner_collision_grace_clear_frames += 1
|
||||
if _owner_collision_grace_clear_frames >= OWNER_COLLISION_GRACE_CLEAR_FRAMES:
|
||||
clear_owner_collision_grace()
|
||||
|
||||
|
||||
func _actor_collision_shapes_overlap(actor_rig: Node2D) -> bool:
|
||||
if not is_inside_tree() or get_world_2d() == null or collision_layer == 0:
|
||||
return false
|
||||
var space_state: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return true
|
||||
var active_shape_count: int = 0
|
||||
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
|
||||
if body.collision_layer == 0:
|
||||
continue
|
||||
for child: Node in body.get_children():
|
||||
var collision_shape: CollisionShape2D = child as CollisionShape2D
|
||||
if collision_shape == null or collision_shape.disabled or collision_shape.shape == null:
|
||||
continue
|
||||
active_shape_count += 1
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = collision_shape.shape
|
||||
query.transform = collision_shape.global_transform
|
||||
query.collision_mask = collision_layer
|
||||
query.margin = OWNER_COLLISION_GRACE_CLEAR_MARGIN
|
||||
query.exclude = [body.get_rid()]
|
||||
for hit: Dictionary in space_state.intersect_shape(query, 16):
|
||||
if hit.get("collider") == self:
|
||||
return true
|
||||
return active_shape_count == 0
|
||||
|
||||
|
||||
func _actor_collision_bodies(actor_rig: Node2D) -> Array[PhysicsBody2D]:
|
||||
var result: Array[PhysicsBody2D] = []
|
||||
var seen: Dictionary = {}
|
||||
if actor_rig == null or not is_instance_valid(actor_rig):
|
||||
return result
|
||||
if actor_rig.has_method("get_collision_bodies"):
|
||||
var collision_bodies: Variant = actor_rig.call("get_collision_bodies")
|
||||
if collision_bodies is Dictionary:
|
||||
for raw_body: Variant in (collision_bodies as Dictionary).values():
|
||||
_append_actor_collision_body(raw_body, result, seen)
|
||||
if actor_rig.has_method("get_ragdoll_bodies"):
|
||||
var ragdoll_bodies: Variant = actor_rig.call("get_ragdoll_bodies")
|
||||
if ragdoll_bodies is Array:
|
||||
for raw_body: Variant in ragdoll_bodies as Array:
|
||||
_append_actor_collision_body(raw_body, result, seen)
|
||||
return result
|
||||
|
||||
|
||||
func _append_actor_collision_body(raw_body: Variant, bodies: Array[PhysicsBody2D], seen: Dictionary) -> void:
|
||||
var body: PhysicsBody2D = raw_body as PhysicsBody2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
return
|
||||
var body_id: int = body.get_instance_id()
|
||||
if seen.has(body_id):
|
||||
return
|
||||
seen[body_id] = true
|
||||
bodies.append(body)
|
||||
|
||||
|
||||
func can_receive_prop_contact() -> bool:
|
||||
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
class_name EditorContextMenu
|
||||
extends PopupPanel
|
||||
## Shared Studio List popup used by right-click menus in both standalone editors.
|
||||
|
||||
signal id_pressed(id: int)
|
||||
|
||||
enum ItemRole { NORMAL, DESTRUCTIVE }
|
||||
|
||||
const DEFAULT_EDITOR := &"prop"
|
||||
const DEFAULT_MIN_WIDTH := 208
|
||||
const DEFAULT_ROW_HEIGHT := 48
|
||||
const DEFAULT_GROUP_GAP := 8
|
||||
const SHELL_INSET := 8
|
||||
const ROW_RADIUS := 6
|
||||
const TARGET_GAP := 12.0
|
||||
|
||||
var _visual_theme: EditorVisualTheme
|
||||
var _editor: StringName = DEFAULT_EDITOR
|
||||
var _font_size_override: int = -1
|
||||
var _shell: MarginContainer
|
||||
var _scroll: ScrollContainer
|
||||
var _items: VBoxContainer
|
||||
var _row_buttons: Array[Button] = []
|
||||
var _row_ids: Dictionary = {}
|
||||
var _keyboard_navigation_active: bool = false
|
||||
var _focused_row_index: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_contents()
|
||||
if not about_to_popup.is_connected(_reset_keyboard_navigation):
|
||||
about_to_popup.connect(_reset_keyboard_navigation)
|
||||
if not popup_hide.is_connected(_reset_keyboard_navigation):
|
||||
popup_hide.connect(_reset_keyboard_navigation)
|
||||
if _visual_theme != null:
|
||||
_apply_visual_style()
|
||||
|
||||
|
||||
func configure(visual_theme: EditorVisualTheme, editor: StringName) -> void:
|
||||
_visual_theme = visual_theme
|
||||
_editor = editor
|
||||
if is_node_ready():
|
||||
_apply_visual_style()
|
||||
|
||||
|
||||
func set_font_size(size: int) -> void:
|
||||
_font_size_override = maxi(1, size)
|
||||
if is_node_ready():
|
||||
_apply_visual_style()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
_reset_keyboard_navigation()
|
||||
_row_buttons.clear()
|
||||
_row_ids.clear()
|
||||
if _items == null:
|
||||
return
|
||||
for child: Node in _items.get_children():
|
||||
_items.remove_child(child)
|
||||
child.free()
|
||||
|
||||
|
||||
func add_item(label: String, id: int, role: ItemRole = ItemRole.NORMAL) -> void:
|
||||
if _items == null:
|
||||
_build_contents()
|
||||
var button := Button.new()
|
||||
button.text = label
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.add_theme_font_size_override("font_size", _font_size())
|
||||
button.add_theme_color_override("font_color", _text_color(role))
|
||||
button.add_theme_color_override("font_hover_color", _text_color(role))
|
||||
button.add_theme_color_override("font_pressed_color", _text_color(role))
|
||||
button.add_theme_color_override("font_focus_color", _text_color(role))
|
||||
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
|
||||
button.add_theme_stylebox_override("hover", _make_hover_box())
|
||||
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
|
||||
button.add_theme_stylebox_override("focus", _make_focus_box())
|
||||
button.add_theme_stylebox_override("disabled", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
|
||||
button.set_meta("context_menu_id", id)
|
||||
button.set_meta("context_menu_role", role)
|
||||
button.pressed.connect(_on_row_pressed.bind(button))
|
||||
button.mouse_entered.connect(_on_row_mouse_entered.bind(button))
|
||||
_items.add_child(button)
|
||||
_row_buttons.append(button)
|
||||
_row_ids[id] = button
|
||||
|
||||
|
||||
func add_destructive_item(label: String, id: int) -> void:
|
||||
add_item(label, id, ItemRole.DESTRUCTIVE)
|
||||
|
||||
|
||||
func add_separator() -> void:
|
||||
if _items == null:
|
||||
_build_contents()
|
||||
var spacer := MarginContainer.new()
|
||||
spacer.custom_minimum_size.y = float(DEFAULT_GROUP_GAP + 1)
|
||||
spacer.add_theme_constant_override("margin_top", int(DEFAULT_GROUP_GAP / 2))
|
||||
spacer.add_theme_constant_override("margin_bottom", int(DEFAULT_GROUP_GAP / 2))
|
||||
var line := ColorRect.new()
|
||||
line.color = _theme_color(&"border", Color(1, 1, 1, 0.2))
|
||||
line.custom_minimum_size.y = 1.0
|
||||
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spacer.add_child(line)
|
||||
_items.add_child(spacer)
|
||||
|
||||
|
||||
func popup_at(global_position: Vector2, avoid_rect: Rect2 = Rect2()) -> void:
|
||||
if _items == null:
|
||||
_build_contents()
|
||||
var parent_viewport: Viewport = get_parent().get_viewport() if get_parent() != null else get_viewport()
|
||||
var safe_rect: Rect2 = parent_viewport.get_visible_rect()
|
||||
var margin := 8.0
|
||||
var popup_size := _items.get_combined_minimum_size() + Vector2(SHELL_INSET * 2, SHELL_INSET * 2)
|
||||
var border_width := float(_theme_px(&"normal_border", 1))
|
||||
popup_size += Vector2(border_width * 2.0, border_width * 2.0)
|
||||
popup_size.x = maxf(popup_size.x, float(_menu_min_width() + SHELL_INSET * 2))
|
||||
var max_popup_size := Vector2(maxf(1.0, safe_rect.size.x - margin * 2.0), maxf(1.0, safe_rect.size.y - margin * 2.0))
|
||||
popup_size = popup_size.min(max_popup_size)
|
||||
if _scroll != null:
|
||||
var content_height: float = _items.get_combined_minimum_size().y
|
||||
var available_content_height: float = max_popup_size.y - float(SHELL_INSET * 2) - border_width * 2.0
|
||||
_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO if content_height > available_content_height else ScrollContainer.SCROLL_MODE_DISABLED
|
||||
size = Vector2i(popup_size.ceil())
|
||||
if _scroll != null:
|
||||
_scroll.scroll_vertical = 0
|
||||
var max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - popup_size.x - margin)
|
||||
var max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - popup_size.y - margin)
|
||||
var popup_origin := _choose_popup_origin(
|
||||
global_position,
|
||||
avoid_rect,
|
||||
popup_size,
|
||||
safe_rect,
|
||||
margin,
|
||||
max_x,
|
||||
max_y
|
||||
)
|
||||
var popup_position := Vector2i(popup_origin.round())
|
||||
popup_on_parent(Rect2i(popup_position, Vector2i.ONE))
|
||||
# PopupPanel sizes itself from the child layout on popup. Reapply the measured
|
||||
# target size after opening so the scroll region can honor the 48 px rows.
|
||||
size = Vector2i(popup_size.ceil())
|
||||
var actual_size := Vector2(size)
|
||||
if popup_position.x + actual_size.x > safe_rect.end.x - margin:
|
||||
popup_origin.x = global_position.x - actual_size.x - margin
|
||||
if popup_position.y + actual_size.y > safe_rect.end.y - margin:
|
||||
popup_origin.y = global_position.y - actual_size.y - margin
|
||||
var actual_max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - actual_size.x - margin)
|
||||
var actual_max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - actual_size.y - margin)
|
||||
popup_origin.x = clampf(popup_origin.x, safe_rect.position.x + margin, actual_max_x)
|
||||
popup_origin.y = clampf(popup_origin.y, safe_rect.position.y + margin, actual_max_y)
|
||||
position = Vector2i(popup_origin.round())
|
||||
|
||||
|
||||
func _choose_popup_origin(
|
||||
global_position: Vector2,
|
||||
avoid_rect: Rect2,
|
||||
popup_size: Vector2,
|
||||
safe_rect: Rect2,
|
||||
margin: float,
|
||||
max_x: float,
|
||||
max_y: float
|
||||
) -> Vector2:
|
||||
var low_x: float = safe_rect.position.x + margin
|
||||
var low_y: float = safe_rect.position.y + margin
|
||||
var cursor_fallback := Vector2(
|
||||
clampf(global_position.x + margin, low_x, max_x),
|
||||
clampf(global_position.y + margin, low_y, max_y)
|
||||
)
|
||||
if not avoid_rect.has_area():
|
||||
return cursor_fallback
|
||||
|
||||
var aligned_y := clampf(global_position.y - 20.0, low_y, max_y)
|
||||
var aligned_x := clampf(global_position.x + margin, low_x, max_x)
|
||||
var candidates: Array[Vector2] = [
|
||||
Vector2(avoid_rect.end.x + TARGET_GAP, aligned_y),
|
||||
Vector2(avoid_rect.position.x - popup_size.x - TARGET_GAP, aligned_y),
|
||||
Vector2(aligned_x, avoid_rect.end.y + TARGET_GAP),
|
||||
Vector2(aligned_x, avoid_rect.position.y - popup_size.y - TARGET_GAP),
|
||||
cursor_fallback,
|
||||
]
|
||||
for candidate: Vector2 in candidates:
|
||||
if candidate.x < low_x or candidate.y < low_y or candidate.x > max_x or candidate.y > max_y:
|
||||
continue
|
||||
var candidate_rect := Rect2(candidate, popup_size)
|
||||
if not candidate_rect.intersects(avoid_rect):
|
||||
return candidate
|
||||
return cursor_fallback
|
||||
|
||||
|
||||
func get_row_button(id: int) -> Button:
|
||||
return _row_ids.get(id) as Button
|
||||
|
||||
|
||||
func _build_contents() -> void:
|
||||
if _items != null:
|
||||
return
|
||||
_shell = MarginContainer.new()
|
||||
_shell.name = "MenuShell"
|
||||
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
|
||||
_shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_shell.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_child(_shell)
|
||||
_scroll = ScrollContainer.new()
|
||||
_scroll.name = "MenuScroll"
|
||||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_shell.add_child(_scroll)
|
||||
_items = VBoxContainer.new()
|
||||
_items.name = "MenuItems"
|
||||
_items.add_theme_constant_override("separation", 0)
|
||||
_items.custom_minimum_size.x = float(_menu_min_width())
|
||||
_scroll.add_child(_items)
|
||||
_apply_visual_style()
|
||||
|
||||
|
||||
func _apply_visual_style() -> void:
|
||||
if _visual_theme == null:
|
||||
return
|
||||
var surface := _theme_color(&"surface", Color("#252B38"))
|
||||
var border := _theme_color(&"border", Color("#FFFFFF33"))
|
||||
var radius := _theme_px(&"panel", 10)
|
||||
var panel := StyleBoxFlat.new()
|
||||
panel.bg_color = surface
|
||||
panel.border_color = border
|
||||
panel.set_border_width_all(_theme_px(&"normal_border", 1))
|
||||
panel.set_corner_radius_all(radius)
|
||||
panel.shadow_color = Color(0.0, 0.0, 0.0, 0.28)
|
||||
panel.shadow_size = 8
|
||||
panel.shadow_offset = Vector2(0.0, 3.0)
|
||||
add_theme_stylebox_override("panel", panel)
|
||||
if _shell != null:
|
||||
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
|
||||
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
|
||||
if _items != null:
|
||||
_items.custom_minimum_size.x = float(_menu_min_width())
|
||||
for button: Button in _row_buttons:
|
||||
if not is_instance_valid(button):
|
||||
continue
|
||||
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
|
||||
button.add_theme_font_size_override("font_size", _font_size())
|
||||
var role := int(button.get_meta("context_menu_role", ItemRole.NORMAL))
|
||||
var item_role: ItemRole = ItemRole.DESTRUCTIVE if role == ItemRole.DESTRUCTIVE else ItemRole.NORMAL
|
||||
button.add_theme_color_override("font_color", _text_color(item_role))
|
||||
button.add_theme_color_override("font_hover_color", _text_color(item_role))
|
||||
button.add_theme_color_override("font_pressed_color", _text_color(item_role))
|
||||
button.add_theme_color_override("font_focus_color", _text_color(item_role))
|
||||
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
|
||||
button.add_theme_stylebox_override("hover", _make_hover_box())
|
||||
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
|
||||
button.add_theme_stylebox_override("focus", _make_focus_box())
|
||||
|
||||
|
||||
func _make_row_box(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = fill
|
||||
style.border_color = border
|
||||
style.set_border_width_all(border_width)
|
||||
style.set_corner_radius_all(ROW_RADIUS)
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
return style
|
||||
|
||||
|
||||
func _make_hover_box(pressed: bool = false) -> StyleBoxFlat:
|
||||
var hover_color := _theme_color(&"surface_hover", Color("#30394A"))
|
||||
if pressed:
|
||||
hover_color = hover_color.darkened(0.1)
|
||||
var style := _make_row_box(hover_color, _theme_color(&"edit", Color("#22C6FF")), 0)
|
||||
style.border_width_left = 3
|
||||
style.content_margin_left = 9
|
||||
return style
|
||||
|
||||
|
||||
func _make_focus_box() -> StyleBoxFlat:
|
||||
var style := _make_row_box(Color(0.0, 0.0, 0.0, 0.0), _theme_color(&"focus", Color("#8BD9FF")), 2)
|
||||
return style
|
||||
|
||||
|
||||
func _on_row_pressed(button: Button) -> void:
|
||||
if not is_instance_valid(button):
|
||||
return
|
||||
var id: int = int(button.get_meta("context_menu_id", -1))
|
||||
id_pressed.emit(id)
|
||||
hide()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible or not event is InputEventKey:
|
||||
return
|
||||
var key_event := event as InputEventKey
|
||||
if not key_event.pressed:
|
||||
return
|
||||
if key_event.keycode == KEY_DOWN:
|
||||
_move_keyboard_focus(1)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif key_event.keycode == KEY_UP:
|
||||
_move_keyboard_focus(-1)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _move_keyboard_focus(direction: int) -> void:
|
||||
if _row_buttons.is_empty():
|
||||
return
|
||||
var candidate_index: int
|
||||
if not _keyboard_navigation_active:
|
||||
candidate_index = 0 if direction > 0 else _row_buttons.size() - 1
|
||||
else:
|
||||
candidate_index = _focused_row_index + direction
|
||||
for _attempt: int in range(_row_buttons.size()):
|
||||
candidate_index = posmod(candidate_index, _row_buttons.size())
|
||||
var button: Button = _row_buttons[candidate_index]
|
||||
if is_instance_valid(button) and button.visible and not button.disabled:
|
||||
_enable_keyboard_navigation()
|
||||
_focused_row_index = candidate_index
|
||||
button.grab_focus()
|
||||
return
|
||||
candidate_index += direction
|
||||
|
||||
|
||||
func _enable_keyboard_navigation() -> void:
|
||||
_keyboard_navigation_active = true
|
||||
for button: Button in _row_buttons:
|
||||
if is_instance_valid(button):
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
|
||||
|
||||
func _reset_keyboard_navigation() -> void:
|
||||
_keyboard_navigation_active = false
|
||||
_focused_row_index = -1
|
||||
if not is_node_ready():
|
||||
return
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner != null and _row_buttons.has(focus_owner):
|
||||
focus_owner.release_focus()
|
||||
for button: Button in _row_buttons:
|
||||
if is_instance_valid(button):
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
|
||||
|
||||
func _on_row_mouse_entered(_button: Button) -> void:
|
||||
_reset_keyboard_navigation()
|
||||
|
||||
|
||||
func _theme_color(role: StringName, fallback: Color) -> Color:
|
||||
if _visual_theme == null:
|
||||
return fallback
|
||||
return _visual_theme.chrome_color(role, _editor)
|
||||
|
||||
|
||||
func _theme_px(role: StringName, fallback: int) -> int:
|
||||
if _visual_theme == null:
|
||||
return fallback
|
||||
return _visual_theme.chrome_px(role, _editor)
|
||||
|
||||
|
||||
func _font_size() -> int:
|
||||
if _font_size_override > 0:
|
||||
return _font_size_override
|
||||
return _theme_px(&"context_menu", 20)
|
||||
|
||||
|
||||
func _menu_row_height() -> int:
|
||||
return maxi(_theme_px(&"context_menu_row_height", DEFAULT_ROW_HEIGHT), DEFAULT_ROW_HEIGHT)
|
||||
|
||||
|
||||
func _menu_min_width() -> int:
|
||||
return maxi(_theme_px(&"context_menu_min_width", DEFAULT_MIN_WIDTH), DEFAULT_MIN_WIDTH)
|
||||
|
||||
|
||||
func _text_color(role: ItemRole) -> Color:
|
||||
if role == ItemRole.DESTRUCTIVE:
|
||||
return _theme_color(&"destructive", Color("#E66B75"))
|
||||
return _theme_color(&"text", Color("#F5F7FA"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjidsdsxomilt
|
||||
@@ -0,0 +1,231 @@
|
||||
class_name EditorSelectionDropdown
|
||||
extends MenuButton
|
||||
## Single-choice editor picker with a Studio-style selected row and no radio lane.
|
||||
|
||||
signal item_selected(index: int)
|
||||
|
||||
var selected: int = -1:
|
||||
set(value):
|
||||
_set_selected(value)
|
||||
get:
|
||||
return _selected_index
|
||||
|
||||
var _selected_index: int = -1
|
||||
var _popup_menu: PopupMenu
|
||||
var _field_icons: Array[Texture2D] = []
|
||||
var _control_theme: Theme
|
||||
var _surface_color: Color = Color("#252B38")
|
||||
var _selected_surface_color: Color = Color("#103B4D")
|
||||
var _border_color: Color = Color("#FFFFFF33")
|
||||
var _accent_color: Color = Color("#22C6FF")
|
||||
var _focus_color: Color = Color("#8BD9FF")
|
||||
var _text_color: Color = Color("#F5F7FA")
|
||||
var _font_size: int = 18
|
||||
var _row_separation: int = 6
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
flat = false
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
_popup_menu = get_popup()
|
||||
_popup_menu.shrink_width = false
|
||||
_popup_menu.transparent = false
|
||||
_popup_menu.transparent_bg = false
|
||||
_popup_menu.id_pressed.connect(_on_popup_item_pressed)
|
||||
about_to_popup.connect(_on_about_to_popup)
|
||||
_apply_popup_theme()
|
||||
_apply_field_theme()
|
||||
_sync_selected_item()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func configure(
|
||||
control_theme: Theme,
|
||||
surface_color: Color,
|
||||
selected_surface_color: Color,
|
||||
border_color: Color,
|
||||
accent_color: Color,
|
||||
focus_color: Color,
|
||||
text_color: Color,
|
||||
font_size: int,
|
||||
row_separation: int = 6
|
||||
) -> void:
|
||||
_control_theme = control_theme
|
||||
_surface_color = surface_color
|
||||
_selected_surface_color = selected_surface_color
|
||||
_border_color = border_color
|
||||
_accent_color = accent_color
|
||||
_focus_color = focus_color
|
||||
_text_color = text_color
|
||||
_font_size = font_size
|
||||
_row_separation = maxi(0, row_separation)
|
||||
if is_node_ready():
|
||||
_apply_popup_theme()
|
||||
|
||||
|
||||
func add_item(label: String, id: int = -1) -> void:
|
||||
var index: int = get_popup().item_count
|
||||
var item_id: int = index if id < 0 else id
|
||||
add_styled_item(label, null, null, item_id)
|
||||
|
||||
|
||||
func add_styled_item(
|
||||
label: String,
|
||||
field_icon: Texture2D = null,
|
||||
popup_icon: Texture2D = null,
|
||||
id: int = -1
|
||||
) -> void:
|
||||
var popup: PopupMenu = get_popup()
|
||||
var index: int = popup.item_count
|
||||
var item_id: int = index if id < 0 else id
|
||||
_field_icons.append(field_icon)
|
||||
if popup_icon != null:
|
||||
popup.add_icon_item(popup_icon, label, item_id)
|
||||
popup.set_item_icon_max_width(index, 44)
|
||||
else:
|
||||
popup.add_item(label, item_id)
|
||||
popup.set_item_as_radio_checkable(index, false)
|
||||
if _selected_index < 0:
|
||||
_set_selected(index)
|
||||
|
||||
|
||||
func get_item_text(index: int) -> String:
|
||||
return get_popup().get_item_text(index)
|
||||
|
||||
|
||||
func get_item_icon(index: int) -> Texture2D:
|
||||
return _field_icons[index] if index >= 0 and index < _field_icons.size() else null
|
||||
|
||||
|
||||
func select(index: int) -> void:
|
||||
_set_selected(index)
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
get_popup().clear()
|
||||
_field_icons.clear()
|
||||
_selected_index = -1
|
||||
_sync_selected_item()
|
||||
|
||||
|
||||
func hide_popup() -> void:
|
||||
get_popup().hide()
|
||||
|
||||
|
||||
func _set_selected(index: int) -> void:
|
||||
var count: int = get_popup().item_count
|
||||
if index < 0 or count == 0:
|
||||
_selected_index = -1
|
||||
else:
|
||||
_selected_index = clampi(index, 0, count - 1)
|
||||
_sync_selected_item()
|
||||
|
||||
|
||||
func _sync_selected_item() -> void:
|
||||
if not is_instance_valid(get_popup()):
|
||||
return
|
||||
if _selected_index < 0 or _selected_index >= get_popup().item_count:
|
||||
text = ""
|
||||
icon = null
|
||||
return
|
||||
text = get_popup().get_item_text(_selected_index)
|
||||
icon = _field_icons[_selected_index] if _selected_index < _field_icons.size() else null
|
||||
|
||||
|
||||
func _apply_popup_theme() -> void:
|
||||
if _popup_menu == null:
|
||||
return
|
||||
if _control_theme != null:
|
||||
_popup_menu.theme = _control_theme
|
||||
var panel := StyleBoxFlat.new()
|
||||
var opaque_surface: Color = _surface_color
|
||||
opaque_surface.a = 1.0
|
||||
panel.bg_color = opaque_surface
|
||||
panel.border_color = _border_color
|
||||
panel.set_border_width_all(1)
|
||||
panel.set_corner_radius_all(8)
|
||||
panel.content_margin_left = 6.0
|
||||
panel.content_margin_top = 6.0
|
||||
panel.content_margin_right = 6.0
|
||||
panel.content_margin_bottom = 6.0
|
||||
_popup_menu.add_theme_stylebox_override("panel", panel)
|
||||
var selected_row := StyleBoxFlat.new()
|
||||
selected_row.bg_color = _selected_surface_color
|
||||
selected_row.border_color = _accent_color
|
||||
selected_row.set_border_width_all(2)
|
||||
selected_row.set_corner_radius_all(7)
|
||||
selected_row.content_margin_left = 5.0
|
||||
selected_row.content_margin_top = 12.0
|
||||
selected_row.content_margin_right = 5.0
|
||||
selected_row.content_margin_bottom = 12.0
|
||||
_popup_menu.add_theme_stylebox_override("hover", selected_row)
|
||||
_popup_menu.add_theme_color_override("font_hover_color", _text_color)
|
||||
_popup_menu.add_theme_color_override("font_color", _text_color)
|
||||
_popup_menu.add_theme_font_size_override("font_size", _font_size)
|
||||
_popup_menu.add_theme_constant_override("icon_max_width", 44)
|
||||
_popup_menu.add_theme_constant_override("h_separation", 10)
|
||||
_popup_menu.add_theme_constant_override("v_separation", _row_separation)
|
||||
_popup_menu.add_theme_constant_override("item_start_padding", 4)
|
||||
_popup_menu.add_theme_constant_override("item_end_padding", 4)
|
||||
add_theme_stylebox_override("focus", _make_focus_style())
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _apply_field_theme() -> void:
|
||||
for state: String in ["normal", "hover", "pressed", "disabled"]:
|
||||
var original: StyleBox = get_theme_stylebox(state, "Button")
|
||||
if original == null:
|
||||
continue
|
||||
var field_style: StyleBox = original.duplicate()
|
||||
field_style.content_margin_right = maxf(field_style.content_margin_right, 28.0)
|
||||
add_theme_stylebox_override(state, field_style)
|
||||
|
||||
|
||||
func _make_focus_style() -> StyleBoxFlat:
|
||||
var focus_box := StyleBoxFlat.new()
|
||||
focus_box.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||
focus_box.border_color = _focus_color
|
||||
focus_box.set_border_width_all(2)
|
||||
focus_box.set_corner_radius_all(8)
|
||||
focus_box.content_margin_right = 28.0
|
||||
return focus_box
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var center := Vector2(size.x - 16.0, size.y * 0.5)
|
||||
var points := PackedVector2Array([
|
||||
center + Vector2(-4.0, -2.0),
|
||||
center + Vector2(0.0, 2.0),
|
||||
center + Vector2(4.0, -2.0),
|
||||
])
|
||||
draw_polyline(points, _text_color.darkened(0.28), 2.0, true)
|
||||
|
||||
|
||||
func _on_about_to_popup() -> void:
|
||||
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
|
||||
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
|
||||
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
|
||||
call_deferred("_sync_popup_size")
|
||||
if _selected_index >= 0:
|
||||
_popup_menu.set_focused_item(_selected_index)
|
||||
else:
|
||||
_popup_menu.set_focused_item(-1)
|
||||
|
||||
|
||||
func _sync_popup_size() -> void:
|
||||
if _popup_menu == null:
|
||||
return
|
||||
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
|
||||
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
|
||||
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
|
||||
if _selected_index >= 0:
|
||||
_popup_menu.set_focused_item(_selected_index)
|
||||
|
||||
|
||||
func _on_popup_item_pressed(id: int) -> void:
|
||||
var index: int = get_popup().get_item_index(id)
|
||||
if index < 0 or index == _selected_index:
|
||||
return
|
||||
_set_selected(index)
|
||||
item_selected.emit(index)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qlg14pj6jfj7
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"background": "#171B24F2",
|
||||
"surface": "#252B38FA",
|
||||
"surface_hover": "#30394A",
|
||||
"selected_surface": "#103B4D",
|
||||
"text": "#F5F7FA",
|
||||
"text_muted": "#AAB2C0",
|
||||
"edit": "#22C6FF",
|
||||
@@ -32,6 +33,8 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"section": 22,
|
||||
"title": 26,
|
||||
"menu": 20,
|
||||
"menu_bar": 22,
|
||||
"menu_popup": 26,
|
||||
"status": 16,
|
||||
"mode": 24,
|
||||
"prop_name": 20,
|
||||
@@ -40,7 +43,13 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"spacing": {"small": 8, "medium": 12, "large": 16},
|
||||
"radii": {"control": 8, "panel": 10},
|
||||
"borders": {"normal": 1, "focus": 2},
|
||||
"targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56},
|
||||
"targets": {
|
||||
"button_min_height": 44,
|
||||
"row_min_height": 44,
|
||||
"mode_button_min_height": 56,
|
||||
"context_menu_row_height": 48,
|
||||
"context_menu_min_width": 208,
|
||||
},
|
||||
},
|
||||
"editors": {"prop": {}, "stickman": {}},
|
||||
"stickman_canvas": {
|
||||
@@ -54,6 +63,7 @@ const COLOR_ROLE_SECTIONS: Dictionary = {
|
||||
"background": "colors",
|
||||
"surface": "colors",
|
||||
"surface_hover": "colors",
|
||||
"selected_surface": "colors",
|
||||
"text": "colors",
|
||||
"text_muted": "colors",
|
||||
"edit": "colors",
|
||||
@@ -73,6 +83,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
|
||||
"section": "fonts",
|
||||
"title": "fonts",
|
||||
"menu": "fonts",
|
||||
"menu_bar": "fonts",
|
||||
"menu_popup": "fonts",
|
||||
"status": "fonts",
|
||||
"mode": "fonts",
|
||||
"prop_name": "fonts",
|
||||
@@ -87,6 +99,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
|
||||
"button_min_height": "targets",
|
||||
"row_min_height": "targets",
|
||||
"mode_button_min_height": "targets",
|
||||
"context_menu_row_height": "targets",
|
||||
"context_menu_min_width": "targets",
|
||||
}
|
||||
const CONFIG_SIZE_KEYS: Dictionary = {
|
||||
"normal_border": "normal",
|
||||
@@ -208,13 +222,12 @@ func control_theme(editor: StringName) -> Theme:
|
||||
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
|
||||
var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width)
|
||||
theme.set_stylebox("panel", "PanelContainer", panel_box)
|
||||
theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate())
|
||||
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
|
||||
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
|
||||
theme.set_color("font_color", "Label", text)
|
||||
theme.set_color("font_hover_color", "Label", text)
|
||||
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor))
|
||||
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor))
|
||||
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu_popup", editor))
|
||||
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu_bar", editor))
|
||||
theme.set_color("font_color", "MenuBar", text)
|
||||
return theme
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
class_name StageActionSummaryGlyph
|
||||
extends Control
|
||||
## Small, screen-sized glyphs for the Stage action-plan summary rows.
|
||||
|
||||
var _kind: String = "move"
|
||||
var _tint: Color = Color("#8BD9FF")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
focus_mode = Control.FOCUS_NONE
|
||||
|
||||
|
||||
func configure(kind: String, tint: Color) -> void:
|
||||
_kind = kind
|
||||
_tint = tint
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_in_canvas(self, _kind, Vector2.ZERO, 1.0, _tint)
|
||||
|
||||
|
||||
## Draws the same vector glyph into another CanvasItem's active draw callback.
|
||||
## `origin` is the top-left of the 28 x 30 glyph design space.
|
||||
static func draw_in_canvas(canvas: CanvasItem, kind: String, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
if canvas == null or scale_factor <= 0.0:
|
||||
return
|
||||
match kind:
|
||||
"move":
|
||||
_draw_move(canvas, origin, scale_factor, tint)
|
||||
"speak":
|
||||
_draw_speech(canvas, origin, scale_factor, tint)
|
||||
"wait":
|
||||
_draw_wait(canvas, origin, scale_factor, tint)
|
||||
"ragdoll":
|
||||
_draw_ragdoll(canvas, origin, scale_factor, tint)
|
||||
"recover":
|
||||
_draw_recover(canvas, origin, scale_factor, tint)
|
||||
"drop_prop":
|
||||
_draw_drop_prop(canvas, origin, scale_factor, tint)
|
||||
"throw_prop":
|
||||
_draw_throw_prop(canvas, origin, scale_factor, tint)
|
||||
"set_down_prop":
|
||||
_draw_set_down_prop(canvas, origin, scale_factor, tint)
|
||||
"use_prop":
|
||||
_draw_use_prop(canvas, origin, scale_factor, tint)
|
||||
|
||||
|
||||
static func _draw_move(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var route := PackedVector2Array([
|
||||
origin + Vector2(4.0, 24.0) * scale_factor,
|
||||
origin + Vector2(8.0, 15.0) * scale_factor,
|
||||
origin + Vector2(13.0, 19.0) * scale_factor,
|
||||
origin + Vector2(24.0, 7.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(route, tint, 2.8 * scale_factor, true)
|
||||
canvas.draw_circle(origin + Vector2(4.0, 24.0) * scale_factor, 2.5 * scale_factor, tint, true, -1.0, true)
|
||||
var arrow_head := PackedVector2Array([
|
||||
origin + Vector2(17.0, 7.0) * scale_factor,
|
||||
origin + Vector2(25.0, 6.0) * scale_factor,
|
||||
origin + Vector2(24.0, 14.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(arrow_head, tint, 2.8 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_speech(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var outline := PackedVector2Array([
|
||||
origin + Vector2(5.0, 5.0) * scale_factor,
|
||||
origin + Vector2(22.0, 5.0) * scale_factor,
|
||||
origin + Vector2(26.0, 9.0) * scale_factor,
|
||||
origin + Vector2(26.0, 18.0) * scale_factor,
|
||||
origin + Vector2(22.0, 22.0) * scale_factor,
|
||||
origin + Vector2(15.0, 22.0) * scale_factor,
|
||||
origin + Vector2(8.0, 28.0) * scale_factor,
|
||||
origin + Vector2(8.0, 22.0) * scale_factor,
|
||||
origin + Vector2(5.0, 22.0) * scale_factor,
|
||||
origin + Vector2(2.0, 19.0) * scale_factor,
|
||||
origin + Vector2(2.0, 8.0) * scale_factor,
|
||||
origin + Vector2(5.0, 5.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_wait(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var outline := PackedVector2Array([
|
||||
origin + Vector2(6.0, 5.0) * scale_factor,
|
||||
origin + Vector2(24.0, 5.0) * scale_factor,
|
||||
origin + Vector2(22.0, 9.0) * scale_factor,
|
||||
origin + Vector2(17.0, 14.0) * scale_factor,
|
||||
origin + Vector2(22.0, 19.0) * scale_factor,
|
||||
origin + Vector2(24.0, 23.0) * scale_factor,
|
||||
origin + Vector2(6.0, 23.0) * scale_factor,
|
||||
origin + Vector2(8.0, 19.0) * scale_factor,
|
||||
origin + Vector2(13.0, 14.0) * scale_factor,
|
||||
origin + Vector2(8.0, 9.0) * scale_factor,
|
||||
origin + Vector2(6.0, 5.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(9.0, 8.0) * scale_factor, origin + Vector2(21.0, 8.0) * scale_factor, tint, 2.0 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(9.0, 20.0) * scale_factor, origin + Vector2(21.0, 20.0) * scale_factor, tint, 2.0 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_ragdoll(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor
|
||||
var points := PackedVector2Array([
|
||||
center + Vector2(0.0, -13.0) * scale_factor,
|
||||
center + Vector2(3.0, -5.0) * scale_factor,
|
||||
center + Vector2(10.0, -10.0) * scale_factor,
|
||||
center + Vector2(7.0, -2.0) * scale_factor,
|
||||
center + Vector2(14.0, 1.0) * scale_factor,
|
||||
center + Vector2(6.0, 4.0) * scale_factor,
|
||||
center + Vector2(10.0, 11.0) * scale_factor,
|
||||
center + Vector2(2.0, 7.0) * scale_factor,
|
||||
center + Vector2(-2.0, 14.0) * scale_factor,
|
||||
center + Vector2(-5.0, 6.0) * scale_factor,
|
||||
center + Vector2(-12.0, 10.0) * scale_factor,
|
||||
center + Vector2(-8.0, 2.0) * scale_factor,
|
||||
center + Vector2(-14.0, -2.0) * scale_factor,
|
||||
center + Vector2(-6.0, -5.0) * scale_factor,
|
||||
center + Vector2(-10.0, -12.0) * scale_factor,
|
||||
center + Vector2(-2.0, -7.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_colored_polygon(points, tint)
|
||||
|
||||
|
||||
static func _draw_recover(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor
|
||||
var arc := PackedVector2Array()
|
||||
for index: int in 16:
|
||||
var angle: float = deg_to_rad(-55.0 + float(index) / 15.0 * 285.0)
|
||||
arc.append(center + Vector2(cos(angle), sin(angle)) * 10.0 * scale_factor)
|
||||
canvas.draw_polyline(arc, tint, 2.4 * scale_factor, true)
|
||||
var end_angle: float = deg_to_rad(230.0)
|
||||
var tip: Vector2 = center + Vector2(cos(end_angle), sin(end_angle)) * 10.0 * scale_factor
|
||||
var tangent: Vector2 = Vector2(-sin(end_angle), cos(end_angle))
|
||||
var base: Vector2 = tip - tangent * 7.0 * scale_factor
|
||||
var wing: Vector2 = Vector2(-tangent.y, tangent.x) * 4.0 * scale_factor
|
||||
canvas.draw_colored_polygon(PackedVector2Array([
|
||||
tip,
|
||||
base + wing,
|
||||
base - wing,
|
||||
]), tint)
|
||||
|
||||
|
||||
static func _draw_drop_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
canvas.draw_rect(Rect2(origin + Vector2(4.0, 3.0) * scale_factor, Vector2(20.0, 9.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(14.0, 13.0) * scale_factor, origin + Vector2(14.0, 23.0) * scale_factor, tint, 2.5 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(8.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(20.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(4.0, 28.0) * scale_factor, origin + Vector2(24.0, 28.0) * scale_factor, tint, 2.0 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_throw_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
var bow := PackedVector2Array([
|
||||
origin + Vector2(17.0, 3.0) * scale_factor,
|
||||
origin + Vector2(11.0, 5.0) * scale_factor,
|
||||
origin + Vector2(6.0, 9.0) * scale_factor,
|
||||
origin + Vector2(4.0, 15.0) * scale_factor,
|
||||
origin + Vector2(6.0, 21.0) * scale_factor,
|
||||
origin + Vector2(11.0, 25.0) * scale_factor,
|
||||
origin + Vector2(17.0, 27.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(bow, tint, 2.4 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(17.0, 3.0) * scale_factor, origin + Vector2(17.0, 27.0) * scale_factor, tint, 1.6 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(2.0, 15.0) * scale_factor, origin + Vector2(26.0, 15.0) * scale_factor, tint, 2.6 * scale_factor, true)
|
||||
canvas.draw_polyline(PackedVector2Array([
|
||||
origin + Vector2(20.0, 9.0) * scale_factor,
|
||||
origin + Vector2(27.0, 15.0) * scale_factor,
|
||||
origin + Vector2(20.0, 21.0) * scale_factor,
|
||||
]), tint, 2.6 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_set_down_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
canvas.draw_line(origin + Vector2(14.0, 3.0) * scale_factor, origin + Vector2(14.0, 12.0) * scale_factor, tint, 2.4 * scale_factor, true)
|
||||
canvas.draw_polyline(PackedVector2Array([
|
||||
origin + Vector2(9.0, 8.0) * scale_factor,
|
||||
origin + Vector2(14.0, 13.0) * scale_factor,
|
||||
origin + Vector2(19.0, 8.0) * scale_factor,
|
||||
]), tint, 2.4 * scale_factor, true)
|
||||
canvas.draw_rect(Rect2(origin + Vector2(6.0, 14.0) * scale_factor, Vector2(16.0, 10.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
|
||||
canvas.draw_line(origin + Vector2(3.0, 27.0) * scale_factor, origin + Vector2(25.0, 27.0) * scale_factor, tint, 2.0 * scale_factor, true)
|
||||
|
||||
|
||||
static func _draw_use_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
|
||||
canvas.draw_rect(Rect2(origin + Vector2(3.0, 12.0) * scale_factor, Vector2(16.0, 13.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
|
||||
var spark := PackedVector2Array([
|
||||
origin + Vector2(22.0, 3.0) * scale_factor,
|
||||
origin + Vector2(24.0, 9.0) * scale_factor,
|
||||
origin + Vector2(28.0, 11.0) * scale_factor,
|
||||
origin + Vector2(23.0, 13.0) * scale_factor,
|
||||
origin + Vector2(21.0, 18.0) * scale_factor,
|
||||
origin + Vector2(19.0, 13.0) * scale_factor,
|
||||
origin + Vector2(15.0, 11.0) * scale_factor,
|
||||
origin + Vector2(20.0, 9.0) * scale_factor,
|
||||
origin + Vector2(22.0, 3.0) * scale_factor,
|
||||
])
|
||||
canvas.draw_polyline(spark, tint, 2.0 * scale_factor, true)
|
||||