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:
parent
0e23ddd88b
commit
bd926b9e0a
3 changed files with 123 additions and 19 deletions
|
|
@ -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; } });
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue