From e374225f11af91d937ef6e43272eae612f89dfa6 Mon Sep 17 00:00:00 2001 From: eben-vranken Date: Wed, 11 Nov 2026 16:37:17 +0100 Subject: [PATCH 1/4] Add licence texts and a CI job (fmt, clippy, tests) Co-Authored-By: Claude --- .github/workflows/ci.yml | 35 ++++++++ LICENSE-APACHE | 176 +++++++++++++++++++++++++++++++++++++++ LICENSE-MIT | 27 ++++++ 3 files changed, 238 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3138ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: fmt, clippy, test + # The engine is developed on Windows. The runner image has LLVM/Clang, + # which AudioNimbus needs to generate its Steam Audio bindings. + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + # Keeps built dependencies and the Steam Audio download (about + # 180 MB, fetched by the first build) between runs. + - uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..2f7b5d7 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,27 @@ +MIT License + +Copyright (c) 2026 Eben Vranken + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. From f2f237fe1cdf21a8960afa02a5163696553588f5 Mon Sep 17 00:00:00 2001 From: eben-vranken Date: Wed, 11 Nov 2026 16:37:17 +0100 Subject: [PATCH 2/4] Saves keep physics and script state; the game's keys are data Saved games now hold where physics bodies and scripted objects are and what scripts remember in `this` (objects and abilities), per object id. The game's keys live in player.ron (keys: forward, jump, use, ...), are edited in the player feel panel, shown in prompts, and scripts can ask for them by action. Co-Authored-By: Claude --- README.md | 12 +- crates/engine/src/bench.rs | 13 +- crates/engine/src/dialogue.rs | 11 +- crates/engine/src/editor/conversation.rs | 2 + crates/engine/src/editor/project_page.rs | 2 + crates/engine/src/editor/script_tab.rs | 6 +- crates/engine/src/input.rs | 114 +++++++++++++++ crates/engine/src/player/mod.rs | 20 +-- crates/engine/src/player/settings.rs | 101 ++++++++++++- crates/engine/src/player/tuning.rs | 28 ++++ crates/engine/src/runtime/game.rs | 3 +- crates/engine/src/runtime/keys.rs | 4 +- crates/engine/src/runtime/mod.rs | 10 +- crates/engine/src/runtime/story.rs | 91 +++++++++--- crates/engine/src/save.rs | 48 +++++- crates/engine/src/script/mod.rs | 177 +++++++++++++++++------ crates/engine/src/script/value.rs | 77 ++++++++++ docs/DECISIONS.md | 4 + docs/ROADMAP.md | 16 ++ sandbox/scripts/double_jump.rhai | 2 +- 20 files changed, 649 insertions(+), 92 deletions(-) create mode 100644 crates/engine/src/script/value.rs diff --git a/README.md b/README.md index 95ce704..2ad2a05 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Themed as a sparse amber-on-black terminal, in three workspaces chosen in the to - **BUILD:** scenes in tabs, each with its own undo history, selection, camera and unsaved state; right-click a tab to rename its scene (its graph, bakes, the start scene and LOAD SCENE / WHEN IN SCENE nodes follow). The outliner groups objects by zone with a type tag each, `!` on objects with a problem, filter chips, search (`candle > flame` finds parts) and a right-click menu (rename, duplicate, copy, paste, frame, move to zone, show in LOGIC, delete); right-click ENVIRONMENT to copy it and paste it into another scene's; the assets drawer lists models (drag them in), textures and sounds. ADD (A, or + ADD) is one searchable dialog for shapes, models, behaviors and zones. The viewport has floating move/rotate/scale tools with LOCAL / WORLD axes, a SHOW menu (zone volumes, triggers, probes) and the bakes' state; drag over it to box-select. The inspector shows collapsible cards generated from the behaviors via `bevy_reflect` (their `///` comments are the tooltips), an IN LOGIC card (the graphs that mention the object) and ADD BEHAVIOR. Multi-select edits apply to every selected object as one undo step. PROBLEMS (status bar) lists every broken link in the project. - **LOGIC:** its own tabs (the scene in BUILD stays as it is): scripts in a code editor (see Scripts), and gameplay graphs grouped by owner: one per scene (`graphs/scenes/.ron`: loaded and run only while the scene is, and carries on where it was when the player returns; flags are global, so one scene's graph can change what another's sees), one per character (their dialogue), and others started by RUN GRAPH. Each graph shows its owner, what uses it and references that point at nothing. Nodes: events (in a zone or trigger, used, picked up, a script's signal, another graph ended), waits, branches on flags, actions (doors, show/hide, sounds, load scene, save, turn an ability on or off) and dialogue (lines with an optional sound, replies with conditions, renaming characters). The CONVERSATION view reads a dialogue as a script with a live preview and TEST CONVERSATION. Flags (`flags.ron`) are edited beside the graphs; a yes/no flag can have a script bound to it, which makes it an ability. A graph runs once at a time: starting one that runs already (RUN GRAPH, talking) waits for that one. - **PLAY** (F5, PLAY FROM HERE at the editor camera, or CONTINUE SAVE) runs the scene in place under a tinted bar with PAUSE (F6), STEP (F7), SPEED ¼–2× and FREE CAMERA (F2); STOP restores it, and asks whether to keep player feel changed while playing. The test drawer (F3; F1 opens its FEEL tab) shows the running graphs (OPEN AT NODE jumps to one in LOGIC) and an events log, flips flags, holds the audio switches, the player feel, the map and frame stats. -- **Game systems:** characters to talk to, pickups, save points (one save per project), objects visible when a flag condition holds (gates), abilities (yes/no flags with a Rhai script from `scripts/` bound to them), and a map of visited zones (M). +- **Game systems:** characters to talk to, pickups, save points (one save per project: the player, flags, running graphs, objects shown or hidden, doors, where physics bodies and scripted objects are, and what scripts remember in `this`), objects visible when a flag condition holds (gates), abilities (yes/no flags with a Rhai script from `scripts/` bound to them), and a map of visited zones (M). - **Stairs:** ADD ▸ STAIRS, or TYPE ▸ STAIRS on any primitive: width, total height, steps (the step height is shown), step depth, a turn over the whole flight (0° straight, 90° a quarter turn, 360° and more a spiral) and an inner radius (0 winds around a central post). A flight is solid to the floor (past a full turn its upper steps are slabs you can walk under) and collides step by step, so the player's step climbing works on every shape; a full spiral is entered from its outside, beside the last step. - **Bakes:** BAKE LIGHTING and BAKE ACOUSTICS per scene. **Packages:** a whole project in one `.gamepack` file to hand to someone else. **Export game:** FILE ▸ EXPORT GAME writes the game as one executable for players. - **Models** are made in Blender (or any glTF exporter) and dragged in; re-exporting over the file updates every scene using it. A glTF's named nodes become **parts** (a candle's stick, wax and flame), which a scene can give their own glow, collision and light. @@ -124,6 +124,8 @@ Other flags: `--player` to play as a player gets the game (no debug tools; the e |---|---| | Playing | WASD move, Space jump, Shift sprint, Ctrl/C crouch, E use / talk / take, M map, Q clap, R respawn, F1 player feel, F2 free camera, F3 test drawer, F6 pause, F7 step, Esc free the mouse | | Dialogue | W/S or arrows choose, 1–9 pick, E / Space / Enter continue | + +The game's own keys (move, jump, sprint, crouch, use, clap, map) are the defaults of `player.ron`'s `keys`: each is one or more key names separated by spaces (`crouch: "ctrl c"`), edited under KEYS in the player feel panel. Prompts and dialogue hints show the bound key, and scripts ask for a key by what it does (`player.pressed("jump")`). The debug and editor keys are fixed. | Anywhere | `[` `]` render scale, 0 auto scale, V vsync | | Editor | Ctrl+K search or run anything, ? every key, click select, Shift+click add, drag box select, W/E/R move/rotate/scale, Ctrl snap, A add, F frame, F2 rename, right mouse + WASD fly, Ctrl+C/V copy/paste, Ctrl+D duplicate, Del delete, Ctrl+Z/Y undo/redo, Ctrl+S save, Ctrl+Shift+S save all, F5 play/stop, Tab/Shift+Tab panels, Space add a node (LOGIC) | @@ -131,7 +133,7 @@ Other flags: `--player` to play as a player gets the game (no debug tools; the e ``` game.ron name, start scene, render scale -player.ron movement, camera and footstep feel +player.ron movement, camera and footstep feel, and the game's keys flags.ron the game's flags and their starting values graphs/ gameplay graphs: scenes/.ron per scene, dialogue scripts/ Rhai scripts: abilities the graphs turn on @@ -158,7 +160,7 @@ fn start() { this.used = 0; } // once, when turned on; `this` is the fn update(player, dt) { // every frame while on if player.grounded { this.used = 0; } - if player.pressed("space") && !player.grounded && !player.jumped && this.used < 1 { + if player.pressed("jump") && !player.grounded && !player.jumped && this.used < 1 { player.jump(); this.used += 1; } @@ -170,7 +172,7 @@ fn update(player, dt) { // every frame while on | `player.grounded`, `.jumped`, `.crouching`, `.sprinting` | the controller's state (`jumped`: its own jump this frame) | | `player.position`, `player.velocity` | feet position (read only) and velocity, as `vec3` with `.x .y .z`; `player.velocity.y = 6` sets it | | `player.jump()`, `player.jump_speed` | a full jump from wherever the player is (held for variable height); its upward speed | -| `player.pressed("e")`, `player.held("shift")` | keys: letters, digits, `space`, `shift`, `ctrl`, `alt`, `tab`, `enter`, `up`/`down`/`left`/`right` | +| `player.pressed("jump")`, `player.held("sprint")` | a key by what the game binds it to (`forward`, `back`, `strafe_left`, `strafe_right`, `jump`, `sprint`, `crouch`, `use`, `clap`, `map`), or a key itself: letters, digits, `space`, `shift`, `ctrl`, `alt`, `tab`, `enter`, `up`/`down`/`left`/`right` | | `object.name`, `object.position`, `.rotation`, `.scale`, `.visible` | the object a script runs on (rotation in degrees; hidden = no collision) | | `player()` | the player, from any script (an object script uses it to find the player) | | `flag("name")`, `set_flag("name", value)` | the game's flags, shared with every graph | @@ -196,4 +198,4 @@ sandbox/ the test project ## License -MIT OR Apache-2.0. +MIT OR Apache-2.0, at your option: see [LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE). diff --git a/crates/engine/src/bench.rs b/crates/engine/src/bench.rs index bb2fada..b0c8833 100644 --- a/crates/engine/src/bench.rs +++ b/crates/engine/src/bench.rs @@ -21,7 +21,6 @@ use crate::player::Player; use glam::{Vec2, Vec3, Vec3Swizzles}; use serde::Deserialize; use std::path::Path; -use winit::keyboard::KeyCode; #[derive(Debug, Clone, Deserialize)] pub struct Route { @@ -132,7 +131,13 @@ impl Bench { /// This frame's input; turns the player toward the stop. `None` when /// the route is done. - pub fn drive(&mut self, player: &mut Player, eye_height: f32, dt: f32) -> Option { + pub fn drive( + &mut self, + player: &mut Player, + keys: &crate::player::settings::Keys, + eye_height: f32, + dt: f32, + ) -> Option { let stop = self.route.stops.get(self.stop)?.clone(); let mut input = Input::default(); let feet = player.feet; @@ -147,7 +152,7 @@ impl Bench { if to.length() > stop.within.unwrap_or(ARRIVED) && !stuck { player.yaw = to.x.atan2(-to.y); player.pitch = 0.0; - input.key_down(KeyCode::KeyW); + input.press_bound(&keys.forward); let speed = player.velocity.xz().length(); if walking && speed < STALLED_SPEED { self.stalled.0 += dt; @@ -189,7 +194,7 @@ impl Bench { player.pitch = d.y.atan2(d.xz().length()); } if stop.press_use { - input.key_down(KeyCode::KeyE); + input.press_bound(&keys.interact); } self.phase = Phase::Waiting { left: stop.wait }; } diff --git a/crates/engine/src/dialogue.rs b/crates/engine/src/dialogue.rs index 98f824b..30ebb4f 100644 --- a/crates/engine/src/dialogue.rs +++ b/crates/engine/src/dialogue.rs @@ -133,6 +133,10 @@ pub struct Shown<'a> { pub replies: &'a [String], /// The highlighted reply. pub choice: usize, + /// The key that goes on, as the hint shows it (`E`). + pub go_key: &'a str, + /// The keys that pick a reply (`W/S`). + pub choose_keys: &'a str, } /// Draws the line over the game view `image`. @@ -163,12 +167,13 @@ pub fn draw(ui: &egui::Ui, image: Rect, style: &DialogueStyle, custom_font: bool let text_color = color(style.text_color); let name_color = color(style.name_color); let dim = text_color.gamma_multiply(0.55); + let (go, choose) = (shown.go_key, shown.choose_keys); let hint = if !shown.complete { - "E ›" + format!("{go} ›") } else if shown.replies.is_empty() { - "E CONTINUE" + format!("{go} CONTINUE") } else { - "W/S CHOOSE E ANSWER" + format!("{choose} CHOOSE {go} ANSWER") }; egui::Area::new(egui::Id::unique("dialogue")) diff --git a/crates/engine/src/editor/conversation.rs b/crates/engine/src/editor/conversation.rs index 5687226..5c5c936 100644 --- a/crates/engine/src/editor/conversation.rs +++ b/crates/engine/src/editor/conversation.rs @@ -274,6 +274,8 @@ pub(super) fn draw_box( complete: true, replies, choice, + go_key: "E", + choose_keys: "W/S", }, ); } diff --git a/crates/engine/src/editor/project_page.rs b/crates/engine/src/editor/project_page.rs index 4003a47..67e7974 100644 --- a/crates/engine/src/editor/project_page.rs +++ b/crates/engine/src/editor/project_page.rs @@ -510,6 +510,8 @@ fn dialogue_preview(ui: &mut Ui, style: &DialogueStyle) { complete, replies: &replies, choice: 0, + go_key: "E", + choose_keys: "W/S", }, ); ui.ctx().request_repaint(); diff --git a/crates/engine/src/editor/script_tab.rs b/crates/engine/src/editor/script_tab.rs index 5749bb6..78a7a7e 100644 --- a/crates/engine/src/editor/script_tab.rs +++ b/crates/engine/src/editor/script_tab.rs @@ -308,8 +308,10 @@ impl Editor { } }); ui.add_space(6.0); - ui.label(dim("KEYS: LETTERS, DIGITS, SPACE, SHIFT, CTRL, ALT,")); - ui.label(dim("TAB, ENTER, UP, DOWN, LEFT, RIGHT.")); + ui.label(dim("KEYS, AS THE GAME BINDS THEM: FORWARD, BACK,")); + ui.label(dim("STRAFE_LEFT, STRAFE_RIGHT, JUMP, SPRINT, CROUCH, USE,")); + ui.label(dim("CLAP, MAP. OR A KEY ITSELF: LETTERS, DIGITS, SPACE,")); + ui.label(dim("SHIFT, CTRL, ALT, TAB, ENTER, UP, DOWN, LEFT, RIGHT.")); } } diff --git a/crates/engine/src/input.rs b/crates/engine/src/input.rs index e7c37b9..88ed3d5 100644 --- a/crates/engine/src/input.rs +++ b/crates/engine/src/input.rs @@ -16,7 +16,92 @@ pub struct Input { pub looking: bool, } +/// A key by its name in `player.ron` and in scripts: a letter or digit, +/// `space`, `shift`, `ctrl`, `alt`, `tab`, `enter`, `up` / `down` / +/// `left` / `right`. +pub fn key_named(name: &str) -> Option { + use KeyCode::*; + const LETTERS: [KeyCode; 26] = [ + KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, KeyO, + KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, + ]; + const DIGITS: [KeyCode; 10] = [ + Digit0, Digit1, Digit2, Digit3, Digit4, Digit5, Digit6, Digit7, Digit8, Digit9, + ]; + let lower = name.to_ascii_lowercase(); + match lower.as_str() { + "space" => Some(Space), + "shift" => Some(ShiftLeft), + "ctrl" => Some(ControlLeft), + "alt" => Some(AltLeft), + "tab" => Some(Tab), + "enter" => Some(Enter), + "up" => Some(ArrowUp), + "down" => Some(ArrowDown), + "left" => Some(ArrowLeft), + "right" => Some(ArrowRight), + one if one.len() == 1 => match one.as_bytes()[0] { + c @ b'a'..=b'z' => Some(LETTERS[(c - b'a') as usize]), + c @ b'0'..=b'9' => Some(DIGITS[(c - b'0') as usize]), + _ => None, + }, + _ => None, + } +} + +/// The keys of a binding: key names separated by spaces (`"ctrl c"`), any +/// of which does the job. Shift, Ctrl, Alt and Enter count on both sides +/// of the keyboard; names that aren't keys are skipped. +fn bound(binding: &str) -> impl Iterator + '_ { + binding + .split_whitespace() + .filter_map(key_named) + .flat_map(|key| { + let twin = match key { + KeyCode::ShiftLeft => Some(KeyCode::ShiftRight), + KeyCode::ControlLeft => Some(KeyCode::ControlRight), + KeyCode::AltLeft => Some(KeyCode::AltRight), + KeyCode::Enter => Some(KeyCode::NumpadEnter), + _ => None, + }; + [Some(key), twin].into_iter().flatten() + }) +} + +/// The names in a binding that aren't keys. +pub fn unknown_keys(binding: &str) -> impl Iterator { + binding + .split_whitespace() + .filter(|name| key_named(name).is_none()) +} + +/// A binding's first key as prompts show it (`E`, `SPACE`). +pub fn key_label(binding: &str) -> String { + binding + .split_whitespace() + .next() + .unwrap_or("?") + .to_uppercase() +} + impl Input { + /// Whether any key of the binding is held. + pub fn bound_held(&self, binding: &str) -> bool { + bound(binding).any(|key| self.held(key)) + } + + /// Whether any key of the binding went down this frame. + pub fn bound_pressed(&self, binding: &str) -> bool { + bound(binding).any(|key| self.pressed(key)) + } + + /// Presses the binding's first key. + pub fn press_bound(&mut self, binding: &str) { + if let Some(key) = bound(binding).next() { + self.key_down(key); + } + } + pub fn held(&self, key: KeyCode) -> bool { self.held.contains(&key) } @@ -49,3 +134,32 @@ impl Input { self.scroll = 0.0; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn any_key_of_a_binding_counts() { + let mut input = Input::default(); + assert!(!input.bound_held("ctrl c")); + input.key_down(KeyCode::KeyC); + assert!(input.bound_held("ctrl c") && input.bound_pressed("CTRL C")); + input.key_up(KeyCode::KeyC); + input.key_down(KeyCode::ControlRight); + assert!(input.bound_held("ctrl c")); + assert!(!input.bound_held("space") && !input.bound_held("")); + } + + #[test] + fn names_that_are_not_keys_are_found() { + assert_eq!( + unknown_keys("w spacebar up").collect::>(), + ["spacebar"] + ); + assert_eq!(key_label("e enter"), "E"); + let mut input = Input::default(); + input.press_bound("spacebar space"); + assert!(input.pressed(KeyCode::Space)); + } +} diff --git a/crates/engine/src/player/mod.rs b/crates/engine/src/player/mod.rs index 2e79d11..64b7daa 100644 --- a/crates/engine/src/player/mod.rs +++ b/crates/engine/src/player/mod.rs @@ -14,7 +14,6 @@ use rapier3d::control::{CharacterLength, KinematicCharacterController}; use rapier3d::parry::query::ShapeCastOptions; use rapier3d::prelude::{Ball, Pose, Ray, SharedShape}; use settings::PlayerSettings; -use winit::keyboard::KeyCode; /// Where the player starts and respawns. #[derive(Debug, Clone, Copy)] @@ -111,6 +110,7 @@ impl Player { jump_speed: settings.jump.takeoff_speed(), jump: false, input: input.clone(), + keys: settings.keys.clone(), } } @@ -163,7 +163,8 @@ impl Player { } // Crouch: going down is instant, standing up needs headroom. - let wants_crouch = input.held(KeyCode::ControlLeft) || input.held(KeyCode::KeyC); + let keys = &settings.keys; + let wants_crouch = input.bound_held(&keys.crouch); if wants_crouch { self.crouching = true; } else if self.crouching && self.has_headroom(settings, physics) { @@ -176,17 +177,17 @@ impl Player { }; // Horizontal movement. - let axis = |pos: KeyCode, neg: KeyCode| { - input.held(pos) as i32 as f32 - input.held(neg) as i32 as f32 + let axis = |pos: &str, neg: &str| { + input.bound_held(pos) as i32 as f32 - input.bound_held(neg) as i32 as f32 }; - let forward_input = axis(KeyCode::KeyW, KeyCode::KeyS); - let right_input = axis(KeyCode::KeyD, KeyCode::KeyA); + let forward_input = axis(&keys.forward, &keys.back); + let right_input = axis(&keys.right, &keys.left); let (sin, cos) = self.yaw.sin_cos(); let forward = Vec2::new(sin, -cos); let right = Vec2::new(cos, sin); let wish = (forward * forward_input + right * right_input).normalize_or_zero(); - self.sprinting = input.held(KeyCode::ShiftLeft) && forward_input > 0.0 && !self.crouching; + self.sprinting = input.bound_held(&keys.sprint) && forward_input > 0.0 && !self.crouching; let speed = if self.crouching { movement.crouch_speed } else if self.sprinting { @@ -219,7 +220,7 @@ impl Player { } // Jump: buffered presses, coyote time. - if input.pressed(KeyCode::Space) { + if input.bound_pressed(&keys.jump) { self.jump_buffer = jump.buffer_time; } else { self.jump_buffer = (self.jump_buffer - dt).max(0.0); @@ -239,7 +240,7 @@ impl Player { // Gravity: lighter on the way up while jump is held, heavier when // falling or when jump was released early. let gravity_scale = if self.velocity.y > 0.0 { - if self.jumped && !input.held(KeyCode::Space) { + if self.jumped && !input.bound_held(&keys.jump) { jump.jump_cut_gravity_multiplier } else { 1.0 @@ -546,6 +547,7 @@ mod tests { use crate::assets::Assets; use crate::render::mesh::Shape; use crate::scene::test_scene; + use winit::keyboard::KeyCode; fn flat_world() -> PhysicsWorld { PhysicsWorld::from_scene( diff --git a/crates/engine/src/player/settings.rs b/crates/engine/src/player/settings.rs index e6cb992..decae48 100644 --- a/crates/engine/src/player/settings.rs +++ b/crates/engine/src/player/settings.rs @@ -17,6 +17,85 @@ pub struct PlayerSettings { pub body: Body, pub camera: CameraFeel, pub footsteps: Footsteps, + pub keys: Keys, +} + +/// The game's keys. Each is one or more key names separated by spaces, any +/// of which does the job (`"ctrl c"`): a letter or digit, `space`, `shift`, +/// `ctrl`, `alt`, `tab`, `enter`, `up`, `down`, `left`, `right`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reflect)] +#[serde(default)] +pub struct Keys { + pub forward: String, + pub back: String, + pub left: String, + pub right: String, + pub jump: String, + pub sprint: String, + pub crouch: String, + /// Use, talk, take, save; also goes on in a dialogue. + pub interact: String, + pub clap: String, + pub map: String, +} + +impl Default for Keys { + fn default() -> Self { + Self { + forward: "w".into(), + back: "s".into(), + left: "a".into(), + right: "d".into(), + jump: "space".into(), + sprint: "shift".into(), + crouch: "ctrl c".into(), + interact: "e".into(), + clap: "q".into(), + map: "m".into(), + } + } +} + +impl Keys { + /// Every key with the name scripts ask for it by + /// (`player.pressed("jump")`). Moving left and right are `strafe_left` + /// and `strafe_right` there: `left` and `right` are the arrow keys. + pub fn actions(&self) -> [(&'static str, &str); 10] { + [ + ("forward", &self.forward), + ("back", &self.back), + ("strafe_left", &self.left), + ("strafe_right", &self.right), + ("jump", &self.jump), + ("sprint", &self.sprint), + ("crouch", &self.crouch), + ("use", &self.interact), + ("clap", &self.clap), + ("map", &self.map), + ] + } + + /// The binding of the action scripts call `name`. + pub fn action(&self, name: &str) -> Option<&str> { + let actions = self.actions(); + actions.iter().find(|(n, _)| *n == name).map(|(_, b)| *b) + } + + /// What is wrong with the bindings: names that aren't keys, and actions + /// without a key. + pub fn problems(&self) -> Vec { + let mut problems = Vec::new(); + for (action, binding) in self.actions() { + for name in crate::input::unknown_keys(binding) { + problems.push(format!("{action}: no key named \"{name}\"")); + } + let keys = binding.split_whitespace(); + if keys.filter_map(crate::input::key_named).next().is_none() { + problems.push(format!("{action} has no key")); + } + } + problems + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reflect)] @@ -248,10 +327,14 @@ impl PlayerSettings { path: path.to_owned(), source, })?; - ron::from_str(&text).map_err(|source| SettingsError::Parse { + let settings: Self = ron::from_str(&text).map_err(|source| SettingsError::Parse { path: path.to_owned(), source: Box::new(source), - }) + })?; + for problem in settings.keys.problems() { + log::warn!("{}: keys: {problem}", path.display()); + } + Ok(settings) } pub fn save(&self, path: &Path) -> Result<(), SettingsError> { @@ -334,6 +417,20 @@ mod tests { assert_eq!(settings.movement, Movement::default()); } + #[test] + fn keys_default_and_report_problems() { + let settings: PlayerSettings = + ron::from_str("(keys: (jump: \"j spacebar\", map: \"\"))").unwrap(); + assert_eq!(settings.keys.forward, "w"); + assert_eq!(settings.keys.action("jump"), Some("j spacebar")); + assert_eq!(settings.keys.action("left"), None); + assert_eq!( + settings.keys.problems(), + ["jump: no key named \"spacebar\"", "map has no key"] + ); + assert!(Keys::default().problems().is_empty()); + } + #[test] fn jump_reaches_its_height() { let jump = Jump::default(); diff --git a/crates/engine/src/player/tuning.rs b/crates/engine/src/player/tuning.rs index dd9d5f5..5e335e1 100644 --- a/crates/engine/src/player/tuning.rs +++ b/crates/engine/src/player/tuning.rs @@ -119,6 +119,27 @@ pub fn sections(ui: &mut Ui, s: &mut PlayerSettings) { row(ui, "FOV KICK TIME", &mut c.fov_kick_time, 0.01..=2.0, "S"); row(ui, "STEP SMOOTHING", &mut c.step_smoothing, 0.0..=0.5, "S"); }); + section(ui, "KEYS", |ui| { + let k = &mut s.keys; + key(ui, "FORWARD", &mut k.forward); + key(ui, "BACK", &mut k.back); + key(ui, "LEFT", &mut k.left); + key(ui, "RIGHT", &mut k.right); + key(ui, "JUMP", &mut k.jump); + key(ui, "SPRINT", &mut k.sprint); + key(ui, "CROUCH", &mut k.crouch); + key(ui, "USE", &mut k.interact); + key(ui, "CLAP", &mut k.clap); + key(ui, "MAP", &mut k.map); + }); + ui.label( + RichText::new("KEY NAMES, SEVERAL WITH SPACES BETWEEN: A LETTER OR DIGIT, SPACE, SHIFT, CTRL, ALT, TAB, ENTER, UP, DOWN, LEFT, RIGHT") + .color(theme::AMBER_DIM) + .small(), + ); + for problem in s.keys.problems() { + ui.label(RichText::new(problem.to_uppercase()).color(theme::ALERT)); + } } fn section(ui: &mut Ui, title: &str, add_rows: impl FnOnce(&mut Ui)) { @@ -150,6 +171,13 @@ fn row(ui: &mut Ui, label: &str, value: &mut f32, range: RangeInclusive, un ui.end_row(); } +/// A key binding, typed as key names. +fn key(ui: &mut Ui, label: &str, binding: &mut String) { + ui.label(RichText::new(label).color(theme::AMBER_DIM)); + ui.add(egui::TextEdit::singleline(binding).desired_width(110.0)); + ui.end_row(); +} + fn toggle(ui: &mut Ui, label: &str, value: &mut bool) { ui.label(RichText::new(label).color(theme::AMBER_DIM)); let text = if *value { "[ON] " } else { "[OFF]" }; diff --git a/crates/engine/src/runtime/game.rs b/crates/engine/src/runtime/game.rs index 2f04448..b0818d5 100644 --- a/crates/engine/src/runtime/game.rs +++ b/crates/engine/src/runtime/game.rs @@ -7,7 +7,6 @@ use crate::physics::PhysicsWorld; use crate::player::{Player, Spawn}; use crate::scene::{Component, Scene}; use glam::Vec3; -use winit::keyboard::KeyCode; impl Running { /// Behaviors act on the scene; moved objects are redrawn and their @@ -92,7 +91,7 @@ impl Running { let Some(audio) = &mut self.audio else { return; }; - if self.input.pressed(KeyCode::KeyQ) { + if self.input.bound_pressed(&self.settings.keys.clap) { audio.clap(eye.position); } if self.mode != CameraMode::Player { diff --git a/crates/engine/src/runtime/keys.rs b/crates/engine/src/runtime/keys.rs index e6b5722..d7b6ed0 100644 --- a/crates/engine/src/runtime/keys.rs +++ b/crates/engine/src/runtime/keys.rs @@ -18,7 +18,7 @@ impl Running { // As a player gets the game: only its own keys (map, clap, use, // dialogue; Esc frees the mouse). No debug keys. if self.player_view { - if self.input.pressed(KeyCode::KeyM) + if self.input.bound_pressed(&self.settings.keys.map) && let Some(story) = &mut self.story && story.line.is_none() { @@ -38,7 +38,7 @@ impl Running { } } // The map while the game runs. - if self.input.pressed(KeyCode::KeyM) + if self.input.bound_pressed(&self.settings.keys.map) && let Some(story) = &mut self.story && story.line.is_none() { diff --git a/crates/engine/src/runtime/mod.rs b/crates/engine/src/runtime/mod.rs index 8a46db2..14a843a 100644 --- a/crates/engine/src/runtime/mod.rs +++ b/crates/engine/src/runtime/mod.rs @@ -665,7 +665,7 @@ impl Running { if let Some(bench) = &mut self.bench { let eye = self.player.camera(&self.settings).position.y - self.player.feet.y; - match bench.drive(&mut self.player, eye, dt) { + match bench.drive(&mut self.player, &self.settings.keys, eye, dt) { Some(input) => self.input = input, None => { self.bench = None; @@ -769,6 +769,12 @@ impl Running { .as_ref() .and_then(|s| s.toast.as_ref().map(|t| t.0.clone())); let style = self.story.as_ref().map(|s| s.style.clone()); + let go_key = crate::input::key_label(&self.settings.keys.interact); + let choose_keys = format!( + "{}/{}", + crate::input::key_label(&self.settings.keys.forward), + crate::input::key_label(&self.settings.keys.back) + ); let use_font = self.story.as_ref().and_then(|s| s.custom_font) == Some(true); let load_font = self.story.as_ref().is_some_and(|s| s.custom_font.is_none()); let mut font_loaded = None; @@ -974,6 +980,8 @@ impl Running { complete, replies, choice: *choice, + go_key: &go_key, + choose_keys: &choose_keys, }; crate::dialogue::draw(ui, view.image, style, use_font, &shown); } diff --git a/crates/engine/src/runtime/story.rs b/crates/engine/src/runtime/story.rs index 92a446d..234a38d 100644 --- a/crates/engine/src/runtime/story.rs +++ b/crates/engine/src/runtime/story.rs @@ -183,6 +183,21 @@ impl Running { state.objects.entry(id).or_default().door_open = Some(open); } } + for (index, object) in self.scene.objects.iter().enumerate() { + if keeps_its_place(object) { + let mut transform = object.transform; + // A floating object: where it floats around, not the bob. + if let Some(behaviors) = &self.behaviors { + transform.position.y -= behaviors.lift(&self.scene, index); + } + state.objects.entry(object.id).or_default().transform = Some(transform); + } + } + for (index, memory) in story.scripts.object_memory() { + if let Some(object) = self.scene.objects.get(index) { + state.objects.entry(object.id).or_default().script = Some(memory); + } + } story.world.scenes.insert(key, state); } @@ -192,6 +207,7 @@ impl Running { let Some(story) = &mut self.story else { return }; let state = story.world.scenes.get(&key); story.conditions.clear(); + let (mut places, mut memories) = (0, 0); for (index, object) in self.scene.objects.iter_mut().enumerate() { // A flag condition decides; what a graph showed or hid (saved) // counts for everything else. @@ -210,6 +226,25 @@ impl Running { if let Some(hidden) = saved { object.hidden = hidden; } + // Physics bodies and scripted objects are where they were left, + // and scripts remember what they did. + let Some(saved) = state.and_then(|s| s.objects.get(&object.id)) else { + continue; + }; + if let Some(transform) = saved.transform.filter(|_| keeps_its_place(object)) { + object.transform = transform; + if let Some(behaviors) = &mut self.behaviors { + behaviors.set_rest(index, transform); + } + places += 1; + } + if let (Some(memory), Some(script)) = (&saved.script, script_of(object)) { + story.scripts.restore_object(index, script, memory); + memories += 1; + } + } + if places + memories > 0 { + log::info!("{key}: {places} objects back where they were, {memories} scripts carry on"); } if let (Some(state), Some(behaviors)) = (state, &mut self.behaviors) { let by_id = state.objects.iter().filter_map(|(id, o)| { @@ -268,18 +303,19 @@ impl Running { fn usable(&self, index: usize) -> Option<(Use, String)> { let object = self.scene.objects.get(index)?; let story = self.story.as_ref(); + let key = crate::input::key_label(&self.settings.keys.interact); object.components.iter().find_map(|c| match c { - Component::Switch(s) if !s.pressure_plate => Some((Use::Switch, "E USE".into())), - Component::Pickup(_) => Some((Use::Pickup, "E TAKE".into())), - Component::SavePoint => Some((Use::Save, "E SAVE".into())), + Component::Switch(s) if !s.pressure_plate => Some((Use::Switch, format!("{key} USE"))), + Component::Pickup(_) => Some((Use::Pickup, format!("{key} TAKE"))), + Component::SavePoint => Some((Use::Save, format!("{key} SAVE"))), Component::Character(character) if !character.dialogue.0.is_empty() => { let name = story .and_then(|s| s.runner.names.get(&character.id).cloned()) .unwrap_or_else(|| character.name.clone()); let prompt = if name.is_empty() { - "E TALK".into() + format!("{key} TALK") } else { - format!("E TALK TO {}", name.to_uppercase()) + format!("{key} TALK TO {}", name.to_uppercase()) }; Some((Use::Talk(character.dialogue.0.clone()), prompt)) } @@ -301,7 +337,7 @@ impl Running { .pick(eye.position, eye.forward(), self.settings.body.reach)?; let (action, prompt) = self.usable(index)?; self.prompt = Some(prompt); - if !self.input.pressed(KeyCode::KeyE) { + if !self.input.bound_pressed(&self.settings.keys.interact) { return None; } // Every use is an event graphs can wait for. @@ -450,19 +486,20 @@ impl Running { } } - /// Keys while a line is on screen: W/S (or arrows) pick a reply, 1–9 - /// pick directly, E / Space / Enter go on (or show all of the text - /// first while it is still typing). + /// Keys while a line is on screen: forward / back (or the arrows) pick + /// a reply, 1–9 pick directly, use / jump / Enter go on (or show all of + /// the text first while it is still typing). fn dialogue_keys(&mut self) { let Some(story) = &mut self.story else { return }; let Some(line) = &mut story.line else { return }; let input = &self.input; + let keys = &self.settings.keys; let count = line.view.replies.len(); if count > 0 { - if input.pressed(KeyCode::KeyS) || input.pressed(KeyCode::ArrowDown) { + if input.bound_pressed(&keys.back) || input.pressed(KeyCode::ArrowDown) { line.choice = (line.choice + 1) % count; } - if input.pressed(KeyCode::KeyW) || input.pressed(KeyCode::ArrowUp) { + if input.bound_pressed(&keys.forward) || input.pressed(KeyCode::ArrowUp) { line.choice = (line.choice + count - 1) % count; } } @@ -483,14 +520,9 @@ impl Running { .iter() .position(|&k| input.pressed(k)) .filter(|&i| i < count && complete); - let go = [ - KeyCode::KeyE, - KeyCode::Space, - KeyCode::Enter, - KeyCode::NumpadEnter, - ] - .iter() - .any(|&k| input.pressed(k)); + let go = input.bound_pressed(&keys.interact) + || input.bound_pressed(&keys.jump) + || input.bound_pressed("enter"); if go && !complete { line.time = f32::MAX / 4.0; } else if let Some(choice) = direct { @@ -606,6 +638,7 @@ impl Running { yaw: self.player.yaw, graphs: story.runner.state(), world: story.world.clone(), + abilities: story.scripts.ability_memory(), }; match save::write(&self.game_dir, &saved) { Ok(path) => { @@ -627,6 +660,9 @@ impl Running { if let Some(story) = &mut self.story { story.world = saved.world; story.runner.restore(saved.graphs); + for (script, memory) in &saved.abilities { + story.scripts.restore_ability(script, memory); + } // An older save kept paused scene graphs with the world. let old = std::mem::take(&mut story.world.scene_graphs); for (scene, threads) in old { @@ -760,6 +796,23 @@ fn visible_when(object: &crate::scene::Object) -> Option<&crate::scene::VisibleW }) } +/// The object's script, if it runs one. +fn script_of(object: &crate::scene::Object) -> Option<&str> { + object.components.iter().find_map(|c| match c { + Component::Script(s) if !s.script.0.is_empty() => Some(s.script.0.as_str()), + _ => None, + }) +} + +/// Whether a saved game keeps where the object is: physics bodies and +/// scripted objects move for good. A door's place is its open or closed +/// state. +fn keeps_its_place(object: &crate::scene::Object) -> bool { + let has = |wanted: fn(&Component) -> bool| object.components.iter().any(wanted); + (has(|c| matches!(c, Component::PhysicsBody(_))) || script_of(object).is_some()) + && !has(|c| matches!(c, Component::Door(_))) +} + fn object_floats(object: &crate::scene::Object) -> bool { object .components diff --git a/crates/engine/src/save.rs b/crates/engine/src/save.rs index 02fa085..5379121 100644 --- a/crates/engine/src/save.rs +++ b/crates/engine/src/save.rs @@ -1,9 +1,12 @@ //! Saved games: where the player is, the graphs (flags, active nodes, //! renamed characters) and what changed in each scene (objects hidden or -//! shown, doors open) plus the zones visited. One save per project, kept in +//! shown, doors open, where physics bodies and scripted objects are, what +//! scripts remember) plus the zones visited. One save per project, kept in //! the user's config folder (never in the project). use crate::graph::run::{RunnerState, ThreadState}; +use crate::scene::Transform; +use crate::script::Value; use glam::Vec3; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -19,6 +22,10 @@ pub struct SaveGame { pub yaw: f32, pub graphs: RunnerState, pub world: WorldState, + /// The memory (`this`) of each ability's script that is on, by script + /// path. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub abilities: BTreeMap, } /// What the player changed in the world, kept across scenes. @@ -58,6 +65,13 @@ pub struct ObjectState { /// Whether the door is open. #[serde(skip_serializing_if = "Option::is_none")] pub door_open: Option, + /// Where a physics body or a scripted object is (a floating one: the + /// place it floats around). + #[serde(skip_serializing_if = "Option::is_none")] + pub transform: Option, + /// The memory (`this`) of the object's script, once it has started. + #[serde(skip_serializing_if = "Option::is_none")] + pub script: Option, } impl WorldState { @@ -164,6 +178,38 @@ mod scene_state_tests { use super::*; /// Old saves (by name) still load; new ones are by id. + #[test] + fn saves_keep_places_and_script_memory() { + let mut save = SaveGame::default(); + save.abilities + .insert("scripts/glide.rhai".into(), Value::Whole(2)); + let crate_state = save + .world + .scenes + .entry("scenes/hall.ron".into()) + .or_default() + .objects + .entry(3) + .or_default(); + crate_state.transform = Some(Transform { + position: Vec3::new(1.0, 0.5, -2.0), + ..Default::default() + }); + crate_state.script = Some(Value::Map( + [("steps".to_string(), Value::Number(1.5))].into(), + )); + let text = ron::ser::to_string_pretty(&save, Default::default()).unwrap(); + assert_eq!(ron::from_str::(&text).unwrap(), save); + + // A save from before any of this still reads. + let old: SaveGame = ron::from_str( + "(scene: \"a\", world: (scenes: {\"a\": (objects: {3: (hidden: Some(true))})}))", + ) + .unwrap(); + assert!(old.abilities.is_empty()); + assert_eq!(old.world.scenes["a"].objects[&3].transform, None); + } + #[test] fn scene_state_reads_old_saves_and_writes_ids() { let old: SceneState = diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index b2fc12d..731585c 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -13,7 +13,7 @@ //! //! fn update(player, dt) { //! if player.grounded { this.used = 0; } -//! if player.pressed("space") && !player.grounded && !player.jumped && this.used < 1 { +//! if player.pressed("jump") && !player.grounded && !player.jumped && this.used < 1 { //! player.jump(); //! this.used += 1; //! } @@ -27,6 +27,9 @@ //! error stops (the error is logged and shown) until the file is fixed. pub mod format; +mod value; + +pub use value::Value; use crate::graph::flags::{FlagValue, Flags}; use crate::input::Input; @@ -62,6 +65,8 @@ pub struct PlayerView { /// A script asked for a jump this frame. pub jump: bool, pub input: Input, + /// The game's keys, so scripts can ask for `jump` instead of a key. + pub keys: crate::player::settings::Keys, } /// What a script sees of the object it runs on, and may change. @@ -113,7 +118,9 @@ struct Instance { /// The script's `this`: a map it fills as it likes. memory: Dynamic, started: bool, - generation: u64, + /// The script's generation it runs; `None` for a copy from a saved + /// game, which carries on with the script as it is when first run. + generation: Option, } impl Instance { @@ -121,9 +128,25 @@ impl Instance { Self { memory: Dynamic::from_map(Map::new()), started: false, - generation, + generation: Some(generation), + } + } + + /// A copy from a saved game: it has started and remembers `memory`. + fn restored(memory: &Value) -> Self { + Self { + memory: memory.to_script(), + started: true, + generation: None, } } + + /// What it remembers, for a saved game; `None` before it has started. + fn saved(&self) -> Option { + self.started + .then(|| Value::from_script(&self.memory)) + .flatten() + } } pub struct Scripts { @@ -206,6 +229,36 @@ impl Scripts { self.objects.clear(); } + /// What the scripts on objects remember, by object index (those that + /// have started), for a saved game. + pub fn object_memory(&self) -> impl Iterator + '_ { + self.objects + .iter() + .filter_map(|((index, _), instance)| Some((*index, instance.saved()?))) + } + + /// What the abilities' scripts remember, by script path. + pub fn ability_memory(&self) -> BTreeMap { + self.abilities + .iter() + .filter_map(|(path, instance)| Some((path.clone(), instance.as_ref()?.saved()?))) + .collect() + } + + /// The script on an object carries on from a saved game: it remembers + /// `memory` and its `start` doesn't run again. + pub fn restore_object(&mut self, index: usize, script: &str, memory: &Value) { + self.objects + .insert((index, script.to_owned()), Instance::restored(memory)); + } + + /// An ability's script carries on from a saved game (while the ability + /// is on). + pub fn restore_ability(&mut self, script: &str, memory: &Value) { + self.abilities + .insert(script.to_owned(), Some(Instance::restored(memory))); + } + /// Runs this frame: the abilities on the player, then the scripts on /// `objects`. Their changes are in `player` and `objects` afterwards; /// flags they set are in `flags`. @@ -353,8 +406,9 @@ fn run( start_args: impl rhai::FuncArgs, update_args: impl rhai::FuncArgs, ) -> Result<(), Box> { - if instance.generation != generation { - *instance = Instance::new(generation); + match instance.generation { + Some(running) if running != generation => *instance = Instance::new(generation), + _ => instance.generation = Some(generation), } if !instance.started { instance.started = true; @@ -478,51 +532,32 @@ fn register_player(engine: &mut Engine) { .register_fn( "pressed", |p: &mut PlayerHandle, key: &str| -> Result> { - Ok(p.0.borrow().input.pressed(key_code(key)?)) + key_state(&p.0.borrow(), key, Input::bound_pressed, Input::pressed) }, ) .register_fn( "held", |p: &mut PlayerHandle, key: &str| -> Result> { - Ok(p.0.borrow().input.held(key_code(key)?)) + key_state(&p.0.borrow(), key, Input::bound_held, Input::held) }, ); } -/// A key by the name scripts use: a letter or digit, `space`, `shift`, -/// `ctrl`, `alt`, `tab`, `enter`, `up` / `down` / `left` / `right`. -fn key_code(name: &str) -> Result> { - use KeyCode::*; - const LETTERS: [KeyCode; 26] = [ - KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, KeyO, - KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, - ]; - const DIGITS: [KeyCode; 10] = [ - Digit0, Digit1, Digit2, Digit3, Digit4, Digit5, Digit6, Digit7, Digit8, Digit9, - ]; - let lower = name.to_ascii_lowercase(); - let key = match lower.as_str() { - "space" => Some(Space), - "shift" => Some(ShiftLeft), - "ctrl" => Some(ControlLeft), - "alt" => Some(AltLeft), - "tab" => Some(Tab), - "enter" => Some(Enter), - "up" => Some(ArrowUp), - "down" => Some(ArrowDown), - "left" => Some(ArrowLeft), - "right" => Some(ArrowRight), - one if one.len() == 1 => { - let c = one.as_bytes()[0]; - match c { - b'a'..=b'z' => Some(LETTERS[(c - b'a') as usize]), - b'0'..=b'9' => Some(DIGITS[(c - b'0') as usize]), - _ => None, - } - } - _ => None, - }; - key.ok_or_else(|| format!("no key named \"{name}\"").into()) +/// Whether a key counts for a script: by the game's own name for it +/// (`jump`, `use`, … from `player.ron`'s keys, so a script follows the +/// game's bindings) or by the key's name (`space`, `e`). +fn key_state( + view: &PlayerView, + name: &str, + bound: fn(&Input, &str) -> bool, + key: fn(&Input, KeyCode) -> bool, +) -> Result> { + if let Some(binding) = view.keys.action(name) { + return Ok(bound(&view.input, binding)); + } + crate::input::key_named(name) + .map(|code| key(&view.input, code)) + .ok_or_else(|| format!("no key named \"{name}\"").into()) } #[cfg(test)] @@ -677,6 +712,66 @@ mod tests { assert!((objects[0].position.y - 1.0).abs() <= 0.1); } + #[test] + fn scripts_carry_on_from_a_saved_game() { + let ability = r#" + fn start() { this.count = 10; this.home = vec3(1, 2, 3); this.names = ["a"]; } + fn update(player, dt) { this.count += 1; set_flag("count", this.count); } + "#; + let on_object = r#" + fn start(object) { this.steps = 0; this.who = player(); } + fn update(object, dt) { this.steps += 1; object.position = vec3(this.steps, 0, 0); } + "#; + let (mut scripts, dir) = scripts_with("saved", ability); + std::fs::write(dir.join("scripts/b.rhai"), on_object).unwrap(); + let lamp = || ObjectView { + index: 4, + script: "scripts/b.rhai".into(), + ..Default::default() + }; + let mut flags = Flags::default(); + let mut objects = [lamp()]; + for _ in 0..3 { + scripts.update(&mut PlayerView::default(), &mut objects, &mut flags, 0.1); + } + assert!(scripts.errors.is_empty(), "{:?}", scripts.errors); + + // What goes into the save reads back from RON; the player a script + // kept can't be saved and is left out. + let abilities = scripts.ability_memory(); + let on_objects: Vec<(usize, Value)> = scripts.object_memory().collect(); + let saved = ron::to_string(&(&abilities, &on_objects)).unwrap(); + assert!(!saved.contains("who"), "{saved}"); + let (abilities, on_objects): (BTreeMap, Vec<(usize, Value)>) = + ron::from_str(&saved).unwrap(); + let Value::Map(memory) = &abilities["scripts/a.rhai"] else { + panic!("{abilities:?}"); + }; + assert_eq!(memory["count"], Value::Whole(13)); + assert_eq!(memory["home"], Value::Vec3(Vec3::new(1.0, 2.0, 3.0))); + assert_eq!(memory["names"], Value::List(vec![Value::Text("a".into())])); + + // A new game session: the scripts go on counting, `start` isn't + // run again. + let mut scripts = Scripts::new(dir.clone()); + for (script, memory) in &abilities { + scripts.restore_ability(script, memory); + } + for (index, memory) in &on_objects { + scripts.restore_object(*index, "scripts/b.rhai", memory); + } + scripts.sync(&BTreeSet::from(["scripts/a.rhai".to_string()])); + let mut objects = [lamp()]; + scripts.update(&mut PlayerView::default(), &mut objects, &mut flags, 0.1); + assert!(scripts.errors.is_empty(), "{:?}", scripts.errors); + assert_eq!(flags.get("count"), FlagValue::Number(14.0)); + assert_eq!(objects[0].position.x, 4.0); + + // A script that hasn't started has nothing to save. + assert_eq!(Scripts::new(dir.clone()).object_memory().count(), 0); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_broken_script_reports_and_stops() { let (mut scripts, dir) = scripts_with("broken", "fn update(player, dt) { player.fly(); }"); diff --git a/crates/engine/src/script/value.rs b/crates/engine/src/script/value.rs new file mode 100644 index 0000000..de021b9 --- /dev/null +++ b/crates/engine/src/script/value.rs @@ -0,0 +1,77 @@ +//! A script's memory (`this`) in a form that goes into a saved game: +//! plain values, lists and maps of them. Anything else a script keeps (the +//! player, an object) can't be saved and is left out. + +use glam::Vec3; +use rhai::{Array, Dynamic, Map}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Value { + Nothing, + Bool(bool), + Whole(i64), + Number(f64), + Text(String), + Vec3(Vec3), + List(Vec), + Map(BTreeMap), +} + +impl Value { + /// The value as saved; `None` for what can't be. + pub fn from_script(value: &Dynamic) -> Option { + if value.is_unit() { + return Some(Self::Nothing); + } + if let Ok(b) = value.as_bool() { + return Some(Self::Bool(b)); + } + if let Ok(i) = value.as_int() { + return Some(Self::Whole(i)); + } + if let Ok(f) = value.as_float() { + return Some(Self::Number(f)); + } + if value.is_string() || value.is_char() { + return Some(Self::Text(value.to_string())); + } + if let Some(v) = value.clone().try_cast::() { + return Some(Self::Vec3(v)); + } + if let Some(list) = value.read_lock::() { + // A list keeps its places: what can't be saved becomes nothing. + let items = list + .iter() + .map(|v| Self::from_script(v).unwrap_or(Self::Nothing)); + return Some(Self::List(items.collect())); + } + if let Some(map) = value.read_lock::() { + let entries = map + .iter() + .filter_map(|(key, v)| Some((key.to_string(), Self::from_script(v)?))); + return Some(Self::Map(entries.collect())); + } + None + } + + /// The value as scripts see it. + pub fn to_script(&self) -> Dynamic { + match self { + Self::Nothing => Dynamic::UNIT, + Self::Bool(b) => (*b).into(), + Self::Whole(i) => (*i).into(), + Self::Number(f) => (*f).into(), + Self::Text(text) => text.clone().into(), + Self::Vec3(v) => Dynamic::from(*v), + Self::List(items) => Dynamic::from_array(items.iter().map(Self::to_script).collect()), + Self::Map(entries) => Dynamic::from_map( + entries + .iter() + .map(|(key, v)| (key.as_str().into(), v.to_script())) + .collect(), + ), + } + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 085a0f8..c4c8de3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -238,3 +238,7 @@ Append new decisions at the bottom. Don't rewrite old entries; supersede them wi | 2026-11-11 | EXPORT GAME (FILE menu, PROJECT ▸ PACKAGES · EXPORT, `--export `): the game for players is a copy of the running engine executable with the project appended as a package and a 24-byte trailer (package hash, length, magic). Started, such an executable unpacks the package once into the user's cache folder (`games/`, stamped with the hash; a new build replaces the files) and plays it as `--player`, ignoring `--editor`, `--game`, `--bench` and `--export`; a start that fails shows a message box. The folder is named after the game, not the build, so the saved game (keyed by project path) survives an update | Requested by the user: a game needs something to hand to players, and it should be an executable. One binary stays true: the game is the engine with data, not a second build. Unpacking to disk keeps every loader (scenes, bakes, hot reload paths) unchanged; reading straight from the archive can come if start-up time or hiding content ever matters | | 2026-11-11 | On Windows the app links `phonon.dll` delay-loaded (`crates/app/build.rs`: `/DELAYLOAD`), and an exported game carries the DLL in its package (`.engine/phonon.dll`) and loads it with `LoadLibraryW` before the first Steam Audio call. On Linux the app gets an rpath of `$ORIGIN` and the export writes `libphonon.so` next to the executable | A normally imported DLL must be on disk before `main` runs, which would make the export a folder of two files; delay-loading lets one file carry everything. The system loader on Linux has no such hook without re-executing, so the library sits beside the game there | | 2026-11-11 | EXPORT GAME marks the copied executable as a window-only app (the subsystem field in its PE header set from console to window), whatever build of the engine exported it | Reported by the user: a game exported from a debug build of the editor opened a terminal next to the game, because debug builds keep their console for the log. A player's game never needs one; the debug engine itself keeps it | +| 2026-11-11 | Saves keep where things are and what scripts remember: `ObjectState` gets `transform` (objects with a PhysicsBody or a Script, not doors; a floating one's place without its bob) and `script` (the script's `this` once it has started); `SaveGame` gets `abilities` (each running ability script's `this`). Applied when a scene's state is (load, and coming back to a scene), before physics is rebuilt; a restored script doesn't run `start` again. Velocities aren't saved: a body carries on from rest. Older saves read as before | Reported in the engine review: pushed crates jumped back and scripts forgot everything on load. Per object id, in the state that already holds hidden and doors, so scenes remember these across scene changes too | +| 2026-11-11 | A script's memory is saved as our own `script::Value` (nothing, yes/no, whole, number, text, Vec3, list, map), not through Rhai's `serde` feature. What can't be saved (the player or an object kept in `this`) is left out of a map and becomes nothing in a list | Rhai's own serializing fails on the engine's types (Vec3, handles); a small enum reads well in the RON save and decides itself what is dropped | +| 2026-11-11 | The game's keys are data: `player.ron` `keys` (forward, back, left, right, jump, sprint, crouch, interact, clap, map), each one or more key names separated by spaces (`"ctrl c"`), the names scripts already used; Shift, Ctrl, Alt and Enter count on both sides. Edited under KEYS in the player feel rows; names that aren't keys are listed there and logged on load. Use prompts and dialogue hints show the bound key; dialogue goes on with use, jump or Enter and picks with forward / back or the arrows. Scripts ask by action (`player.pressed("jump")`; `strafe_left` / `strafe_right`, since `left` / `right` are the arrow keys) or still by key. Debug and editor keys stay fixed; the fly camera keeps WASD | The brief: gameplay values are data in the game project, never constants in engine code. Per game, not per player: a settings menu for players can write the same fields later | +| 2026-11-11 | `LICENSE-MIT` and `LICENSE-APACHE` at the root (the manifest already said MIT OR Apache-2.0), and a GitHub Actions job on pushes to `main` and pull requests: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, on `windows-latest` with dependencies cached | An open-source repo needs the licence texts, and the three checks every task ends with should not depend on someone remembering them. Windows because it is the development platform and its runner image has Clang for AudioNimbus; a Linux job can follow when Linux is tested regularly | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7dc6b24..c13c1db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -348,6 +348,22 @@ The game as something to hand to players: one executable. **Acceptance:** the user exports their game from a release build of the editor, copies the one file to another Windows PC without Rust or the engine, double-clicks it and plays; after exporting again with a change, the change is there and the save still loads. +## 14. Saves keep the world +A saved game brings back the world as it was left, not only flags and doors. + +- [ ] Physics bodies are where they were (a pushed crate stays pushed), after a load and after leaving a scene and coming back +- [ ] Objects moved by a script are where they were, and a script remembers its `this` (on objects and for abilities); `start` doesn't run again +- [ ] Older saves still load + +**Acceptance:** the user pushes the crates around, saves at the save stone, quits, starts again, and finds the crates where they left them and the double jump still working. + +## 15. The game's keys are data +- [ ] `player.ron` `keys`: forward, back, left, right, jump, sprint, crouch, use, clap, map; several keys per action +- [ ] Edited under KEYS in the player feel rows (F1 while playing, Player Start, PROJECT ▸ PLAYER FEEL), with names that aren't keys pointed out +- [ ] Use prompts and dialogue hints show the bound key; scripts ask for keys by action (`player.pressed("jump")`) + +**Acceptance:** the user changes jump and use to other keys in the feel panel while playing, both work at once, the prompts show the new key, the double jump follows, and the keys are still set after a restart. + ## Later (optional) Not scheduled; picked up only if a game needs it. diff --git a/sandbox/scripts/double_jump.rhai b/sandbox/scripts/double_jump.rhai index c6de139..f64f8c7 100644 --- a/sandbox/scripts/double_jump.rhai +++ b/sandbox/scripts/double_jump.rhai @@ -12,7 +12,7 @@ fn update(player, dt) { this.used = 0; } // `player.jumped` is the normal jump off the ground: leave that alone. - if player.pressed("space") && !player.grounded && !player.jumped && this.used < 1 { + if player.pressed("jump") && !player.grounded && !player.jumped && this.used < 1 { player.jump(); this.used += 1; } From 313e7418298353e5138456a7803fd985d6af71ca Mon Sep 17 00:00:00 2001 From: eben-vranken Date: Wed, 11 Nov 2026 16:44:56 +0100 Subject: [PATCH 3/4] CI: use the Rust version the engine is developed with Co-Authored-By: Claude --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3138ea..617895b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,10 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # The version the engine is developed with. A newer Rust brings new + # clippy lints, which would fail the job on code nobody touched: raise + # this together with the fixes. + - uses: dtolnay/rust-toolchain@1.97.1 with: components: rustfmt, clippy # Keeps built dependencies and the Steam Audio download (about From dc718aab12058330c51ee607d68f3018e5044127 Mon Sep 17 00:00:00 2001 From: eben-vranken Date: Wed, 11 Nov 2026 17:11:45 +0100 Subject: [PATCH 4/4] Roadmap: tick 14 (saves keep the world) and 15 (keys are data) Confirmed by the user. Co-Authored-By: Claude --- docs/ROADMAP.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c13c1db..20e46ab 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -351,16 +351,16 @@ The game as something to hand to players: one executable. ## 14. Saves keep the world A saved game brings back the world as it was left, not only flags and doors. -- [ ] Physics bodies are where they were (a pushed crate stays pushed), after a load and after leaving a scene and coming back -- [ ] Objects moved by a script are where they were, and a script remembers its `this` (on objects and for abilities); `start` doesn't run again -- [ ] Older saves still load +- [x] Physics bodies are where they were (a pushed crate stays pushed), after a load and after leaving a scene and coming back +- [x] Objects moved by a script are where they were, and a script remembers its `this` (on objects and for abilities); `start` doesn't run again +- [x] Older saves still load **Acceptance:** the user pushes the crates around, saves at the save stone, quits, starts again, and finds the crates where they left them and the double jump still working. ## 15. The game's keys are data -- [ ] `player.ron` `keys`: forward, back, left, right, jump, sprint, crouch, use, clap, map; several keys per action -- [ ] Edited under KEYS in the player feel rows (F1 while playing, Player Start, PROJECT ▸ PLAYER FEEL), with names that aren't keys pointed out -- [ ] Use prompts and dialogue hints show the bound key; scripts ask for keys by action (`player.pressed("jump")`) +- [x] `player.ron` `keys`: forward, back, left, right, jump, sprint, crouch, use, clap, map; several keys per action +- [x] Edited under KEYS in the player feel rows (F1 while playing, Player Start, PROJECT ▸ PLAYER FEEL), with names that aren't keys pointed out +- [x] Use prompts and dialogue hints show the bound key; scripts ask for keys by action (`player.pressed("jump")`) **Acceptance:** the user changes jump and use to other keys in the feel panel while playing, both work at once, the prompts show the new key, the double jump follows, and the keys are still set after a restart.