- 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
487 lines
No EOL
17 KiB
HTML
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> |