dreamstack/examples/game-pong.html
enzotar 62830fa82a fix: for-in parser token mismatch + enhanced step sequencer
- Fix for...in parser: TokenKind::In → TokenKind::InKw (was using wrong token)
- Remove dead In variant from TokenKind enum
- Fix pre-existing test: When pattern match 2→3 fields
- Enhanced step-sequencer.ds: 16 steps, 4 instrument arrays, play/pause,
  BPM controls with limits, presets, dynamic variants, streaming output
- All 118 tests pass
2026-02-26 23:42:29 -08:00

487 lines
No EOL
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>🏓 DreamStack Pong — Streamed</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: #0a0a0f;
color: #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 1.5rem;
user-select: none;
}
h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.25rem;
}
.subtitle {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.scores {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.9rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-p1 {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.3);
}
.badge-p2 {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-info {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.2);
}
.badge-live {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
canvas {
border: 2px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
background: #0f0f1a;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), inset 0 0 60px rgba(99, 102, 241, 0.03);
}
.controls {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: rgba(99, 102, 241, 0.3);
transform: scale(1.03);
}
.btn:active {
transform: scale(0.97);
}
.btn-reset {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
.btn-start {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.status {
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.mode-btns {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.mode-active {
background: rgba(99, 102, 241, 0.4) !important;
}
</style>
</head>
<body>
<h1>🏓 DreamStack Pong</h1>
<p class="subtitle">Mouse/touch to move paddle · Streamed live via relay</p>
<div class="mode-btns">
<button class="btn mode-active" id="btn-ai" onclick="setMode('ai')">vs AI</button>
<button class="btn" id="btn-2p" onclick="setMode('2p')">2 Player</button>
</div>
<div class="scores">
<span class="badge badge-p1" id="s1">Player: 0</span>
<span class="badge badge-info" id="rally-badge">Rally: 0</span>
<span class="badge badge-p2" id="s2">AI: 0</span>
<span class="badge badge-live" id="stream-badge">⏳ Connecting…</span>
</div>
<canvas id="c" width="600" height="400"></canvas>
<div class="controls">
<button class="btn btn-start" onclick="serve()">🏓 Serve</button>
<button class="btn btn-reset" onclick="resetGame()">🔄 Reset</button>
</div>
<p class="status" id="status">Connecting to relay…</p>
<script>
const W = 600, H = 400;
const PADDLE_W = 12, PADDLE_H = 80, BALL_R = 8;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// ── State ──
let mode = 'ai'; // 'ai' or '2p'
let p1y = H / 2, p2y = H / 2;
let ballX = W / 2, ballY = H / 2, ballVX = 0, ballVY = 0;
let score1 = 0, score2 = 0, rally = 0;
let serving = true;
let maxRally = 0;
let lastTime = 0;
// ── Mode ──
function setMode(m) {
mode = m;
document.getElementById('btn-ai').classList.toggle('mode-active', m === 'ai');
document.getElementById('btn-2p').classList.toggle('mode-active', m === '2p');
document.getElementById('s2').textContent = m === 'ai' ? `AI: ${score2}` : `P2: ${score2}`;
resetGame();
}
// ── Input ──
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const y = (e.clientY - rect.top) * (H / rect.height);
p1y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, y));
if (mode === '2p') {
// In 2P mode, second player uses vertical position mirrored
// Actually let's use keyboard for P2
}
});
// Touch support
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const y = (e.touches[0].clientY - rect.top) * (H / rect.height);
p1y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, y));
}, { passive: false });
// Keyboard for 2P mode
const keys = {};
document.addEventListener('keydown', e => { keys[e.key] = true; });
document.addEventListener('keyup', e => { keys[e.key] = false; });
function serve() {
ballX = W / 2; ballY = H / 2;
const angle = (Math.random() - 0.5) * Math.PI * 0.5;
const side = Math.random() > 0.5 ? 1 : -1;
ballVX = side * Math.cos(angle) * 5;
ballVY = Math.sin(angle) * 4;
rally = 0;
serving = false;
}
function resetGame() {
score1 = 0; score2 = 0; rally = 0; maxRally = 0;
ballX = W / 2; ballY = H / 2; ballVX = 0; ballVY = 0;
p1y = H / 2; p2y = H / 2;
serving = true;
updateUI();
}
// ── Game Loop ──
function update(ts) {
const dt = Math.min((ts - lastTime) / 16.67, 2); // Normalize to ~60fps
lastTime = ts;
// 2P keyboard controls
if (mode === '2p') {
if (keys['ArrowUp']) p2y = Math.max(PADDLE_H / 2, p2y - 6 * dt);
if (keys['ArrowDown']) p2y = Math.min(H - PADDLE_H / 2, p2y + 6 * dt);
}
if (!serving) {
// Move ball
ballX += ballVX * dt;
ballY += ballVY * dt;
// Wall bounce (top/bottom)
if (ballY - BALL_R <= 0) { ballY = BALL_R; ballVY = Math.abs(ballVY); }
if (ballY + BALL_R >= H) { ballY = H - BALL_R; ballVY = -Math.abs(ballVY); }
// Left paddle collision (P1)
if (ballX - BALL_R <= PADDLE_W + 16 && ballVX < 0) {
if (ballY >= p1y - PADDLE_H / 2 - 4 && ballY <= p1y + PADDLE_H / 2 + 4) {
ballX = PADDLE_W + 16 + BALL_R;
ballVX = Math.abs(ballVX) * 1.03; // Slight speedup
// Angle based on where ball hits paddle
const offset = (ballY - p1y) / (PADDLE_H / 2);
ballVY = offset * 5;
rally++;
}
}
// Right paddle collision (P2/AI)
if (ballX + BALL_R >= W - PADDLE_W - 16 && ballVX > 0) {
if (ballY >= p2y - PADDLE_H / 2 - 4 && ballY <= p2y + PADDLE_H / 2 + 4) {
ballX = W - PADDLE_W - 16 - BALL_R;
ballVX = -Math.abs(ballVX) * 1.03;
const offset = (ballY - p2y) / (PADDLE_H / 2);
ballVY = offset * 5;
rally++;
}
}
// AI paddle
if (mode === 'ai') {
const aiSpeed = 3.5 + Math.min(rally * 0.1, 2); // Gets harder
const aiTarget = ballVX > 0 ? ballY : H / 2; // Track ball when coming toward AI
const diff = aiTarget - p2y;
if (Math.abs(diff) > 3) {
p2y += Math.sign(diff) * Math.min(aiSpeed * dt, Math.abs(diff));
}
p2y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, p2y));
}
// Score — ball passed left
if (ballX < -20) {
score2++;
maxRally = Math.max(maxRally, rally);
serving = true;
ballVX = 0; ballVY = 0;
ballX = W / 2; ballY = H / 2;
}
// Score — ball passed right
if (ballX > W + 20) {
score1++;
maxRally = Math.max(maxRally, rally);
serving = true;
ballVX = 0; ballVY = 0;
ballX = W / 2; ballY = H / 2;
}
}
draw();
updateUI();
broadcastState();
requestAnimationFrame(update);
}
// ── Drawing ──
function draw() {
// Background
ctx.fillStyle = '#0f0f1a';
ctx.fillRect(0, 0, W, H);
// Center line
ctx.setLineDash([8, 8]);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
// Center circle
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.arc(W / 2, H / 2, 50, 0, Math.PI * 2);
ctx.stroke();
// Score display on court
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.font = 'bold 80px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(score1, W / 4, H / 2 + 28);
ctx.fillText(score2, W * 3 / 4, H / 2 + 28);
// Left paddle (P1) — indigo glow
const p1Gradient = ctx.createLinearGradient(16, p1y - PADDLE_H / 2, 16 + PADDLE_W, p1y + PADDLE_H / 2);
p1Gradient.addColorStop(0, '#818cf8');
p1Gradient.addColorStop(1, '#6366f1');
ctx.fillStyle = p1Gradient;
ctx.shadowColor = '#6366f1';
ctx.shadowBlur = 15;
roundRect(ctx, 16, p1y - PADDLE_H / 2, PADDLE_W, PADDLE_H, 6);
ctx.fill();
// Right paddle (P2/AI) — red glow
const p2Gradient = ctx.createLinearGradient(W - 16 - PADDLE_W, p2y - PADDLE_H / 2, W - 16, p2y + PADDLE_H / 2);
p2Gradient.addColorStop(0, '#f87171');
p2Gradient.addColorStop(1, '#ef4444');
ctx.fillStyle = p2Gradient;
ctx.shadowColor = '#ef4444';
ctx.shadowBlur = 15;
roundRect(ctx, W - 16 - PADDLE_W, p2y - PADDLE_H / 2, PADDLE_W, PADDLE_H, 6);
ctx.fill();
ctx.shadowBlur = 0;
// Ball — white glow
ctx.fillStyle = '#f1f5f9';
ctx.shadowColor = '#f1f5f9';
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Ball trail
if (!serving && (Math.abs(ballVX) > 0.1 || Math.abs(ballVY) > 0.1)) {
for (let i = 1; i <= 4; i++) {
ctx.fillStyle = `rgba(241,245,249,${0.15 - i * 0.03})`;
ctx.beginPath();
ctx.arc(ballX - ballVX * i * 0.8, ballY - ballVY * i * 0.8, BALL_R - i, 0, Math.PI * 2);
ctx.fill();
}
}
// Serving text
if (serving) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '500 16px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Press SERVE or SPACE to start', W / 2, H - 30);
}
// Rally counter
if (rally > 0) {
ctx.fillStyle = `rgba(250,204,21,${Math.min(0.8, 0.3 + rally * 0.05)})`;
ctx.font = 'bold 14px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Rally: ${rally}`, W / 2, 24);
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function updateUI() {
document.getElementById('s1').textContent = `Player: ${score1}`;
document.getElementById('s2').textContent = (mode === 'ai' ? 'AI' : 'P2') + `: ${score2}`;
document.getElementById('rally-badge').textContent = `Rally: ${rally}`;
}
// Space to serve
document.addEventListener('keydown', e => {
if (e.key === ' ' && serving) { e.preventDefault(); serve(); }
});
// ── DreamStack Relay Streaming ──
let ws = null;
let frameCount = 0;
function connectRelay() {
try {
ws = new WebSocket('ws://localhost:9100/peer/pong');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('stream-badge').textContent = '🔴 STREAMING';
document.getElementById('status').textContent = 'Connected to relay — game is streaming live!';
};
ws.onclose = () => {
document.getElementById('stream-badge').textContent = '⏳ Reconnecting…';
setTimeout(connectRelay, 3000);
};
ws.onerror = () => {
document.getElementById('status').textContent = 'Relay unavailable — playing offline. Start: cargo run -p ds-stream';
};
} catch (e) {
document.getElementById('status').textContent = 'Playing offline';
}
}
function broadcastState() {
if (!ws || ws.readyState !== 1) return;
frameCount++;
if (frameCount % 3 !== 0) return; // Throttle: every 3rd frame
const state = {
score1, score2, rally, maxRally, mode, serving,
p1y: Math.round(p1y), p2y: Math.round(p2y),
ballX: Math.round(ballX), ballY: Math.round(ballY),
speed: Math.round(Math.sqrt(ballVX * ballVX + ballVY * ballVY) * 10)
};
const json = JSON.stringify(state);
const enc = new TextEncoder();
const payload = enc.encode(json);
const frame = new Uint8Array(1 + payload.length);
frame[0] = 0x31;
frame.set(payload, 1);
ws.send(frame);
if (frameCount % 150 === 0) {
const sync = new Uint8Array(1 + payload.length);
sync[0] = 0x30;
sync.set(payload, 1);
ws.send(sync);
}
}
// ── Start ──
requestAnimationFrame(update);
connectRelay();
</script>
</body>
</html>