Files
Oxide/docs/play-mode.md
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

121 lines
5.2 KiB
Markdown

# Editor Play Mode (Stage 8.7)
Play mode lets the editor **run the open scene in place** — play, pause, single-
step, and stop — driving the same fixed-timestep
[`Schedule`](modules.md) a shipped game uses, so what you see while playing
behaves like the real runtime. Mutations made while playing (physics moving
bodies, scripts spawning entities) are reverted on **Stop**, so the authored
scene is never corrupted.
This page covers the play-state model, the snapshot/restore that makes Stop
safe, and how the host runner drives the engine. The standalone **"Launch"**
button (running the *real* exported runtime in a separate process) is a Stage 16
follow-up and is **not** part of play mode.
## The play states
`oxide_editor::state::PlayState` is a three-state machine held on `EditorState`:
| State | Meaning | Schedule ticked? |
|-------|---------|------------------|
| `Editing` | Normal authoring | no |
| `Playing` | Running | every frame (`App::update`) |
| `Paused` | Frozen, still live | only on **Step** (one fixed tick) |
Transitions are methods on `EditorState`:
- `enter_play()` — snapshots the scene and switches to `Playing`. No-op if
already running (re-entering must not clobber the original snapshot).
- `toggle_pause()``Playing``Paused`; no-op while `Editing`.
- `stop()` — restores the snapshot, clears the selection and any in-flight gizmo
drag (entity handles change on restore), and returns to `Editing`.
The shell wraps these with status messages and undo-history clearing (see
[Controls](#controls)); `is_in_play()` is the "running or paused" predicate the
host uses to gate the runtime.
## Snapshot / restore
Pressing Play captures a [`SceneSnapshot`](scene.md) of the current scene;
pressing Stop restores it. Unlike `Scene::to_ron` (which records only the node-
baked `Node`/`Transform`/hierarchy), a snapshot is **registry-aware**: it also
serializes every reflected component on each entity, plus the engine-intrinsic
non-reflected components (`Tags` and `DisabledComponents`). That makes Stop a
**bit-for-bit** revert across the full component set, not just transforms.
```rust
use oxide_engine::scene::SceneSnapshot;
let snap = scene.snapshot(&registry); // capture
// … play-mode mutations …
let scene = snap.restore(&registry)?; // revert (fresh entity handles)
```
Fidelity is bounded by what the [`TypeRegistry`](reflection.md) knows: a
component that is neither registered nor one of the two intrinsics is invisible
to capture. Modules register their components anyway (that is what makes them
editable), so they survive play mode automatically.
`SceneSnapshot` also exposes `to_ron`/`from_ron`, so the same capture format
seeds full scene files in a later stage.
## Driving the schedule
The editor holds the live scene in `EditorState.scene`, not in an
[`App`](modules.md). On Play the host runner (`oxide_editor::main`) builds a play
`App` (currently just `DefaultModules`; Stage 9's physics module and the
project's modules will register here too) and, each frame:
1. **swaps** `state.scene` into `app.scene` (an O(1) move),
2. advances the engine, then
3. **swaps** the scene back out.
So the `App` only "holds" the editor scene for the duration of a tick, and
`state.scene` stays the single source of truth the inspector, hierarchy, and
viewport read between frames. The `App` persists its `Time` and resources across
frames (so a physics world accumulates correctly) and is dropped on Stop.
How far to advance is decided by a small pure function,
`oxide_editor::play::tick_for`, kept separate from the (un-testable) GUI runner
so the contract is pinned in a unit test:
```rust
use oxide_editor::play::{tick_for, Tick};
match tick_for(state.play, step_requested) {
Tick::Frame => app.update(dt), // Playing
Tick::FixedStep => app.step(), // Paused + Step
Tick::Idle => {} // Editing, or Paused with no Step
}
```
`App::step()` (engine) advances **exactly one fixed timestep** — one
`FixedUpdate` plus the per-frame phases once, bypassing the accumulator — which
is the Step primitive. `App::update(dt)` runs a normal frame, fixed steps driven
by the accumulator as usual (see [modules.md](modules.md)).
## Controls
A toolbar below the menu bar exposes **Play / Pause / Step / Stop**, gated by the
play state (Pause/Step/Stop enable only while running) with an
`Editing`/`PLAYING`/`PAUSED` badge. The viewport additionally draws a coloured
border + corner label while running (green = playing, amber = paused) so
edit-vs-play is unmistakable.
Shortcuts:
- **Ctrl+P** — Play when editing; Pause ⇄ Resume while running.
- **Ctrl+.** — Step one fixed tick (while paused).
Entering Play and pressing Stop both **clear the undo history**: the scene is
restored wholesale on Stop, so play-mode edits are deliberately not part of
edit-mode undo. The reflection inspector and gizmos stay live while paused (and
playing), so a field can be tweaked and the result observed immediately — the
payoff of the Stage-8.5 reflection work.
## Not in play mode (Stage 16)
A **"Launch standalone"** button that runs the real exported runtime in a
separate window/process — the truest-to-ship check — is deferred to Stage 16,
where it reuses the export builder rather than the in-editor loop.