Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/editor.yml
Original file line number Diff line number Diff line change
@@ -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/
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,21 @@ 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.
7 changes: 6 additions & 1 deletion app/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ mod web {
}
}
#[wasm_bindgen::prelude::wasm_bindgen(
inline_js = "export function setDirty(dirty) { window.onbeforeunload = dirty ? (event) => { event.preventDefault(); event.returnValue = ''; } : null; }"
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()?
Expand Down Expand Up @@ -114,3 +116,6 @@ pub fn stage_point(_: f64, _: f64) -> Option<(f64, f64)> {
pub fn capture_pointer(_: i32) {}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_dirty(_: bool) {}

#[cfg(not(target_arch = "wasm32"))]
pub fn open_rehearsal() {}
2 changes: 2 additions & 0 deletions app/src/editor.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ pub mod model;
mod rooms;
mod ui;
pub use ui::App;

mod preview;
pub mod rehearsal;
mod story;
31 changes: 29 additions & 2 deletions app/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ impl Default for Project {

impl Project {
pub fn to_json(&self) -> Result<String, String> {
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());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Self, String> {
if bytes.len() > MAX_IMAGE_BYTES {
return Err("Background exceeds 8 MiB. Resize it before importing.".into());
Expand Down
47 changes: 47 additions & 0 deletions app/src/preview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use crate::rehearsal::Rehearsal;
use dioxus::prelude::*;

#[component]
pub fn DraftPreview(mut session: Signal<Option<Rehearsal>>) -> 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}" }
}
}
}
}
Loading
Loading