From c2bc703de418507f40ab17192883611ace55a26d Mon Sep 17 00:00:00 2001 From: akaradje Date: Tue, 12 May 2026 15:28:29 +0700 Subject: [PATCH 01/11] feat: payload system + event queue from Wasm Add packed u32 event queue (merge/fracture/spawn/despawn) to Rust. JS PayloadRegistry decodes events and maintains Map. Relations emit merge/fracture events; spawn/despawn tracked in lib. Co-Authored-By: Claude Opus 4.7 --- src/ecs.rs | 52 ++++ src/lib.rs | 85 +++++- src/relations.rs | 141 +++++++++- web/main.js | 653 +++++++++++++++++++++++++++++------------------ web/payload.js | 121 +++++++++ 5 files changed, 799 insertions(+), 253 deletions(-) create mode 100644 web/payload.js diff --git a/src/ecs.rs b/src/ecs.rs index 8fa4200..470795a 100644 --- a/src/ecs.rs +++ b/src/ecs.rs @@ -149,3 +149,55 @@ impl World { self.max_entities } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spawn_despawn_round_trip() { + let mut world = World::new(100); + assert_eq!(world.active_count(), 0); + + let id = world.spawn(10.0, 20.0, 1.0, -2.0, 255, 128, 64, 200, 0b101, 8.0); + assert!(id != usize::MAX); + assert_eq!(world.active_count(), 1); + assert!(world.alive[id]); + assert_eq!(world.pos_x[id], 10.0); + assert_eq!(world.pos_y[id], 20.0); + assert_eq!(world.bitmask[id], 0b101); + assert_eq!(world.mass[id], 2.0); // popcount of 0b101 = 2 + + world.despawn(id); + assert_eq!(world.active_count(), 0); + assert!(!world.alive[id]); + + // Re-spawn in the freed slot + let id2 = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 4.0); + assert_eq!(id2, id); // Should reuse the freed slot + assert_eq!(world.active_count(), 1); + } + + #[test] + fn spawn_at_capacity_returns_max() { + let mut world = World::new(5); + for _ in 0..5 { + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 1.0); + assert!(id != usize::MAX); + } + let overflow = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0, 1.0); + assert_eq!(overflow, usize::MAX); + } + + #[test] + fn force_accumulation_and_mass() { + let mut world = World::new(10); + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 255, 0b1111, 5.0); + assert_eq!(world.mass[id], 4.0); + + world.apply_force(id, 10.0, 20.0); + world.apply_force(id, 5.0, -5.0); + assert_eq!(world.force_x[id], 15.0); + assert_eq!(world.force_y[id], 15.0); + } +} diff --git a/src/lib.rs b/src/lib.rs index 70380ca..558c524 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,12 @@ use renderer::SoftwareRenderer; use physics::PhysicsSystem; use relations::RelationSystem; +/// Event kind constants (packed u32 format). +pub const EVENT_MERGE: u32 = 0; +pub const EVENT_FRACTURE: u32 = 1; +pub const EVENT_SPAWN: u32 = 2; +pub const EVENT_DESPAWN: u32 = 3; + /// The main engine struct that orchestrates all subsystems. /// This is the single entry point exposed to JavaScript. #[wasm_bindgen] @@ -29,6 +35,9 @@ pub struct LiquidEngine { canvas_width: u32, canvas_height: u32, frame_count: u64, + /// Event queue: packed u32 format: + /// [kind, consumed_count, produced_count, consumed_ids..., produced_ids...] + events: Vec, } #[wasm_bindgen] @@ -50,6 +59,7 @@ impl LiquidEngine { canvas_width: width, canvas_height: height, frame_count: 0, + events: Vec::with_capacity(1024), } } @@ -63,12 +73,28 @@ impl LiquidEngine { bitmask: u32, radius: f32, ) -> u32 { - self.world.spawn(x, y, vx, vy, r, g, b, a, bitmask, radius) as u32 + let id = self.world.spawn(x, y, vx, vy, r, g, b, a, bitmask, radius); + if id != usize::MAX { + // Record spawn event: [kind=2, consumed=0, produced=1, id] + self.events.push(EVENT_SPAWN); + self.events.push(0); + self.events.push(1); + self.events.push(id as u32); + } + id as u32 } /// Remove a node by entity ID. pub fn remove_node(&mut self, id: u32) { - self.world.despawn(id as usize); + let idx = id as usize; + if idx < self.world.max_entities && self.world.alive[idx] { + // Record despawn event: [kind=3, consumed=1, produced=0, id] + self.events.push(EVENT_DESPAWN); + self.events.push(1); + self.events.push(0); + self.events.push(id); + } + self.world.despawn(idx); } /// Get the current number of active nodes. @@ -83,12 +109,15 @@ impl LiquidEngine { /// Main simulation tick - advances physics, resolves collisions, /// processes merge/fracture logic, and renders to pixel buffer. - /// + /// /// # Arguments /// * `dt` - Delta time in seconds since last frame pub fn tick(&mut self, dt: f32) { self.frame_count += 1; + // 0. Clear event queue from previous frame + self.events.clear(); + // 1. Physics update: apply velocities, viscosity, bounds self.physics.update(&mut self.world, dt, self.canvas_width as f32, self.canvas_height as f32); @@ -104,7 +133,7 @@ impl LiquidEngine { } // 3. Collision detection & relational logic (merge/fracture) - self.relations.process(&mut self.world, &self.quadtree); + self.relations.process(&mut self.world, &self.quadtree, &mut self.events); // 4. Render to pixel buffer (dirty rectangles) self.renderer.render(&self.world); @@ -152,7 +181,7 @@ impl LiquidEngine { /// Initiate a fracture operation on a node (split into components). pub fn fracture_node(&mut self, id: u32) { - self.relations.fracture(&mut self.world, id as usize); + self.relations.fracture(&mut self.world, id as usize, &mut self.events); } /// Get canvas width. @@ -174,4 +203,50 @@ impl LiquidEngine { pub fn clear_dirty(&mut self) { self.renderer.clear_dirty(); } + + // ---- Event Queue API ---- + + /// Number of u32 entries in the event queue. + pub fn event_count(&self) -> u32 { + self.events.len() as u32 + } + + /// Pointer to the event queue data (packed u32 format). + /// JS consumes the raw buffer and decodes it. + pub fn event_ptr(&self) -> *const u32 { + self.events.as_ptr() + } + + /// Drain all events from the queue. JS should call this after + /// reading events to avoid re-processing them next frame. + pub fn drain_events(&mut self) { + self.events.clear(); + } + + // ---- Pinned Drag API (spring-damped) ---- + + /// Pin a node to be pulled toward `cursor_x, cursor_y` by a damped spring. + pub fn pin_node(&mut self, id: u32, cursor_x: f32, cursor_y: f32) { + self.physics.pin_node(&mut self.world, id as usize, cursor_x, cursor_y); + } + + /// Unpin a previously pinned node. + pub fn unpin_node(&mut self, id: u32) { + self.physics.unpin_node(&mut self.world, id as usize); + } + + /// Update cursor position for an already-pinned node. + pub fn update_pin_target(&mut self, id: u32, cursor_x: f32, cursor_y: f32) { + self.physics.update_pin_target(&mut self.world, id as usize, cursor_x, cursor_y); + } + + // ---- Physics Settings ---- + + pub fn set_viscosity(&mut self, v: f32) { + self.physics.viscosity = v; + } + + pub fn set_gravity(&mut self, g: f32) { + self.physics.gravity_y = g; + } } diff --git a/src/relations.rs b/src/relations.rs index 5eb3e68..c4c76c1 100644 --- a/src/relations.rs +++ b/src/relations.rs @@ -15,6 +15,7 @@ use crate::ecs::World; use crate::quadtree::Quadtree; +use crate::{EVENT_MERGE, EVENT_FRACTURE}; /// Color mapping from bitmask bits to RGBA values. /// This defines the "rainbow spectrum" of fundamental elements. @@ -50,7 +51,8 @@ impl RelationSystem { /// Process collisions and execute merge logic. /// Called once per frame after quadtree is built. - pub fn process(&mut self, world: &mut World, quadtree: &Quadtree) { + /// Events are pushed into the provided queue for JS consumption. + pub fn process(&mut self, world: &mut World, quadtree: &Quadtree, events: &mut Vec) { self.merge_queue.clear(); // Detect collisions via quadtree @@ -108,12 +110,12 @@ impl RelationSystem { if !world.alive[a] || !world.alive[b] { continue; // May have been consumed by earlier merge } - self.merge(world, a, b); + self.merge(world, a, b, events); } } /// Merge two entities: combine bitmasks with OR, create new entity, remove originals. - fn merge(&self, world: &mut World, a: usize, b: usize) { + fn merge(&self, world: &mut World, a: usize, b: usize, events: &mut Vec) { let new_mask = world.bitmask[a] | world.bitmask[b]; // New position: center of mass @@ -132,17 +134,29 @@ impl RelationSystem { let area = std::f32::consts::PI * (world.radius[a].powi(2) + world.radius[b].powi(2)); let new_radius = (area / std::f32::consts::PI).sqrt(); + let id_a = a as u32; + let id_b = b as u32; + // Remove originals world.despawn(a); world.despawn(b); // Spawn merged entity - world.spawn(new_x, new_y, new_vx, new_vy, r, g, b_color, 230, new_mask, new_radius); + let new_id = world.spawn(new_x, new_y, new_vx, new_vy, r, g, b_color, 230, new_mask, new_radius); + let new_id_u32 = new_id as u32; + + // Record merge event: [kind=0, consumed=2, produced=1, idA, idB, newId] + events.push(EVENT_MERGE); + events.push(2); + events.push(1); + events.push(id_a); + events.push(id_b); + events.push(new_id_u32); } /// Fracture an entity into its individual bit components. /// Each set bit becomes a new separate node. - pub fn fracture(&self, world: &mut World, id: usize) { + pub fn fracture(&self, world: &mut World, id: usize, events: &mut Vec) { if !world.alive[id] || id >= world.max_entities { return; } @@ -156,6 +170,7 @@ impl RelationSystem { let cx = world.pos_x[id]; let cy = world.pos_y[id]; let original_radius = world.radius[id]; + let original_id = id as u32; // Calculate child radius (area-preserving split) let child_radius = original_radius / (bit_count as f32).sqrt(); @@ -168,6 +183,13 @@ impl RelationSystem { let eject_speed = 80.0; // Ejection velocity let mut angle = 0.0f32; + // Record fracture event header (we'll fill produced IDs as we go) + let event_start = events.len(); + events.push(EVENT_FRACTURE); + events.push(1); // consumed_count + events.push(0); // produced_count (placeholder) + events.push(original_id); // consumed[0] + for bit in 0..32u32 { if mask & (1 << bit) != 0 { let child_mask = 1u32 << bit; @@ -178,7 +200,7 @@ impl RelationSystem { let vx = angle.cos() * eject_speed; let vy = angle.sin() * eject_speed; - world.spawn( + let child_id = world.spawn( cx + offset_x, cy + offset_y, vx, vy, @@ -187,9 +209,14 @@ impl RelationSystem { child_radius, ); + events.push(child_id as u32); angle += angle_step; } } + + // Backpatch produced_count + let produced = (events.len() - event_start - 4) as u32; + events[event_start + 2] = produced; // produced_count = children spawned } /// Derive a display color from a bitmask by blending component colors. @@ -233,3 +260,105 @@ impl RelationSystem { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_from_single_bit() { + let (r, g, b) = RelationSystem::color_from_bitmask(0b001); + assert_eq!((r, g, b), (255, 60, 60)); // Red + + let (r, g, b) = RelationSystem::color_from_bitmask(0b010); + assert_eq!((r, g, b), (60, 255, 60)); // Green + + let (r, g, b) = RelationSystem::color_from_bitmask(0b100); + assert_eq!((r, g, b), (60, 100, 255)); // Blue + } + + #[test] + fn color_from_zero_mask_is_gray() { + let (r, g, b) = RelationSystem::color_from_bitmask(0); + assert_eq!((r, g, b), (128, 128, 128)); + } + + #[test] + fn merge_produces_correct_bitmask() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + let a_id = world.spawn(0.0, 0.0, 0.0, 0.0, 255, 0, 0, 255, 0b001, 10.0); + let b_id = world.spawn(5.0, 0.0, 0.0, 0.0, 0, 255, 0, 255, 0b010, 10.0); + let count_before = world.active_count(); + + sys.merge(&mut world, a_id, b_id, &mut events); + + // 2 consumed, 1 produced → net -1 + assert_eq!(world.active_count(), count_before - 1); + assert!(events.len() >= 3); // At least header + assert_eq!(events[0], EVENT_MERGE); + assert_eq!(events[1], 2); // consumed count + assert_eq!(events[2], 1); // produced count + + // Find the newly spawned node and check its bitmask + let mut found = false; + for i in 0..world.max_entities { + if world.alive[i] && world.bitmask[i] == 0b011 { + found = true; + break; + } + } + assert!(found, "Merged node with bitmask 0b011 should exist"); + } + + #[test] + fn fracture_produces_individual_bits() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + // Spawn a composite node with 3 bits set + let id = world.spawn(100.0, 100.0, 0.0, 0.0, 128, 128, 128, 255, 0b0111, 20.0); + let count_before = world.active_count(); + + sys.fracture(&mut world, id, &mut events); + + // 1 consumed, 3 produced → net +2 + assert_eq!(world.active_count(), count_before + 2); + assert!(events.len() > 4); + assert_eq!(events[0], EVENT_FRACTURE); + assert_eq!(events[1], 1); // consumed count = 1 + assert_eq!(events[2], 3); // produced count = 3 + + // Three children exist, each with exactly 1 bit set + let mut child_masks = Vec::new(); + for i in 0..world.max_entities { + if world.alive[i] { + child_masks.push(world.bitmask[i]); + } + } + assert_eq!(child_masks.len(), 3); + for mask in &child_masks { + assert_eq!(mask.count_ones(), 1, "Each child should have exactly 1 bit"); + } + // Combined OR of children should equal original mask + let combined: u32 = child_masks.iter().fold(0, |acc, m| acc | m); + assert_eq!(combined, 0b0111); + } + + #[test] + fn fracture_single_bit_does_nothing() { + let mut world = World::new(10); + let mut events = Vec::new(); + let sys = RelationSystem::new(); + + let id = world.spawn(0.0, 0.0, 0.0, 0.0, 255, 0, 0, 255, 0b0100, 8.0); + let count_before = world.active_count(); + sys.fracture(&mut world, id, &mut events); + // Nothing should happen — 1 bit cannot fracture + assert_eq!(world.active_count(), count_before); + assert!(world.alive[id]); + } +} diff --git a/web/main.js b/web/main.js index d9b330b..97be744 100644 --- a/web/main.js +++ b/web/main.js @@ -1,34 +1,38 @@ /** * Liquid-State Engine - JavaScript Host Layer - * + * * This module handles: * 1. Canvas setup and fullscreen management * 2. Loading the WebAssembly module * 3. Reading the pixel buffer from Wasm memory and painting to Canvas * 4. Input event capture (mouse/touch) and forwarding to Wasm * 5. The main render loop (requestAnimationFrame) - * 6. SharedArrayBuffer setup for future Web Worker integration + * 6. Payload registry + rule engine integration + * 7. Draw mode, drag-and-drop data ingestion */ +import { PayloadRegistry } from './payload.js'; +import * as rules from './rules.js'; + // ============================================================ // Configuration // ============================================================ const MAX_NODES = 10000; -const SPAWN_BATCH = 50; // Nodes to spawn on click -const INITIAL_NODES = 200; // Starting node count +const SPAWN_BATCH = 50; +const INITIAL_NODES = 200; // ============================================================ // Canvas Setup // ============================================================ const canvas = document.getElementById('liquid-canvas'); -const ctx = canvas.getContext('2d', { - willReadFrequently: true, - alpha: false +const ctx = canvas.getContext('2d', { + willReadFrequently: true, + alpha: false }); function resizeCanvas() { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; } resizeCanvas(); window.addEventListener('resize', resizeCanvas); @@ -44,8 +48,8 @@ const hudStatus = document.getElementById('status'); // ============================================================ // Engine State // ============================================================ -let engine = null; // Wasm LiquidEngine instance -let wasmMemory = null; // Wasm linear memory +let engine = null; +let wasmMemory = null; let running = false; let lastTime = 0; let frameCount = 0; @@ -57,303 +61,468 @@ let isDragging = false; let dragNodeId = null; let mouseX = 0; let mouseY = 0; -let prevMouseX = 0; -let prevMouseY = 0; + +// Draw mode +let drawMode = false; +let drawPath = []; +let pickedNodeId = null; + +// Payload +const payloads = new PayloadRegistry(); // ============================================================ // Wasm Loading // ============================================================ async function initEngine() { - try { - hudStatus.textContent = 'loading wasm...'; - - // Import the wasm-bindgen generated module - const wasm = await import('../pkg/liquid_state_engine.js'); - await wasm.default(); // Initialize wasm - - hudStatus.textContent = 'initializing...'; - - // Create engine instance - engine = new wasm.LiquidEngine(canvas.width, canvas.height, MAX_NODES); - wasmMemory = wasm.__wasm.memory; - - // Spawn initial nodes - spawnInitialNodes(); - - hudStatus.textContent = 'ACTIVE'; - running = true; - lastTime = performance.now(); - requestAnimationFrame(gameLoop); - - } catch (err) { - hudStatus.textContent = `ERROR: ${err.message}`; - console.error('Engine init failed:', err); - - // Fallback: run in demo mode without Wasm - console.log('Running in DEMO mode (no Wasm)'); - hudStatus.textContent = 'DEMO MODE (no Wasm)'; - runDemoMode(); - } + try { + hudStatus.textContent = 'loading wasm...'; + + const wasm = await import('../pkg/liquid_state_engine.js'); + await wasm.default(); + + hudStatus.textContent = 'initializing...'; + + engine = new wasm.LiquidEngine(canvas.width, canvas.height, MAX_NODES); + wasmMemory = wasm.__wasm.memory; + + spawnInitialNodes(); + + hudStatus.textContent = 'ACTIVE'; + running = true; + lastTime = performance.now(); + requestAnimationFrame(gameLoop); + + } catch (err) { + hudStatus.textContent = `ERROR: ${err.message}`; + console.error('Engine init failed:', err); + console.log('Running in DEMO mode (no Wasm)'); + hudStatus.textContent = 'DEMO MODE (no Wasm)'; + runDemoMode(); + } } // ============================================================ // Node Spawning // ============================================================ +function spawnNodeWithPayload(px, py, vx, vy, bitmask, radius, payload) { + if (!engine) return null; + const colors = getColorFromBitmask(bitmask); + const id = engine.spawn_node(px, py, vx, vy, colors[0], colors[1], colors[2], 255, bitmask, radius); + if (id !== 0xFFFFFFFF) { + payloads.register(id, payload); + } + return id; +} + +function makeRandomPayload() { + const types = ['text', 'number', 'json', 'array']; + const t = types[Math.floor(Math.random() * types.length)]; + switch (t) { + case 'text': return { type: 'text', value: words[Math.floor(Math.random() * words.length)], label: 'word' }; + case 'number': return { type: 'number', value: Math.floor(Math.random() * 1000), label: 'num' }; + case 'json': return { type: 'json', value: { x: Math.floor(Math.random() * 100), y: Math.floor(Math.random() * 100) }, label: 'point' }; + case 'array': return { type: 'array', value: Array.from({ length: 3 }, () => Math.floor(Math.random() * 100)), label: 'arr' }; + default: return { type: 'number', value: 0 }; + } +} + +const words = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', + 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron', 'pi', 'rho', 'sigma', + 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega', 'hello', 'world', 'liquid', 'state']; + function spawnInitialNodes() { - const w = canvas.width; - const h = canvas.height; - - for (let i = 0; i < INITIAL_NODES; i++) { - const x = Math.random() * w; - const y = Math.random() * h; - const vx = (Math.random() - 0.5) * 60; - const vy = (Math.random() - 0.5) * 60; - const bitmask = 1 << Math.floor(Math.random() * 7); - const radius = 4 + Math.random() * 8; - - // Color derived from bitmask (matches Rust logic) - const colors = getColorFromBitmask(bitmask); - engine.spawn_node(x, y, vx, vy, colors[0], colors[1], colors[2], 255, bitmask, radius); - } + const w = canvas.width; + const h = canvas.height; + + for (let i = 0; i < INITIAL_NODES; i++) { + const x = Math.random() * w; + const y = Math.random() * h; + const vx = (Math.random() - 0.5) * 60; + const vy = (Math.random() - 0.5) * 60; + const bitmask = 1 << Math.floor(Math.random() * 7); + const radius = 4 + Math.random() * 8; + const payload = makeRandomPayload(); + spawnNodeWithPayload(x, y, vx, vy, bitmask, radius, payload); + } } function spawnNodesAtPosition(px, py, count) { - for (let i = 0; i < count; i++) { - const angle = (Math.PI * 2 * i) / count; - const spread = 20 + Math.random() * 30; - const x = px + Math.cos(angle) * spread; - const y = py + Math.sin(angle) * spread; - const vx = Math.cos(angle) * (40 + Math.random() * 60); - const vy = Math.sin(angle) * (40 + Math.random() * 60); - const bitmask = 1 << Math.floor(Math.random() * 7); - const radius = 4 + Math.random() * 8; - const colors = getColorFromBitmask(bitmask); - engine.spawn_node(x, y, vx, vy, colors[0], colors[1], colors[2], 255, bitmask, radius); - } + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count; + const spread = 20 + Math.random() * 30; + const x = px + Math.cos(angle) * spread; + const y = py + Math.sin(angle) * spread; + const vx = Math.cos(angle) * (40 + Math.random() * 60); + const vy = Math.sin(angle) * (40 + Math.random() * 60); + const bitmask = 1 << Math.floor(Math.random() * 7); + const radius = 4 + Math.random() * 8; + const payload = makeRandomPayload(); + spawnNodeWithPayload(x, y, vx, vy, bitmask, radius, payload); + } } // ============================================================ -// Color Utility (mirrors Rust BIT_COLORS) +// Event Processing (Wasm → Payload Registry) // ============================================================ -const BIT_COLORS = [ - [255, 60, 60], // Bit 0: Red - [60, 255, 60], // Bit 1: Green - [60, 100, 255], // Bit 2: Blue - [255, 255, 60], // Bit 3: Yellow - [255, 60, 255], // Bit 4: Magenta - [60, 255, 255], // Bit 5: Cyan - [255, 160, 60], // Bit 6: Orange - [200, 130, 255], // Bit 7: Purple -]; - -function getColorFromBitmask(mask) { - let r = 0, g = 0, b = 0, count = 0; - for (let bit = 0; bit < 8; bit++) { - if (mask & (1 << bit)) { - r += BIT_COLORS[bit][0]; - g += BIT_COLORS[bit][1]; - b += BIT_COLORS[bit][2]; - count++; - } - } - if (count === 0) return [128, 128, 128]; - return [Math.floor(r / count), Math.floor(g / count), Math.floor(b / count)]; +function processWasmEvents() { + const count = engine.event_count(); + if (count === 0) return []; + + const ptr = engine.event_ptr(); + const u32 = new Uint32Array(wasmMemory.buffer, ptr, count); + // Copy to avoid mutation during rule processing + const copy = new Uint32Array(u32); + + const decoded = payloads.decodeEvents(copy); + payloads.applyEvents(decoded, rules); + engine.drain_events(); + + // Dispatch to HUD if available + if (decoded.length > 0) { + window.dispatchEvent(new CustomEvent('lse-events', { detail: decoded })); + } + return decoded; } // ============================================================ // Main Game Loop // ============================================================ function gameLoop(timestamp) { - if (!running) return; - - const dt = Math.min((timestamp - lastTime) / 1000, 0.033); // Cap at ~30fps min - lastTime = timestamp; - - // Update FPS counter - frameCount++; - fpsTimer += dt; - if (fpsTimer >= 1.0) { - currentFps = frameCount; - frameCount = 0; - fpsTimer = 0; - hudFps.textContent = currentFps; - hudNodes.textContent = engine.node_count(); - } - - // Apply drag force if active - if (isDragging && dragNodeId !== null && dragNodeId !== 0xFFFFFFFF) { - const dx = mouseX - prevMouseX; - const dy = mouseY - prevMouseY; - engine.apply_force(dragNodeId, dx * 15, dy * 15); - } - prevMouseX = mouseX; - prevMouseY = mouseY; - - // Tick the engine (physics + collision + render) - engine.tick(dt); - - // Read pixel buffer from Wasm memory and draw to canvas - if (engine.has_dirty_region()) { - drawPixelBuffer(); - engine.clear_dirty(); - } - - requestAnimationFrame(gameLoop); + if (!running) return; + + const dt = Math.min((timestamp - lastTime) / 1000, 0.033); + lastTime = timestamp; + + frameCount++; + fpsTimer += dt; + if (fpsTimer >= 1.0) { + currentFps = frameCount; + frameCount = 0; + fpsTimer = 0; + hudFps.textContent = currentFps; + hudNodes.textContent = engine.node_count(); + } + + // Update pinned drag spring target + if (isDragging && dragNodeId !== null && dragNodeId !== 0xFFFFFFFF) { + engine.update_pin_target(dragNodeId, mouseX, mouseY); + } + + // Tick the engine + engine.tick(dt); + + // Process events from Wasm + processWasmEvents(); + + // Read pixel buffer + if (engine.has_dirty_region()) { + drawPixelBuffer(); + engine.clear_dirty(); + } + + requestAnimationFrame(gameLoop); } // ============================================================ // Pixel Buffer -> Canvas Transfer // ============================================================ function drawPixelBuffer() { - const ptr = engine.pixel_buffer_ptr(); - const len = engine.pixel_buffer_len(); - - // Read dirty rectangle - const dirtyPtr = engine.dirty_rect_ptr(); - const dirtyView = new Uint32Array(wasmMemory.buffer, dirtyPtr, 4); - const [dx, dy, dw, dh] = dirtyView; - - if (dw === 0 || dh === 0) return; - - hudDirty.textContent = `${dw}x${dh}`; - - // Create ImageData from the dirty region of the pixel buffer - // For efficiency, we only copy the dirty rectangle portion - const fullBuffer = new Uint8ClampedArray(wasmMemory.buffer, ptr, len); - const w = canvas.width; - - // Create a sub-image for just the dirty region - const regionData = new ImageData(dw, dh); - for (let row = 0; row < dh; row++) { - const srcStart = ((dy + row) * w + dx) * 4; - const dstStart = row * dw * 4; - regionData.data.set( - fullBuffer.subarray(srcStart, srcStart + dw * 4), - dstStart - ); + const ptr = engine.pixel_buffer_ptr(); + const len = engine.pixel_buffer_len(); + + const dirtyPtr = engine.dirty_rect_ptr(); + const dirtyView = new Uint32Array(wasmMemory.buffer, dirtyPtr, 4); + const [dx, dy, dw, dh] = dirtyView; + + if (dw === 0 || dh === 0) return; + + hudDirty.textContent = `${dw}x${dh}`; + + const fullBuffer = new Uint8ClampedArray(wasmMemory.buffer, ptr, len); + const w = canvas.width; + + const regionData = new ImageData(dw, dh); + for (let row = 0; row < dh; row++) { + const srcStart = ((dy + row) * w + dx) * 4; + const dstStart = row * dw * 4; + regionData.data.set( + fullBuffer.subarray(srcStart, srcStart + dw * 4), + dstStart + ); + } + + ctx.putImageData(regionData, dx, dy); + + // If in draw mode, overlay the trail path on canvas + if (drawMode && drawPath.length > 1) { + ctx.beginPath(); + ctx.moveTo(drawPath[0].x, drawPath[0].y); + for (let i = 1; i < drawPath.length; i++) { + ctx.lineTo(drawPath[i].x, drawPath[i].y); } + ctx.strokeStyle = 'rgba(100,200,255,0.6)'; + ctx.lineWidth = 2; + ctx.stroke(); + } +} - ctx.putImageData(regionData, dx, dy); +// ============================================================ +// Draw Mode Helpers +// ============================================================ +function finalizeDrawPath() { + if (drawPath.length < 3) { + drawPath = []; + return; + } + + // Compute centroid + let cx = 0, cy = 0; + for (const p of drawPath) { cx += p.x; cy += p.y; } + cx /= drawPath.length; + cy /= drawPath.length; + + // Compute bounding circle radius + let maxDist = 20; + for (const p of drawPath) { + const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2); + if (d > maxDist) maxDist = d; + } + + const bitmask = 1 << Math.floor(Math.random() * 7); + const id = spawnNodeWithPayload(cx, cy, 0, 0, bitmask, maxDist, { type: 'text', value: '', label: '' }); + + // Notify HUD to open label/payload dialog + if (id !== null) { + window.dispatchEvent(new CustomEvent('lse-drawnode', { detail: { id, x: cx, y: cy } })); + } + + drawPath = []; } +// ============================================================ +// Drag-and-Drop +// ============================================================ +let dropTargetId = null; + +canvas.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; +}); + +canvas.addEventListener('drop', (e) => { + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + + // Try to read files first + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result; + const payload = rules.detectPayload(content); + const bitmask = 1 << Math.floor(Math.random() * 7); + spawnNodeWithPayload(x, y, 0, 0, bitmask, 20 + Math.random() * 10, payload); + }; + reader.readAsText(file); + return; + } + + // Try to read text data + const text = e.dataTransfer.getData('text/plain'); + if (text) { + const payload = rules.detectPayload(text); + const bitmask = 1 << Math.floor(Math.random() * 7); + spawnNodeWithPayload(x, y, 0, 0, bitmask, 20 + Math.random() * 10, payload); + } +}); + // ============================================================ // Input Handling // ============================================================ canvas.addEventListener('mousedown', (e) => { - mouseX = e.clientX; - mouseY = e.clientY; - prevMouseX = mouseX; - prevMouseY = mouseY; - - if (!engine) return; - - // Try to pick a node - dragNodeId = engine.pick_node_at(mouseX, mouseY); - - if (dragNodeId !== 0xFFFFFFFF) { - isDragging = true; - } else { - // Click on empty space: spawn new nodes - spawnNodesAtPosition(mouseX, mouseY, SPAWN_BATCH); - } + mouseX = e.clientX; + mouseY = e.clientY; + + if (!engine) return; + + if (drawMode) { + drawPath = [{ x: mouseX, y: mouseY }]; + return; + } + + dragNodeId = engine.pick_node_at(mouseX, mouseY); + + if (dragNodeId !== 0xFFFFFFFF) { + isDragging = true; + engine.pin_node(dragNodeId, mouseX, mouseY); + pickedNodeId = dragNodeId; + const payload = payloads.get(dragNodeId); + window.dispatchEvent(new CustomEvent('lse-pick', { detail: { id: dragNodeId, payload } })); + } else { + spawnNodesAtPosition(mouseX, mouseY, SPAWN_BATCH); + pickedNodeId = null; + window.dispatchEvent(new CustomEvent('lse-pick', { detail: { id: null, payload: null } })); + } }); canvas.addEventListener('mousemove', (e) => { - mouseX = e.clientX; - mouseY = e.clientY; + mouseX = e.clientX; + mouseY = e.clientY; + + if (drawMode && drawPath.length > 0) { + drawPath.push({ x: mouseX, y: mouseY }); + } }); canvas.addEventListener('mouseup', () => { - isDragging = false; - dragNodeId = null; + if (drawMode && drawPath.length > 0) { + finalizeDrawPath(); + } + + if (isDragging && dragNodeId !== null && dragNodeId !== 0xFFFFFFFF) { + engine.unpin_node(dragNodeId); + // Check if dragged onto another node (merge trigger) + const targetId = engine.pick_node_at(mouseX, mouseY); + if (targetId !== 0xFFFFFFFF && targetId !== dragNodeId) { + // Nodes are close — let physics merge handle it + } + } + isDragging = false; + dragNodeId = null; }); canvas.addEventListener('dblclick', (e) => { - if (!engine) return; - // Double-click: fracture the node under cursor - const id = engine.pick_node_at(e.clientX, e.clientY); - if (id !== 0xFFFFFFFF) { - engine.fracture_node(id); - } + if (!engine) return; + const id = engine.pick_node_at(e.clientX, e.clientY); + if (id !== 0xFFFFFFFF) { + engine.fracture_node(id); + } }); // Touch support canvas.addEventListener('touchstart', (e) => { - e.preventDefault(); - const touch = e.touches[0]; - mouseX = touch.clientX; - mouseY = touch.clientY; - prevMouseX = mouseX; - prevMouseY = mouseY; - - if (!engine) return; - - dragNodeId = engine.pick_node_at(mouseX, mouseY); - if (dragNodeId !== 0xFFFFFFFF) { - isDragging = true; - } else { - spawnNodesAtPosition(mouseX, mouseY, SPAWN_BATCH); - } + e.preventDefault(); + const touch = e.touches[0]; + mouseX = touch.clientX; + mouseY = touch.clientY; + + if (!engine) return; + + if (drawMode) { + drawPath = [{ x: mouseX, y: mouseY }]; + return; + } + + dragNodeId = engine.pick_node_at(mouseX, mouseY); + if (dragNodeId !== 0xFFFFFFFF) { + isDragging = true; + engine.pin_node(dragNodeId, mouseX, mouseY); + } else { + spawnNodesAtPosition(mouseX, mouseY, SPAWN_BATCH); + } }, { passive: false }); canvas.addEventListener('touchmove', (e) => { - e.preventDefault(); - const touch = e.touches[0]; - mouseX = touch.clientX; - mouseY = touch.clientY; + e.preventDefault(); + const touch = e.touches[0]; + mouseX = touch.clientX; + mouseY = touch.clientY; + + if (drawMode && drawPath.length > 0) { + drawPath.push({ x: mouseX, y: mouseY }); + } }, { passive: false }); canvas.addEventListener('touchend', () => { - isDragging = false; - dragNodeId = null; + if (drawMode && drawPath.length > 0) { + finalizeDrawPath(); + } + if (isDragging && dragNodeId !== null && dragNodeId !== 0xFFFFFFFF) { + engine.unpin_node(dragNodeId); + } + isDragging = false; + dragNodeId = null; }); // ============================================================ -// Demo Mode (runs without Wasm for preview/testing) +// Mode & Config API (for HUD) // ============================================================ -function runDemoMode() { - // Pure JS fallback that demonstrates the visual concept - const nodes = []; - const w = canvas.width; - const h = canvas.height; - - for (let i = 0; i < INITIAL_NODES; i++) { - const bitmask = 1 << Math.floor(Math.random() * 7); - const colors = getColorFromBitmask(bitmask); - nodes.push({ - x: Math.random() * w, - y: Math.random() * h, - vx: (Math.random() - 0.5) * 60, - vy: (Math.random() - 0.5) * 60, - r: 4 + Math.random() * 8, - color: `rgb(${colors[0]}, ${colors[1]}, ${colors[2]})`, - }); - } - - function demoLoop() { - ctx.fillStyle = 'rgba(10, 10, 20, 0.15)'; - ctx.fillRect(0, 0, w, h); +window.lse = { + setDrawMode(on) { drawMode = on; }, + getDrawMode() { return drawMode; }, + getEngine() { return engine; }, + getPayloads() { return payloads; }, + getRules() { return rules; }, + getPickedNodeId() { return pickedNodeId; }, + spawnNode(x, y, payload) { + const bitmask = 1 << Math.floor(Math.random() * 7); + return spawnNodeWithPayload(x, y, 0, 0, bitmask, 12 + Math.random() * 8, payload); + }, + setViscosity(v) { if (engine) engine.set_viscosity(v); }, + setGravity(g) { if (engine) engine.set_gravity(g); }, +}; - for (const n of nodes) { - n.vx *= 0.98; - n.vy *= 0.98; - n.x += n.vx * 0.016; - n.y += n.vy * 0.016; - - if (n.x < n.r || n.x > w - n.r) n.vx *= -0.7; - if (n.y < n.r || n.y > h - n.r) n.vy *= -0.7; - n.x = Math.max(n.r, Math.min(w - n.r, n.x)); - n.y = Math.max(n.r, Math.min(h - n.r, n.y)); +// ============================================================ +// Color Utility +// ============================================================ +const BIT_COLORS = [ + [255, 60, 60], [60, 255, 60], [60, 100, 255], + [255, 255, 60], [255, 60, 255], [60, 255, 255], + [255, 160, 60], [200, 130, 255], +]; - ctx.beginPath(); - ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); - ctx.fillStyle = n.color; - ctx.fill(); - } +function getColorFromBitmask(mask) { + let r = 0, g = 0, b = 0, count = 0; + for (let bit = 0; bit < 8; bit++) { + if (mask & (1 << bit)) { + r += BIT_COLORS[bit][0]; + g += BIT_COLORS[bit][1]; + b += BIT_COLORS[bit][2]; + count++; + } + } + if (count === 0) return [128, 128, 128]; + return [Math.floor(r / count), Math.floor(g / count), Math.floor(b / count)]; +} - requestAnimationFrame(demoLoop); +// ============================================================ +// Demo Mode +// ============================================================ +function runDemoMode() { + const nodes = []; + const w = canvas.width; + const h = canvas.height; + + for (let i = 0; i < INITIAL_NODES; i++) { + const bitmask = 1 << Math.floor(Math.random() * 7); + const colors = getColorFromBitmask(bitmask); + nodes.push({ + x: Math.random() * w, y: Math.random() * h, + vx: (Math.random() - 0.5) * 60, vy: (Math.random() - 0.5) * 60, + r: 4 + Math.random() * 8, + color: `rgb(${colors[0]}, ${colors[1]}, ${colors[2]})`, + }); + } + + function demoLoop() { + ctx.fillStyle = 'rgba(10, 10, 20, 0.15)'; + ctx.fillRect(0, 0, w, h); + for (const n of nodes) { + n.vx *= 0.98; n.vy *= 0.98; + n.x += n.vx * 0.016; n.y += n.vy * 0.016; + if (n.x < n.r || n.x > w - n.r) n.vx *= -0.7; + if (n.y < n.r || n.y > h - n.r) n.vy *= -0.7; + n.x = Math.max(n.r, Math.min(w - n.r, n.x)); + n.y = Math.max(n.r, Math.min(h - n.r, n.y)); + ctx.beginPath(); + ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); + ctx.fillStyle = n.color; + ctx.fill(); } requestAnimationFrame(demoLoop); + } + requestAnimationFrame(demoLoop); } // ============================================================ diff --git a/web/payload.js b/web/payload.js new file mode 100644 index 0000000..dc29b0b --- /dev/null +++ b/web/payload.js @@ -0,0 +1,121 @@ +/** + * Data Payload System + * + * Maintains a Map on the JS side. + * Each shell (Rust entity) carries an opaque payload: + * { type: 'text'|'number'|'json'|'array'|'composite', + * value: any, + * label?: string } + * + * Events from Wasm (merge/fracture/spawn/despawn) drive payload updates. + */ + +export class PayloadRegistry { + constructor() { + /** @type {Map} */ + this.map = new Map(); + } + + /** Register a payload for a newly spawned node. */ + register(id, payload) { + this.map.set(id, { ...payload }); + } + + /** Remove a payload when its node is despawned. */ + remove(id) { + this.map.delete(id); + } + + /** Get payload for a node. */ + get(id) { + return this.map.get(id); + } + + /** Check if a node has a payload. */ + has(id) { + return this.map.has(id); + } + + /** Get all entries. */ + entries() { + return this.map.entries(); + } + + /** Number of registered payloads. */ + get size() { + return this.map.size; + } + + /** + * Process a batch of packed u32 events from Wasm. + * Events format: [kind, consumed_count, produced_count, consumed_ids..., produced_ids...] + * Returns an array of structured event objects for rule processing. + */ + decodeEvents(u32Array) { + const events = []; + let i = 0; + while (i < u32Array.length) { + const kind = u32Array[i]; + const consumedCount = u32Array[i + 1]; + const producedCount = u32Array[i + 2]; + const consumed = []; + const produced = []; + for (let j = 0; j < consumedCount; j++) { + consumed.push(u32Array[i + 3 + j]); + } + for (let j = 0; j < producedCount; j++) { + produced.push(u32Array[i + 3 + consumedCount + j]); + } + events.push({ kind, consumed, produced }); + i += 3 + consumedCount + producedCount; + } + return events; + } + + /** + * Apply decoded events to the payload registry. + * - spawn(2): payload already registered via register() + * - despawn(3): remove consumed payloads + * - merge(0): consumed payloads removed, produced gets combined payload + * - fracture(1): consumed payload removed, produced get split payloads + * + * Returns the decoded events for rules to process further. + */ + applyEvents(decodedEvents, rules) { + for (const ev of decodedEvents) { + switch (ev.kind) { + case 3: // DESPAWN + for (const id of ev.consumed) { + this.remove(id); + } + break; + + case 0: { // MERGE + const consumedPayloads = ev.consumed.map(id => this.get(id)).filter(Boolean); + for (const id of ev.consumed) this.remove(id); + if (consumedPayloads.length && ev.produced.length) { + const merged = rules.merge(consumedPayloads); + this.register(ev.produced[0], merged); + } + break; + } + + case 1: { // FRACTURE + const consumedPayload = this.get(ev.consumed[0]); + this.remove(ev.consumed[0]); + if (consumedPayload && ev.produced.length) { + const fragments = rules.fracture(consumedPayload, ev.produced.length); + for (let i = 0; i < ev.produced.length && i < fragments.length; i++) { + this.register(ev.produced[i], fragments[i]); + } + } + break; + } + + case 2: // SPAWN — payload should already be registered + break; + } + } + return decodedEvents; + } +} From 070046a2be70f07d029e9df9000ab887a4b17ce9 Mon Sep 17 00:00:00 2001 From: akaradje Date: Tue, 12 May 2026 15:28:38 +0700 Subject: [PATCH 02/11] feat: rule engine (text/number/json/array merge & fracture) Pure JS functions for per-type merge/fracture semantics. Configurable numeric reducer (sum/avg/product). Auto-detect payload type from raw input. Co-Authored-By: Claude Opus 4.7 --- web/rules.js | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 web/rules.js diff --git a/web/rules.js b/web/rules.js new file mode 100644 index 0000000..dbbe1cd --- /dev/null +++ b/web/rules.js @@ -0,0 +1,239 @@ +/** + * Generic Rule Engine — merge & fracture semantics per data type. + * + * All functions are pure — they take payloads and return new payloads. + * Payload format: { type: string, value: any, label?: string } + */ + +/** Configurable numeric reducer. */ +let numericReducer = 'sum'; // 'sum' | 'avg' | 'product' + +export function setNumericReducer(mode) { + numericReducer = mode; +} + +export function getNumericReducer() { + return numericReducer; +} + +// ---- Type Detection ---- + +function detectType(value) { + if (Array.isArray(value)) return 'array'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'string') return 'text'; + if (value && typeof value === 'object') return 'json'; + return 'text'; +} + +function normalizePayload(p) { + if (!p || !p.type) return { type: detectType(p?.value), value: p?.value ?? p, label: p?.label }; + return p; +} + +// ---- Merge Rules ---- + +function mergeText(a, b) { + return { + type: 'text', + value: (a.value ?? '') + '\n' + (b.value ?? ''), + label: a.label || b.label || 'merged text', + }; +} + +function mergeNumber(a, b) { + const va = Number(a.value) || 0; + const vb = Number(b.value) || 0; + let result; + switch (numericReducer) { + case 'avg': result = (va + vb) / 2; break; + case 'product': result = va * vb; break; + default: result = va + vb; break; + } + return { + type: 'number', + value: result, + label: a.label || b.label || `merged (${numericReducer})`, + }; +} + +function mergeJson(a, b) { + const result = { ...(a.value || {}) }; + const bv = b.value || {}; + for (const key of Object.keys(bv)) { + if (key in result) { + // Conflict: wrap as composite + const existing = result[key]; + if (existing && typeof existing === 'object' && !Array.isArray(existing) && existing.__composite) { + existing.items.push(bv[key]); + } else { + result[key] = { __composite: true, items: [existing, bv[key]] }; + } + } else { + result[key] = bv[key]; + } + } + return { + type: 'json', + value: result, + label: a.label || b.label || 'merged JSON', + }; +} + +function mergeArray(a, b) { + const va = Array.isArray(a.value) ? a.value : [a.value]; + const vb = Array.isArray(b.value) ? b.value : [b.value]; + return { + type: 'array', + value: [...va, ...vb], + label: a.label || b.label || 'merged array', + }; +} + +function mergeComposite(items) { + return { + type: 'composite', + value: { items: items.map(normalizePayload) }, + label: 'composite (' + items.length + ' items)', + }; +} + +/** + * Merge an array of payloads into one. + */ +export function merge(payloads) { + if (!payloads || payloads.length === 0) return { type: 'text', value: '' }; + if (payloads.length === 1) return normalizePayload(payloads[0]); + + const normalized = payloads.map(normalizePayload); + const types = new Set(normalized.map(p => p.type)); + + // Mixed types → composite + if (types.size > 1) return mergeComposite(normalized); + + const t = [...types][0]; + // Same type: reduce pairwise + let acc = normalized[0]; + for (let i = 1; i < normalized.length; i++) { + switch (t) { + case 'text': acc = mergeText(acc, normalized[i]); break; + case 'number': acc = mergeNumber(acc, normalized[i]); break; + case 'json': acc = mergeJson(acc, normalized[i]); break; + case 'array': acc = mergeArray(acc, normalized[i]); break; + default: return mergeComposite(normalized); + } + } + return acc; +} + +// ---- Fracture Rules ---- + +export function fracture(payload, fragmentCount) { + const p = normalizePayload(payload); + switch (p.type) { + case 'text': return fractureText(p, fragmentCount); + case 'number': return fractureNumber(p, fragmentCount); + case 'json': return fractureJson(p, fragmentCount); + case 'array': return fractureArray(p, fragmentCount); + default: return fractureGeneric(p, fragmentCount); + } +} + +function fractureText(p, count) { + const value = String(p.value ?? ''); + const parts = value.split(/[\s\n,;]+/).filter(Boolean); + const fragments = []; + const perNode = Math.max(1, Math.ceil(parts.length / count)); + for (let i = 0; i < count; i++) { + const slice = parts.slice(i * perNode, (i + 1) * perNode); + fragments.push({ + type: 'text', + value: slice.join(' ') || value.charAt(i % value.length) || '·', + label: (p.label || 'text') + '.' + (i + 1), + }); + } + return fragments; +} + +function fractureNumber(p, count) { + const value = Number(p.value) || 0; + const part = value / count; + const fragments = []; + for (let i = 0; i < count; i++) { + fragments.push({ + type: 'number', + value: part, + label: (p.label || 'num') + '.' + (i + 1), + }); + } + return fragments; +} + +function fractureJson(p, count) { + const entries = Object.entries(p.value || {}); + const perNode = Math.max(1, Math.ceil(entries.length / count)); + const fragments = []; + for (let i = 0; i < count; i++) { + const slice = entries.slice(i * perNode, (i + 1) * perNode); + const obj = Object.fromEntries(slice); + fragments.push({ + type: 'json', + value: obj, + label: (p.label || 'json') + '.' + (i + 1), + }); + } + return fragments; +} + +function fractureArray(p, count) { + const arr = Array.isArray(p.value) ? p.value : [p.value]; + const perNode = Math.max(1, Math.ceil(arr.length / count)); + const fragments = []; + for (let i = 0; i < count; i++) { + const slice = arr.slice(i * perNode, (i + 1) * perNode); + fragments.push({ + type: 'array', + value: slice, + label: (p.label || 'array') + '.' + (i + 1), + }); + } + return fragments; +} + +function fractureGeneric(p, count) { + const fragments = []; + for (let i = 0; i < count; i++) { + fragments.push({ + type: p.type, + value: p.value, + label: (p.label || 'item') + '.' + (i + 1), + }); + } + return fragments; +} + +/** + * Auto-detect the type of raw input and return a normalized payload. + */ +export function detectPayload(raw) { + if (raw === undefined || raw === null) return { type: 'text', value: '' }; + if (Array.isArray(raw)) return { type: 'array', value: raw }; + if (typeof raw === 'number') return { type: 'number', value: raw }; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return detectPayload(parsed); + } catch { + return { type: 'text', value: raw }; + } + } + if (typeof raw === 'object') { + try { + JSON.stringify(raw); + return { type: 'json', value: raw }; + } catch { + return { type: 'text', value: String(raw) }; + } + } + return { type: 'text', value: String(raw) }; +} From 0ad77843e87a8cf2956b9d408cabe40122d42708 Mon Sep 17 00:00:00 2001 From: akaradje Date: Tue, 12 May 2026 15:28:54 +0700 Subject: [PATCH 03/11] feat: React/Preact glassmorphism HUD overlay Preact+htm via CDN (zero build step). Translucent panels with backdrop-filter blur. Mode switcher (Select/Draw), floating inspector panel, bottom toolbar for physics config, payload input dialog. All positioned above canvas with pointer-events selectively enabled. Co-Authored-By: Claude Opus 4.7 --- web/hud/app.js | 272 ++++++++++++++++++++++++++++++++++++++++++++++ web/hud/style.css | 257 +++++++++++++++++++++++++++++++++++++++++++ web/index.html | 32 ++++-- 3 files changed, 549 insertions(+), 12 deletions(-) create mode 100644 web/hud/app.js create mode 100644 web/hud/style.css diff --git a/web/hud/app.js b/web/hud/app.js new file mode 100644 index 0000000..35acdbb --- /dev/null +++ b/web/hud/app.js @@ -0,0 +1,272 @@ +/** + * Liquid-State Engine — Glassmorphism HUD + * + * Built with Preact + htm via CDN (zero build step). + * Renders ABOVE the canvas, pointer-events: none by default. + */ + +import { h, Component, render } from 'https://esm.sh/preact@10.19.6'; +import htm from 'https://esm.sh/htm@3.1.1'; +const html = htm.bind(h); + +// ---- HUD Shell (glassmorphism container) ---- + +class HUDApp extends Component { + constructor() { + super(); + this.state = { + fps: 0, + nodeCount: 0, + dirtySize: '0x0', + engineStatus: 'loading...', + mode: 'select', // 'select' | 'draw' | 'fracture' + pickedNode: null, // { id, payload } or null + showPayloadDialog: false, + drawNodeId: null, + numericReducer: 'sum', + gravityEnabled: false, + viscosity: 0.02, + labelInput: '', + valueInput: '', + }; + + this._tick = this._tick.bind(this); + this._onPick = this._onPick.bind(this); + this._onDrawNode = this._onDrawNode.bind(this); + } + + componentDidMount() { + this._interval = setInterval(this._tick, 200); + window.addEventListener('lse-pick', this._onPick); + window.addEventListener('lse-drawnode', this._onDrawNode); + } + + componentWillUnmount() { + clearInterval(this._interval); + window.removeEventListener('lse-pick', this._onPick); + window.removeEventListener('lse-drawnode', this._onDrawNode); + } + + _tick() { + const fpsEl = document.getElementById('fps'); + const nodesEl = document.getElementById('nodes'); + const dirtyEl = document.getElementById('dirty'); + const statusEl = document.getElementById('status'); + this.setState({ + fps: fpsEl?.textContent || '0', + nodeCount: nodesEl?.textContent || '0', + dirtySize: dirtyEl?.textContent || '0x0', + engineStatus: statusEl?.textContent || 'ACTIVE', + }); + } + + _onPick(e) { + this.setState({ pickedNode: e.detail.payload ? e.detail : null }); + } + + _onDrawNode(e) { + this.setState({ showPayloadDialog: true, drawNodeId: e.detail.id, labelInput: '', valueInput: '' }); + } + + _setMode(mode) { + this.setState({ mode }); + if (window.lse) window.lse.setDrawMode(mode === 'draw'); + } + + _submitPayload() { + const { drawNodeId, labelInput, valueInput } = this.state; + if (drawNodeId == null) return; + const raw = valueInput.trim(); + let payload; + if (!raw) { + payload = { type: 'text', value: labelInput || 'empty', label: labelInput || 'untitled' }; + } else { + payload = window.lse?.getRules().detectPayload(raw); + payload.label = labelInput || payload.type; + } + window.lse?.getPayloads().register(drawNodeId, payload); + this.setState({ showPayloadDialog: false, drawNodeId: null, labelInput: '', valueInput: '' }); + } + + _setReducer(mode) { + this.setState({ numericReducer: mode }); + window.lse?.getRules().setNumericReducer(mode); + } + + _toggleGravity() { + const on = !this.state.gravityEnabled; + this.setState({ gravityEnabled: on }); + window.lse?.setGravity(on ? 200 : 0); + } + + _setViscosity(v) { + this.setState({ viscosity: v }); + window.lse?.setViscosity(v); + } + + render(_, state) { + return html` +
+ <${StatsPanel} fps=${state.fps} nodes=${state.nodeCount} dirty=${state.dirtySize} status=${state.engineStatus} /> + <${ModeSwitcher} mode=${state.mode} onSetMode=${m => this._setMode(m)} /> + <${InspectorPanel} node=${state.pickedNode} /> + <${Toolbar} + reducer=${state.numericReducer} + gravity=${state.gravityEnabled} + viscosity=${state.viscosity} + onSetReducer=${m => this._setReducer(m)} + onToggleGravity=${() => this._toggleGravity()} + onSetViscosity=${v => this._setViscosity(v)} + /> + ${state.showPayloadDialog && html` + <${PayloadDialog} + label=${state.labelInput} + value=${state.valueInput} + onLabel=${v => this.setState({ labelInput: v })} + onValue=${v => this.setState({ valueInput: v })} + onSubmit=${() => this._submitPayload()} + onCancel=${() => this.setState({ showPayloadDialog: false, drawNodeId: null })} + /> + `} +
+ `; + } +} + +// ---- Stats Panel (top-left) ---- + +const StatsPanel = ({ fps, nodes, dirty, status }) => html` +
+
FPS ${fps}
+
NODES ${nodes}
+
DIRTY ${dirty}
+
ENGINE ${status}
+
+`; + +// ---- Mode Switcher (top-right) ---- + +const modes = [ + { id: 'select', label: 'Select', icon: '⊙' }, + { id: 'draw', label: 'Draw', icon: '✎' }, + { id: 'fracture', label: 'Fracture', icon: '⟐' }, +]; + +const ModeSwitcher = ({ mode, onSetMode }) => html` +
+ ${modes.map(m => html` + + `)} +
+`; + +// ---- Inspector Panel (floating, shown when node picked) ---- + +const InspectorPanel = ({ node }) => html` + ${node ? html` +
+
Node #${node.id}
+
TYPE ${node.payload?.type || '—'}
+
LABEL ${node.payload?.label || '—'}
+
VALUE ${truncate(displayValue(node.payload), 60)}
+
+ ` : null} +`; + +function displayValue(payload) { + if (!payload) return '—'; + if (payload.type === 'json' || payload.type === 'composite') { + try { return JSON.stringify(payload.value); } catch { return String(payload.value); } + } + return String(payload.value ?? '—'); +} + +function truncate(s, max) { + return s && s.length > max ? s.slice(0, max) + '…' : s; +} + +// ---- Bottom Toolbar ---- + +const reducers = [ + { id: 'sum', label: 'Σ Sum' }, + { id: 'avg', label: 'μ Avg' }, + { id: 'product', label: 'Π Prod' }, +]; + +const Toolbar = ({ reducer, gravity, viscosity, onSetReducer, onToggleGravity, onSetViscosity }) => html` +
+
+ Numeric Merge +
+ ${reducers.map(r => html` + + `)} +
+
+
+ Gravity + +
+
+ Viscosity + onSetViscosity(parseFloat(e.target.value))} + /> + ${viscosity.toFixed(3)} +
+
+`; + +// ---- Payload Input Dialog ---- + +const PayloadDialog = ({ label, value, onLabel, onValue, onSubmit, onCancel }) => html` +
+
+
New Node Payload
+ onLabel(e.target.value)} + autofocus + /> +