feat: keyboard input, Web Audio synthesis, and multiplayer demo

Compiler changes:
- emit_handler: DOM events (keydown/keyup/etc) route to document.addEventListener
- emit_handler: handler params (ev) pushed into scope to prevent .value suffix
- play_tone(freq, dur, type) and play_noise(dur, vol) builtins
- Web Audio runtime: _playTone (oscillator) and _playNoise (filtered noise)
- Reactive textContent: signal-dependent If expressions wrapped in DS.effect

Example updates:
- game-pong.ds: Arrow Up/Down for paddle, Space for pause/resume, paddle-hit sounds
- step-sequencer.ds: 4 audio trigger timers (kick=60Hz, snare=noise, hihat=noise, bass=110Hz)

Verification:
- All 136 tests pass, 45 examples compile
- Relay connects successfully, multiplayer sync confirmed
- Keyboard controls and pause toggle verified in browser
This commit is contained in:
enzotar 2026-02-27 09:34:20 -08:00
parent 0e23ddd88b
commit bd926b9e0a
3 changed files with 123 additions and 19 deletions

View file

@ -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; } });

View file

@ -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" }
]

View file

@ -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,