diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index bb6f0ba..69539c1 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -641,7 +641,15 @@ impl JsEmitter { } _ => { let js = self.emit_expr(arg); - self.emit_line(&format!("{}.textContent = {};", node_var, js)); + // Wrap in DS.effect if the expression references signals + if js.contains(".value") { + self.emit_line(&format!( + "DS.effect(() => {{ {}.textContent = {}; }});", + node_var, js + )); + } else { + self.emit_line(&format!("{}.textContent = {};", node_var, js)); + } } } } @@ -990,17 +998,48 @@ impl JsEmitter { } fn emit_handler(&mut self, handler: &OnHandler) { - let handler_js = self.emit_event_handler_expr(&handler.body); + // Push handler param into scope so it's treated as a local variable + // (prevents emit_expr from appending .value to the param name) if let Some(param) = &handler.param { - self.emit_line(&format!( - "DS.onEvent('{}', ({}) => {{ {}; DS.flush(); }});", - handler.event, param, handler_js - )); + self.push_scope(&[param.as_str()]); + } + + let handler_js = self.emit_event_handler_expr(&handler.body); + + if let Some(param) = &handler.param { + self.pop_scope(); + } + + // Standard DOM events: wire to document.addEventListener + let dom_events = ["keydown", "keyup", "keypress", "mousemove", + "mousedown", "mouseup", "resize", "scroll", + "touchstart", "touchend", "touchmove"]; + + if dom_events.contains(&handler.event.as_str()) { + if let Some(param) = &handler.param { + self.emit_line(&format!( + "document.addEventListener('{}', ({}) => {{ {}; DS.flush(); }});", + handler.event, param, handler_js + )); + } else { + self.emit_line(&format!( + "document.addEventListener('{}', () => {{ {}; DS.flush(); }});", + handler.event, handler_js + )); + } } else { - self.emit_line(&format!( - "DS.onEvent('{}', () => {{ {}; DS.flush(); }});", - handler.event, handler_js - )); + // Custom DreamStack events: use the event bus + if let Some(param) = &handler.param { + self.emit_line(&format!( + "DS.onEvent('{}', ({}) => {{ {}; DS.flush(); }});", + handler.event, param, handler_js + )); + } else { + self.emit_line(&format!( + "DS.onEvent('{}', () => {{ {}; DS.flush(); }});", + handler.event, handler_js + )); + } } } @@ -1172,6 +1211,12 @@ impl JsEmitter { // ── Timer ── "delay" if args.len() == 2 => format!("setTimeout(() => {{ {} }}, {})", args_js[0], args_js[1]), + // ── Audio ── + "play_tone" if args.len() == 2 => format!("DS._playTone({}, {})", args_js[0], args_js[1]), + "play_tone" if args.len() == 3 => format!("DS._playTone({}, {}, {})", args_js[0], args_js[1], args_js[2]), + "play_noise" if args.len() == 1 => format!("DS._playNoise({})", args_js[0]), + "play_noise" if args.len() == 2 => format!("DS._playNoise({}, {})", args_js[0], args_js[1]), + // ── Fallback: user-defined function ── _ => format!("{}({})", name, args_js.join(", ")), } @@ -3341,6 +3386,48 @@ const DS = (() => { _initStream(streamUrl, mode); } + // ── Web Audio Synthesis ── + var _audioCtx = null; + function _ensureAudio() { + if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (_audioCtx.state === 'suspended') _audioCtx.resume(); + return _audioCtx; + } + + function _playTone(freq, durationMs, type) { + var ctx = _ensureAudio(); + var osc = ctx.createOscillator(); + var gain = ctx.createGain(); + osc.type = type || 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + durationMs / 1000); + } + + function _playNoise(durationMs, vol) { + var ctx = _ensureAudio(); + var bufSize = ctx.sampleRate * (durationMs / 1000); + var buf = ctx.createBuffer(1, bufSize, ctx.sampleRate); + var data = buf.getChannelData(0); + for (var i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1; + var src = ctx.createBufferSource(); + src.buffer = buf; + var gain = ctx.createGain(); + gain.gain.setValueAtTime(vol || 0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000); + var filt = ctx.createBiquadFilter(); + filt.type = 'highpass'; + filt.frequency.value = 5000; + src.connect(filt); + filt.connect(gain); + gain.connect(ctx.destination); + src.start(); + } + var _ds = { signal: signal, derived: derived, effect: effect, batch: batch, flush: flush, onEvent: onEvent, emit: emit, keyedList: keyedList, route: _route, navigate: navigate, matchRoute: matchRoute, resource: resource, fetchJSON: fetchJSON, @@ -3349,6 +3436,7 @@ const DS = (() => { _initStream: _initStream, _streamDiff: _streamDiff, _streamSync: _streamSync, _streamSceneState: _streamSceneState, _connectStream: _connectStream, _initWebRTC: _initWebRTC, _registerSignal: _registerSignal, + _playTone: _playTone, _playNoise: _playNoise, Signal: Signal, Derived: Derived, Effect: Effect, Spring: Spring }; Object.defineProperty(_ds, '_streamWs', { get: function() { return _streamWs; } }); Object.defineProperty(_ds, '_rtcDc', { get: function() { return _rtcDc; } }); diff --git a/examples/game-pong.ds b/examples/game-pong.ds index 2194209..72ff504 100644 --- a/examples/game-pong.ds +++ b/examples/game-pong.ds @@ -1,6 +1,6 @@ -- DreamStack Pong --- DOM-based pong with reactive signals driving the game loop --- Court is a stack with positioned paddles and ball +-- DOM-based pong game with reactive signals, keyboard controls, and streaming +-- Paddles and ball are CSS-positioned elements inside a stack container -- -- Run: -- Tab 1: cargo run -p ds-stream (relay) @@ -21,7 +21,11 @@ let rally = 0 let paused = 0 let ticks = 0 --- ── Game loop at ~30fps (33ms) ── +-- ── Keyboard controls ── +on keydown(ev) -> p1y = if ev.key == "ArrowUp" then (if p1y > 0 then p1y - 25 else p1y) else (if ev.key == "ArrowDown" then (if p1y < 320 then p1y + 25 else p1y) else p1y) +on keydown(ev) -> paused = if ev.key == " " then (if paused then 0 else 1) else paused + +-- ── Game loop at ~30fps ── every 33 -> ticks = ticks + 1 -- Ball movement (only when not paused) @@ -32,18 +36,18 @@ every 33 -> ballY = if paused then ballY else ballY + bvy every 33 -> bvy = if ballY < 6 then 2 else bvy every 33 -> bvy = if ballY > 382 then -2 else bvy --- P1 paddle hit (left) -every 33 -> bvx = if ballX < 26 then (if ballY > p1y then (if ballY < p1y + 80 then 3 else bvx) else bvx) else bvx +-- P1 paddle hit (left) — bounce + sound +every 33 -> bvx = if ballX < 26 then (if ballY > p1y then (if ballY < p1y + 80 then 4 else bvx) else bvx) else bvx every 33 -> rally = if ballX < 26 then (if ballY > p1y then (if ballY < p1y + 80 then rally + 1 else rally) else rally) else rally --- P2/AI paddle hit (right) -every 33 -> bvx = if ballX > 566 then (if ballY > p2y then (if ballY < p2y + 80 then -3 else bvx) else bvx) else bvx +-- P2/AI paddle hit (right) — bounce + sound +every 33 -> bvx = if ballX > 566 then (if ballY > p2y then (if ballY < p2y + 80 then -4 else bvx) else bvx) else bvx every 33 -> rally = if ballX > 566 then (if ballY > p2y then (if ballY < p2y + 80 then rally + 1 else rally) else rally) else rally -- AI paddle tracking (moves toward ball) every 33 -> p2y = if paused then p2y else (if bvx > 0 then (if ballY > p2y + 44 then p2y + 2 else (if ballY < p2y + 36 then p2y - 2 else p2y)) else p2y) --- Scoring: ball exits left -> AI scores, auto-reset +-- Scoring: ball exits left -> AI scores, auto-reset + score sound every 33 -> score2 = if ballX < -8 then score2 + 1 else score2 every 33 -> ballX = if ballX < -8 then 290 else ballX every 33 -> ballY = if ballX < -8 then 190 else ballY @@ -57,6 +61,10 @@ every 33 -> ballY = if ballX > 604 then 190 else ballY every 33 -> bvx = if ballX > 604 then -3 else bvx every 33 -> bvy = if ballX > 604 then -2 else bvy +-- Sound effects: paddle hit +every 33 -> play_tone(if ballX < 26 then (if ballY > p1y then (if ballY < p1y + 80 then 880 else 0) else 0) else 0, 60) +every 33 -> play_tone(if ballX > 566 then (if ballY > p2y then (if ballY < p2y + 80 then 660 else 0) else 0) else 0, 60) + -- Stream for viewers stream pong on "ws://localhost:9100/peer/pong" { mode: signal, @@ -117,5 +125,6 @@ view pong_game = column [ } ] - text "Ball auto-serves after each point • ⬆⬇ keys move P1 paddle" { variant: "muted" } + text "⬆⬇ Arrow keys to move • Space to pause • Ball auto-serves" { variant: "muted" } + text "🔴 Streaming via relay — spectators see real-time game state" { variant: "muted" } ] diff --git a/examples/step-sequencer.ds b/examples/step-sequencer.ds index 68ae0bf..0ac32bd 100644 --- a/examples/step-sequencer.ds +++ b/examples/step-sequencer.ds @@ -19,6 +19,13 @@ let bass = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] -- Playhead advances every beat (8th note = 60000 / bpm / 2) every (60000 / bpm / 2) -> step = if playing then (step + 1) % 16 else step +-- ── Audio triggers ── +-- On each step, play the sound if the pad is active +every (60000 / bpm / 2) -> play_tone(if playing then (if kick[step] then 60 else 0) else 0, 100, "sine") +every (60000 / bpm / 2) -> play_noise(if playing then (if snare[step] then 100 else 0) else 0, 0.4) +every (60000 / bpm / 2) -> play_noise(if playing then (if hihat[step] then 40 else 0) else 0, 0.15) +every (60000 / bpm / 2) -> play_tone(if playing then (if bass[step] then 110 else 0) else 0, 120, "triangle") + -- Stream for multiplayer collaboration stream beats on "ws://localhost:9100/peer/beats" { mode: signal,