1016 lines
No EOL
28 KiB
HTML
1016 lines
No EOL
28 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=800, height=1280, initial-scale=1.0">
|
||
<title>DreamStack Panel IR Previewer</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
:root {
|
||
--bg: #1a1a2e;
|
||
--surface: #16213e;
|
||
--card: #0f3460;
|
||
--text: #e4e4ef;
|
||
--text-dim: #8888aa;
|
||
--accent: #818cf8;
|
||
--btn: #533483;
|
||
--btn-hover: #6a4c9c;
|
||
--green: #34d399;
|
||
--border: #2a2a4e;
|
||
--radius: 8px;
|
||
}
|
||
|
||
html,
|
||
body {
|
||
width: 800px;
|
||
height: 1280px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Inter', -apple-system, sans-serif;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* Panel frame */
|
||
.panel-frame {
|
||
width: 800px;
|
||
min-height: 1280px;
|
||
position: relative;
|
||
background: var(--bg);
|
||
padding: 0;
|
||
}
|
||
|
||
.panel-header {
|
||
padding: 8px 16px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.panel-header .dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
/* IR widgets */
|
||
.ir-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.ir-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.ir-stk {
|
||
position: relative;
|
||
}
|
||
|
||
.ir-lbl {
|
||
font-size: 14px;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.ir-btn {
|
||
background: var(--btn);
|
||
border: none;
|
||
color: var(--text);
|
||
padding: 10px 20px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
transition: all 0.15s;
|
||
font-family: inherit;
|
||
min-width: 50px;
|
||
text-align: center;
|
||
}
|
||
|
||
.ir-btn:hover {
|
||
background: var(--btn-hover);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.ir-btn:active {
|
||
transform: translateY(0);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.ir-btn.primary {
|
||
background: #6366f1;
|
||
}
|
||
|
||
.ir-btn.primary:hover {
|
||
background: #818cf8;
|
||
}
|
||
|
||
.ir-btn.secondary {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.ir-btn.destructive {
|
||
background: #dc2626;
|
||
}
|
||
|
||
.ir-btn.destructive:hover {
|
||
background: #ef4444;
|
||
}
|
||
|
||
.ir-btn.ghost {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.ir-btn.ghost:hover {
|
||
background: var(--surface);
|
||
}
|
||
|
||
.ir-inp {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px 12px;
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
outline: none;
|
||
width: 100%;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.ir-inp:focus {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.ir-sld {
|
||
width: 100%;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
.ir-sw {
|
||
width: 50px;
|
||
height: 26px;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
.ir-bar {
|
||
width: 100%;
|
||
height: 8px;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
.ir-pnl {
|
||
background: var(--card);
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border);
|
||
padding: 16px;
|
||
}
|
||
|
||
.ir-badge {
|
||
display: inline-flex;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
gap: 4px;
|
||
}
|
||
|
||
.ir-badge.success {
|
||
background: rgba(52, 211, 153, 0.15);
|
||
color: #34d399;
|
||
border: 1px solid rgba(52, 211, 153, 0.3);
|
||
}
|
||
|
||
.ir-badge.info {
|
||
background: rgba(56, 189, 248, 0.15);
|
||
color: #38bdf8;
|
||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||
}
|
||
|
||
.ir-badge.warning {
|
||
background: rgba(251, 191, 36, 0.15);
|
||
color: #fbbf24;
|
||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||
}
|
||
|
||
.ir-badge.error {
|
||
background: rgba(248, 113, 113, 0.15);
|
||
color: #f87171;
|
||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||
}
|
||
|
||
.ir-badge.default {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--text-dim);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
/* Game board grid */
|
||
.game-board {
|
||
display: grid;
|
||
gap: 1px;
|
||
margin: 8px auto;
|
||
width: fit-content;
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.game-cell {
|
||
border-radius: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.12s, opacity 0.12s;
|
||
}
|
||
|
||
.game-cell.empty {
|
||
background: rgba(255, 255, 255, 0.025);
|
||
}
|
||
|
||
.game-cell.snake-head {
|
||
background: #16a34a;
|
||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||
z-index: 1;
|
||
}
|
||
|
||
.game-cell.snake-body {
|
||
background: #22c55e;
|
||
}
|
||
|
||
.game-cell.food {
|
||
background: rgba(239, 68, 68, 0.2);
|
||
animation: pulse-food 1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse-food {
|
||
|
||
0%,
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
|
||
50% {
|
||
transform: scale(1.15);
|
||
}
|
||
}
|
||
|
||
.game-cell.wall-hit {
|
||
background: #dc2626;
|
||
}
|
||
|
||
.game-over-overlay {
|
||
text-align: center;
|
||
padding: 12px;
|
||
color: #f87171;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.game-hud {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 4px;
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
font-weight: 600;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.game-hud .hud-score {
|
||
color: #34d399;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.game-hud .hud-hi {
|
||
color: #fbbf24;
|
||
}
|
||
|
||
.game-hud .hud-speed {
|
||
color: #818cf8;
|
||
}
|
||
|
||
.game-hud .hud-len {
|
||
color: #38bdf8;
|
||
}
|
||
|
||
.new-high-score {
|
||
animation: flash-gold 0.5s ease 3;
|
||
}
|
||
|
||
@keyframes flash-gold {
|
||
|
||
0%,
|
||
100% {
|
||
color: #fbbf24;
|
||
}
|
||
|
||
50% {
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
/* Debug panel */
|
||
#debug {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
color: var(--green);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 8px 12px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
#debug .sig {
|
||
color: var(--accent);
|
||
}
|
||
|
||
#debug .evt {
|
||
color: #fbbf24;
|
||
}
|
||
</style>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<div class="panel-frame">
|
||
<div class="panel-header">
|
||
<span><span class="dot"></span> Panel IR Preview — 800×1280</span>
|
||
<span id="status">No IR loaded</span>
|
||
</div>
|
||
<div id="root"></div>
|
||
</div>
|
||
|
||
<div id="debug">
|
||
<div>DreamStack Panel IR Previewer — drag-drop an .ir.json file or use ?file= URL</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ─── Panel IR Runtime (JavaScript version for testing) ───
|
||
// This mirrors what ds_runtime.c does on the ESP32,
|
||
// but renders to HTML instead of LVGL.
|
||
|
||
const signals = {};
|
||
let bindings = {}; // nodeId → { text template, element }
|
||
let currentIR = null; // Store full IR for reactive rebuilds
|
||
let rebuildQueued = false;
|
||
let debugEl;
|
||
|
||
function log(msg, cls = '') {
|
||
const d = document.getElementById('debug');
|
||
const line = document.createElement('div');
|
||
line.className = cls;
|
||
line.textContent = `[${new Date().toISOString().slice(11, 19)}] ${msg}`;
|
||
d.appendChild(line);
|
||
d.scrollTop = d.scrollHeight;
|
||
}
|
||
|
||
// ── Build UI from IR ────────────────────────────────
|
||
let activeTimers = [];
|
||
|
||
function buildUI(ir, isRebuild) {
|
||
const root = document.getElementById('root');
|
||
root.innerHTML = '';
|
||
bindings = {};
|
||
currentIR = ir;
|
||
|
||
// Initialize signals (only on first build)
|
||
if (!isRebuild && ir.signals) {
|
||
for (const s of ir.signals) {
|
||
signals[s.id] = s.v;
|
||
log(`Signal ${s.id} = ${JSON.stringify(s.v)}`, 'sig');
|
||
}
|
||
}
|
||
|
||
// Start timers (only on first build)
|
||
if (!isRebuild && ir.timers) {
|
||
// Clear any previous timers
|
||
activeTimers.forEach(t => clearInterval(t));
|
||
activeTimers = [];
|
||
for (const timer of ir.timers) {
|
||
const tid = setInterval(() => {
|
||
executeAction(timer.action);
|
||
}, timer.ms);
|
||
activeTimers.push(tid);
|
||
log(`Timer started: every ${timer.ms}ms`, 'sig');
|
||
}
|
||
}
|
||
|
||
// Build the widget tree
|
||
if (ir.root) {
|
||
const el = buildNode(ir.root);
|
||
if (el) root.appendChild(el);
|
||
}
|
||
|
||
document.getElementById('status').textContent =
|
||
`${Object.keys(signals).length} signals, ${countNodes(ir.root)} nodes`;
|
||
if (!isRebuild) log(`UI built: ${countNodes(ir.root)} nodes`);
|
||
}
|
||
|
||
function countNodes(node) {
|
||
if (!node) return 0;
|
||
let n = 1;
|
||
if (node.c) for (const ch of node.c) n += countNodes(ch);
|
||
if (node.then) n += countNodes(node.then);
|
||
if (node.else) n += countNodes(node.else);
|
||
if (node.body) n += countNodes(node.body);
|
||
return n;
|
||
}
|
||
|
||
function buildNode(node) {
|
||
if (!node || !node.t) return null;
|
||
|
||
switch (node.t) {
|
||
case 'col': return buildContainer(node, 'ir-col');
|
||
case 'row': return buildContainer(node, 'ir-row');
|
||
case 'stk': return buildContainer(node, 'ir-stk');
|
||
case 'lst': return buildContainer(node, 'ir-col');
|
||
case 'pnl': return buildPanel(node);
|
||
case 'lbl': return buildLabel(node);
|
||
case 'btn': return buildButton(node);
|
||
case 'inp': return buildInput(node);
|
||
case 'sld': return buildSlider(node);
|
||
case 'sw': return buildSwitch(node);
|
||
case 'bar': return buildProgress(node);
|
||
case 'img': return buildImage(node);
|
||
case 'cond': return buildConditional(node);
|
||
case 'each': return buildEach(node);
|
||
default:
|
||
log(`Unknown node type: ${node.t}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildContainer(node, className) {
|
||
const el = document.createElement('div');
|
||
el.className = className;
|
||
el.dataset.nodeId = node.id;
|
||
if (node.gap) el.style.gap = `${node.gap}px`;
|
||
if (node.pad) el.style.padding = `${node.pad}px`;
|
||
applyStyle(el, node.style);
|
||
if (node.c) for (const ch of node.c) {
|
||
const child = buildNode(ch);
|
||
if (child) el.appendChild(child);
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function buildPanel(node) {
|
||
// If it's a Badge component, render as badge
|
||
if (node._comp === 'Badge') {
|
||
const el = document.createElement('span');
|
||
el.className = `ir-badge ${node.variant || 'default'}`;
|
||
el.dataset.nodeId = node.id;
|
||
const text = expandTemplate(node.text || '');
|
||
el.textContent = text;
|
||
if (node.text && node.text.includes('{')) {
|
||
bindings[node.id] = { template: node.text, element: el };
|
||
}
|
||
return el;
|
||
}
|
||
// If it's a Card component, render as panel with title
|
||
const el = document.createElement('div');
|
||
el.className = 'ir-pnl';
|
||
el.dataset.nodeId = node.id;
|
||
if (node.text) {
|
||
const title = document.createElement('div');
|
||
title.style.cssText = 'font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:1px;';
|
||
title.textContent = expandTemplate(node.text);
|
||
el.appendChild(title);
|
||
}
|
||
// Inject visual game board for Board cards
|
||
if (node.text && node.text.toLowerCase().includes('board')) {
|
||
const board = buildGameBoard();
|
||
el.appendChild(board);
|
||
}
|
||
if (node.c) for (const ch of node.c) {
|
||
const child = buildNode(ch);
|
||
if (child) el.appendChild(child);
|
||
}
|
||
return el;
|
||
}
|
||
|
||
// ── Snake Game Engine ────────────────────────────────
|
||
const GRID = 12;
|
||
const CELL = 32;
|
||
let snake = [{ x: 5, y: 5 }];
|
||
let snakeDir = { x: 1, y: 0 };
|
||
let food = { x: 2, y: 2 };
|
||
let gameScore = 0;
|
||
let gameMoves = 0;
|
||
let gameOver = false;
|
||
let gameStarted = false;
|
||
let gamePaused = false;
|
||
let gameTimer = null;
|
||
let currentTickMs = 250;
|
||
let highScore = parseInt(localStorage.getItem('ds-snake-hi') || '0');
|
||
|
||
function getTickSpeed() {
|
||
// Speed up as score grows: 250 → 200 → 160 → 130 → 100
|
||
if (gameScore >= 20) return 100;
|
||
if (gameScore >= 15) return 130;
|
||
if (gameScore >= 10) return 160;
|
||
if (gameScore >= 5) return 200;
|
||
return 250;
|
||
}
|
||
|
||
function startSnakeGame() {
|
||
if (gameTimer) clearInterval(gameTimer);
|
||
snake = [{ x: Math.floor(GRID / 2), y: Math.floor(GRID / 2) }];
|
||
snakeDir = { x: 1, y: 0 };
|
||
food = spawnFood();
|
||
gameScore = 0;
|
||
gameMoves = 0;
|
||
gameOver = false;
|
||
gamePaused = false;
|
||
gameStarted = true;
|
||
currentTickMs = 250;
|
||
syncSignals();
|
||
gameTimer = setInterval(snakeTick, currentTickMs);
|
||
log('Snake game started!', 'evt');
|
||
}
|
||
|
||
function togglePause() {
|
||
if (gameOver || !gameStarted) return;
|
||
gamePaused = !gamePaused;
|
||
if (gamePaused) {
|
||
clearInterval(gameTimer);
|
||
log('Game paused', 'evt');
|
||
} else {
|
||
gameTimer = setInterval(snakeTick, currentTickMs);
|
||
log('Game resumed', 'evt');
|
||
}
|
||
queueRebuild();
|
||
}
|
||
|
||
function spawnFood() {
|
||
let fx, fy;
|
||
do {
|
||
fx = Math.floor(Math.random() * GRID);
|
||
fy = Math.floor(Math.random() * GRID);
|
||
} while (snake.some(s => s.x === fx && s.y === fy));
|
||
return { x: fx, y: fy };
|
||
}
|
||
|
||
function snakeTick() {
|
||
if (gameOver || gamePaused) return;
|
||
const head = snake[0];
|
||
const nx = head.x + snakeDir.x;
|
||
const ny = head.y + snakeDir.y;
|
||
|
||
// Wall collision
|
||
if (nx < 0 || nx >= GRID || ny < 0 || ny >= GRID) {
|
||
endGame(); return;
|
||
}
|
||
// Self collision
|
||
if (snake.some(s => s.x === nx && s.y === ny)) {
|
||
endGame(); return;
|
||
}
|
||
|
||
snake.unshift({ x: nx, y: ny });
|
||
gameMoves++;
|
||
|
||
if (nx === food.x && ny === food.y) {
|
||
gameScore++;
|
||
food = spawnFood();
|
||
log(`Ate food! Score: ${gameScore}, Length: ${snake.length}`, 'evt');
|
||
// Speed up
|
||
const newSpeed = getTickSpeed();
|
||
if (newSpeed !== currentTickMs) {
|
||
currentTickMs = newSpeed;
|
||
clearInterval(gameTimer);
|
||
gameTimer = setInterval(snakeTick, currentTickMs);
|
||
log(`Speed up! ${currentTickMs}ms/tick`, 'evt');
|
||
}
|
||
} else {
|
||
snake.pop();
|
||
}
|
||
|
||
syncSignals();
|
||
queueRebuild();
|
||
}
|
||
|
||
function endGame() {
|
||
gameOver = true;
|
||
clearInterval(gameTimer);
|
||
if (gameScore > highScore) {
|
||
highScore = gameScore;
|
||
localStorage.setItem('ds-snake-hi', String(highScore));
|
||
log(`NEW HIGH SCORE: ${highScore}!`, 'evt');
|
||
}
|
||
log(`Game Over! Score: ${gameScore}, Length: ${snake.length}`, 'evt');
|
||
syncSignals();
|
||
queueRebuild();
|
||
}
|
||
|
||
function setSnakeDir(dx, dy) {
|
||
if (snake.length > 1 && snakeDir.x === -dx && snakeDir.y === -dy) return;
|
||
snakeDir = { x: dx, y: dy };
|
||
if (!gameStarted) startSnakeGame();
|
||
if (gamePaused) togglePause();
|
||
}
|
||
|
||
function syncSignals() {
|
||
const head = snake[0];
|
||
signals[0] = head.x;
|
||
signals[1] = head.y;
|
||
signals[2] = gameScore;
|
||
signals[3] = gameMoves;
|
||
signals[4] = food.x;
|
||
signals[5] = food.y;
|
||
}
|
||
|
||
// Keyboard handler
|
||
document.addEventListener('keydown', (e) => {
|
||
switch (e.key) {
|
||
case 'ArrowUp': setSnakeDir(0, -1); e.preventDefault(); break;
|
||
case 'ArrowDown': setSnakeDir(0, 1); e.preventDefault(); break;
|
||
case 'ArrowLeft': setSnakeDir(-1, 0); e.preventDefault(); break;
|
||
case 'ArrowRight': setSnakeDir(1, 0); e.preventDefault(); break;
|
||
case ' ': togglePause(); e.preventDefault(); break;
|
||
}
|
||
});
|
||
|
||
function buildGameBoard() {
|
||
const wrapper = document.createElement('div');
|
||
|
||
// HUD bar
|
||
const hud = document.createElement('div');
|
||
hud.className = 'game-hud';
|
||
hud.innerHTML = `
|
||
<span class="hud-score">Score: ${gameScore}</span>
|
||
<span class="hud-len">🐍 ×${snake.length}</span>
|
||
<span class="hud-speed">${currentTickMs}ms</span>
|
||
<span class="hud-hi ${gameScore > 0 && gameScore >= highScore ? 'new-high-score' : ''}">Hi: ${highScore}</span>
|
||
`;
|
||
wrapper.appendChild(hud);
|
||
|
||
const grid = document.createElement('div');
|
||
grid.className = 'game-board';
|
||
grid.style.gridTemplateColumns = `repeat(${GRID}, ${CELL}px)`;
|
||
grid.style.gridTemplateRows = `repeat(${GRID}, ${CELL}px)`;
|
||
|
||
const head = snake[0];
|
||
const bodyMap = new Map();
|
||
snake.forEach((s, i) => { if (i > 0) bodyMap.set(`${s.x},${s.y}`, i); });
|
||
|
||
for (let r = 0; r < GRID; r++) {
|
||
for (let c = 0; c < GRID; c++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'game-cell';
|
||
cell.style.width = `${CELL}px`;
|
||
cell.style.height = `${CELL}px`;
|
||
cell.style.fontSize = `${Math.floor(CELL * 0.55)}px`;
|
||
|
||
if (r === head.y && c === head.x) {
|
||
cell.classList.add('snake-head');
|
||
cell.textContent = gameOver ? '\u{1F480}' : '\u{1F7E9}';
|
||
} else if (bodyMap.has(`${c},${r}`)) {
|
||
cell.classList.add('snake-body');
|
||
// Gradient: body segments fade from 0.9 near head to 0.4 at tail
|
||
const idx = bodyMap.get(`${c},${r}`);
|
||
const opacity = 0.9 - (idx / snake.length) * 0.5;
|
||
cell.style.opacity = opacity.toFixed(2);
|
||
cell.textContent = '\u{1F7E2}';
|
||
} else if (r === food.y && c === food.x) {
|
||
cell.classList.add('food');
|
||
cell.textContent = '\u{1F34E}';
|
||
} else {
|
||
cell.classList.add('empty');
|
||
}
|
||
grid.appendChild(cell);
|
||
}
|
||
}
|
||
wrapper.appendChild(grid);
|
||
|
||
if (gameOver) {
|
||
const isNewHi = gameScore === highScore && gameScore > 0;
|
||
const ov = document.createElement('div');
|
||
ov.className = 'game-over-overlay';
|
||
ov.innerHTML = `\u{1F480} Game Over — Score: ${gameScore}${isNewHi ? ' \u{1F3C6} NEW HIGH SCORE!' : ''} — <span style="cursor:pointer;text-decoration:underline" onclick="startSnakeGame()">Play Again</span>`;
|
||
wrapper.appendChild(ov);
|
||
} else if (gamePaused) {
|
||
const p = document.createElement('div');
|
||
p.className = 'game-over-overlay';
|
||
p.style.color = '#818cf8';
|
||
p.innerHTML = '⏸ Paused — press any arrow or Space to resume';
|
||
wrapper.appendChild(p);
|
||
} else if (!gameStarted) {
|
||
const start = document.createElement('div');
|
||
start.className = 'game-over-overlay';
|
||
start.style.color = '#34d399';
|
||
start.innerHTML = `Press any arrow key or d-pad to start${highScore > 0 ? ` — High: ${highScore}` : ''}`;
|
||
wrapper.appendChild(start);
|
||
}
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function buildLabel(node) {
|
||
const el = document.createElement('span');
|
||
el.className = 'ir-lbl';
|
||
el.dataset.nodeId = node.id;
|
||
const text = expandTemplate(node.text || '');
|
||
el.textContent = text;
|
||
if (node.size) el.style.fontSize = `${node.size}px`;
|
||
applyStyle(el, node.style);
|
||
|
||
// Register binding for signal updates
|
||
if (node.text && node.text.includes('{')) {
|
||
bindings[node.id] = { template: node.text, element: el };
|
||
}
|
||
|
||
return el;
|
||
}
|
||
|
||
function buildButton(node) {
|
||
const el = document.createElement('button');
|
||
el.className = `ir-btn ${node.variant || ''}`;
|
||
el.dataset.nodeId = node.id;
|
||
el.textContent = expandTemplate(node.text || '');
|
||
applyStyle(el, node.style);
|
||
|
||
if (node.on && node.on.click) {
|
||
el.addEventListener('click', () => {
|
||
executeAction(node.on.click);
|
||
log(`Event: node ${node.id} clicked`, 'evt');
|
||
});
|
||
} else if (node.text && node.text.includes('⏹')) {
|
||
// Wire stop/pause button to game engine
|
||
el.addEventListener('click', () => {
|
||
togglePause();
|
||
log(`Event: node ${node.id} pause toggled`, 'evt');
|
||
});
|
||
}
|
||
|
||
return el;
|
||
}
|
||
|
||
function buildInput(node) {
|
||
const el = document.createElement('input');
|
||
el.className = 'ir-inp';
|
||
el.dataset.nodeId = node.id;
|
||
if (node.placeholder) el.placeholder = node.placeholder;
|
||
if (node.bind !== undefined) {
|
||
el.value = signals[node.bind] || '';
|
||
el.addEventListener('input', () => {
|
||
updateSignal(node.bind, el.value);
|
||
});
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function buildSlider(node) {
|
||
const el = document.createElement('input');
|
||
el.type = 'range';
|
||
el.className = 'ir-sld';
|
||
el.dataset.nodeId = node.id;
|
||
el.min = node.min || 0;
|
||
el.max = node.max || 100;
|
||
if (node.bind !== undefined) {
|
||
el.value = signals[node.bind] || 0;
|
||
el.addEventListener('input', () => {
|
||
updateSignal(node.bind, parseInt(el.value));
|
||
});
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function buildSwitch(node) {
|
||
const el = document.createElement('input');
|
||
el.type = 'checkbox';
|
||
el.className = 'ir-sw';
|
||
el.dataset.nodeId = node.id;
|
||
if (node.bind !== undefined) {
|
||
el.checked = !!signals[node.bind];
|
||
el.addEventListener('change', () => {
|
||
updateSignal(node.bind, el.checked);
|
||
});
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function buildProgress(node) {
|
||
const el = document.createElement('progress');
|
||
el.className = 'ir-bar';
|
||
el.dataset.nodeId = node.id;
|
||
el.min = node.min || 0;
|
||
el.max = node.max || 100;
|
||
if (node.bind !== undefined) {
|
||
el.value = signals[node.bind] || 0;
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function buildImage(node) {
|
||
const el = document.createElement('img');
|
||
el.dataset.nodeId = node.id;
|
||
el.src = node.src || '';
|
||
el.style.maxWidth = '100%';
|
||
return el;
|
||
}
|
||
|
||
function buildConditional(node) {
|
||
// Evaluate the condition against current signal values
|
||
if (node.if && evalCondition(node.if)) {
|
||
return buildNode(node.then);
|
||
} else if (node.else) {
|
||
return buildNode(node.else);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function evalCondition(condStr) {
|
||
// Replace signal refs (s0, s1, ...) with actual values
|
||
const expr = condStr.replace(/s(\d+)/g, (_, id) => {
|
||
const val = signals[parseInt(id)];
|
||
return JSON.stringify(val !== undefined ? val : null);
|
||
});
|
||
try { return eval(expr); } catch { return false; }
|
||
}
|
||
|
||
function buildEach(node) {
|
||
const el = document.createElement('div');
|
||
el.className = 'ir-col';
|
||
el.dataset.nodeId = node.id;
|
||
// For preview, render the body template 3 times as example
|
||
for (let i = 0; i < 3; i++) {
|
||
const child = buildNode(node.body);
|
||
if (child) el.appendChild(child);
|
||
}
|
||
return el;
|
||
}
|
||
|
||
// ── Signal system ───────────────────────────────────
|
||
function updateSignal(id, value) {
|
||
signals[id] = value;
|
||
log(`Signal ${id} = ${JSON.stringify(value)}`, 'sig');
|
||
queueRebuild();
|
||
}
|
||
|
||
// Debounced rebuild — batch multiple signal updates in one frame
|
||
function queueRebuild() {
|
||
if (rebuildQueued) return;
|
||
rebuildQueued = true;
|
||
requestAnimationFrame(() => {
|
||
rebuildQueued = false;
|
||
if (currentIR) buildUI(currentIR, true);
|
||
});
|
||
}
|
||
|
||
function expandTemplate(template) {
|
||
return template.replace(/\{(\d+)\}/g, (_, id) => {
|
||
const val = signals[parseInt(id)];
|
||
return val !== undefined ? String(val) : `{${id}}`;
|
||
});
|
||
}
|
||
|
||
// ── Action executor ─────────────────────────────────
|
||
function executeAction(action) {
|
||
// Handle multi-action arrays
|
||
if (Array.isArray(action)) {
|
||
// Check if this is a d-pad action batch (modifies headX or headY)
|
||
const dirAction = action.find(a => a && (a.s === 0 || a.s === 1) &&
|
||
(a.op === 'sub' || a.op === 'add' || a.op === 'inc' || a.op === 'dec'));
|
||
if (dirAction) {
|
||
// It's a d-pad press — route to game engine
|
||
if (dirAction.s === 0) {
|
||
// headX: sub=left, add/inc=right
|
||
const dx = (dirAction.op === 'sub' || dirAction.op === 'dec') ? -1 : 1;
|
||
setSnakeDir(dx, 0);
|
||
} else {
|
||
// headY: sub=up, add/inc=down
|
||
const dy = (dirAction.op === 'sub' || dirAction.op === 'dec') ? -1 : 1;
|
||
setSnakeDir(0, dy);
|
||
}
|
||
return;
|
||
}
|
||
// Check if this is a reset action (sets multiple signals to initial values)
|
||
const setActions = action.filter(a => a && a.op === 'set');
|
||
if (setActions.length >= 3) {
|
||
startSnakeGame();
|
||
return;
|
||
}
|
||
for (const a of action) executeAction(a);
|
||
return;
|
||
}
|
||
if (!action || !action.op) return;
|
||
|
||
// Intercept single-action d-pad presses
|
||
if ((action.s === 0 || action.s === 1) &&
|
||
(action.op === 'sub' || action.op === 'add' || action.op === 'inc' || action.op === 'dec')) {
|
||
if (action.s === 0) {
|
||
const dx = (action.op === 'sub' || action.op === 'dec') ? -1 : 1;
|
||
setSnakeDir(dx, 0);
|
||
} else {
|
||
const dy = (action.op === 'sub' || action.op === 'dec') ? -1 : 1;
|
||
setSnakeDir(0, dy);
|
||
}
|
||
return;
|
||
}
|
||
|
||
switch (action.op) {
|
||
case 'inc':
|
||
updateSignal(action.s, (signals[action.s] || 0) + 1);
|
||
break;
|
||
case 'dec':
|
||
updateSignal(action.s, (signals[action.s] || 0) - 1);
|
||
break;
|
||
case 'add':
|
||
updateSignal(action.s, (signals[action.s] || 0) + (action.v || 0));
|
||
break;
|
||
case 'sub':
|
||
updateSignal(action.s, (signals[action.s] || 0) - (action.v || 0));
|
||
break;
|
||
case 'set':
|
||
updateSignal(action.s, action.v);
|
||
break;
|
||
case 'toggle':
|
||
updateSignal(action.s, !signals[action.s]);
|
||
break;
|
||
case 'remote':
|
||
log(`Remote action: ${action.name} (would forward to hub)`, 'evt');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Style application ───────────────────────────────
|
||
function applyStyle(el, style) {
|
||
if (!style) return;
|
||
if (style.bg) el.style.background = style.bg;
|
||
if (style.fg) el.style.color = style.fg;
|
||
if (style.size) el.style.fontSize = `${style.size}px`;
|
||
if (style.radius) el.style.borderRadius = `${style.radius}px`;
|
||
if (style.w) el.style.width = `${style.w}px`;
|
||
if (style.h) el.style.height = `${style.h}px`;
|
||
if (style.align) el.style.textAlign = style.align;
|
||
}
|
||
|
||
// ── File loading ────────────────────────────────────
|
||
// Load from URL param
|
||
const params = new URLSearchParams(location.search);
|
||
const fileUrl = params.get('file');
|
||
if (fileUrl) {
|
||
fetch(fileUrl)
|
||
.then(r => r.json())
|
||
.then(ir => { buildUI(ir); log(`Loaded from ${fileUrl}`); })
|
||
.catch(e => log(`Error loading: ${e}`));
|
||
}
|
||
|
||
// Drag and drop
|
||
document.addEventListener('dragover', e => e.preventDefault());
|
||
document.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const ir = JSON.parse(reader.result);
|
||
buildUI(ir);
|
||
log(`Loaded from dropped file: ${file.name}`);
|
||
} catch (e) {
|
||
log(`Error parsing: ${e}`);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
});
|
||
|
||
// Also try loading app.ir.json from same directory
|
||
if (!fileUrl) {
|
||
fetch('app.ir.json')
|
||
.then(r => r.json())
|
||
.then(ir => { buildUI(ir); log('Auto-loaded app.ir.json'); })
|
||
.catch(() => log('No app.ir.json found. Drag-drop an IR file or use ?file=URL'));
|
||
}
|
||
</script>
|
||
</body>
|
||
|
||
</html> |