diff --git a/.github/workflows/editor.yml b/.github/workflows/editor.yml new file mode 100644 index 0000000..8e91fbf --- /dev/null +++ b/.github/workflows/editor.yml @@ -0,0 +1,43 @@ +name: Web editor +on: + push: + pull_request: +permissions: + contents: read +jobs: + editor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.90.0' + targets: wasm32-unknown-unknown + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo test --workspace --locked + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo check -p web_view --target wasm32-unknown-unknown --locked + - uses: taiki-e/install-action@v2 + with: + tool: trunk@0.21.14 + - run: trunk build --release --locked + env: + NO_COLOR: 'true' + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: always() + with: + name: browser-test-results + path: test-results/ + - uses: actions/upload-artifact@v4 + with: + name: pace-web-editor + path: dist/ diff --git a/README.md b/README.md index c2fa040..9e44192 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,46 @@ Save downloads the full project and attempts an atomic browser recovery copy. Recovery is origin-specific and limited by browser quota; a warning instructs you to save a file if it is unavailable. Project files are the portable source of truth. Keep a downloaded copy before clearing browser data. + +## Room tools + +Add a room and import its background. Use **Draw hotspot** to drag a rectangle +in either direction, or **Add hotspot** for keyboard-friendly placement. Drag a +selected hotspot to move it; arrow keys nudge it by 1% of the image. Set exact +X/Y/width/height percentages in the inspector. Image replacement preserves the +normalized bounds. A **Go to room** action links to a stable room ID, so renaming +a destination does not break the link. Add a separate return hotspot for a +bidirectional connection. + +Validation entries identify the object ID and field; click an entry to select +its room, hotspot, item, or dialogue. Saves may contain incomplete drafts. +Invalid drafts cannot be rehearsed. + +## Browser checks + +```sh +npm ci +npx playwright install --with-deps chromium +trunk build +npm run test:e2e +``` + +For an existing Chromium installation, set `CHROMIUM_PATH=/usr/bin/chromium`. + +## Dialogue, inventory, and walkthrough + +Create inventory definitions and dialogue nodes from their sidebar sections. +Dialogue choices can branch to another node, grant an item, or end the +conversation. Hotspots can inspect text, link rooms, pick up an item, or start a +dialogue; any action can require an inventory item. Names are editable while IDs +stay stable. + +Follow [the illustrated walkthrough](docs/walkthrough.md) to author **The Garden +Key**, a three-room example, using only the editor controls. The browser test +reproduces the walkthrough, including save/reopen equality for the whole project. + +**Rehearse draft** runs an isolated editor simulation to exercise the draft's +links, dialogue branches and item gates. Restart clears its session. It never +modifies the source project and is **not** the PACE core runtime. **Core preview** +and **Export game** remain disabled. This implementation does not yet meet the +core-format serialization or pinned-runtime preview/export requirements. diff --git a/app/Cargo.toml b/app/Cargo.toml index 1833d4b..d0e407b 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] dioxus = { version = "=0.5.6", default-features = false, features = ["macro", "html", "signals", "hooks"] } -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" base64 = "0.22" imagesize = "0.13" diff --git a/app/src/browser.rs b/app/src/browser.rs index 6fe1e46..33e1291 100644 --- a/app/src/browser.rs +++ b/app/src/browser.rs @@ -58,6 +58,23 @@ mod web { } Ok(()) } + pub fn capture_pointer(id: i32) { + if let Some(element) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("room-stage")) + { + let _ = element.set_pointer_capture(id); + } + } + #[wasm_bindgen::prelude::wasm_bindgen( + inline_js = "export function setDirty(dirty) { window.onbeforeunload = dirty ? (event) => { event.preventDefault(); event.returnValue = ''; } : null; } export function openRehearsal() { const dialog = document.getElementById('draft-rehearsal'); if (!dialog) return; dialog.oncancel = event => { event.preventDefault(); dialog.querySelector('#close-rehearsal').click(); }; dialog.showModal(); }" + )] + extern "C" { + #[wasm_bindgen::prelude::wasm_bindgen(js_name = setDirty)] + pub fn set_dirty(dirty: bool); + #[wasm_bindgen::prelude::wasm_bindgen(js_name = openRehearsal)] + pub fn open_rehearsal(); + } pub fn stage_point(x: f64, y: f64) -> Option<(f64, f64)> { let rect = web_sys::window()? .document()? @@ -94,3 +111,11 @@ pub fn download(_: &str) -> Result<(), String> { pub fn stage_point(_: f64, _: f64) -> Option<(f64, f64)> { None } + +#[cfg(not(target_arch = "wasm32"))] +pub fn capture_pointer(_: i32) {} +#[cfg(not(target_arch = "wasm32"))] +pub fn set_dirty(_: bool) {} + +#[cfg(not(target_arch = "wasm32"))] +pub fn open_rehearsal() {} diff --git a/app/src/editor.css b/app/src/editor.css index 2ccc8dd..967ae5c 100644 --- a/app/src/editor.css +++ b/app/src/editor.css @@ -1 +1,3 @@ :root{font-family:'DM Sans',system-ui,sans-serif;color:#263d3c;background:#f5f6f3;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d9dfda;background:#fff;color:#263d3c;border-radius:7px;padding:9px 13px;cursor:pointer;text-decoration:none;font-weight:600}button:hover,.button:hover{background:#edf2e9;border-color:#9cae9d}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,.stage:focus-visible{outline:3px solid #85b2ea;outline-offset:2px}button:disabled{opacity:.45;cursor:not-allowed}.primary{background:#224c43;color:#fff;border-color:#224c43}.primary:hover{background:#16392f}.button input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.button:focus-within{outline:3px solid #85b2ea}h1,h2,h3,p{margin:0}h1,h2,h3{font-family:Manrope,system-ui,sans-serif}h2{font-size:17px}h3{font-size:12px;letter-spacing:.06em;margin:24px 0 12px}p{line-height:1.65;color:#6d7d76}label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:600;margin:16px 0}input:not([type=file]),select,textarea{width:100%;padding:10px;border:1px solid #d9dfda;border-radius:6px;background:#fff;color:#263d3c}textarea{min-height:90px;resize:vertical}.app{display:flex;flex-direction:column;min-height:100vh}.topbar{height:76px;display:flex;align-items:center;gap:12px;padding:0 24px;background:#fff;border-bottom:1px solid #e1e6e0}.brand{font-family:Manrope,sans-serif;font-weight:800;background:#224c43;color:#d9e8aa;width:38px;height:42px;border-radius:12px 12px 12px 2px;font-size:27px;text-align:center;padding-top:2px}.brand-copy{display:flex;flex-direction:column;gap:2px}.brand-copy strong{font-size:16px;letter-spacing:.13em}.brand-copy span{font-size:10px;color:#78867f}.project-title{margin:auto;font-weight:600}.dependency-banner{font-size:11px;padding:9px 24px;background:#edf0df;color:#69734b;border-bottom:1px solid #dfe3ce}.workspace{display:grid;grid-template-columns:220px minmax(320px,1fr) 290px;flex:1;min-height:calc(100vh - 149px)}.sidebar{background:#f9faf7;border-right:1px solid #e0e5dc;padding:24px 14px;display:flex;flex-direction:column}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:28px 10px 12px}.section-heading h2{font-family:inherit;font-size:10px;letter-spacing:.12em;color:#849087}.section-heading button{padding:0;border:0;background:none;font-size:21px}.project-link{justify-content:flex-start;background:none;border:0;font-size:12px}.tree-row{display:flex;justify-content:flex-start;gap:9px;text-align:left;width:100%;border:0;background:none;margin:3px 0;padding:12px}.tree-row.active{background:#e8eddf;color:#234b40}.sidebar-foot{margin-top:auto;padding:48px 10px 0;font-size:11px;color:#849087;line-height:1.5}.main-panel{padding:20px 28px;min-width:0;background:radial-gradient(#dce3d9 .7px,transparent .7px);background-size:16px 16px}.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:24px}.eyebrow{margin-right:auto;color:#859086;font-size:10px;letter-spacing:.12em;font-weight:700}.toolbar button{font-size:11px;padding:7px 11px}.empty-state{padding:90px 20px;text-align:center;max-width:530px;margin:auto}.empty-state h1{font-size:25px;line-height:1.5;margin:18px 0 12px}.empty-icon{display:inline-flex;align-items:center;justify-content:center;font-size:52px;width:90px;height:90px;border-radius:50%;background:#e5ecdd;color:#506c4a}.inspector{padding:26px 22px;background:#fff;border-left:1px solid #e0e5dc;overflow-wrap:anywhere}.issue{display:flex;flex-direction:column;align-items:flex-start;text-align:left;width:100%;font-size:11px;background:#fff8ef;border-color:#f1dfc5;color:#805d28;margin:8px 0;line-height:1.5}.issue span{font-weight:400}.success{color:#357350;font-size:12px}footer{min-height:36px;background:#fff;border-top:1px solid #e0e5dc;padding:9px 24px;font-size:11px;color:#64756c}.muted{font-size:11px;color:#829087;line-height:1.5}.danger{color:#a4473d;border-color:#e9d2cb;margin-top:18px}.room-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.room-heading h1{font-size:25px}.room-heading p{font-size:12px;margin-top:6px}.stage{position:relative;box-shadow:0 12px 40px #263d3c18;background:#e2e8df;border-radius:8px;overflow:hidden;user-select:none;touch-action:none}.stage>img{display:block;width:100%;height:100%;pointer-events:none;object-fit:fill}.hotspot{position:absolute;background:#e5f29530;border:2px solid #e5f295;border-radius:3px;padding:0;color:#fff;text-shadow:0 1px 3px #000;font-size:11px;overflow:hidden;min-width:0}.hotspot:hover,.hotspot.selected{background:#e5f29555;border-color:#fff}.stage.drawing .hotspot{pointer-events:none}.ghost{position:absolute;border:2px dashed #fff;background:#e5f29555;pointer-events:none}.stage-caption{display:flex;justify-content:space-between;font-size:11px;color:#829087;margin-top:14px}.hotspot-list{margin-top:30px}.hotspot-row{display:flex;gap:12px;align-items:center;background:#fff;border:1px solid #e1e6dc;border-radius:7px;margin:7px 0;padding:12px;width:100%;text-align:left;font-size:12px;justify-content:flex-start}.hotspot-row span{margin-left:auto;color:#849087;font-size:10px}.fields-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.fields-grid label{margin:8px 0}.segmented{display:flex;gap:7px;margin:16px 0}.segmented button{font-size:11px}.card{background:#fff;border:1px solid #e1e6dc;border-radius:10px;padding:24px;margin-bottom:18px}.choice{padding:14px;background:#f6f8f1;border-radius:8px;margin:14px 0}.preview-overlay{position:fixed;inset:0;z-index:20;background:#132624e8;display:flex;align-items:center;justify-content:center;padding:24px}.preview{background:#f8f9f4;border-radius:12px;padding:24px;width:min(960px,95vw);max-height:95vh;overflow:auto}.preview .stage{max-height:56vh}.preview-head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.preview-head h2{margin-right:auto}.inventory{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:16px 0;font-size:12px}.chip{background:#e6eddb;padding:7px 11px;border-radius:20px}.dialogue{padding:18px;background:#fff;border:1px solid #d9dfda;border-radius:8px}.dialogue p{margin:8px 0 14px}.dialogue button{margin-right:8px}.preview-note{font-size:11px;color:#93734e;margin:10px 0}.error{color:#a4473d}.inline-actions{display:flex;gap:8px;flex-wrap:wrap}@media(max-width:1050px){.workspace{grid-template-columns:180px minmax(280px,1fr) 250px}.main-panel{padding:18px}.topbar{gap:8px;padding:0 15px}.project-title{font-size:12px}.brand-copy span{display:none}}@media(max-width:760px){.workspace{grid-template-columns:150px 1fr}.inspector{grid-column:1/-1;border-top:1px solid #e0e5dc}.topbar{height:auto;min-height:76px;flex-wrap:wrap;padding:12px}.project-title{flex:1}.brand-copy{display:none}.topbar button,.topbar .button{font-size:11px}.sidebar{padding:12px 6px}.main-panel{padding:14px}.room-heading{align-items:flex-start;gap:8px}.toolbar{flex-wrap:wrap}.eyebrow{width:100%}.empty-state{padding:40px 10px}} + +.preview-overlay{width:100vw;height:100vh;max-width:none;max-height:none;border:0;margin:0;color:inherit}.preview-overlay::backdrop{background:transparent} diff --git a/app/src/editor/mod.rs b/app/src/editor/mod.rs index a691e79..52b6ebb 100644 --- a/app/src/editor/mod.rs +++ b/app/src/editor/mod.rs @@ -1,4 +1,6 @@ use crate::model::Project; +use std::sync::atomic::{AtomicUsize, Ordering}; +static NEXT_DOCUMENT: AtomicUsize = AtomicUsize::new(1); #[derive(Clone, Debug, PartialEq, Eq)] pub enum Selection { @@ -11,6 +13,7 @@ pub enum Selection { #[derive(Clone)] pub struct Editor { + pub document_id: usize, pub project: Project, pub selection: Selection, pub dirty: bool, @@ -32,6 +35,7 @@ impl Editor { .map(|r| Selection::Room(r.id.clone())) .unwrap_or(Selection::Project); Self { + document_id: NEXT_DOCUMENT.fetch_add(1, Ordering::Relaxed), project, selection, dirty: false, diff --git a/app/src/lib.rs b/app/src/lib.rs index e34be3a..d4304c5 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2,5 +2,10 @@ mod browser; pub mod editor; pub mod model; +mod rooms; mod ui; pub use ui::App; + +mod preview; +pub mod rehearsal; +mod story; diff --git a/app/src/model.rs b/app/src/model.rs index 1101d36..6112864 100644 --- a/app/src/model.rs +++ b/app/src/model.rs @@ -2,7 +2,7 @@ //! format: the inspected upstream revision has no serializable model or runtime. use base64::{engine::general_purpose::STANDARD, Engine}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::{collections::HashSet, sync::Arc}; pub const FORMAT: &str = "pace-editor-draft"; pub const VERSION: u32 = 1; @@ -35,7 +35,7 @@ pub struct Room { #[serde(deny_unknown_fields)] pub struct Background { pub name: String, - pub data_url: String, + pub data_url: Arc, pub width: u32, pub height: u32, } @@ -154,6 +154,16 @@ impl Default for Project { impl Project { pub fn to_json(&self) -> Result { + if self.rooms.iter().flat_map(|r| &r.hotspots).any(|h| { + [h.rect.x, h.rect.y, h.rect.width, h.rect.height] + .iter() + .any(|n| !n.is_finite()) + }) { + return Err( + "Cannot save a hotspot with non-finite coordinates. Correct its bounds first." + .into(), + ); + } let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; if text.len() > MAX_PROJECT_BYTES { return Err("Project exceeds the 64 MiB file limit. Reduce background sizes.".into()); @@ -287,10 +297,10 @@ impl Project { if self.title.trim().is_empty() { push(None, "title", "Give the project a title.".into()); } - if !self + if self .start_room .as_deref() - .is_some_and(|id| self.room(id).is_some()) + .is_none_or(|id| self.room(id).is_none()) { push( None, @@ -400,6 +410,23 @@ impl Project { } impl Background { + /// Only embedded raster data is rendered; malformed imports cannot load a + /// remote URL or an active document through the image element. + pub fn display_url(&self) -> &str { + if [ + "data:image/png;base64,", + "data:image/jpeg;base64,", + "data:image/webp;base64,", + ] + .iter() + .any(|prefix| self.data_url.starts_with(prefix)) + { + &self.data_url + } else { + "" + } + } + pub fn import(name: String, bytes: &[u8]) -> Result { if bytes.len() > MAX_IMAGE_BYTES { return Err("Background exceeds 8 MiB. Resize it before importing.".into()); @@ -420,7 +447,7 @@ impl Background { } Ok(Self { name, - data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)), + data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)).into(), width: size.width as u32, height: size.height as u32, }) diff --git a/app/src/preview.rs b/app/src/preview.rs new file mode 100644 index 0000000..a09f37b --- /dev/null +++ b/app/src/preview.rs @@ -0,0 +1,47 @@ +use crate::rehearsal::Rehearsal; +use dioxus::prelude::*; + +#[component] +pub fn DraftPreview(mut session: Signal>) -> Element { + use_effect(crate::browser::open_rehearsal); + let state = session.read().clone()?; + let room = state.project.room(&state.room_id)?; + let dialogue = state + .dialogue_id + .as_ref() + .and_then(|id| state.project.dialogues.iter().find(|d| &d.id == id)); + rsx! { + dialog { id: "draft-rehearsal", class: "preview-overlay", role: "dialog", "aria-modal": "true", "aria-label": "Draft rehearsal", + div { class: "preview", + div { class: "preview-head", h2 { "{room.name}" } + button { onclick: move |_| { let project = session.read().as_ref().map(|s| s.project.clone()); if let Some(p) = project { session.set(Rehearsal::new(&p).ok()); } }, "Restart" } + button { id: "close-rehearsal", autofocus: true, onclick: move |_| session.set(None), "Close rehearsal" } + } + p { class: "preview-note", "Editor rehearsal only · PACE runtime integration is pending. This does not verify runtime compatibility." } + if let Some(bg) = &room.background { + div { class: "stage", style: "aspect-ratio:{bg.width}/{bg.height}", + img { src: bg.display_url(), alt: "{room.name} background" } + for hotspot in room.hotspots.clone() { + button { class: "hotspot", disabled: dialogue.is_some(), style: format!("left:{}%;top:{}%;width:{}%;height:{}%",hotspot.rect.x*100.,hotspot.rect.y*100.,hotspot.rect.width*100.,hotspot.rect.height*100.), "aria-label": "Interact with {hotspot.name}", + onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.activate(&hotspot.id) { state.message = e; } } }, "{hotspot.name}" + } + } + } + } + div { class: "inventory", strong { "INVENTORY" } + if state.inventory.is_empty() { span { class: "muted", "Nothing collected yet" } } + for item in state.project.items.iter().filter(|i| state.inventory.contains(&i.id)) { span { class: "chip", title: "{item.description}", "{item.name}" } } + } + if let Some(d) = dialogue { + div { class: "dialogue", strong { "{d.speaker}" } p { "{d.text}" } + for choice in d.choices.clone() { + button { onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.choose(&choice.id) { state.message = e; } } }, "{choice.text}" } + } + if d.choices.is_empty() { button { onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.continue_dialogue() { state.message = e; } } }, "Continue" } } + } + } + p { role: "status", "{state.message}" } + } + } + } +} diff --git a/app/src/rehearsal.rs b/app/src/rehearsal.rs new file mode 100644 index 0000000..75b0c45 --- /dev/null +++ b/app/src/rehearsal.rs @@ -0,0 +1,120 @@ +//! An editor-only interaction rehearsal, not the unavailable PACE runtime. +//! It runs on an isolated snapshot and must never be used as game export. +use crate::model::{Action, Project}; +use std::collections::BTreeSet; + +#[derive(Clone)] +pub struct Rehearsal { + pub project: Project, + pub room_id: String, + pub inventory: BTreeSet, + pub dialogue_id: Option, + pub message: String, +} +impl Rehearsal { + pub fn new(project: &Project) -> Result { + let issues = project.validate(); + if !issues.is_empty() { + return Err(format!( + "Resolve {} validation error(s) before rehearsing.", + issues.len() + )); + } + Ok(Self { + project: project.clone(), + room_id: project.start_room.clone().ok_or("Choose a starting room")?, + inventory: BTreeSet::new(), + dialogue_id: None, + message: "Click a hotspot to try its action.".into(), + }) + } + pub fn activate(&mut self, hotspot_id: &str) -> Result<(), String> { + if self.dialogue_id.is_some() { + return Err("Finish the conversation first.".into()); + } + let h = self + .project + .room(&self.room_id) + .and_then(|r| r.hotspots.iter().find(|h| h.id == hotspot_id)) + .ok_or("Hotspot is not in the current room")? + .clone(); + if let Some(id) = &h.requires_item { + if !self.inventory.contains(id) { + let name = self + .project + .items + .iter() + .find(|i| &i.id == id) + .map(|i| i.name.as_str()) + .unwrap_or(id); + self.message = format!("You need {name} first."); + return Ok(()); + } + } + match h.action { + Action::Look { text } => self.message = text, + Action::GoTo { room_id } => { + if self.project.room(&room_id).is_none() { + return Err("Destination room is missing".into()); + } + self.room_id = room_id; + self.message = "You enter another room.".into(); + } + Action::Take { item_id } => { + let item = self + .project + .items + .iter() + .find(|i| i.id == item_id) + .ok_or("Item is missing")?; + self.message = if self.inventory.insert(item_id) { + format!("Picked up {}. {}", item.name, item.description) + } else { + format!("You already have {}.", item.name) + }; + } + Action::Talk { dialogue_id } => { + if !self.project.dialogues.iter().any(|d| d.id == dialogue_id) { + return Err("Dialogue is missing".into()); + } + self.dialogue_id = Some(dialogue_id); + self.message.clear(); + } + } + Ok(()) + } + pub fn choose(&mut self, choice_id: &str) -> Result<(), String> { + let choice = self + .project + .dialogues + .iter() + .find(|d| Some(&d.id) == self.dialogue_id.as_ref()) + .and_then(|d| d.choices.iter().find(|c| c.id == choice_id)) + .ok_or("Choice is not in the active dialogue")? + .clone(); + if let Some(item_id) = choice.give_item { + self.inventory.insert(item_id); + } + self.dialogue_id = choice.next; + self.message = if self.dialogue_id.is_none() { + "The conversation ends.".into() + } else { + String::new() + }; + Ok(()) + } + pub fn continue_dialogue(&mut self) -> Result<(), String> { + let d = self + .project + .dialogues + .iter() + .find(|d| Some(&d.id) == self.dialogue_id.as_ref()) + .ok_or("No active conversation")?; + if !d.choices.is_empty() { + return Err("Choose a response first".into()); + } + self.dialogue_id = None; + self.message = "The conversation ends.".into(); + Ok(()) + } +} diff --git a/app/src/rooms.rs b/app/src/rooms.rs new file mode 100644 index 0000000..43e2c21 --- /dev/null +++ b/app/src/rooms.rs @@ -0,0 +1,237 @@ +use crate::{ + browser, + editor::{Editor, Selection}, + model::{Action, Background, Hotspot, Rect, Room}, +}; +use dioxus::prelude::*; + +pub fn update_room(mut editor: Signal, change: impl FnOnce(&mut Room)) { + let id = editor.read().selected_room().map(str::to_owned); + if let Some(id) = id { + editor.write().edit(|p| { + if let Some(r) = p.room_mut(&id) { + change(r); + } + }); + } +} +pub fn update_hotspot(mut editor: Signal, change: impl FnOnce(&mut Hotspot)) { + let selection = editor.read().selection.clone(); + if let Selection::Hotspot(room_id, id) = selection { + editor.write().edit(|p| { + if let Some(h) = p + .room_mut(&room_id) + .and_then(|r| r.hotspots.iter_mut().find(|h| h.id == id)) + { + change(h); + } + }); + } +} +fn position(rect: Rect) -> String { + format!( + "left:{}%;top:{}%;width:{}%;height:{}%", + rect.x * 100., + rect.y * 100., + rect.width * 100., + rect.height * 100. + ) +} +#[derive(Clone)] +enum Drag { + Draw { + start: (f64, f64), + end: (f64, f64), + }, + Move { + id: String, + start: (f64, f64), + end: (f64, f64), + original: Rect, + }, +} +impl Drag { + fn rect(&self) -> Option { + match self { + Self::Draw { start, end } => Rect::from_drag(*start, *end), + Self::Move { + start, + end, + original, + .. + } => Some(original.translated(end.0 - start.0, end.1 - start.1)), + } + } + fn set_end(&mut self, point: (f64, f64)) { + match self { + Self::Draw { end, .. } | Self::Move { end, .. } => *end = point, + } + } +} + +#[component] +pub fn RoomWorkspace(mut editor: Signal, mut notice: Signal) -> Element { + let mut drawing = use_signal(|| false); + let mut drag: Signal> = use_signal(|| None); + let room = editor + .read() + .selected_room() + .and_then(|id| editor.read().project.room(id).cloned()); + let Some(room) = room else { + return rsx! { div { class: "empty-state", span { class: "empty-icon", "◇" } h1 { "Every adventure starts somewhere." } p { "Add a room, import its background, and bring your world to life." } } }; + }; + let selected_id = match &editor.read().selection { + Selection::Hotspot(_, id) => Some(id.clone()), + _ => None, + }; + let dimensions = room + .background + .as_ref() + .map(|b| format!("{} × {}", b.width, b.height)) + .unwrap_or_default(); + rsx! { + div { class: "room-workspace", onkeydown: move |event| { if event.key() == Key::Escape { drag.set(None); drawing.set(false); } }, + div { class: "room-heading", div { h1 { "{room.name}" } p { "Set the scene. Give every detail a story." } } + label { class: "button", "Import background" + input { r#type: "file", accept: "image/png,image/jpeg,image/webp", "aria-label": "Import background", onchange: move |event: Event| async move { + let room_id = editor.read().selected_room().map(str::to_owned); + let document_id = editor.read().document_id; + if let (Some(files), Some(room_id)) = (event.files(), room_id) { + if let Some(name) = files.files().first() { + match files.read_file(name).await { + Some(_) if editor.read().document_id != document_id => notice.set("Image import cancelled because a different project was opened.".into()), + Some(bytes) => match Background::import(name.clone(), &bytes) { + Ok(background) => { editor.write().edit(|p| { if let Some(room) = p.room_mut(&room_id) { room.background = Some(background); } }); notice.set("Background imported. Hotspot bounds scale with the image.".into()); }, + Err(e) => notice.set(e), + }, + None => notice.set("Cannot read this image.".into()), + } + } + } + } } + } + } + if let Some(background) = &room.background { + div { class: "segmented", + button { class: if !drawing() { "primary" } else { "" }, onclick: move |_| { drawing.set(false); drag.set(None); }, "Select / move" } + button { class: if drawing() { "primary" } else { "" }, onclick: move |_| { drawing.set(true); drag.set(None); }, "Draw hotspot" } + button { onclick: move |_| { + let room_id = editor.read().selected_room().map(str::to_owned); + if let Some(room_id) = room_id { let mut id = None; editor.write().edit(|p| id = p.add_hotspot(&room_id, Rect { x:0.35,y:0.35,width:0.2,height:0.2 })); if let Some(id) = id { editor.write().selection = Selection::Hotspot(room_id,id); } } + }, "Add hotspot" } + } + div { id: "room-stage", tabindex: "0", class: if drawing() { "stage drawing" } else { "stage" }, style: "aspect-ratio:{background.width}/{background.height}", "aria-label": "Room canvas", + onkeydown: move |event| { if event.key() == Key::Escape { drag.set(None); drawing.set(false); } }, + onpointerdown: move |event| { if drawing() && event.trigger_button() == Some(dioxus::html::input_data::MouseButton::Primary) { + let point = event.client_coordinates(); if let Some(point) = browser::stage_point(point.x,point.y) { browser::capture_pointer(event.pointer_id()); drag.set(Some(Drag::Draw {start:point,end:point})); } + } }, + onpointermove: move |event| { let point = event.client_coordinates(); if let Some(point) = browser::stage_point(point.x,point.y) { if let Some(drag) = drag.write().as_mut() { drag.set_end(point); } } }, + onpointercancel: move |_| drag.set(None), + onpointerup: move |_| { + let gesture = drag.write().take(); + if let Some(gesture) = gesture { if let Some(rect) = gesture.rect() { + let room_id = editor.read().selected_room().map(str::to_owned); + if let Some(room_id) = room_id { match gesture { + Drag::Draw {..} => { let mut id = None; editor.write().edit(|p| id = p.add_hotspot(&room_id,rect)); if let Some(id) = id { editor.write().selection = Selection::Hotspot(room_id,id); } drawing.set(false); }, + Drag::Move {id,..} => editor.write().edit(|p| { if let Some(h) = p.room_mut(&room_id).and_then(|r| r.hotspots.iter_mut().find(|h| h.id == id)) { h.rect = rect; } }), + } } + } } + }, + img { src: background.display_url(), alt: "{room.name} background", draggable: "false" } + for hotspot in room.hotspots.clone() { + HotspotButton { key: "{hotspot.id}", hotspot: hotspot.clone(), selected: selected_id.as_deref() == Some(&hotspot.id), editor, drag } + } + if let Some(rect) = drag.read().as_ref().and_then(Drag::rect) { div { class: "ghost", style: position(rect) } } + } + div { class: "stage-caption", span { "{dimensions} · Original image coordinates" } span { "{room.hotspots.len()} hotspots" } } + p { class: "muted", style: "margin-top:14px", "Draw a rectangle, drag to move, or use Add hotspot and edit its bounds. Escape cancels a gesture." } + } else { + div { class: "empty-state", span { class: "empty-icon", "▧" } h1 { "A place for your story." } p { "Import a PNG, JPEG, or WebP background to start placing hotspots." } p { class: "muted", "Up to 8 MiB per image. Images travel with your project." } } + } + if !room.hotspots.is_empty() { + div { class: "hotspot-list", h3 { "IN THIS ROOM" } + for hotspot in room.hotspots { + button { class: "hotspot-row", onclick: move |_| { + let room_id = editor.read().selected_room().map(str::to_owned); if let Some(room_id) = room_id { editor.write().selection = Selection::Hotspot(room_id,hotspot.id.clone()); } + }, "◇ {hotspot.name}" span { {match hotspot.action { Action::Look {..} => "INSPECT", Action::GoTo {..} => "GO TO ROOM", Action::Take {..} => "PICK UP ITEM", Action::Talk {..} => "DIALOGUE" }} } } + } + } + } + } + } +} + +#[component] +fn HotspotButton( + hotspot: Hotspot, + selected: bool, + mut editor: Signal, + mut drag: Signal>, +) -> Element { + let id = hotspot.id.clone(); + let key_id = id.clone(); + let click_id = id.clone(); + rsx! { button { class: if selected { "hotspot selected" } else { "hotspot" }, style: position(hotspot.rect), "aria-label": "Select hotspot {hotspot.name}", + onclick: move |_| { let room_id = editor.read().selected_room().map(str::to_owned); if let Some(room_id) = room_id { editor.write().selection = Selection::Hotspot(room_id,click_id.clone()); } }, + onpointerdown: move |event| { + event.stop_propagation(); + let room_id = editor.read().selected_room().map(str::to_owned); + if let Some(room_id) = room_id { editor.write().selection = Selection::Hotspot(room_id,id.clone()); } + let point = event.client_coordinates(); if let Some(point) = browser::stage_point(point.x,point.y) { browser::capture_pointer(event.pointer_id()); drag.set(Some(Drag::Move { id:id.clone(),start:point,end:point,original:hotspot.rect })); } + }, + onkeydown: move |event| { + let delta = match event.key() { Key::ArrowLeft => Some((-0.01,0.)), Key::ArrowRight => Some((0.01,0.)), Key::ArrowUp => Some((0.,-0.01)), Key::ArrowDown => Some((0.,0.01)), Key::Escape => { drag.set(None); None }, _ => None }; + if let Some((dx,dy)) = delta { let room_id = editor.read().selected_room().map(str::to_owned); if let Some(room_id) = room_id { editor.write().edit(|p| { if let Some(h) = p.room_mut(&room_id).and_then(|r| r.hotspots.iter_mut().find(|h| h.id == key_id)) { h.rect = h.rect.translated(dx,dy); } }); } } + }, + "{hotspot.name}" + } } +} + +#[component] +pub fn RoomInspector(mut editor: Signal) -> Element { + let room = editor + .read() + .selected_room() + .and_then(|id| editor.read().project.room(id).cloned()); + let room = room?; + let hotspot = match &editor.read().selection { + Selection::Hotspot(_, id) => room.hotspots.iter().find(|h| &h.id == id).cloned(), + _ => None, + }; + if let Some(h) = hotspot { + let action = match h.action { + Action::Look { .. } => "look", + Action::GoTo { .. } => "go_to", + Action::Take { .. } => "take", + Action::Talk { .. } => "talk", + }; + rsx! { + h2 { "Hotspot" } p { class: "muted", "{h.id}" } + label { "Hotspot name" input { value: "{h.name}", oninput: move |e| update_hotspot(editor,|h| h.name = e.value()) } } + h3 { "BOUNDS · % OF IMAGE" } + div { class: "fields-grid", + for (field,value) in [("X",h.rect.x),("Y",h.rect.y),("Width",h.rect.width),("Height",h.rect.height)] { + label { "{field} (%)" input { r#type: "number", min: "0", max: "100", step: "0.1", value: format!("{:.2}",value*100.), oninput: move |e| { if let Ok(value) = e.value().parse::() { update_hotspot(editor, |h| match field { "X" => h.rect.x = value/100., "Y" => h.rect.y = value/100., "Width" => h.rect.width = value/100., _ => h.rect.height = value/100. }); } } } } + } + } + label { "Action" select { value: action, onchange: move |e| update_hotspot(editor, |h| h.action = match e.value().as_str() { "go_to" => Action::GoTo { room_id: String::new() }, "take" => Action::Take { item_id: String::new() }, "talk" => Action::Talk { dialogue_id: String::new() }, _ => Action::Look { text: String::new() } }), + option { value: "look", selected: action == "look", "Inspect" } option { value: "go_to", selected: action == "go_to", "Go to room" } option { value: "take", selected: action == "take", "Pick up item" } option { value: "talk", selected: action == "talk", "Start dialogue" } + } } + match h.action { + Action::Look {text} => rsx! { label { "Inspection text" textarea { value: "{text}", oninput: move |e| update_hotspot(editor, |h| h.action = Action::Look { text:e.value() }) } } }, + Action::GoTo {room_id} => rsx! { label { "Destination room" select { value: "{room_id}", onchange: move |e| update_hotspot(editor, |h| h.action = Action::GoTo { room_id:e.value() }), option { value: "", selected: room_id.is_empty(), "Choose destination" } for r in editor.read().project.rooms.clone() { option { value: "{r.id}", selected: r.id == room_id, "{r.name}" } } } } }, + Action::Take {item_id} => rsx! { label { "Item to pick up" select { value: "{item_id}", onchange: move |e| update_hotspot(editor, |h| h.action = Action::Take { item_id:e.value() }), option { value: "", selected: item_id.is_empty(), "Choose item" } for item in editor.read().project.items.clone() { option { value: "{item.id}", selected: item.id == item_id, "{item.name}" } } } } }, + Action::Talk {dialogue_id} => rsx! { label { "Dialogue to start" select { value: "{dialogue_id}", onchange: move |e| update_hotspot(editor, |h| h.action = Action::Talk { dialogue_id:e.value() }), option { value: "", selected: dialogue_id.is_empty(), "Choose dialogue" } for d in editor.read().project.dialogues.clone() { option { value: "{d.id}", selected: d.id == dialogue_id, "{d.name}" } } } } }, + } + label { "Requires item" select { value: h.requires_item.clone().unwrap_or_default(), onchange: move |e| update_hotspot(editor, |h| h.requires_item = if e.value().is_empty() { None } else { Some(e.value()) }), option { value: "", selected: h.requires_item.is_none(), "No requirement" } for item in editor.read().project.items.clone() { option { value: "{item.id}", selected: h.requires_item.as_deref() == Some(item.id.as_str()), "{item.name}" } } } } + p { class: "muted", "Room links are one-way. Add a return hotspot in the destination room." } + button { class: "danger", onclick: move |_| editor.write().delete_selection(), "Delete hotspot" } + } + } else { + rsx! { h2 { "Room" } p { class: "muted", "{room.id}" } + label { "Room name" input { value: "{room.name}", oninput: move |e| update_room(editor, |r| r.name = e.value()) } } + p { class: "muted", "Select a hotspot on the canvas or in the list to edit its action and bounds." } + button { class: "danger", onclick: move |_| { if browser::confirm("Delete this room and its hotspots? Links from other objects will be reported in validation. You can undo this.") { editor.write().delete_selection(); } }, "Delete room" } + } + } +} diff --git a/app/src/story.rs b/app/src/story.rs new file mode 100644 index 0000000..272b02b --- /dev/null +++ b/app/src/story.rs @@ -0,0 +1,102 @@ +use crate::{ + browser, + editor::{Editor, Selection}, + model::{Choice, Dialogue, Item}, +}; +use dioxus::prelude::*; + +fn update_item(mut editor: Signal, change: impl FnOnce(&mut Item)) { + let selection = editor.read().selection.clone(); + if let Selection::Item(id) = selection { + editor.write().edit(|p| { + if let Some(item) = p.items.iter_mut().find(|i| i.id == id) { + change(item); + } + }); + } +} +fn update_dialogue(mut editor: Signal, change: impl FnOnce(&mut Dialogue)) { + let selection = editor.read().selection.clone(); + if let Selection::Dialogue(id) = selection { + editor.write().edit(|p| { + if let Some(dialogue) = p.dialogues.iter_mut().find(|d| d.id == id) { + change(dialogue); + } + }); + } +} + +#[component] +pub fn StoryWorkspace(mut editor: Signal) -> Element { + let selection = editor.read().selection.clone(); + match selection { + Selection::Item(id) => { + let item = editor + .read() + .project + .items + .iter() + .find(|i| i.id == id) + .cloned(); + let item = item?; + rsx! { div { class: "room-heading", div { h1 { "{item.name}" } p { "Something to find. Something to take along." } } } + div { class: "card", h2 { "Inventory item" } p { class: "muted", "{item.id}" } + label { "Item name" input { value: "{item.name}", oninput: move |e| update_item(editor, |i| i.name = e.value()) } } + label { "Item description" textarea { value: "{item.description}", oninput: move |e| update_item(editor, |i| i.description = e.value()) } } + p { "Use a Pick up item hotspot or a dialogue reward to add this item to the player's inventory. A hotspot can require it before its action is available." } + button { class: "danger", onclick: move |_| { if browser::confirm("Delete this item? References will appear in validation. You can undo this.") { editor.write().delete_selection(); } }, "Delete item" } + } + } + } + Selection::Dialogue(id) => { + let dialogue = editor + .read() + .project + .dialogues + .iter() + .find(|d| d.id == id) + .cloned(); + let d = dialogue?; + rsx! { div { class: "room-heading", div { h1 { "{d.name}" } p { "A conversation can open a whole new world." } } } + div { class: "card", h2 { "Dialogue node" } p { class: "muted", "{d.id}" } + label { "Dialogue name" input { value: "{d.name}", oninput: move |e| update_dialogue(editor, |d| d.name = e.value()) } } + label { "Speaker" input { value: "{d.speaker}", oninput: move |e| update_dialogue(editor, |d| d.speaker = e.value()) } } + label { "Dialogue text" textarea { value: "{d.text}", oninput: move |e| update_dialogue(editor, |d| d.text = e.value()) } } + h3 { "PLAYER CHOICES" } + p { class: "muted", "Each choice can lead to another node and grant an item. No choices gives the player a Continue button to end the conversation." } + for choice in d.choices { ChoiceEditor { key: "{choice.id}", choice, editor } } + button { onclick: move |_| { + let selection = editor.read().selection.clone(); + if let Selection::Dialogue(dialogue_id) = selection { editor.write().edit(|p| { + let id = p.allocate_id("choice"); + if let Some(d) = p.dialogues.iter_mut().find(|d| d.id == dialogue_id) { d.choices.push(Choice { id, text: "New choice".into(), next: None, give_item: None }); } + }); } + }, "Add choice" } + br {} + button { class: "danger", onclick: move |_| { if browser::confirm("Delete this dialogue? References will appear in validation. You can undo this.") { editor.write().delete_selection(); } }, "Delete dialogue" } + } + } + } + _ => rsx! {}, + } +} + +#[component] +fn ChoiceEditor(choice: Choice, editor: Signal) -> Element { + let id = choice.id.clone(); + let next_id = id.clone(); + let item_id = id.clone(); + let delete_id = id.clone(); + rsx! { div { class: "choice", "data-choice-id": "{choice.id}", + label { "Choice text" input { value: "{choice.text}", oninput: move |e| update_dialogue(editor, |d| { if let Some(c) = d.choices.iter_mut().find(|c| c.id == id) { c.text = e.value(); } }) } } + label { "Next dialogue" select { value: choice.next.clone().unwrap_or_default(), onchange: move |e| update_dialogue(editor, |d| { if let Some(c) = d.choices.iter_mut().find(|c| c.id == next_id) { c.next = if e.value().is_empty() { None } else { Some(e.value()) }; } }), + option { value: "", selected: choice.next.is_none(), "End conversation" } + for dialogue in editor.read().project.dialogues.clone() { option { value: "{dialogue.id}", selected: choice.next.as_deref() == Some(dialogue.id.as_str()), "{dialogue.name}" } } + } } + label { "Give item" select { value: choice.give_item.clone().unwrap_or_default(), onchange: move |e| update_dialogue(editor, |d| { if let Some(c) = d.choices.iter_mut().find(|c| c.id == item_id) { c.give_item = if e.value().is_empty() { None } else { Some(e.value()) }; } }), + option { value: "", selected: choice.give_item.is_none(), "No item" } + for item in editor.read().project.items.clone() { option { value: "{item.id}", selected: choice.give_item.as_deref() == Some(item.id.as_str()), "{item.name}" } } + } } + button { onclick: move |_| update_dialogue(editor, |d| d.choices.retain(|c| c.id != delete_id)), "Remove choice" } + } } +} diff --git a/app/src/ui.rs b/app/src/ui.rs index 47b6ced..0fd8583 100644 --- a/app/src/ui.rs +++ b/app/src/ui.rs @@ -19,21 +19,27 @@ pub fn App() -> Element { } editor }); + use_effect(move || browser::set_dirty(editor.read().dirty)); let issues = editor.read().project.validate(); + let has_issues = !issues.is_empty(); + let mut session = use_signal(|| None::); rsx! { style { {include_str!("editor.css")} } div { class: "app", header { class: "topbar", div { class: "brand", "P" } div { class: "brand-copy", strong { "PACE" } span { "Adventure editor" } } - div { class: "project-title", "{editor.read().project.title}" } + div { class: "project-title", "{editor.read().project.title}" if editor.read().dirty { span { title: "Changes not yet saved to file", " •" } } } button { onclick: move |_| { if !editor.read().dirty || browser::confirm("Discard changes that have not been saved to a project file?") { editor.set(Editor::default()); } }, "New project" } label { class: "button", "Open project" input { r#type: "file", accept: ".json", "aria-label": "Open project", onchange: move |event: Event| async move { if editor.read().dirty && !browser::confirm("Replace this project? Save a file first to keep your changes.") { return; } + let document_id = editor.read().document_id; + let original = editor.read().project.clone(); if let Some(files) = event.files() { if let Some(name) = files.files().first() { match files.read_file_to_string(name).await { + Some(_) if editor.read().document_id != document_id || editor.read().project != original => notice.set("Open cancelled because the project changed while the file was being read. Try opening it again.".into()), Some(text) => match editor.write().open(&text) { Ok(()) => notice.set("Project opened.".into()), Err(e) => notice.set(e) }, None => notice.set("Could not read project file.".into()), } @@ -43,7 +49,13 @@ pub fn App() -> Element { } button { class: "primary", onclick: move |_| { let result = editor.read().project.to_json().and_then(|text| { browser::download(&text)?; Ok(text) }); - match result { Ok(text) => { editor.write().dirty = false; notice.set(match browser::recover(&text) { Ok(()) => "Project file downloaded and browser recovery saved.".into(), Err(e) => format!("Project file downloaded. {e}") }); }, Err(e) => notice.set(e) } + match result { + Ok(text) => match browser::recover(&text) { + Ok(()) => { editor.write().dirty = false; notice.set("Project download requested and browser recovery saved.".into()); }, + Err(e) => notice.set(format!("Project download requested. {e}")), + }, + Err(e) => notice.set(e), + } }, "Save project" } } div { class: "dependency-banner", "Draft format v1 · PACE core integration pending. Your project files include all imported images." } @@ -56,6 +68,18 @@ pub fn App() -> Element { for room in editor.read().project.rooms.clone() { button { class: if editor.read().selected_room() == Some(room.id.as_str()) { "tree-row active" } else { "tree-row" }, onclick: move |_| editor.write().selection = Selection::Room(room.id.clone()), "{room.name}" } } + div { class: "section-heading", h2 { "INVENTORY" } button { "aria-label": "Add item", onclick: move |_| { + let mut id = String::new(); editor.write().edit(|p| id = p.add_item()); editor.write().selection = Selection::Item(id); + }, "+" } } + for item in editor.read().project.items.clone() { + button { class: if editor.read().selection == Selection::Item(item.id.clone()) { "tree-row active" } else { "tree-row" }, onclick: move |_| editor.write().selection = Selection::Item(item.id.clone()), "{item.name}" } + } + div { class: "section-heading", h2 { "DIALOGUE" } button { "aria-label": "Add dialogue", onclick: move |_| { + let mut id = String::new(); editor.write().edit(|p| id = p.add_dialogue()); editor.write().selection = Selection::Dialogue(id); + }, "+" } } + for dialogue in editor.read().project.dialogues.clone() { + button { class: if editor.read().selection == Selection::Dialogue(dialogue.id.clone()) { "tree-row active" } else { "tree-row" }, onclick: move |_| editor.write().selection = Selection::Dialogue(dialogue.id.clone()), "{dialogue.name}" } + } div { class: "sidebar-foot", "Build a world, one room at a time." } } main { class: "main-panel", @@ -63,21 +87,31 @@ pub fn App() -> Element { span { class: "eyebrow", "YOUR ADVENTURE" } button { disabled: !editor.read().can_undo(), onclick: move |_| editor.write().undo(), "Undo" } button { disabled: !editor.read().can_redo(), onclick: move |_| editor.write().redo(), "Redo" } + button { disabled: has_issues, onclick: move |_| { + match crate::rehearsal::Rehearsal::new(&editor.read().project) { Ok(state) => session.set(Some(state)), Err(e) => notice.set(e) } + }, "Rehearse draft" } + button { disabled: true, title: "No PACE core runtime is available at the inspected revision.", "Core preview" } + button { disabled: true, title: "Game export requires a pinned PACE core runtime.", "Export game" } + } + if matches!(editor.read().selection, Selection::Item(_) | Selection::Dialogue(_)) { crate::story::StoryWorkspace { editor } } else { + crate::rooms::RoomWorkspace { key: "{editor.read().selected_room().unwrap_or_default()}", editor, notice } } - div { class: "empty-state", span { class: "empty-icon", "◇" } h1 { "Every adventure starts somewhere." } p { "Add a room, import its background, and bring your world to life." } } } aside { class: "inspector", + if editor.read().selected_room().is_some() { crate::rooms::RoomInspector { editor } } else { h2 { "Project" } label { "Project title" input { value: "{editor.read().project.title}", oninput: move |e| editor.write().edit(|p| p.title = e.value()) } } - label { "Starting room" select { value: editor.read().project.start_room.clone().unwrap_or_default(), onchange: move |e| editor.write().edit(|p| p.start_room = if e.value().is_empty() { None } else { Some(e.value()) }), option { value: "", "Choose a room" } for room in editor.read().project.rooms.clone() { option { value: "{room.id}", "{room.name}" } } } } + label { "Starting room" select { value: editor.read().project.start_room.clone().unwrap_or_default(), onchange: move |e| editor.write().edit(|p| p.start_room = if e.value().is_empty() { None } else { Some(e.value()) }), option { value: "", selected: editor.read().project.start_room.is_none(), "Choose a room" } for room in editor.read().project.rooms.clone() { option { value: "{room.id}", selected: editor.read().project.start_room.as_deref() == Some(room.id.as_str()), "{room.name}" } } } } + } h3 { "Validation · {issues.len()}" } if issues.is_empty() { p { class: "success", "Ready for draft authoring." } } for issue in issues { - button { class: "issue", onclick: move |_| editor.write().focus(issue.object_id.as_deref()), strong { "{issue.field}" } span { "{issue.message}" } } + button { class: "issue", onclick: move |_| editor.write().focus(issue.object_id.as_deref()), strong { {format!("{} · {}", issue.object_id.as_deref().unwrap_or("Project"), issue.field)} } span { "{issue.message}" } } } } } footer { role: "status", "{notice}" } + if session.read().is_some() { crate::preview::DraftPreview { session } } } } } diff --git a/app/tests/project.rs b/app/tests/project.rs index d9bed90..86ca37c 100644 --- a/app/tests/project.rs +++ b/app/tests/project.rs @@ -136,3 +136,82 @@ fn import_rejects_unsupported_corrupt_or_oversize_images() { p.rooms[0].background.as_mut().unwrap().width += 1; assert!(p.validate().iter().any(|i| i.field == "background")); } +#[test] +fn canvas_geometry_handles_reverse_drag_clamping_and_tiny_gestures() { + assert_eq!( + Rect::from_drag((0.8, 0.9), (0.2, 0.3)).unwrap(), + Rect { + x: 0.2, + y: 0.3, + width: 0.6000000000000001, + height: 0.6000000000000001 + } + ); + let rect = Rect::from_drag((-1., -1.), (2., 2.)).unwrap(); + assert_eq!( + rect, + Rect { + x: 0., + y: 0., + width: 1., + height: 1. + } + ); + assert!(Rect::from_drag((0.5, 0.5), (0.5, 0.6)).is_none()); + assert!(!Rect { + x: f64::NAN, + y: 0., + width: 0.1, + height: 0.1 + } + .valid()); + let moved = Rect { + x: 0.1, + y: 0.2, + width: 0.25, + height: 0.3, + } + .translated(2., -2.); + assert_eq!(moved.x, 0.75); + assert_eq!(moved.y, 0.); +} +#[test] +fn room_and_hotspot_operations_preserve_links_across_rename_and_report_deletion() { + let mut p = project(); + let destination = p.add_room(); + p.rooms[0].hotspots[1].action = Action::GoTo { + room_id: destination.clone(), + }; + p.room_mut(&destination).unwrap().name = "Renamed".into(); + assert!(!p.validate().iter().any(|i| i.field == "action.room_id")); + let affected = p.rooms[0].hotspots[1].id.clone(); + let mut e = Editor::new(p.clone()); + e.selection = Selection::Room(destination); + e.delete_selection(); + assert!(e + .project + .validate() + .iter() + .any(|i| i.object_id == Some(affected.clone()) && i.field == "action.room_id")); + e.focus(Some(&affected)); + assert_eq!( + e.selection, + Selection::Hotspot(p.rooms[0].id.clone(), affected) + ); + e.undo(); + assert_eq!(e.project, p); +} +#[test] +fn non_finite_bounds_cannot_silently_serialize_as_null() { + let mut p = project(); + p.rooms[0].hotspots[0].rect.x = f64::INFINITY; + assert!(p.to_json().unwrap_err().contains("non-finite")); +} +#[test] +fn renderer_does_not_load_external_or_active_background_urls() { + let mut bg = background(); + bg.data_url = "https://example.com/tracking.png".into(); + assert_eq!(bg.display_url(), ""); + bg.data_url = "data:image/svg+xml,".into(); + assert_eq!(bg.display_url(), ""); +} diff --git a/app/tests/rehearsal.rs b/app/tests/rehearsal.rs new file mode 100644 index 0000000..f1f34fe --- /dev/null +++ b/app/tests/rehearsal.rs @@ -0,0 +1,90 @@ +use app::{model::*, rehearsal::Rehearsal}; +fn sample() -> Project { + let mut p = Project::default(); + let a = p.add_room(); + let b = p.add_room(); + for r in &mut p.rooms { + r.background = Some( + Background::import( + "test.png".into(), + include_bytes!("../../fixtures/backgrounds/foyer.png"), + ) + .unwrap(), + ); + } + let item = p.add_item(); + p.items[0].name = "Brass key".into(); + let dialogue = p.add_dialogue(); + let choice = p.allocate_id("choice"); + p.dialogues[0].choices.push(Choice { + id: choice, + text: "Take the key".into(), + next: None, + give_item: Some(item.clone()), + }); + let rect = Rect { + x: 0.1, + y: 0.1, + width: 0.2, + height: 0.2, + }; + p.add_hotspot(&a, rect); + p.add_hotspot(&a, rect); + p.add_hotspot(&a, rect); + p.rooms[0].hotspots[0].action = Action::GoTo { room_id: b }; + p.rooms[0].hotspots[0].requires_item = Some(item.clone()); + p.rooms[0].hotspots[1].action = Action::Talk { + dialogue_id: dialogue, + }; + p.rooms[0].hotspots[2].action = Action::Take { item_id: item }; + p +} +#[test] +fn item_gate_dialogue_reward_and_room_transition_do_not_mutate_project() { + let p = sample(); + let original = p.clone(); + let mut s = Rehearsal::new(&p).unwrap(); + let door = &p.rooms[0].hotspots[0].id; + s.activate(door).unwrap(); + assert_eq!(s.room_id, p.rooms[0].id); + assert_eq!(s.message, "You need Brass key first."); + assert!(s.choose("not active").is_err()); + s.activate(&p.rooms[0].hotspots[1].id).unwrap(); + assert!(s.activate(door).is_err()); + assert!(s.continue_dialogue().is_err()); + s.choose(&p.dialogues[0].choices[0].id).unwrap(); + assert!(s.inventory.contains(&p.items[0].id)); + assert_eq!(s.dialogue_id, None); + s.activate(door).unwrap(); + assert_eq!(s.room_id, p.rooms[1].id); + assert!(s.activate(door).is_err()); + assert_eq!(p, original); + assert_eq!(s.project, original); + let reset = Rehearsal::new(&p).unwrap(); + assert!(reset.inventory.is_empty()); + assert_eq!(reset.room_id, p.rooms[0].id); +} +#[test] +fn pickup_is_idempotent_and_invalid_projects_cannot_start() { + let mut p = sample(); + let mut s = Rehearsal::new(&p).unwrap(); + let pickup = &p.rooms[0].hotspots[2].id; + s.activate(pickup).unwrap(); + s.activate(pickup).unwrap(); + assert_eq!(s.inventory.len(), 1); + assert!(s.message.starts_with("You already have")); + p.items.clear(); + assert!(Rehearsal::new(&p).is_err()); +} +#[test] +fn dialogue_branches_and_terminal_continue_are_explicit() { + let mut p = sample(); + let terminal = p.add_dialogue(); + p.dialogues[0].choices[0].next = Some(terminal.clone()); + let mut s = Rehearsal::new(&p).unwrap(); + s.activate(&p.rooms[0].hotspots[1].id).unwrap(); + s.choose(&p.dialogues[0].choices[0].id).unwrap(); + assert_eq!(s.dialogue_id, Some(terminal)); + s.continue_dialogue().unwrap(); + assert!(s.dialogue_id.is_none()); +} diff --git a/docs/images/01-room-editor.png b/docs/images/01-room-editor.png new file mode 100644 index 0000000..0ff144c Binary files /dev/null and b/docs/images/01-room-editor.png differ diff --git a/docs/images/02-dialogue-editor.png b/docs/images/02-dialogue-editor.png new file mode 100644 index 0000000..d564aa8 Binary files /dev/null and b/docs/images/02-dialogue-editor.png differ diff --git a/docs/images/03-draft-rehearsal.png b/docs/images/03-draft-rehearsal.png new file mode 100644 index 0000000..cb4e35d Binary files /dev/null and b/docs/images/03-draft-rehearsal.png differ diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 0000000..a32d373 --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,122 @@ +# Author The Garden Key + +This walkthrough authors an **editor-owned example** through the UI. It is not +the missing upstream core fixture. Allow about ten minutes. No scene-data editing +is required. Run `trunk serve`, open http://localhost:8080, and keep the three PNGs +in [`fixtures/backgrounds`](../fixtures/backgrounds) handy. + +## 1. Set the scene + +In **Project settings**, name the adventure **The Garden Key**. Click **+** beside +**ROOMS**, name the room **Foyer**, and use **Import background** to select +`foyer.png`. Repeat for **Study** (`study.png`) and **Garden** (`garden.png`). The +first room becomes the starting room; check **Project settings → Starting room** +to change it. Each room can be selected in the left sidebar. + +## 2. Create the things the player can carry + +Click **+** beside **INVENTORY**. Name the item **Brass key**, with description +“Opens the study door.” Create a second item named **Garden pass**, with description +“A gift from the gardener.” These are definitions: the player starts with neither. + +## 3. Write the conversation + +Click **+** beside **DIALOGUE**. Name the node **Farewell**, set **Speaker** to +“Gardener”, and **Dialogue text** to “The garden is waiting for you.” Leave it +without choices; the player will get a Continue button. + +Create another node, **Meet the gardener**, with speaker “Gardener” and text +“Would you like to visit the garden?” Click **Add choice**: + +- **Choice text:** May I enter? +- **Next dialogue:** Farewell +- **Give item:** Garden pass + +Add a second choice, **Not yet.**, leaving its next dialogue as **End conversation** +and its reward as **No item**. Both branches are now authored. + +![Dialogue and branching choices](images/02-dialogue-editor.png) + +## 4. Place and connect hotspots + +Select a room, click **Draw hotspot**, and drag over the intended area. Reverse +drags work too. Use **Select / move** to reposition it. For keyboard-only or exact +placement, click **Add hotspot**, then set its name and percentage bounds in the +right inspector. Arrow keys nudge a focused canvas hotspot by 1%. + +The following exact bounds reproduce the example. All coordinates are percentages +of the original background, so they stay aligned when the editor is resized. + +| Room | Hotspot name | X, Y, width, height | Action | Target | Requires item | +|---|---|---|---|---|---| +| Foyer | Brass key on table | 44, 60, 16, 15 | Pick up item | Brass key | No requirement | +| Foyer | Study door | 72, 20, 20, 48 | Go to room | Study | Brass key | +| Study | Return to foyer | 8, 20, 18, 48 | Go to room | Foyer | No requirement | +| Study | Talk to gardener | 40, 55, 24, 18 | Start dialogue | Meet the gardener | No requirement | +| Study | Garden gate | 72, 20, 20, 48 | Go to room | Garden | Garden pass | +| Garden | Return to study | 8, 20, 18, 48 | Go to room | Study | No requirement | +| Garden | Statue | 42, 40, 18, 30 | Inspect | The garden remembers every visitor. | No requirement | + +For the Statue, enter the target text in **Inspection text**. Links are one-way; +the return hotspots above explicitly create the reverse paths. + +![A room with its hotspot inspector](images/01-room-editor.png) + +## 5. Validate and save + +The inspector should show **Validation · 0**. To try an error, temporarily change +Study door's destination to **Choose destination**. Its validation entry includes +the hotspot's stable ID and `action.room_id`. Click the error to select that +hotspot, then choose Study again (or Undo). + +Click **Save project** and keep the downloaded `adventure.pace-draft.json`. It +contains every background and all authored fields. Click **New project**, then +**Open project** and select the downloaded file. Everything reappears; importing +the original image files again is unnecessary. Invalid and unsupported files do +not replace the open project. The saved browser recovery copy also opens on a +refresh; it is a convenience, not a substitute for a downloaded project file. + +## 6. Rehearse the draft + +Click **Rehearse draft**. This is an editor-only simulation on a copy of the +project, not the PACE runtime: + +1. Click **Study door** before taking the key. It tells you what is missing. +2. Click **Brass key on table**, then **Study door**. You arrive in the Study. +3. Try **Garden gate**. It requires the Garden pass. +4. Click **Talk to gardener**, then **Not yet.** to try the non-reward branch. +5. Talk again; choose **May I enter?** The pass enters inventory and Farewell runs. +6. Click **Continue**, then **Garden gate**, then **Statue**. +7. Return to Study and Foyer using their return hotspots. **Restart** resets the + inventory and current room. **Close rehearsal** returns to the unchanged draft. + +![Draft rehearsal after reaching the garden](images/03-draft-rehearsal.png) + +**Core preview** and **Export game** remain disabled until the upstream core +format and executable runtime exist and can be pinned. See +[the integration boundary](core-integration.md). Do not treat rehearsal as proof +of core compatibility. + +## Reproduce the evidence + +`tests/e2e/authoring.spec.ts` performs the entire authoring workflow using browser +controls, verifies both dialogue choices and the gated room transitions, then +saves, reopens, and deeply compares the project—including embedded backgrounds. +No test writes scene data into the page to create the example. + +```sh +trunk build +npm ci +npx playwright install chromium +npm run test:e2e +``` + +To regenerate the committed example and screenshots from those same UI actions: + +```sh +UPDATE_WALKTHROUGH=1 npm run test:e2e -- authoring.spec.ts +``` + +The fixture is [`three-rooms.pace-draft.json`](../fixtures/three-rooms.pace-draft.json). +The background illustrations were generated for this repository and contain no +external assets. diff --git a/fixtures/three-rooms.pace-draft.json b/fixtures/three-rooms.pace-draft.json new file mode 100644 index 0000000..cfe3646 --- /dev/null +++ b/fixtures/three-rooms.pace-draft.json @@ -0,0 +1,191 @@ +{ + "format": "pace-editor-draft", + "version": 1, + "title": "The Garden Key", + "start_room": "room-1", + "rooms": [ + { + "id": "room-1", + "name": "Foyer", + "background": { + "name": "foyer.png", + "data_url": "data:image/png;base64,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", + "width": 960, + "height": 600 + }, + "hotspots": [ + { + "id": "hotspot-10", + "name": "Brass key on table", + "rect": { + "x": 0.44, + "y": 0.6, + "width": 0.16, + "height": 0.15 + }, + "action": { + "kind": "take", + "item_id": "item-4" + }, + "requires_item": null + }, + { + "id": "hotspot-11", + "name": "Study door", + "rect": { + "x": 0.72, + "y": 0.2, + "width": 0.2, + "height": 0.48 + }, + "action": { + "kind": "go_to", + "room_id": "room-2" + }, + "requires_item": "item-4" + } + ] + }, + { + "id": "room-2", + "name": "Study", + "background": { + "name": "study.png", + "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA8AAAAJYCAIAAAAi9hhWAAAWRklEQVR4nO3da4xmd13A8bO7Q7F10wqNFCNYV5DaoG2sYrfGRuuFDZDGEgFJIxoJBS8vgDThFTHGNBpNjOILwRskGm0bbdRqxFapJUW6zRLrBgtdS2naCmULu724pHZLd3wxm+nu7Mwz53ee8z//y/l8XvUynf2d85zn/L/nv093dlx93fUdAADQz87cAwAAQE0ENAAABAhoAAAIENAAABAgoAEAIEBAAwBAgIAGAIAAAQ0AAAECGgAAAgQ0AAAECGgAAAgQ0AAAECCgAQAgQEADAECAgAYAgAABDQAAAQIaAAACBDQAAAQIaAAACBDQAAAQIKABACBAQAMAQICABgCAAAENAAABAhoAAAIENAAABAhoAAAIWEn0ffd8+7de+6a9u3btfP7E6kduvuO7XvGyN1x5Sdd137Pn2+5/6LGu627798++521XvfODf7r29R+94V1rf/3nv/XuLzzyeNetruza9Yl7PvfJA/e/4oKXvPcd+z7wuzevrq52Xfeb73vrh2/6xKNfOZpocgAAWCBVQP/Sz17123/2T0efOnb5Ja/6uat/+EN/cfuB//pi13UfveFdv/Hhv1v7mve87aoz/8NvPH9i7QtefNaLPvDONz57/Ln9Bx989CtHrrj01Z/+zwe+/+ILDx95Sj0DAJBLqo9wnLf7nLNetKvrus/c99Btn/rsgO/w7PHn/vIfP722b33Lv3zmzT/5Azt27LjmJy675fYDI88KAAC9pdqBvvHj+3/9V9987+cf/tR//Pd9X/jSsG/y8GNHXn7+eV3XfenwE488duS6t/zo15449j+Hnxh1UgAACEi1A/3JA/df/zs3HnrosV/46R95y+tfN+yb7Nq58/kTJ9b++pbbD1z1QxfbfgYAIK8kAX3u7rNf850v//ozz9554P4b/ujWn7ritZt+2erq6s6dO7rTQ/lUr/6Olz3y2MmPO3/5q08+8+xzX/7qkykGBgCAnpIE9Orq6vvese/8b9nddd3uc77pa08e2/TLHnz08e97zSu7rrvkold+8ZHHN/zbbz77xde+6Ypb/+3eFBMCAMAwST4D/b9f/78/+Zs73//z+44/940TJ1Y/cvMdm37Zx/72rne/9ceu+fHLuq7747++8+RAu3b+2i9f03Wru3bt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", + "width": 960, + "height": 600 + }, + "hotspots": [ + { + "id": "hotspot-12", + "name": "Return to foyer", + "rect": { + "x": 0.08, + "y": 0.2, + "width": 0.18, + "height": 0.48 + }, + "action": { + "kind": "go_to", + "room_id": "room-1" + }, + "requires_item": null + }, + { + "id": "hotspot-13", + "name": "Talk to gardener", + "rect": { + "x": 0.4, + "y": 0.55, + "width": 0.24, + "height": 0.18 + }, + "action": { + "kind": "talk", + "dialogue_id": "dialogue-7" + }, + "requires_item": null + }, + { + "id": "hotspot-14", + "name": "Garden gate", + "rect": { + "x": 0.72, + "y": 0.2, + "width": 0.2, + "height": 0.48 + }, + "action": { + "kind": "go_to", + "room_id": "room-3" + }, + "requires_item": "item-5" + } + ] + }, + { + "id": "room-3", + "name": "Garden", + "background": { + "name": "garden.png", + "data_url": "data:image/png;base64,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", + "width": 960, + "height": 600 + }, + "hotspots": [ + { + "id": "hotspot-15", + "name": "Return to study", + "rect": { + "x": 0.08, + "y": 0.2, + "width": 0.18, + "height": 0.48 + }, + "action": { + "kind": "go_to", + "room_id": "room-2" + }, + "requires_item": null + }, + { + "id": "hotspot-16", + "name": "Statue", + "rect": { + "x": 0.42, + "y": 0.4, + "width": 0.18, + "height": 0.3 + }, + "action": { + "kind": "look", + "text": "The garden remembers every visitor." + }, + "requires_item": null + } + ] + } + ], + "items": [ + { + "id": "item-4", + "name": "Brass key", + "description": "Opens the study door." + }, + { + "id": "item-5", + "name": "Garden pass", + "description": "A gift from the gardener." + } + ], + "dialogues": [ + { + "id": "dialogue-6", + "name": "Farewell", + "speaker": "Gardener", + "text": "The garden is waiting for you.", + "choices": [] + }, + { + "id": "dialogue-7", + "name": "Meet the gardener", + "speaker": "Gardener", + "text": "Would you like to visit the garden?", + "choices": [ + { + "id": "choice-8", + "text": "May I enter?", + "next": "dialogue-6", + "give_item": "item-5" + }, + { + "id": "choice-9", + "text": "Not yet.", + "next": null, + "give_item": null + } + ] + } + ], + "next_id": 17 +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ea46827 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "pace-editor-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pace-editor-browser-tests", + "devDependencies": { + "@playwright/test": "1.56.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..429ce01 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "pace-editor-browser-tests", + "private": true, + "scripts": { "test:e2e": "playwright test" }, + "devDependencies": { "@playwright/test": "1.56.1" } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4806be4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + workers: 1, + use: { + baseURL: 'http://127.0.0.1:8080', + actionTimeout: 10_000, + viewport: { width: 1440, height: 1000 }, + launchOptions: process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { command: 'python3 -m http.server 8080 --bind 127.0.0.1 --directory dist', url: 'http://127.0.0.1:8080', reuseExistingServer: !process.env.CI }, +}); diff --git a/tests/e2e/authoring.spec.ts b/tests/e2e/authoring.spec.ts new file mode 100644 index 0000000..e2ec2c1 --- /dev/null +++ b/tests/e2e/authoring.spec.ts @@ -0,0 +1,132 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'node:fs/promises'; + +async function room(page: Page, name: string, image: string) { + await page.getByRole('button', { name: 'Add room', exact: true }).click(); + await page.getByLabel('Room name', { exact: true }).fill(name); + await page.getByLabel('Import background', { exact: true }).setInputFiles(`fixtures/backgrounds/${image}.png`); + await expect(page.getByRole('img', { name: `${name} background` })).toBeVisible(); +} +async function hotspot(page: Page, room: string, name: string, bounds: number[], action: string, target: string) { + await page.getByRole('button', { name: room, exact: true }).click(); + await page.getByRole('button', { name: 'Add hotspot', exact: true }).click(); + await page.getByLabel('Hotspot name', { exact: true }).fill(name); + for (const [i, field] of ['X', 'Y', 'Width', 'Height'].entries()) + await page.getByLabel(`${field} (%)`, { exact: true }).fill(String(bounds[i])); + await page.getByRole('combobox', { name: 'Action', exact: true }).selectOption(action); + if (action === 'look') await page.getByLabel('Inspection text', { exact: true }).fill(target); + else await page.getByRole('combobox', { name: { go_to: 'Destination room', take: 'Item to pick up', talk: 'Dialogue to start' }[action]!, exact: true }).selectOption({ label: target }); +} + +test('authors the three-room adventure entirely through controls and rehearses the story', async ({ page }, testInfo) => { + test.setTimeout(90_000); + const errors: string[] = []; + page.on('pageerror', e => errors.push(e.message)); + page.on('dialog', d => d.accept()); + await page.goto('/'); + await page.getByLabel('Project title', { exact: true }).fill('The Garden Key'); + await room(page, 'Foyer', 'foyer'); + await room(page, 'Study', 'study'); + await room(page, 'Garden', 'garden'); + for (const [name, description] of [['Brass key', 'Opens the study door.'], ['Garden pass', 'A gift from the gardener.']]) { + await page.getByRole('button', { name: 'Add item', exact: true }).click(); + await page.getByLabel('Item name', { exact: true }).fill(name); + await page.getByLabel('Item description', { exact: true }).fill(description); + } + await page.getByRole('button', { name: 'Add dialogue', exact: true }).click(); + await page.getByLabel('Dialogue name', { exact: true }).fill('Farewell'); + await page.getByLabel('Speaker', { exact: true }).fill('Gardener'); + await page.getByLabel('Dialogue text', { exact: true }).fill('The garden is waiting for you.'); + await page.getByRole('button', { name: 'Add dialogue', exact: true }).click(); + await page.getByLabel('Dialogue name', { exact: true }).fill('Meet the gardener'); + await page.getByLabel('Speaker', { exact: true }).fill('Gardener'); + await page.getByLabel('Dialogue text', { exact: true }).fill('Would you like to visit the garden?'); + await page.getByRole('button', { name: 'Add choice', exact: true }).click(); + await page.getByLabel('Choice text', { exact: true }).fill('May I enter?'); + await page.getByRole('combobox', { name: 'Next dialogue', exact: true }).selectOption({ label: 'Farewell' }); + await page.getByRole('combobox', { name: 'Give item', exact: true }).selectOption({ label: 'Garden pass' }); + await page.getByRole('button', { name: 'Add choice', exact: true }).click(); + await page.getByLabel('Choice text', { exact: true }).nth(1).fill('Not yet.'); + await hotspot(page, 'Foyer', 'Brass key on table', [44, 60, 16, 15], 'take', 'Brass key'); + await hotspot(page, 'Foyer', 'Study door', [72, 20, 20, 48], 'go_to', 'Study'); + await page.getByRole('combobox', { name: 'Requires item', exact: true }).selectOption({ label: 'Brass key' }); + await hotspot(page, 'Study', 'Return to foyer', [8, 20, 18, 48], 'go_to', 'Foyer'); + await hotspot(page, 'Study', 'Talk to gardener', [40, 55, 24, 18], 'talk', 'Meet the gardener'); + await hotspot(page, 'Study', 'Garden gate', [72, 20, 20, 48], 'go_to', 'Garden'); + await page.getByRole('combobox', { name: 'Requires item', exact: true }).selectOption({ label: 'Garden pass' }); + await hotspot(page, 'Garden', 'Return to study', [8, 20, 18, 48], 'go_to', 'Study'); + await hotspot(page, 'Garden', 'Statue', [42, 40, 18, 30], 'look', 'The garden remembers every visitor.'); + await expect(page.getByText('Validation · 0', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Core preview', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Export game', exact: true })).toBeDisabled(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + const download = await downloadPromise; + const file = testInfo.outputPath('three-rooms.pace-draft.json'); + await download.saveAs(file); + const saved = JSON.parse(await fs.readFile(file, 'utf8')); + expect(saved.rooms.map((r: any) => r.name)).toEqual(['Foyer', 'Study', 'Garden']); + expect(saved.rooms.flatMap((r: any) => r.hotspots)).toHaveLength(7); + expect(saved.items).toHaveLength(2); expect(saved.dialogues).toHaveLength(2); + await page.getByRole('button', { name: 'Foyer', exact: true }).click(); + await page.getByRole('button', { name: 'Select hotspot Study door', exact: true }).click(); + await page.screenshot({ path: testInfo.outputPath('01-room-editor.png'), fullPage: true }); + if (process.env.UPDATE_WALKTHROUGH) { + await fs.copyFile(file, 'fixtures/three-rooms.pace-draft.json'); + await page.screenshot({ path: 'docs/images/01-room-editor.png', fullPage: true }); + } + await page.getByRole('button', { name: 'Meet the gardener', exact: true }).click(); + await expect(page.getByRole('combobox', { name: 'Starting room', exact: true }).locator('option:checked')).toHaveText('Foyer'); + await expect(page.getByRole('combobox', { name: 'Next dialogue', exact: true }).first().locator('option:checked')).toHaveText('Farewell'); + await expect(page.getByRole('combobox', { name: 'Give item', exact: true }).first().locator('option:checked')).toHaveText('Garden pass'); + await page.screenshot({ path: testInfo.outputPath('02-dialogue-editor.png'), fullPage: true }); + if (process.env.UPDATE_WALKTHROUGH) await page.screenshot({ path: 'docs/images/02-dialogue-editor.png', fullPage: true }); + await page.getByRole('button', { name: 'Rehearse draft', exact: true }).click(); + const preview = page.getByRole('dialog', { name: 'Draft rehearsal' }); + await preview.getByRole('button', { name: 'Interact with Study door', exact: true }).click(); + await expect(preview.getByRole('status')).toHaveText('You need Brass key first.'); + await preview.getByRole('button', { name: 'Interact with Brass key on table', exact: true }).click(); + await preview.getByRole('button', { name: 'Interact with Study door', exact: true }).click(); + await expect(preview.getByRole('heading', { name: 'Study', exact: true })).toBeVisible(); + await preview.getByRole('button', { name: 'Interact with Garden gate', exact: true }).click(); + await expect(preview.getByRole('status')).toHaveText('You need Garden pass first.'); + await preview.getByRole('button', { name: 'Interact with Talk to gardener', exact: true }).click(); + await preview.getByRole('button', { name: 'Not yet.', exact: true }).click(); + await expect(preview.getByRole('status')).toHaveText('The conversation ends.'); + await preview.getByRole('button', { name: 'Interact with Talk to gardener', exact: true }).click(); + await preview.getByRole('button', { name: 'May I enter?', exact: true }).click(); + await expect(preview.getByText('The garden is waiting for you.', { exact: true })).toBeVisible(); + await preview.getByRole('button', { name: 'Continue', exact: true }).click(); + await preview.getByRole('button', { name: 'Interact with Garden gate', exact: true }).click(); + await expect(preview.getByRole('heading', { name: 'Garden', exact: true })).toBeVisible(); + await preview.getByRole('button', { name: 'Interact with Statue', exact: true }).click(); + await expect(preview.getByRole('status')).toHaveText('The garden remembers every visitor.'); + if (process.env.UPDATE_WALKTHROUGH) await page.screenshot({ path: 'docs/images/03-draft-rehearsal.png' }); + await preview.getByRole('button', { name: 'Interact with Return to study', exact: true }).click(); + await preview.getByRole('button', { name: 'Interact with Return to foyer', exact: true }).click(); + await preview.getByRole('button', { name: 'Restart', exact: true }).click(); + await expect(preview.getByText('Nothing collected yet', { exact: true })).toBeVisible(); + await preview.getByRole('button', { name: 'Close rehearsal', exact: true }).click(); + await expect(preview).toHaveCount(0); + await page.getByRole('button', { name: 'Rehearse draft', exact: true }).click(); + // The native modal keeps keyboard focus away from editing controls. + for (let i = 0; i < 7; i++) { + await page.keyboard.press('Tab'); + expect(await page.evaluate(() => document.activeElement === document.body || !!document.activeElement?.closest('#draft-rehearsal'))).toBe(true); + } + await page.keyboard.press('Escape'); + await expect(preview).toHaveCount(0); + // Rehearsal must not dirty or change the authored data. + const afterPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + await (await afterPromise).saveAs(testInfo.outputPath('after-rehearsal.json')); + expect(JSON.parse(await fs.readFile(testInfo.outputPath('after-rehearsal.json'), 'utf8'))).toEqual(saved); + await page.getByRole('button', { name: 'New project', exact: true }).click(); + await page.getByLabel('Open project', { exact: true }).setInputFiles(file); + await expect(page.getByText('Validation · 0', { exact: true })).toBeVisible(); + const reopenedPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + await (await reopenedPromise).saveAs(testInfo.outputPath('reopened.json')); + expect(JSON.parse(await fs.readFile(testInfo.outputPath('reopened.json'), 'utf8'))).toEqual(saved); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/recovery.spec.ts b/tests/e2e/recovery.spec.ts new file mode 100644 index 0000000..56c3e35 --- /dev/null +++ b/tests/e2e/recovery.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs/promises'; + +test('quota failure preserves edits and offers a portable file; malformed open is non-destructive', async ({ page }, testInfo) => { + page.on('dialog', d => d.accept()); + await page.addInitScript(() => { + Storage.prototype.setItem = () => { throw new DOMException('Quota exceeded', 'QuotaExceededError'); }; + }); + await page.goto('/'); + await page.getByLabel('Project title', { exact: true }).fill('Keep this draft'); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + const file = testInfo.outputPath('quota-draft.json'); + await (await downloadPromise).saveAs(file); + expect(JSON.parse(await fs.readFile(file, 'utf8')).title).toBe('Keep this draft'); + await expect(page.getByRole('status')).toContainText('Browser recovery is full or unavailable'); + await expect(page.locator('.project-title')).toContainText('•'); + await page.getByLabel('Open project', { exact: true }).setInputFiles({ name: 'broken.json', mimeType: 'application/json', buffer: Buffer.from('{broken') }); + await expect(page.getByRole('status')).toContainText('Invalid project JSON'); + await expect(page.getByLabel('Project title', { exact: true })).toHaveValue('Keep this draft'); +}); + +test('cancelled destructive action retains the current draft', async ({ page }) => { + page.on('dialog', d => d.dismiss()); + await page.goto('/'); + await page.getByLabel('Project title', { exact: true }).fill('Unsaved story'); + await page.getByRole('button', { name: 'New project', exact: true }).click(); + await expect(page.getByLabel('Project title', { exact: true })).toHaveValue('Unsaved story'); +}); diff --git a/tests/e2e/rooms.spec.ts b/tests/e2e/rooms.spec.ts new file mode 100644 index 0000000..e40749b --- /dev/null +++ b/tests/e2e/rooms.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs/promises'; + +test('import, draw, move, link, validate, undo, save and reopen rooms', async ({ page }, testInfo) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + await page.goto('/'); + await page.getByRole('button', { name: 'Add room', exact: true }).click(); + await page.getByLabel('Room name', { exact: true }).fill('Foyer'); + await page.getByLabel('Import background', { exact: true }).setInputFiles('fixtures/backgrounds/foyer.png'); + await expect(page.getByRole('img', { name: 'Foyer background' })).toBeVisible(); + await page.getByRole('button', { name: 'Draw hotspot', exact: true }).click(); + const canvas = await page.locator('#room-stage').boundingBox(); + if (!canvas) throw new Error('Missing canvas'); + await page.mouse.move(canvas.x + canvas.width * .3, canvas.y + canvas.height * .7); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width * .1, canvas.y + canvas.height * .2, { steps: 8 }); + await page.mouse.up(); + await page.getByLabel('Hotspot name', { exact: true }).fill('Study door'); + const drawnX = Number(await page.getByLabel('X (%)', { exact: true }).inputValue()); + expect(Math.abs(drawnX - 10)).toBeLessThan(.2); + expect(Math.abs(Number(await page.getByLabel('Width (%)', { exact: true }).inputValue()) - 20)).toBeLessThan(.2); + await page.getByRole('button', { name: 'Select hotspot Study door', exact: true }).press('ArrowRight'); + await expect(page.getByLabel('X (%)', { exact: true })).toHaveValue((drawnX + 1).toFixed(2)); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByLabel('X (%)', { exact: true })).toHaveValue(drawnX.toFixed(2)); + const hit = await page.getByRole('button', { name: 'Select hotspot Study door', exact: true }).boundingBox(); + if (!hit) throw new Error('Missing hotspot'); + await page.mouse.move(hit.x + hit.width / 2, hit.y + hit.height / 2); + await page.mouse.down(); + await page.mouse.move(hit.x + hit.width / 2 + canvas.width * .05, hit.y + hit.height / 2, { steps: 6 }); + await page.mouse.up(); + expect(Math.abs(Number(await page.getByLabel('X (%)', { exact: true }).inputValue()) - drawnX - 5)).toBeLessThan(.2); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByLabel('X (%)', { exact: true })).toHaveValue(drawnX.toFixed(2)); + await page.getByRole('button', { name: 'Draw hotspot', exact: true }).click(); + await page.mouse.move(canvas.x + canvas.width * .5, canvas.y + canvas.height * .5); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width * .8, canvas.y + canvas.height * .8); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await expect(page.locator('#room-stage .hotspot')).toHaveCount(1); + await page.getByRole('button', { name: 'Add room', exact: true }).click(); + await page.getByLabel('Room name', { exact: true }).fill('Study'); + await page.getByLabel('Import background', { exact: true }).setInputFiles('fixtures/backgrounds/study.png'); + await expect(page.getByRole('img', { name: 'Study background' })).toBeVisible(); + await page.getByRole('button', { name: 'Foyer', exact: true }).click(); + await page.getByRole('button', { name: 'Select hotspot Study door', exact: true }).click(); + await page.getByRole('combobox', { name: 'Action', exact: true }).selectOption('go_to'); + await page.getByRole('combobox', { name: 'Destination room', exact: true }).selectOption({ label: 'Study' }); + await expect(page.getByText('Validation · 0', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Study', exact: true }).click(); + await page.getByRole('button', { name: 'Delete room', exact: true }).click(); + await page.locator('.issue').filter({ hasText: 'action.room_id' }).click(); + await expect(page.getByLabel('Hotspot name', { exact: true })).toHaveValue('Study door'); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByText('Validation · 0', { exact: true })).toBeVisible(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + const download = await downloadPromise; + const file = testInfo.outputPath('rooms.pace-draft.json'); + await download.saveAs(file); + const saved = JSON.parse(await fs.readFile(file, 'utf8')); + expect(saved.rooms).toHaveLength(2); + expect(saved.rooms[0].hotspots[0].action.room_id).toBe(saved.rooms[1].id); + expect(saved.rooms[0].background.data_url).toMatch(/^data:image\/png;base64,/); + await page.getByRole('button', { name: 'New project', exact: true }).click(); + await page.getByLabel('Open project', { exact: true }).setInputFiles(file); + await expect(page.getByRole('heading', { name: 'Foyer', exact: true })).toBeVisible(); + const secondPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project', exact: true }).click(); + const second = await secondPromise; + await second.saveAs(testInfo.outputPath('roundtrip.json')); + expect(JSON.parse(await fs.readFile(testInfo.outputPath('roundtrip.json'), 'utf8'))).toEqual(saved); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Foyer', exact: true })).toBeVisible(); + const malformed = testInfo.outputPath('unsupported.json'); + await fs.writeFile(malformed, JSON.stringify({ ...saved, version: 999 })); + await page.getByLabel('Open project', { exact: true }).setInputFiles(malformed); + await expect(page.getByRole('status')).toContainText('Unsupported draft version'); + await expect(page.getByRole('heading', { name: 'Foyer', exact: true })).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/web_view/index.html b/web_view/index.html index 6a738a7..034e039 100644 --- a/web_view/index.html +++ b/web_view/index.html @@ -4,6 +4,7 @@ + PACE — Adventure editor