From 0e23ddd88b436dbb72cdcf2b6a2bc62218de79a2 Mon Sep 17 00:00:00 2001 From: enzotar Date: Fri, 27 Feb 2026 00:02:58 -0800 Subject: [PATCH] feat: game-pong.ds + two compiler improvements - Native DreamStack pong game with visual court (stack container), CSS-positioned paddles/ball, 30fps game loop, AI tracking, auto-serve - Parser: containers (column/row/stack) now support leading props e.g. column { style: '...' } [children] - Codegen: fixed signal detection for container layout props (strip .value suffix for signal graph lookup) - All 136 tests pass, 45 examples compile --- compiler/ds-codegen/src/js_emitter.rs | 7 +- compiler/ds-parser/src/parser.rs | 24 ++++- examples/game-pong.ds | 121 ++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 examples/game-pong.ds diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 528a249..bb6f0ba 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -563,9 +563,12 @@ impl JsEmitter { _ => { // Layout props (x, y, width, height) or arbitrary style let js_val = self.emit_expr(val); - if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) { + // Check if this is a signal reference: emit_expr on signal `foo` + // returns `foo.value`, so also check with `.value` stripped + let raw_name = js_val.strip_suffix(".value").unwrap_or(&js_val); + if graph.name_to_id.contains_key(raw_name) || graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) { self.emit_line(&format!( - "DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});", + "DS.effect(() => {{ {}.style.{} = {} + 'px'; }});", node_var, key, js_val )); } else { diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 4e8d621..f861696 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -1445,6 +1445,27 @@ impl Parser { fn parse_container(&mut self, kind: ContainerKind) -> Result { self.advance(); // consume container keyword + + // Optional LEADING props: `column { style: "..." } [children]` + let mut props = Vec::new(); + if self.check(&TokenKind::LBrace) { + self.advance(); + self.skip_newlines(); + while !self.check(&TokenKind::RBrace) && !self.is_at_end() { + self.skip_newlines(); + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + let val = self.parse_expr()?; + props.push((key, val)); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBrace)?; + } + self.expect(&TokenKind::LBracket)?; self.skip_newlines(); @@ -1462,8 +1483,7 @@ impl Parser { self.expect(&TokenKind::RBracket)?; - // Optional trailing props: `column [ ... ] { variant: "card" }` - let mut props = Vec::new(); + // Optional TRAILING props: `column [ ... ] { variant: "card" }` if self.check(&TokenKind::LBrace) { self.advance(); self.skip_newlines(); diff --git a/examples/game-pong.ds b/examples/game-pong.ds new file mode 100644 index 0000000..2194209 --- /dev/null +++ b/examples/game-pong.ds @@ -0,0 +1,121 @@ +-- DreamStack Pong +-- DOM-based pong with reactive signals driving the game loop +-- Court is a stack with positioned paddles and ball +-- +-- Run: +-- Tab 1: cargo run -p ds-stream (relay) +-- Tab 2: dreamstack dev examples/game-pong.ds (player) + +import { Badge } from "../registry/components/badge" + +-- ── State ── +let p1y = 160 +let p2y = 160 +let ballX = 290 +let ballY = 190 +let bvx = 3 +let bvy = 2 +let score1 = 0 +let score2 = 0 +let rally = 0 +let paused = 0 +let ticks = 0 + +-- ── Game loop at ~30fps (33ms) ── +every 33 -> ticks = ticks + 1 + +-- Ball movement (only when not paused) +every 33 -> ballX = if paused then ballX else ballX + bvx +every 33 -> ballY = if paused then ballY else ballY + bvy + +-- Wall bounces (top/bottom) +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 +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 +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 +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 +every 33 -> bvx = if ballX < -8 then 3 else bvx +every 33 -> bvy = if ballX < -8 then 2 else bvy + +-- Scoring: ball exits right -> P1 scores, auto-reset +every 33 -> score1 = if ballX > 604 then score1 + 1 else score1 +every 33 -> ballX = if ballX > 604 then 290 else ballX +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 + +-- Stream for viewers +stream pong on "ws://localhost:9100/peer/pong" { + mode: signal, + output: p1y, p2y, ballX, ballY, score1, score2, rally, paused +} + +view pong_game = column [ + text "🏓 DreamStack Pong" { variant: "title" } + + -- Score bar + row [ + Badge { label: "P1: {score1}", variant: "info" } + Badge { label: "Rally: {rally}", variant: "warning" } + Badge { label: "AI: {score2}", variant: "error" } + ] + + -- ── The Court ── + stack { style: "position:relative; width:600px; height:400px; background:linear-gradient(180deg,#0f172a,#1e293b); border:2px solid #334155; border-radius:16px; margin:0.5rem auto; overflow:hidden; box-shadow:0 0 40px rgba(99,102,241,0.15)" } [ + -- Center line + column { style: "position:absolute; left:298px; top:0; width:4px; height:400px; background:repeating-linear-gradient(to bottom, #475569 0px, #475569 12px, transparent 12px, transparent 24px)" } [] + + -- Center circle + column { style: "position:absolute; left:260px; top:160px; width:80px; height:80px; border:2px solid #475569; border-radius:50%" } [] + + -- P1 paddle (blue, left) + column { style: "position:absolute; left:8px; width:12px; height:80px; background:linear-gradient(180deg,#818cf8,#6366f1); border-radius:6px; box-shadow:0 0 12px rgba(129,140,248,0.5); transition:top 0.05s", top: p1y } [] + + -- P2/AI paddle (red, right) + column { style: "position:absolute; left:580px; width:12px; height:80px; background:linear-gradient(180deg,#f87171,#ef4444); border-radius:6px; box-shadow:0 0 12px rgba(248,113,113,0.5); transition:top 0.05s", top: p2y } [] + + -- Ball (yellow glow) + column { style: "position:absolute; width:14px; height:14px; background:radial-gradient(circle,#fde68a,#f59e0b); border-radius:50%; box-shadow:0 0 16px #fbbf24,0 0 4px #fbbf24", top: ballY, left: ballX } [] + + -- Score overlay on court + row { style: "position:absolute; top:12px; left:0; width:100%; justify-content:center; gap:140px; pointer-events:none" } [ + text "{score1}" { style: "font-size:56px; color:rgba(129,140,248,0.2); font-weight:900; font-family:monospace" } + text "{score2}" { style: "font-size:56px; color:rgba(248,113,113,0.2); font-weight:900; font-family:monospace" } + ] + ] + + -- Controls + row [ + button (if paused then "▶ Resume" else "⏸ Pause") { + click: paused = if paused then 0 else 1, + variant: "primary" + } + button "⬆️ Up" { + click: p1y = if p1y > 0 then p1y - 25 else p1y, + variant: "secondary" + } + button "⬇️ Down" { + click: p1y = if p1y < 320 then p1y + 25 else p1y, + variant: "secondary" + } + button "🔄 Reset" { + click: score1 = 0; score2 = 0; rally = 0; paused = 0; bvx = 3; bvy = 2; ballX = 290; ballY = 190; p1y = 160; p2y = 160, + variant: "destructive" + } + ] + + text "Ball auto-serves after each point • ⬆⬇ keys move P1 paddle" { variant: "muted" } +]