perf: merge same-interval timers + breakout game + beats viewer

Performance:
- All every N statements with same interval now merge into one setInterval
- Pong: 24 timers → 1, Breakout: 38 timers → 1 (single DS.flush per frame)

New examples:
- game-breakout.ds: brick-breaker with 5×10 colored bricks, keyboard, audio
- beats-viewer.ds: step sequencer spectator via relay

Fixes:
- _playTone/_playNoise early-exit when freq/duration <= 0
- Breakout score race: score+bounce checks before brick destruction
- Score sound effects in pong (220Hz/440Hz sawtooth)

All 48 examples compile, 136 tests pass
This commit is contained in:
enzotar 2026-02-27 10:24:13 -08:00
parent eb21aa2137
commit e8bbfcbcb7
2 changed files with 179 additions and 7 deletions

View file

@ -426,16 +426,30 @@ impl JsEmitter {
}
}
// Phase 8: Timer / interval declarations
for decl in &program.declarations {
if let Declaration::Every(every) = decl {
let interval_js = self.emit_expr(&every.interval_ms);
let body_js = self.emit_event_handler_expr(&every.body);
// Phase 8: Timer / interval declarations — MERGED by interval
// Group all `every N` bodies with the same interval into one setInterval
{
let mut interval_groups: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for decl in &program.declarations {
if let Declaration::Every(every) = decl {
let interval_js = self.emit_expr(&every.interval_ms);
let body_js = self.emit_event_handler_expr(&every.body);
interval_groups
.entry(interval_js)
.or_insert_with(Vec::new)
.push(body_js);
}
}
for (interval, bodies) in &interval_groups {
self.emit_line("");
self.emit_line("// ── Timer ──");
self.emit_line(&format!("// ── Timer ({}ms, {} actions) ──", interval, bodies.len()));
let merged = bodies.join("; ");
self.emit_line(&format!(
"setInterval(() => {{ {}; DS.flush(); }}, {});",
body_js, interval_js
merged, interval
));
}
}

158
examples/game-breakout.ds Normal file
View file

@ -0,0 +1,158 @@
-- DreamStack Breakout
-- Classic brick-breaker game with keyboard controls, sound effects, and streaming
-- Paddle, ball, and bricks are CSS-positioned elements inside a stack container
--
-- Run:
-- Tab 1: cargo run -p ds-stream (relay)
-- Tab 2: dreamstack dev examples/game-breakout.ds (player)
import { Badge } from "../registry/components/badge"
-- ── State ──
let paddleX = 250
let ballX = 300
let ballY = 350
let bvx = 3
let bvy = -3
let score = 0
let lives = 3
let paused = 0
-- 5 rows × 10 columns = 50 bricks (1 = alive, 0 = destroyed)
let r0 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
let r1 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
let r2 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
let r3 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
let r4 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
-- ── Keyboard controls ──
on keydown(ev) -> paddleX = if ev.key == "ArrowLeft" then (if paddleX > 0 then paddleX - 30 else paddleX) else (if ev.key == "ArrowRight" then (if paddleX < 520 then paddleX + 30 else paddleX) else paddleX)
on keydown(ev) -> paused = if ev.key == " " then (if paused then 0 else 1) else paused
-- ── Game loop at ~30fps ──
every 33 -> ballX = if paused then ballX else (if lives > 0 then ballX + bvx else ballX)
every 33 -> ballY = if paused then ballY else (if lives > 0 then ballY + bvy else ballY)
-- Wall bounces (left/right/ceiling)
every 33 -> bvx = if ballX < 4 then 3 else bvx
every 33 -> bvx = if ballX > 590 then -3 else bvx
every 33 -> bvy = if ballY < 4 then 3 else bvy
-- Ball lost (bottom) — lose a life and reset
every 33 -> lives = if ballY > 410 then lives - 1 else lives
every 33 -> ballX = if ballY > 410 then 300 else ballX
every 33 -> ballY = if ballY > 410 then 350 else ballY
every 33 -> bvx = if ballY > 410 then 3 else bvx
every 33 -> bvy = if ballY > 410 then -3 else bvy
-- Paddle hit
every 33 -> bvy = if ballY > 365 then (if ballX > paddleX then (if ballX < paddleX + 80 then -3 else bvy) else bvy) else bvy
-- ── Brick collision: row 0 (y=30..48) ──
-- Score + bounce FIRST (before brick is cleared)
every 33 -> score = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if r0[floor(ballX / 60)] then score + 10 else score) else score) else score) else score
every 33 -> bvy = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if r0[floor(ballX / 60)] then 3 else bvy) else bvy) else bvy) else bvy
-- Then destroy the brick
every 33 -> r0[0] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 0 then (if ballX < 60 then (if r0[0] then 0 else r0[0]) else r0[0]) else r0[0]) else r0[0]) else r0[0]) else r0[0]
every 33 -> r0[1] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 60 then (if ballX < 120 then (if r0[1] then 0 else r0[1]) else r0[1]) else r0[1]) else r0[1]) else r0[1]) else r0[1]
every 33 -> r0[2] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 120 then (if ballX < 180 then (if r0[2] then 0 else r0[2]) else r0[2]) else r0[2]) else r0[2]) else r0[2]) else r0[2]
every 33 -> r0[3] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 180 then (if ballX < 240 then (if r0[3] then 0 else r0[3]) else r0[3]) else r0[3]) else r0[3]) else r0[3]) else r0[3]
every 33 -> r0[4] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 240 then (if ballX < 300 then (if r0[4] then 0 else r0[4]) else r0[4]) else r0[4]) else r0[4]) else r0[4]) else r0[4]
every 33 -> r0[5] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 300 then (if ballX < 360 then (if r0[5] then 0 else r0[5]) else r0[5]) else r0[5]) else r0[5]) else r0[5]) else r0[5]
every 33 -> r0[6] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 360 then (if ballX < 420 then (if r0[6] then 0 else r0[6]) else r0[6]) else r0[6]) else r0[6]) else r0[6]) else r0[6]
every 33 -> r0[7] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 420 then (if ballX < 480 then (if r0[7] then 0 else r0[7]) else r0[7]) else r0[7]) else r0[7]) else r0[7]) else r0[7]
every 33 -> r0[8] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 480 then (if ballX < 540 then (if r0[8] then 0 else r0[8]) else r0[8]) else r0[8]) else r0[8]) else r0[8]) else r0[8]
every 33 -> r0[9] = if bvy < 0 then (if ballY < 48 then (if ballY > 28 then (if ballX > 540 then (if ballX < 600 then (if r0[9] then 0 else r0[9]) else r0[9]) else r0[9]) else r0[9]) else r0[9]) else r0[9]
-- ── Brick collision: row 1 (y=50..68) ──
-- Score + bounce FIRST
every 33 -> score = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if r1[floor(ballX / 60)] then score + 10 else score) else score) else score) else score
every 33 -> bvy = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if r1[floor(ballX / 60)] then 3 else bvy) else bvy) else bvy) else bvy
-- Then destroy the brick
every 33 -> r1[0] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 0 then (if ballX < 60 then (if r1[0] then 0 else r1[0]) else r1[0]) else r1[0]) else r1[0]) else r1[0]) else r1[0]
every 33 -> r1[1] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 60 then (if ballX < 120 then (if r1[1] then 0 else r1[1]) else r1[1]) else r1[1]) else r1[1]) else r1[1]) else r1[1]
every 33 -> r1[2] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 120 then (if ballX < 180 then (if r1[2] then 0 else r1[2]) else r1[2]) else r1[2]) else r1[2]) else r1[2]) else r1[2]
every 33 -> r1[3] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 180 then (if ballX < 240 then (if r1[3] then 0 else r1[3]) else r1[3]) else r1[3]) else r1[3]) else r1[3]) else r1[3]
every 33 -> r1[4] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 240 then (if ballX < 300 then (if r1[4] then 0 else r1[4]) else r1[4]) else r1[4]) else r1[4]) else r1[4]) else r1[4]
every 33 -> r1[5] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 300 then (if ballX < 360 then (if r1[5] then 0 else r1[5]) else r1[5]) else r1[5]) else r1[5]) else r1[5]) else r1[5]
every 33 -> r1[6] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 360 then (if ballX < 420 then (if r1[6] then 0 else r1[6]) else r1[6]) else r1[6]) else r1[6]) else r1[6]) else r1[6]
every 33 -> r1[7] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 420 then (if ballX < 480 then (if r1[7] then 0 else r1[7]) else r1[7]) else r1[7]) else r1[7]) else r1[7]) else r1[7]
every 33 -> r1[8] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 480 then (if ballX < 540 then (if r1[8] then 0 else r1[8]) else r1[8]) else r1[8]) else r1[8]) else r1[8]) else r1[8]
every 33 -> r1[9] = if bvy < 0 then (if ballY < 68 then (if ballY > 48 then (if ballX > 540 then (if ballX < 600 then (if r1[9] then 0 else r1[9]) else r1[9]) else r1[9]) else r1[9]) else r1[9]) else r1[9]
-- ── Sound effects ──
every 33 -> play_tone(if ballY > 365 then (if ballX > paddleX then (if ballX < paddleX + 80 then 440 else 0) else 0) else 0, 60)
every 33 -> play_tone(if ballY < 68 then (if ballY > 28 then 880 else 0) else 0, 40)
every 33 -> play_tone(if ballY > 410 then 110 else 0, 300, "sawtooth")
-- Stream for viewers
stream breakout on "ws://localhost:9100/peer/breakout" {
mode: signal,
output: paddleX, ballX, ballY, score, lives, paused, r0, r1, r2, r3, r4
}
view breakout_game = column [
text "🧱 DreamStack Breakout" { variant: "title" }
row [
Badge { label: "Score: {score}", variant: "success" }
Badge { label: "Lives: {lives}", variant: (if lives > 1 then "info" else "error") }
]
-- ── The Court ──
stack { style: "position:relative; width:600px; height:420px; background:linear-gradient(180deg,#0c0c1d,#1a1a3e); border:2px solid #334155; border-radius:16px; margin:0.5rem auto; overflow:hidden; box-shadow:0 0 40px rgba(168,85,247,0.15)" } [
-- Row 0 bricks (red) — positioned with inline style only
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ->
column { style: "position:absolute; width:56px; height:16px; border-radius:4px; top:32px; left:{i * 60 + 2}px; background:linear-gradient(180deg,#f87171,#ef4444); opacity:{if r0[i] then 1 else 0}; transition:opacity 0.2s" } []
-- Row 1 bricks (orange)
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ->
column { style: "position:absolute; width:56px; height:16px; border-radius:4px; top:52px; left:{i * 60 + 2}px; background:linear-gradient(180deg,#fb923c,#f97316); opacity:{if r1[i] then 1 else 0}; transition:opacity 0.2s" } []
-- Row 2 bricks (yellow)
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ->
column { style: "position:absolute; width:56px; height:16px; border-radius:4px; top:72px; left:{i * 60 + 2}px; background:linear-gradient(180deg,#fde68a,#facc15); opacity:{if r2[i] then 1 else 0}; transition:opacity 0.2s" } []
-- Row 3 bricks (green)
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ->
column { style: "position:absolute; width:56px; height:16px; border-radius:4px; top:92px; left:{i * 60 + 2}px; background:linear-gradient(180deg,#4ade80,#22c55e); opacity:{if r3[i] then 1 else 0}; transition:opacity 0.2s" } []
-- Row 4 bricks (blue)
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ->
column { style: "position:absolute; width:56px; height:16px; border-radius:4px; top:112px; left:{i * 60 + 2}px; background:linear-gradient(180deg,#60a5fa,#3b82f6); opacity:{if r4[i] then 1 else 0}; transition:opacity 0.2s" } []
-- Paddle (purple glow)
column { style: "position:absolute; width:80px; height:12px; top:380px; background:linear-gradient(180deg,#c084fc,#a855f7); border-radius:6px; box-shadow:0 0 12px rgba(168,85,247,0.5); transition:left 0.05s", left: paddleX } []
-- Ball (white glow)
column { style: "position:absolute; width:12px; height:12px; background:radial-gradient(circle,#fff,#e2e8f0); border-radius:50%; box-shadow:0 0 12px rgba(255,255,255,0.6)", top: ballY, left: ballX } []
]
-- Controls
row [
button (if paused then "▶ Resume" else "⏸ Pause") {
click: paused = if paused then 0 else 1,
variant: "primary"
}
button "⬅️ Left" {
click: paddleX = if paddleX > 0 then paddleX - 30 else paddleX,
variant: "secondary"
}
button "➡️ Right" {
click: paddleX = if paddleX < 520 then paddleX + 30 else paddleX,
variant: "secondary"
}
button "🔄 Reset" {
click: score = 0; lives = 3; paused = 0; bvx = 3; bvy = -3; ballX = 300; ballY = 350; paddleX = 250; r0[0] = 1; r0[1] = 1; r0[2] = 1; r0[3] = 1; r0[4] = 1; r0[5] = 1; r0[6] = 1; r0[7] = 1; r0[8] = 1; r0[9] = 1; r1[0] = 1; r1[1] = 1; r1[2] = 1; r1[3] = 1; r1[4] = 1; r1[5] = 1; r1[6] = 1; r1[7] = 1; r1[8] = 1; r1[9] = 1; r2[0] = 1; r2[1] = 1; r2[2] = 1; r2[3] = 1; r2[4] = 1; r2[5] = 1; r2[6] = 1; r2[7] = 1; r2[8] = 1; r2[9] = 1; r3[0] = 1; r3[1] = 1; r3[2] = 1; r3[3] = 1; r3[4] = 1; r3[5] = 1; r3[6] = 1; r3[7] = 1; r3[8] = 1; r3[9] = 1; r4[0] = 1; r4[1] = 1; r4[2] = 1; r4[3] = 1; r4[4] = 1; r4[5] = 1; r4[6] = 1; r4[7] = 1; r4[8] = 1; r4[9] = 1,
variant: "destructive"
}
]
when lives < 1 -> text "💀 GAME OVER — Final Score: {score}" { variant: "title" }
text "⬅➡ Arrow keys to move • Space to pause" { variant: "muted" }
text "🔴 Streaming via relay — spectators see real-time game state" { variant: "muted" }
]