diff --git a/VERIFICATION-0.79.0.md b/VERIFICATION-0.79.0.md new file mode 100644 index 0000000..c28da9e --- /dev/null +++ b/VERIFICATION-0.79.0.md @@ -0,0 +1,11 @@ +# Orbit TV 0.79.0: OrbitHive in the Arcade + +Paul's Hive Harvest game, renamed OrbitHive, runs in the Arcade beside Snake. It's JavaScript he had built, all drawn in code, about 50 KB plus a font, shown in a WebView with network loads blocked. Progress stays on the box. + +- Two skins, under Help & Settings: Orbit, the default, with starry space and the bee in a helmet, and Classic, the game as it was. +- Names that echoed Beehive Bedlam are gone from the game's text: no Buzz and no Bedlam. +- Back goes through the game's own menus, then leaves to the Arcade. + +Checked: both skins and a level start in a headless browser with screenshots. Build, lint and 294 unit tests pass. + +Not checked until installed: the remote and Back in the WebView on the box, sound, and how smoothly it runs. Rules resemble Beehive Bedlam's: fine for Paul's own use, but they want a proper look before any public release. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8dfbc1..852c9e9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 85 - versionName = "0.78.0-idents" + versionCode = 86 + versionName = "0.79.0-orbithive" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/assets/orbithive/barlow-semibold.ttf b/android/app/src/main/assets/orbithive/barlow-semibold.ttf new file mode 100644 index 0000000..7c974a4 Binary files /dev/null and b/android/app/src/main/assets/orbithive/barlow-semibold.ttf differ diff --git a/android/app/src/main/assets/orbithive/game.js b/android/app/src/main/assets/orbithive/game.js new file mode 100644 index 0000000..c34b309 --- /dev/null +++ b/android/app/src/main/assets/orbithive/game.js @@ -0,0 +1,1134 @@ +// OrbitHive: a honeycomb flower shooter for TV remotes, game pads, mice and touch. +// Left/Right aim, OK fires, Down swaps the flower, Up arms a Honeybomb, Back pauses. +(function () { + 'use strict'; + + // ---------- constants ---------- + const W = 1280, H = 720; + const R = 24, D = R * 2, RH = R * Math.sqrt(3); + const COLS = 11, MAXROWS = 18; + const FX = W / 2 - (COLS * D) / 2, FW = COLS * D, FX2 = FX + FW; + const TOP0 = 64; + const LAUNCH = { x: W / 2, y: 632 }; + const DEADLINE = 592; + const SHOT_SPEED = 1500; + const LEVELS = 200, PER_PAGE = 20; + const COLOURS = [ + { petal: '#e53935', dark: '#9e1b1b', name: 'red' }, + { petal: '#1e66d0', dark: '#123f82', name: 'blue' }, + { petal: '#2e9d3a', dark: '#1b5e20', name: 'green' }, + { petal: '#f4f4f4', dark: '#9e9e9e', name: 'white' }, + { petal: '#f5a623', dark: '#a35a00', name: 'orange' }, + { petal: '#8e44ad', dark: '#4a235a', name: 'purple' }, + ]; + const FONT = 'Barlow, Arial, sans-serif'; + + // ---------- saved progress ---------- + const SAVE_KEY = 'orbithive-save-v1'; + const save = Object.assign( + { unlocked: 1, stars: {}, best: {}, bombs: 2, lastSpin: '', guide: true, sound: true, page: 0, skin: 'orbit' }, + safeParse(localStorage.getItem(SAVE_KEY)) + ); + function safeParse(s) { try { return JSON.parse(s) || {}; } catch (e) { return {}; } } + function persist() { try { localStorage.setItem(SAVE_KEY, JSON.stringify(save)); } catch (e) { /* storage full or blocked */ } } + const today = () => new Date().toISOString().slice(0, 10); + + // ---------- canvas ---------- + const canvas = document.getElementById('game'); + const ctx = canvas.getContext('2d'); + let scale = 1, ox = 0, oy = 0, dpr = 1; + function resize() { + dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(innerWidth * dpr); + canvas.height = Math.round(innerHeight * dpr); + scale = Math.min(canvas.width / W, canvas.height / H); + ox = (canvas.width - W * scale) / 2; + oy = (canvas.height - H * scale) / 2; + flowerSprites = makeFlowerSprites(); + } + + // Pre-drawn flowers, one per colour, so the grid draws quickly on TV boxes. + let flowerSprites = []; + function makeFlowerSprites() { + const size = Math.ceil(D * Math.max(scale, 0.5) * 1.1); + return COLOURS.map((c) => { + const cv = document.createElement('canvas'); + cv.width = cv.height = size; + const g = cv.getContext('2d'); + const k = size / (D * 1.1); + g.translate(size / 2, size / 2); + g.scale(k, k); + drawFlowerShape(g, 0, 0, R, c); + return cv; + }); + } + function drawFlowerShape(g, x, y, r, c) { + g.save(); + g.translate(x, y); + g.fillStyle = 'rgba(0,0,0,.25)'; + g.beginPath(); g.arc(2, 3, r * 0.98, 0, Math.PI * 2); g.fill(); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2 + Math.PI / 6; + const px = Math.cos(a) * r * 0.5, py = Math.sin(a) * r * 0.5; + g.fillStyle = c.dark; + g.beginPath(); g.arc(px, py, r * 0.5, 0, Math.PI * 2); g.fill(); + g.fillStyle = c.petal; + g.beginPath(); g.arc(px * 0.96, py * 0.96, r * 0.44, 0, Math.PI * 2); g.fill(); + } + g.fillStyle = 'rgba(255,255,255,.35)'; + g.beginPath(); g.arc(-r * 0.25, -r * 0.35, r * 0.2, 0, Math.PI * 2); g.fill(); + const grad = g.createRadialGradient(-r * 0.1, -r * 0.1, 1, 0, 0, r * 0.38); + grad.addColorStop(0, '#fff3a0'); grad.addColorStop(1, '#f2b400'); + g.fillStyle = grad; + g.beginPath(); g.arc(0, 0, r * 0.36, 0, Math.PI * 2); g.fill(); + g.strokeStyle = 'rgba(120,70,0,.5)'; g.lineWidth = 1.5; g.stroke(); + g.restore(); + } + function drawFlower(x, y, colour, s = 1, alpha = 1, rot = 0) { + const sp = flowerSprites[colour]; + if (!sp) return; + const size = D * 1.1 * s; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.translate(x, y); + if (rot) ctx.rotate(rot); + ctx.drawImage(sp, -size / 2, -size / 2, size, size); + ctx.restore(); + } + + // ---------- sound ---------- + let audio = null; + function beep(freq, dur, type = 'sine', vol = 0.08, slide = 0) { + if (!save.sound) return; + try { + audio = audio || new (window.AudioContext || window.webkitAudioContext)(); + const o = audio.createOscillator(), g = audio.createGain(); + o.type = type; o.frequency.value = freq; + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), audio.currentTime + dur); + g.gain.value = vol; + g.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + dur); + o.connect(g); g.connect(audio.destination); + o.start(); o.stop(audio.currentTime + dur); + } catch (e) { /* audio not available */ } + } + const sfx = { + shoot: () => beep(520, 0.12, 'triangle', 0.06, 300), + stick: () => beep(220, 0.08, 'sine', 0.06), + pop: (n) => { for (let i = 0; i < Math.min(n, 5); i++) setTimeout(() => beep(600 + i * 120, 0.1, 'sine', 0.05), i * 45); }, + drop: () => beep(400, 0.35, 'triangle', 0.05, -300), + move: () => beep(880, 0.04, 'square', 0.02), + lid: () => beep(110, 0.4, 'sawtooth', 0.05, -40), + win: () => [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => beep(f, 0.18, 'triangle', 0.06), i * 120)), + lose: () => [392, 330, 262, 196].forEach((f, i) => setTimeout(() => beep(f, 0.22, 'sawtooth', 0.04), i * 150)), + boom: () => beep(90, 0.5, 'sawtooth', 0.09, -60), + }; + + // ---------- levels ---------- + function rng(seed) { + let s = (seed * 2654435761) >>> 0 || 1; + return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); + } + const cellsIn = (r) => (r % 2 ? COLS - 1 : COLS); + const SHAPES = [ + (r) => true, + (r, c, n) => { const w = cellsIn(r); return c >= Math.floor(r / 2) && c < w - Math.floor(r / 2); }, + (r, c, n) => { const heart = ['01100110', '11111111', '11111111', '01111110', '00111100', '00011000']; const row = heart[Math.min(r, 5)]; const w = cellsIn(r); const x = Math.round(((c + (r % 2 ? 0.5 : 0)) / (w - (r % 2 ? 0 : 1))) * 7); return r < 6 && row[x] === '1'; }, + (r, c) => r % 3 !== 2, + (r, c) => c % 3 !== 1, + (r, c) => (((r >> 1) + (c >> 1)) % 2) === 0, + (r, c) => c < 3 || c > cellsIn(r) - 4 || r < 2, + (r, c, n) => { const w = cellsIn(r); return r < 2 || c === 0 || c === w - 1 || r === n - 1 || (r > 3 && Math.abs(c - (w - 1) / 2) < 1.6); }, + (r, c, n) => Math.abs(c - (cellsIn(r) - 1) / 2) <= Math.min(r, n - 1 - r) + 1, + (r, c) => (r + c) % 4 !== 3, + (r, c, n) => c < 2 || c > cellsIn(r) - 3 || (r >= Math.floor(n / 2) - 1 && r <= Math.floor(n / 2)), + (r, c) => Math.abs(((c + r) % 6) - 3) > 0, + ]; + function levelInfo(n) { + const colours = Math.min(6, 3 + Math.floor((n - 1) / 15)); + const rows = Math.min(10, 4 + Math.floor((n - 1) / 8) + (n % 3 === 0 ? 1 : 0)); + const shape = n <= 3 ? 0 : (n * 7) % SHAPES.length; + const panic = Math.max(7, 20 - ((n - 1) / 120) * 13); // seconds until the ceiling pushes down + return { colours, rows, shape, panic }; + } + function buildLevel(n) { + const info = levelInfo(n); + const rand = rng(n * 7919 + 13); + const grid = emptyGrid(); + const pick = SHAPES[info.shape]; + let count = 0; + for (let r = 0; r < info.rows; r++) { + for (let c = 0; c < cellsIn(r); c++) { + if (!pick(r, c, info.rows)) continue; + let col = Math.floor(rand() * info.colours); + // Clump colours a little so there are matches to find. + if (rand() < 0.45) { + const left = c > 0 ? grid[r][c - 1] : -1; + const up = r > 0 ? grid[r - 1][Math.min(c, cellsIn(r - 1) - 1)] : -1; + if (left >= 0 && rand() < 0.6) col = left; else if (up >= 0) col = up; + } + grid[r][c] = col; + count++; + } + } + if (count < 8) for (let r = 0; r < 3; r++) for (let c = 0; c < cellsIn(r); c++) grid[r][c] = Math.floor(rand() * info.colours); + // Keep only flowers that hang from the ceiling. + const keep = attachedSet(grid); + forEachCell(grid, (r, c) => { if (grid[r][c] >= 0 && !keep.has(key(r, c))) grid[r][c] = -1; }); + return { grid, info }; + } + + // ---------- grid helpers ---------- + function emptyGrid() { const g = []; for (let r = 0; r < MAXROWS; r++) g.push(new Array(cellsIn(r)).fill(-1)); return g; } + const key = (r, c) => r * 32 + c; + function forEachCell(g, fn) { for (let r = 0; r < MAXROWS; r++) for (let c = 0; c < cellsIn(r); c++) fn(r, c); } + function cellPos(r, c) { return { x: FX + R + c * D + (r % 2 ? R : 0), y: game.top + R + r * RH }; } + function neighbours(r, c) { + const odd = r % 2 === 1; + const list = odd + ? [[r - 1, c], [r - 1, c + 1], [r, c - 1], [r, c + 1], [r + 1, c], [r + 1, c + 1]] + : [[r - 1, c - 1], [r - 1, c], [r, c - 1], [r, c + 1], [r + 1, c - 1], [r + 1, c]]; + return list.filter(([a, b]) => a >= 0 && a < MAXROWS && b >= 0 && b < cellsIn(a)); + } + function attachedSet(g) { + const seen = new Set(), stack = []; + for (let c = 0; c < cellsIn(0); c++) if (g[0][c] >= 0) { seen.add(key(0, c)); stack.push([0, c]); } + while (stack.length) { + const [r, c] = stack.pop(); + for (const [a, b] of neighbours(r, c)) { + const k = key(a, b); + if (!seen.has(k) && g[a][b] >= 0) { seen.add(k); stack.push([a, b]); } + } + } + return seen; + } + function cluster(g, r, c) { + const col = g[r][c], seen = new Set([key(r, c)]), out = [[r, c]], stack = [[r, c]]; + while (stack.length) { + const [a, b] = stack.pop(); + for (const [x, y] of neighbours(a, b)) { + const k = key(x, y); + if (!seen.has(k) && g[x][y] === col) { seen.add(k); out.push([x, y]); stack.push([x, y]); } + } + } + return out; + } + function coloursLeft(g) { const s = new Set(); forEachCell(g, (r, c) => { if (g[r][c] >= 0) s.add(g[r][c]); }); return [...s]; } + function flowerCount(g) { let n = 0; forEachCell(g, (r, c) => { if (g[r][c] >= 0) n++; }); return n; } + + // ---------- game state ---------- + const game = { + level: 1, grid: null, info: null, top: TOP0, angle: 0, aimVel: 0, + current: 0, next: 0, shot: null, effects: [], score: 0, panic: 0, drops: 0, + swaps: 5, bombArmed: false, time: 0, bannerT: 0, banner: '', stars: 0, + }; + let screen = 'title'; + let focus = 0; + let wheel = { angle: 0, speed: 0, prize: null, spun: false }; + + function startLevel(n) { + const lv = buildLevel(n); + Object.assign(game, { + level: n, grid: lv.grid, info: lv.info, top: TOP0, angle: 0, aimVel: 0, shot: null, effects: [], + score: 0, panic: 0, drops: 0, swaps: 5, bombArmed: false, time: 0, banner: `Level ${n}`, bannerT: 1.6, + }); + game.current = randomColour(); + game.next = randomColour(); + setScreen('play'); + } + function randomColour() { + const left = coloursLeft(game.grid); + const pool = left.length ? left : [0, 1, 2]; + return pool[Math.floor(Math.random() * pool.length)]; + } + + // ---------- shooting ---------- + function fire() { + if (game.shot || screen !== 'play') return; + const a = game.angle; + game.shot = { x: LAUNCH.x, y: LAUNCH.y - 28, vx: Math.sin(a) * SHOT_SPEED, vy: -Math.cos(a) * SHOT_SPEED, colour: game.current, bomb: game.bombArmed }; + if (game.bombArmed) { game.bombArmed = false; save.bombs = Math.max(0, save.bombs - 1); persist(); } + game.current = game.next; + game.next = randomColour(); + sfx.shoot(); + } + function swap() { + if (screen !== 'play' || game.shot || game.swaps <= 0) return; + [game.current, game.next] = [game.next, game.current]; + game.swaps--; + sfx.move(); + } + function toggleBomb() { + if (screen !== 'play' || game.shot) return; + if (!game.bombArmed && save.bombs <= 0) { game.banner = 'No Honeybombs left'; game.bannerT = 1.2; return; } + game.bombArmed = !game.bombArmed; + sfx.move(); + } + + function updateShot(dt) { + const s = game.shot; + if (!s) return; + let dist = SHOT_SPEED * dt; + while (dist > 0 && game.shot) { + const step = Math.min(dist, 6); + dist -= step; + const k = step / SHOT_SPEED; + s.x += s.vx * k; s.y += s.vy * k; + if (s.x < FX + R) { s.x = FX + R + (FX + R - s.x); s.vx = Math.abs(s.vx); beep(300, 0.03, 'square', 0.02); } + if (s.x > FX2 - R) { s.x = FX2 - R - (s.x - (FX2 - R)); s.vx = -Math.abs(s.vx); beep(300, 0.03, 'square', 0.02); } + if (s.y - R <= game.top) { land(s); return; } + let hit = false; + forEachCell(game.grid, (r, c) => { + if (hit || game.grid[r][c] < 0) return; + const p = cellPos(r, c); + const dx = p.x - s.x, dy = p.y - s.y; + if (dx * dx + dy * dy < (D - 6) * (D - 6)) hit = true; + }); + if (hit) { land(s); return; } + } + } + + function land(s) { + game.shot = null; + // Choose the nearest free cell that touches the ceiling or another flower. + let best = null, bestD = Infinity; + const approxR = Math.round((s.y - game.top - R) / RH); + for (let r = Math.max(0, approxR - 1); r <= Math.min(MAXROWS - 1, approxR + 1); r++) { + for (let c = 0; c < cellsIn(r); c++) { + if (game.grid[r][c] >= 0) continue; + const supported = r === 0 || neighbours(r, c).some(([a, b]) => game.grid[a][b] >= 0); + if (!supported) continue; + const p = cellPos(r, c); + const d = (p.x - s.x) ** 2 + (p.y - s.y) ** 2; + if (d < bestD) { bestD = d; best = [r, c]; } + } + } + if (!best) { best = [0, Math.max(0, Math.min(COLS - 1, Math.round((s.x - FX - R) / D)))]; } + const [r, c] = best; + game.grid[r][c] = s.colour; + sfx.stick(); + + let popped = []; + if (s.bomb) { + const centre = cellPos(r, c); + forEachCell(game.grid, (a, b) => { + if (game.grid[a][b] < 0) return; + const p = cellPos(a, b); + if ((p.x - centre.x) ** 2 + (p.y - centre.y) ** 2 <= (D * 2.1) ** 2) popped.push([a, b]); + }); + sfx.boom(); + game.effects.push({ type: 'ring', x: centre.x, y: centre.y, t: 0 }); + } else { + const group = cluster(game.grid, r, c); + if (group.length >= 3) popped = group; + } + if (popped.length) { + for (const [a, b] of popped) { + const p = cellPos(a, b); + game.effects.push({ type: 'pop', x: p.x, y: p.y, colour: game.grid[a][b], t: 0 }); + game.grid[a][b] = -1; + } + game.score += popped.length * 10; + sfx.pop(popped.length); + // Anything no longer hanging from the ceiling falls away. + const keep = attachedSet(game.grid); + let fallen = 0; + forEachCell(game.grid, (a, b) => { + if (game.grid[a][b] >= 0 && !keep.has(key(a, b))) { + const p = cellPos(a, b); + game.effects.push({ type: 'fall', x: p.x, y: p.y, vx: (Math.random() - 0.5) * 160, vy: -120 - Math.random() * 120, rot: 0, vr: (Math.random() - 0.5) * 8, colour: game.grid[a][b], t: 0 }); + game.grid[a][b] = -1; + fallen++; + } + }); + if (fallen) { + const bonus = 20 * fallen * fallen; + game.score += bonus; + game.effects.push({ type: 'text', x: s.x, y: s.y, text: `+${bonus}`, t: 0 }); + sfx.drop(); + } + } + if (flowerCount(game.grid) === 0) return win(); + checkBreach(); + if (!coloursLeft(game.grid).includes(game.current)) game.current = randomColour(); + if (!coloursLeft(game.grid).includes(game.next)) game.next = randomColour(); + } + + function checkBreach() { + let breached = false; + forEachCell(game.grid, (r, c) => { if (game.grid[r][c] >= 0 && cellPos(r, c).y + R > DEADLINE) breached = true; }); + if (breached) lose(); + } + + function win() { + game.score += 1000 + Math.round((1 - game.panic) * 500); + game.stars = game.drops <= 1 ? 3 : game.drops <= 3 ? 2 : 1; + save.stars[game.level] = Math.max(save.stars[game.level] || 0, game.stars); + save.best[game.level] = Math.max(save.best[game.level] || 0, game.score); + save.unlocked = Math.max(save.unlocked, Math.min(LEVELS, game.level + 1)); + persist(); + sfx.win(); + setScreen('win'); + } + function lose() { sfx.lose(); setScreen('lose'); } + + // ---------- menus ---------- + function buttonsFor(name) { + const B = (label, x, y, w, h, act, extra) => Object.assign({ label, x, y, w, h, act }, extra || {}); + switch (name) { + case 'title': { + const list = [B('PLAY', 520, 330, 240, 64, () => startLevel(Math.min(save.unlocked, LEVELS))), B('LEVELS', 520, 410, 240, 64, () => setScreen('levels')), B('HELP & SETTINGS', 520, 490, 240, 64, () => setScreen('help'))]; + if (save.lastSpin !== today()) list.push(B('DAILY PRIZE', 520, 570, 240, 64, () => { wheel = { angle: wheel.angle, speed: 0, prize: null, spun: false }; setScreen('daily'); }, { glow: true })); + return list; + } + case 'levels': { + const list = []; + const page = save.page; + for (let i = 0; i < PER_PAGE; i++) { + const n = page * PER_PAGE + i + 1; + if (n > LEVELS) break; + const col = i % 5, row = Math.floor(i / 5); + list.push(B(String(n), 400 + col * 110 + (row % 2 ? 55 : 0) - 27, 170 + row * 100, 100, 92, () => { if (n <= save.unlocked) startLevel(n); else sfx.lose(); }, { hex: true, level: n })); + } + list.push(B('◀', 260, 330, 80, 80, () => { save.page = Math.max(0, save.page - 1); persist(); refreshButtons(); }, { round: true })); + list.push(B('▶', 940, 330, 80, 80, () => { save.page = Math.min(Math.ceil(LEVELS / PER_PAGE) - 1, save.page + 1); persist(); refreshButtons(); }, { round: true })); + list.push(B('BACK', 540, 600, 200, 56, () => setScreen('title'))); + return list; + } + case 'help': + return [ + B(`SKIN: ${save.skin === 'orbit' ? 'ORBIT' : 'CLASSIC'}`, 210, 560, 260, 56, () => { save.skin = save.skin === 'orbit' ? 'classic' : 'orbit'; skyCache = null; persist(); refreshButtons(); }), + B(`GUIDE LINE: ${save.guide ? 'ON' : 'OFF'}`, 510, 560, 260, 56, () => { save.guide = !save.guide; persist(); refreshButtons(); }), + B(`SOUND: ${save.sound ? 'ON' : 'OFF'}`, 810, 560, 260, 56, () => { save.sound = !save.sound; persist(); refreshButtons(); }), + B('BACK', 540, 632, 200, 56, () => setScreen('title')), + ]; + case 'pause': + return [B('RESUME', 520, 300, 240, 60, () => setScreen('play')), B('RESTART', 520, 375, 240, 60, () => startLevel(game.level)), B('LEVELS', 520, 450, 240, 60, () => setScreen('levels')), B('MENU', 520, 525, 240, 60, () => setScreen('title'))]; + case 'win': + return [ + B('NEXT LEVEL', 520, 430, 240, 60, () => startLevel(Math.min(LEVELS, game.level + 1))), + B('REPLAY', 520, 505, 240, 60, () => startLevel(game.level)), + B('LEVELS', 520, 580, 240, 60, () => setScreen('levels')), + ]; + case 'lose': + return [B('TRY AGAIN', 520, 430, 240, 60, () => startLevel(game.level)), B('LEVELS', 520, 505, 240, 60, () => setScreen('levels')), B('MENU', 520, 580, 240, 60, () => setScreen('title'))]; + case 'daily': + return wheel.spun + ? [B('COLLECT', 520, 610, 240, 60, () => setScreen('title'))] + : [B('SPIN', 520, 610, 240, 60, spinWheel)]; + default: return []; + } + } + let buttons = []; + function refreshButtons() { buttons = buttonsFor(screen); focus = Math.min(focus, Math.max(0, buttons.length - 1)); } + function setScreen(name) { + screen = name; + buttons = buttonsFor(name); + focus = 0; + if (name === 'levels') { + const want = Math.min(save.unlocked, LEVELS); + const idx = buttons.findIndex((b) => b.level === want); + if (idx >= 0) focus = idx; else { save.page = Math.floor((want - 1) / PER_PAGE); buttons = buttonsFor(name); focus = Math.max(0, buttons.findIndex((b) => b.level === want)); } + } + } + function moveFocus(dx, dy) { + if (!buttons.length) return; + const cur = buttons[focus]; + const cx = cur.x + cur.w / 2, cy = cur.y + cur.h / 2; + let best = -1, bestScore = Infinity; + buttons.forEach((b, i) => { + if (i === focus) return; + const bx = b.x + b.w / 2 - cx, by = b.y + b.h / 2 - cy; + const along = bx * dx + by * dy; + if (along <= 4) return; + const across = Math.abs(bx * dy - by * dx); + const score = along + across * 2.2; + if (score < bestScore) { bestScore = score; best = i; } + }); + if (best >= 0) { focus = best; sfx.move(); } + } + + // ---------- daily prize wheel ---------- + const PRIZES = [1, 2, 1, 3, 1, 2, 1, 5]; + function spinWheel() { + if (wheel.speed > 0 || wheel.spun) return; + wheel.speed = 14 + Math.random() * 6; + sfx.shoot(); + } + function updateWheel(dt) { + if (wheel.speed <= 0) return; + wheel.angle += wheel.speed * dt; + wheel.speed = Math.max(0, wheel.speed - 6 * dt); + if (Math.floor(wheel.angle * 8 / (Math.PI * 2)) !== Math.floor((wheel.angle - wheel.speed * dt) * 8 / (Math.PI * 2))) beep(1200, 0.02, 'square', 0.02); + if (wheel.speed === 0) { + const seg = PRIZES.length; + // The pointer sits at the top of the wheel; find which segment is under it. + const a = (((-Math.PI / 2 - wheel.angle) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + const idx = Math.floor(a / ((Math.PI * 2) / seg)) % seg; + wheel.prize = PRIZES[idx]; + wheel.spun = true; + save.bombs += wheel.prize; + save.lastSpin = today(); + persist(); + sfx.win(); + refreshButtons(); + } + } + + // ---------- input ---------- + const held = { left: false, right: false }; + let heldTime = 0; + function onAction(action) { + if (screen === 'play') { + if (action === 'ok') fire(); + else if (action === 'down') swap(); + else if (action === 'up') toggleBomb(); + else if (action === 'back') setScreen('pause'); + return; + } + if (action === 'left') moveFocus(-1, 0); + else if (action === 'right') moveFocus(1, 0); + else if (action === 'up') moveFocus(0, -1); + else if (action === 'down') moveFocus(0, 1); + else if (action === 'ok') { const b = buttons[focus]; if (b) b.act(); } + else if (action === 'back') { + if (screen === 'pause') setScreen('play'); + else if (screen === 'levels' || screen === 'help' || screen === 'daily') setScreen('title'); + else if (screen === 'win' || screen === 'lose') setScreen('levels'); + else if (screen === 'title' && window.OrbitApp) window.OrbitApp.leave(); + } + } + function keyAction(e) { + const k = e.key, code = e.keyCode; + if (k === 'ArrowLeft' || code === 21) return 'left'; + if (k === 'ArrowRight' || code === 22) return 'right'; + if (k === 'ArrowUp' || code === 19) return 'up'; + if (k === 'ArrowDown' || code === 20) return 'down'; + if (k === 'Enter' || k === ' ' || k === 'Select' || code === 23 || code === 66 || code === 13) return 'ok'; + if (k === 'Escape' || k === 'Backspace' || k === 'GoBack' || k === 'BrowserBack' || code === 4 || code === 27 || code === 8) return 'back'; + if (k === 'MediaPlayPause' || k === 'ContextMenu' || k === 'p' || code === 179 || code === 82) return 'pause'; + if (k === 's') return 'down'; + if (k === 'b') return 'up'; + return null; + } + addEventListener('keydown', (e) => { + const a = keyAction(e); + if (!a) return; + e.preventDefault(); + if (a === 'pause') { if (screen === 'play') setScreen('pause'); else if (screen === 'pause') setScreen('play'); return; } + if (screen === 'play' && (a === 'left' || a === 'right')) { + if (!held[a]) { held[a] = true; heldTime = 0; game.angle += (a === 'left' ? -1 : 1) * 0.012; } + return; + } + if (e.repeat && a === 'ok') return; + onAction(a); + }); + addEventListener('keyup', (e) => { + const a = keyAction(e); + if (a === 'left' || a === 'right') held[a] = false; + }); + addEventListener('blur', () => { held.left = held.right = false; if (screen === 'play') setScreen('pause'); }); + + function toGame(ev) { + const rect = canvas.getBoundingClientRect(); + return { x: ((ev.clientX - rect.left) * dpr - ox) / scale, y: ((ev.clientY - rect.top) * dpr - oy) / scale }; + } + function hitButton(p) { return buttons.findIndex((b) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h); } + function aimAt(p) { + if (p.y > LAUNCH.y - 10) return; + game.angle = clampAngle(Math.atan2(p.x - LAUNCH.x, LAUNCH.y - p.y)); + } + let pointerDown = false; + canvas.addEventListener('pointermove', (ev) => { + const p = toGame(ev); + if (screen === 'play') { if (ev.pointerType === 'mouse' || pointerDown) aimAt(p); return; } + const i = hitButton(p); + if (i >= 0 && i !== focus) focus = i; + }); + canvas.addEventListener('pointerdown', (ev) => { + canvas.focus(); + pointerDown = true; + const p = toGame(ev); + if (screen === 'play') { + if (hudHit(p)) return; + aimAt(p); + if (ev.pointerType === 'mouse') fire(); + return; + } + const i = hitButton(p); + if (i >= 0) { focus = i; buttons[i].act(); } + }); + canvas.addEventListener('pointerup', (ev) => { + if (screen === 'play' && pointerDown && ev.pointerType !== 'mouse') { + const p = toGame(ev); + if (!hudHit(p) && p.y < LAUNCH.y - 10) fire(); + } + pointerDown = false; + }); + function hudHit(p) { + if (p.y < 668) { + if (p.x > 1150 && p.y < 80) { setScreen('pause'); return true; } + return false; + } + if (p.x > 860 && p.x < 990) { swap(); return true; } + if (p.x > 990 && p.x < 1130) { toggleBomb(); return true; } + if (p.x > 1150) { setScreen('pause'); return true; } + return false; + } + + // Game pads (Android TV controllers). + const padPrev = {}; + function pollPads() { + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + for (const pad of pads) { + if (!pad) continue; + const btn = (i) => !!(pad.buttons[i] && pad.buttons[i].pressed); + const ax = pad.axes[0] || 0; + const state = { + left: btn(14) || ax < -0.5, right: btn(15) || ax > 0.5, up: btn(12) || btn(3), down: btn(13) || btn(2), + ok: btn(0) || btn(7), back: btn(1), pause: btn(9), + }; + for (const k in state) { + const was = padPrev[pad.index + k]; + if (state[k] && !was) { + if (screen === 'play' && (k === 'left' || k === 'right')) { held[k] = true; heldTime = 0; } + else if (k === 'pause') onAction(screen === 'play' ? 'back' : 'ok'); + else onAction(k); + } + if (!state[k] && was && (k === 'left' || k === 'right')) held[k] = false; + padPrev[pad.index + k] = state[k]; + } + } + } + + // ---------- update ---------- + const MAX_ANGLE = 1.38; + const clampAngle = (a) => Math.max(-MAX_ANGLE, Math.min(MAX_ANGLE, a)); + function update(dt) { + pollPads(); + game.time += dt; + if (screen === 'daily') updateWheel(dt); + if (screen !== 'play') { updateEffects(dt); return; } + // Aim with weight: slow at first, faster the longer the button is held. + const dir = (held.right ? 1 : 0) - (held.left ? 1 : 0); + if (dir) { + heldTime += dt; + const speed = 0.45 + Math.min(1, heldTime / 0.7) * 1.35; + game.angle = clampAngle(game.angle + dir * speed * dt); + } else heldTime = 0; + + updateShot(dt); + if (screen !== 'play') return; + + // Panic bar: when it fills, the ceiling pushes down one row. + game.panic += dt / game.info.panic; + if (game.panic >= 1) { + game.panic = 0; + game.top += RH; + game.drops++; + game.effects.push({ type: 'shake', t: 0 }); + sfx.lid(); + checkBreach(); + } + if (game.bannerT > 0) game.bannerT -= dt; + updateEffects(dt); + } + function updateEffects(dt) { + for (const f of game.effects) { + f.t += dt; + if (f.type === 'fall') { f.vy += 1400 * dt; f.x += f.vx * dt; f.y += f.vy * dt; f.rot += f.vr * dt; } + } + game.effects = game.effects.filter((f) => (f.type === 'fall' ? f.y < H + 60 : f.t < (f.type === 'text' ? 0.9 : f.type === 'ring' ? 0.5 : f.type === 'shake' ? 0.3 : 0.35))); + } + + // ---------- drawing ---------- + let skyCache = null; + // The Orbit skin: deep space, a ringed planet and a slow twinkle, kept dark so the flowers stand out. + const STARS = Array.from({ length: 150 }, (_, i) => ({ x: (i * 397) % W, y: (i * 211) % H, r: 0.6 + (i % 4) * 0.5, phase: i * 0.7 })); + function drawSpace() { + if (!skyCache) { + skyCache = document.createElement('canvas'); + skyCache.width = W; skyCache.height = H; + const g = skyCache.getContext('2d'); + const grad = g.createLinearGradient(0, 0, W, H); + grad.addColorStop(0, '#040a1e'); grad.addColorStop(0.6, '#0b1538'); grad.addColorStop(1, '#2a1760'); + g.fillStyle = grad; g.fillRect(0, 0, W, H); + const nebula = g.createRadialGradient(1080, 180, 20, 1080, 180, 460); + nebula.addColorStop(0, 'rgba(160,80,255,.22)'); nebula.addColorStop(1, 'rgba(160,80,255,0)'); + g.fillStyle = nebula; g.fillRect(0, 0, W, H); + const planet = g.createRadialGradient(1090, 590, 18, 1130, 620, 150); + planet.addColorStop(0, '#6c8cff'); planet.addColorStop(0.7, '#3a3fa8'); planet.addColorStop(1, '#1a1650'); + g.save(); g.translate(1130, 620); g.rotate(-0.24); + g.strokeStyle = 'rgba(255,216,74,.4)'; g.lineWidth = 7; + g.beginPath(); g.ellipse(0, 0, 215, 44, 0, Math.PI, Math.PI * 2); g.stroke(); + g.rotate(0.24); g.fillStyle = planet; g.beginPath(); g.arc(0, 0, 120, 0, Math.PI * 2); g.fill(); g.rotate(-0.24); + g.beginPath(); g.ellipse(0, 0, 215, 44, 0, 0, Math.PI); g.stroke(); + g.restore(); + g.fillStyle = '#c9d4e8'; g.beginPath(); g.arc(150, 140, 34, 0, Math.PI * 2); g.fill(); + g.fillStyle = '#aab6cc'; g.beginPath(); g.arc(140, 130, 7, 0, Math.PI * 2); g.arc(163, 150, 5, 0, Math.PI * 2); g.fill(); + } + ctx.drawImage(skyCache, 0, 0); + for (const s of STARS) { + ctx.fillStyle = `rgba(255,255,255,${0.35 + 0.35 * Math.sin(game.time * 1.4 + s.phase)})`; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); + } + } + + function drawSky() { + if (save.skin === 'orbit') { drawSpace(); return; } + if (!skyCache) { + skyCache = document.createElement('canvas'); + skyCache.width = W; skyCache.height = H; + const g = skyCache.getContext('2d'); + const grad = g.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, '#3d8fd6'); grad.addColorStop(0.7, '#8fd0f5'); grad.addColorStop(1, '#bfe6f7'); + g.fillStyle = grad; g.fillRect(0, 0, W, H); + g.fillStyle = '#6cbf43'; + g.beginPath(); g.moveTo(0, 560); g.quadraticCurveTo(250, 480, 520, 560); g.quadraticCurveTo(800, 640, 1280, 520); g.lineTo(1280, 720); g.lineTo(0, 720); g.fill(); + g.fillStyle = '#4f9e32'; + g.beginPath(); g.moveTo(0, 640); g.quadraticCurveTo(400, 590, 760, 650); g.quadraticCurveTo(1000, 690, 1280, 620); g.lineTo(1280, 720); g.lineTo(0, 720); g.fill(); + for (let i = 0; i < 26; i++) { + const x = (i * 97) % W, y = 600 + ((i * 53) % 100); + g.fillStyle = ['#ffd54a', '#ffffff', '#e57373'][i % 3]; + g.beginPath(); g.arc(x, y, 5, 0, Math.PI * 2); g.fill(); + } + } + ctx.drawImage(skyCache, 0, 0); + // Turning sun rays. + ctx.save(); + ctx.translate(170, 120); + ctx.rotate(game.time * 0.05); + ctx.fillStyle = 'rgba(255,246,201,.28)'; + for (let i = 0; i < 14; i++) { + ctx.rotate((Math.PI * 2) / 14); + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(1400, -110); ctx.lineTo(1400, 110); ctx.fill(); + } + ctx.restore(); + ctx.fillStyle = '#ffd54a'; + ctx.beginPath(); ctx.arc(170, 120, 62, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#f2a900'; + ctx.beginPath(); ctx.arc(150, 112, 6, 0, Math.PI * 2); ctx.arc(190, 112, 6, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#f2a900'; ctx.lineWidth = 4; + ctx.beginPath(); ctx.arc(170, 128, 22, 0.2 * Math.PI, 0.8 * Math.PI); ctx.stroke(); + } + + function hexPath(x, y, r) { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const a = Math.PI / 6 + (i * Math.PI) / 3; + const px = x + Math.cos(a) * r, py = y + Math.sin(a) * r; + if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); + } + ctx.closePath(); + } + + function drawHive() { + // Wooden frame. + ctx.fillStyle = '#7a4a22'; + roundRect(FX - 26, 8, FW + 52, 668, 18); ctx.fill(); + const wood = ctx.createLinearGradient(FX - 26, 0, FX2 + 26, 0); + wood.addColorStop(0, '#9b6531'); wood.addColorStop(0.5, '#c48848'); wood.addColorStop(1, '#9b6531'); + ctx.fillStyle = wood; + roundRect(FX - 20, 14, FW + 40, 656, 14); ctx.fill(); + // Honeycomb interior. + ctx.fillStyle = '#1d1209'; + ctx.fillRect(FX, TOP0 - 6, FW, 680 - TOP0 - 6 - 8); + ctx.save(); + ctx.beginPath(); ctx.rect(FX, TOP0 - 6, FW, 680 - TOP0 - 14); ctx.clip(); + ctx.strokeStyle = '#4a2f16'; ctx.lineWidth = 2; + ctx.fillStyle = '#2a1a0e'; + for (let r = -1; r < 16; r++) { + for (let c = 0; c < 12; c++) { + const x = FX + R + c * D + (Math.abs(r) % 2 ? R : 0), y = TOP0 + R + r * RH; + hexPath(x, y, R + 1); ctx.fill(); ctx.stroke(); + } + } + ctx.restore(); + // Lid that pushes down. + const lidH = game.top - TOP0; + const lid = ctx.createLinearGradient(0, 14, 0, game.top); + lid.addColorStop(0, '#b77b3e'); lid.addColorStop(1, '#8a5a2b'); + ctx.fillStyle = lid; + ctx.fillRect(FX - 6, 14, FW + 12, game.top - 14); + ctx.fillStyle = '#6d4220'; + ctx.fillRect(FX - 6, game.top - 6, FW + 12, 6); + ctx.strokeStyle = 'rgba(0,0,0,.18)'; ctx.lineWidth = 2; + for (let y = 30; y < game.top - 8; y += 18) { ctx.beginPath(); ctx.moveTo(FX, y); ctx.lineTo(FX2, y); ctx.stroke(); } + if (lidH > 0) { + ctx.fillStyle = 'rgba(255,255,255,.18)'; + ctx.font = `700 18px ${FONT}`; ctx.textAlign = 'center'; + ctx.fillText('▼'.repeat(Math.min(game.drops, 8)), W / 2, game.top - 14); + } + // Launcher line. + ctx.strokeStyle = 'rgba(255,213,74,.35)'; ctx.setLineDash([10, 8]); ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(FX, DEADLINE); ctx.lineTo(FX2, DEADLINE); ctx.stroke(); + ctx.setLineDash([]); + } + + function roundRect(x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); + } + + function drawGrid() { + forEachCell(game.grid, (r, c) => { + const col = game.grid[r][c]; + if (col < 0) return; + const p = cellPos(r, c); + drawFlower(p.x, p.y, col); + }); + } + + function guidePoints() { + const pts = [{ x: LAUNCH.x, y: LAUNCH.y - 28 }]; + let x = LAUNCH.x, y = LAUNCH.y - 28, vx = Math.sin(game.angle), vy = -Math.cos(game.angle); + for (let i = 0; i < 700; i++) { + x += vx * 4; y += vy * 4; + if (x < FX + R) { x = FX + R; vx = -vx; pts.push({ x, y }); } + if (x > FX2 - R) { x = FX2 - R; vx = -vx; pts.push({ x, y }); } + if (y - R <= game.top) break; + let hit = false; + forEachCell(game.grid, (r, c) => { + if (hit || game.grid[r][c] < 0) return; + const p = cellPos(r, c); + if ((p.x - x) ** 2 + (p.y - y) ** 2 < (D - 6) ** 2) hit = true; + }); + if (hit) break; + } + pts.push({ x, y }); + return pts; + } + + function drawLauncher() { + if (save.guide && !game.shot) { + const pts = guidePoints(); + ctx.save(); + ctx.strokeStyle = 'rgba(255,240,180,.75)'; ctx.lineWidth = 3; ctx.setLineDash([4, 12]); + ctx.lineDashOffset = -game.time * 40; + ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); + for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y); + ctx.stroke(); + ctx.restore(); + } + // Pendulum launcher. + ctx.save(); + ctx.translate(LAUNCH.x, LAUNCH.y); + ctx.rotate(game.angle); + ctx.strokeStyle = '#e8a33a'; ctx.lineWidth = 5; ctx.lineJoin = 'round'; + ctx.beginPath(); ctx.moveTo(-16, 8); ctx.lineTo(0, -70); ctx.lineTo(16, 8); ctx.stroke(); + ctx.fillStyle = '#ffd54a'; + ctx.beginPath(); ctx.arc(0, -70, 5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + if (!game.shot) { + if (game.bombArmed) drawBomb(LAUNCH.x, LAUNCH.y - 28, 1 + Math.sin(game.time * 8) * 0.05); + else drawFlower(LAUNCH.x, LAUNCH.y - 28, game.current); + } + drawBee(LAUNCH.x - 92, LAUNCH.y - 8 + Math.sin(game.time * 3) * 4, 1.15, game.angle * 0.3); + } + + function drawBomb(x, y, s = 1) { + ctx.save(); + ctx.translate(x, y); ctx.scale(s, s); + ctx.fillStyle = '#f2a900'; + hexPath(0, 0, R); ctx.fill(); + ctx.strokeStyle = '#7a4400'; ctx.lineWidth = 3; ctx.stroke(); + ctx.fillStyle = '#7a4400'; + ctx.font = `700 22px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('✸', 0, 1); + ctx.restore(); + } + + function drawBee(x, y, s, tilt = 0) { + ctx.save(); + ctx.translate(x, y); ctx.scale(s, s); ctx.rotate(tilt); + const flap = Math.sin(game.time * 30) * 0.25; + ctx.fillStyle = 'rgba(227,242,253,.85)'; + ctx.save(); ctx.rotate(-0.5 + flap); ctx.beginPath(); ctx.ellipse(-12, -22, 16, 10, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); + ctx.save(); ctx.rotate(0.5 - flap); ctx.beginPath(); ctx.ellipse(12, -22, 16, 10, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); + ctx.fillStyle = '#ffcc1a'; ctx.strokeStyle = '#3b2a12'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.ellipse(0, 0, 22, 26, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.save(); ctx.beginPath(); ctx.ellipse(0, 0, 22, 26, 0, 0, Math.PI * 2); ctx.clip(); + ctx.fillStyle = '#3b2a12'; ctx.fillRect(-24, 4, 48, 6); ctx.fillRect(-24, 15, 48, 6); + ctx.restore(); + ctx.fillStyle = '#4fc3f7'; ctx.strokeStyle = '#1a237e'; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.arc(-8, -10, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.arc(8, -10, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(-10, -12, 2, 0, Math.PI * 2); ctx.arc(6, -12, 2, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#3b2a12'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(-5, -25); ctx.quadraticCurveTo(-10, -40, -18, -38); ctx.moveTo(5, -25); ctx.quadraticCurveTo(10, -40, 18, -38); ctx.stroke(); + ctx.beginPath(); ctx.arc(0, -1, 6, 0.15 * Math.PI, 0.85 * Math.PI); ctx.stroke(); + if (save.skin === 'orbit') { + // An astronaut's helmet over the head. + ctx.fillStyle = 'rgba(191,227,255,.2)'; ctx.strokeStyle = '#e8edf5'; ctx.lineWidth = 3.5; + ctx.beginPath(); ctx.arc(0, -10, 21, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.beginPath(); ctx.ellipse(-8, -20, 6, 3.5, -0.6, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#b7cbe8'; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(0, -10, 21, 0.28 * Math.PI, 0.72 * Math.PI); ctx.stroke(); + } + ctx.restore(); + } + + function drawEffects() { + for (const f of game.effects) { + if (f.type === 'pop') { + const k = f.t / 0.35; + drawFlower(f.x, f.y, f.colour, 1 + k * 0.8, 1 - k); + ctx.fillStyle = COLOURS[f.colour].petal; + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + ctx.globalAlpha = 1 - k; + ctx.beginPath(); ctx.arc(f.x + Math.cos(a) * k * 50, f.y + Math.sin(a) * k * 50, 6 * (1 - k), 0, Math.PI * 2); ctx.fill(); + } + ctx.globalAlpha = 1; + } else if (f.type === 'fall') { + drawFlower(f.x, f.y, f.colour, 1, 1, f.rot); + } else if (f.type === 'text') { + ctx.globalAlpha = 1 - f.t / 0.9; + ctx.font = `700 30px ${FONT}`; ctx.textAlign = 'center'; + ctx.lineWidth = 5; ctx.strokeStyle = '#7a4400'; ctx.strokeText(f.text, f.x, f.y - f.t * 60); + ctx.fillStyle = '#ffd54a'; ctx.fillText(f.text, f.x, f.y - f.t * 60); + ctx.globalAlpha = 1; + } else if (f.type === 'ring') { + ctx.strokeStyle = `rgba(255,213,74,${1 - f.t / 0.5})`; ctx.lineWidth = 10; + ctx.beginPath(); ctx.arc(f.x, f.y, 20 + f.t * 300, 0, Math.PI * 2); ctx.stroke(); + } + } + } + + function text(str, x, y, size, colour = '#fff', align = 'center', weight = 700, stroke) { + ctx.font = `${weight} ${size}px ${FONT}`; + ctx.textAlign = align; ctx.textBaseline = 'alphabetic'; + if (stroke) { ctx.lineWidth = size / 6; ctx.strokeStyle = stroke; ctx.lineJoin = 'round'; ctx.strokeText(str, x, y); } + ctx.fillStyle = colour; ctx.fillText(str, x, y); + } + + function panel(x, y, w, h) { + ctx.fillStyle = 'rgba(70,40,15,.82)'; + roundRect(x, y, w, h, 16); ctx.fill(); + ctx.strokeStyle = '#f2a900'; ctx.lineWidth = 3; ctx.stroke(); + } + + function drawHud() { + // Bottom bar. + ctx.fillStyle = '#a86d34'; + roundRect(FX - 20, 668, FW + 40, 46, 10); ctx.fill(); + ctx.fillStyle = '#2a1a0e'; + roundRect(W / 2 - 90, 676, 180, 32, 6); ctx.fill(); + text(String(game.score).padStart(6, '0'), W / 2, 701, 24, '#fff', 'center', 700); + // Next flower. + text('NEXT', FX + 14, 699, 15, '#fff3c4', 'left', 700); + drawFlower(FX + 86, 691, game.next, 0.62); + // Swap and Honeybomb counters. + ctx.fillStyle = 'rgba(0,0,0,.25)'; + roundRect(FX2 - 196, 674, 92, 34, 8); ctx.fill(); + roundRect(FX2 - 98, 674, 92, 34, 8); ctx.fill(); + text(`⇅ ${game.swaps}`, FX2 - 150, 699, 18, '#fff', 'center', 700); + ctx.save(); ctx.translate(FX2 - 78, 691); ctx.scale(0.5, 0.5); drawBomb(0, 0, game.bombArmed ? 1.2 : 1); ctx.restore(); + text(`×${save.bombs}`, FX2 - 38, 699, 18, game.bombArmed ? '#ffd54a' : '#fff', 'center', 700); + + // Left panel: level, stars challenge, panic bar. + panel(80, 200, 250, 330); + text(`LEVEL ${game.level}`, 205, 248, 30, '#ffd54a', 'center', 700, '#7a4400'); + const target = game.drops <= 1 ? 3 : game.drops <= 3 ? 2 : 1; + text('★'.repeat(target) + '☆'.repeat(3 - target), 205, 292, 30, '#ffd54a'); + text('Star challenge: clear before', 205, 322, 14, '#fff3c4', 'center', 500); + text('the ceiling drops twice', 205, 340, 14, '#fff3c4', 'center', 500); + text('CEILING', 205, 380, 16, '#fff', 'center', 700); + ctx.fillStyle = '#2a1a0e'; + roundRect(110, 394, 190, 30, 15); ctx.fill(); + const pc = game.panic; + const barCol = pc > 0.8 ? (Math.floor(game.time * 8) % 2 ? '#e53935' : '#ff7043') : pc > 0.55 ? '#f5a623' : '#7cc242'; + ctx.fillStyle = barCol; + roundRect(114, 398, Math.max(22, 182 * pc), 22, 11); ctx.fill(); + text(`Drops: ${game.drops}`, 205, 460, 18, '#fff', 'center', 500); + text(`Best: ${save.best[game.level] || 0}`, 205, 494, 18, '#fff', 'center', 500); + + // Right panel: remote help. + panel(950, 200, 250, 330); + text('REMOTE', 1075, 244, 22, '#ffd54a', 'center', 700, '#7a4400'); + const rows = [['◀ ▶', 'Aim'], ['OK', 'Fire'], ['▼', 'Swap flower'], ['▲', 'Honeybomb'], ['BACK', 'Pause']]; + rows.forEach(([k, v], i) => { + ctx.fillStyle = 'rgba(255,255,255,.14)'; + roundRect(972, 268 + i * 50, 76, 34, 8); ctx.fill(); + text(k, 1010, 292 + i * 50, 16, '#fff', 'center', 700); + text(v, 1062, 292 + i * 50, 17, '#fff3c4', 'left', 500); + }); + // Pause button (touch). + ctx.fillStyle = 'rgba(0,0,0,.25)'; + roundRect(1176, 16, 84, 48, 12); ctx.fill(); + text('II', 1218, 50, 26, '#fff'); + + if (game.bannerT > 0) { + ctx.globalAlpha = Math.min(1, game.bannerT); + text(game.banner, W / 2, 360, 54, '#ffd54a', 'center', 700, '#7a4400'); + ctx.globalAlpha = 1; + } + } + + function drawButtons() { + buttons.forEach((b, i) => { + const on = i === focus; + ctx.save(); + if (b.hex) { + const locked = b.level > save.unlocked; + const cx = b.x + b.w / 2, cy = b.y + b.h / 2 - 8; + ctx.fillStyle = locked ? '#9ea7ad' : '#f7a823'; + hexPath(cx, cy, on ? 44 : 40); ctx.fill(); + ctx.lineWidth = on ? 6 : 3; ctx.strokeStyle = on ? '#ffffff' : locked ? '#59636a' : '#a35a00'; ctx.stroke(); + text(locked ? '🔒' : b.label, cx, cy + 10, locked ? 22 : 28, '#fff', 'center', 700, locked ? null : '#a35a00'); + const st = save.stars[b.level] || 0; + if (!locked) text('★'.repeat(st) + '☆'.repeat(3 - st), cx, cy + 50, 18, '#ffd54a', 'center', 700, '#7a4400'); + } else if (b.round) { + ctx.fillStyle = on ? '#ffd54a' : '#f7a823'; + ctx.beginPath(); ctx.arc(b.x + b.w / 2, b.y + b.h / 2, b.w / 2, 0, Math.PI * 2); ctx.fill(); + ctx.lineWidth = on ? 6 : 3; ctx.strokeStyle = on ? '#fff' : '#a35a00'; ctx.stroke(); + text(b.label, b.x + b.w / 2, b.y + b.h / 2 + 12, 32, '#fff', 'center', 700, '#a35a00'); + } else { + if (on) { ctx.shadowColor = 'rgba(255,255,255,.9)'; ctx.shadowBlur = 24; } + const g = ctx.createLinearGradient(0, b.y, 0, b.y + b.h); + g.addColorStop(0, on ? '#ffd27a' : '#fbbf47'); g.addColorStop(1, on ? '#f7a823' : '#e08c10'); + ctx.fillStyle = g; + roundRect(b.x, b.y, b.w, b.h, b.h / 2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.lineWidth = on ? 5 : 3; ctx.strokeStyle = on ? '#ffffff' : '#a35a00'; ctx.stroke(); + if (b.glow) { ctx.globalAlpha = 0.5 + Math.sin(game.time * 5) * 0.3; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; roundRect(b.x - 6, b.y - 6, b.w + 12, b.h + 12, b.h / 2 + 6); ctx.stroke(); ctx.globalAlpha = 1; } + text(b.label, b.x + b.w / 2, b.y + b.h / 2 + b.h * 0.15, Math.round(b.h * 0.4), '#fff', 'center', 700, '#a35a00'); + } + ctx.restore(); + }); + } + + function drawTitle() { + drawSky(); + const bob = Math.sin(game.time * 2) * 6; + text('Orbit', W / 2, 150 + bob, 110, '#ffc21a', 'center', 700, '#8a4b00'); + text('Hive', W / 2, 260 + bob, 110, '#ffc21a', 'center', 700, '#8a4b00'); + drawBee(900, 210 + Math.sin(game.time * 3) * 10, 2.4, Math.sin(game.time) * 0.1); + for (let i = 0; i < 5; i++) drawFlower(250 + i * 40, 620 + Math.sin(game.time * 2 + i) * 4, i % 5, 1); + text(`Honeybombs: ${save.bombs} • Levels unlocked: ${save.unlocked}/${LEVELS}`, W / 2, 700, 18, '#fff', 'center', 500, 'rgba(0,0,0,.35)'); + drawButtons(); + } + + function drawLevels() { + drawSky(); + text('Level Select', W / 2, 100, 64, '#ffc21a', 'center', 700, '#8a4b00'); + const pages = Math.ceil(LEVELS / PER_PAGE); + let stars = 0; for (const k in save.stars) stars += save.stars[k]; + text(`Page ${save.page + 1} of ${pages} • ★ ${stars}`, W / 2, 140, 20, '#fff', 'center', 500, 'rgba(0,0,0,.35)'); + drawButtons(); + } + + function drawHelp() { + drawSky(); + panel(220, 40, 840, 500); + text('How to play', W / 2, 100, 46, '#ffd54a', 'center', 700, '#7a4400'); + const lines = [ + 'Your bee fires flowers into the honeycomb.', + 'Join three or more of the same colour to burst them.', + 'Flowers that lose their hold on the ceiling fall away for big bonus points.', + 'The ceiling bar fills over time. When it is full, the ceiling pushes down a row.', + 'Clear every flower to win. If they reach the dashed line, you are swarmed!', + 'Finish before the ceiling drops twice for three stars.', + 'Remote: ◀ ▶ aim, OK fires, ▼ swaps your flower, ▲ arms a Honeybomb, BACK pauses.', + 'Change the skin below: Orbit or Classic.', + ]; + lines.forEach((l, i) => text(l, W / 2, 160 + i * 44, 21, '#fff', 'center', 500)); + drawButtons(); + } + + function drawWheel() { + drawSky(); + text('Daily Prize', W / 2, 90, 60, '#ffc21a', 'center', 700, '#8a4b00'); + const cx = W / 2, cy = 340, r = 210; + const seg = PRIZES.length; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(wheel.angle); + for (let i = 0; i < seg; i++) { + ctx.fillStyle = i % 2 ? '#f7a823' : '#ffd54a'; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, r, (i / seg) * Math.PI * 2, ((i + 1) / seg) * Math.PI * 2); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#a35a00'; ctx.lineWidth = 3; ctx.stroke(); + ctx.save(); + ctx.rotate(((i + 0.5) / seg) * Math.PI * 2); + text(`${PRIZES[i]}`, r * 0.68, 12, 36, '#fff', 'center', 700, '#a35a00'); + ctx.restore(); + } + ctx.restore(); + ctx.fillStyle = '#a35a00'; ctx.beginPath(); ctx.arc(cx, cy, 34, 0, Math.PI * 2); ctx.fill(); + drawBomb(cx, cy, 0.9); + ctx.fillStyle = '#e53935'; + ctx.beginPath(); ctx.moveTo(cx - 18, cy - r - 24); ctx.lineTo(cx + 18, cy - r - 24); ctx.lineTo(cx, cy - r + 14); ctx.fill(); + if (wheel.spun) text(`You won ${wheel.prize} Honeybomb${wheel.prize > 1 ? 's' : ''}!`, W / 2, 590, 34, '#fff', 'center', 700, '#7a4400'); + else text('Press OK to spin for Honeybombs', W / 2, 590, 26, '#fff', 'center', 500, 'rgba(0,0,0,.35)'); + drawButtons(); + } + + function drawOverlay(title, sub, colour) { + ctx.fillStyle = 'rgba(20,10,0,.55)'; ctx.fillRect(0, 0, W, H); + panel(400, 150, 480, 520); + text(title, W / 2, 240, 66, colour, 'center', 700, '#7a4400'); + if (sub) sub(); + drawButtons(); + } + + function render() { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#3d7fb8'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(scale, 0, 0, scale, ox, oy); + ctx.save(); + ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip(); + if (screen === 'title') drawTitle(); + else if (screen === 'levels') drawLevels(); + else if (screen === 'help') drawHelp(); + else if (screen === 'daily') drawWheel(); + else { + const shake = game.effects.find((f) => f.type === 'shake'); + ctx.save(); + if (shake) ctx.translate(0, Math.sin(shake.t * 60) * 4 * (1 - shake.t / 0.3)); + drawSky(); + drawHive(); + drawGrid(); + if (game.shot) { if (game.shot.bomb) drawBomb(game.shot.x, game.shot.y); else drawFlower(game.shot.x, game.shot.y, game.shot.colour); } + drawLauncher(); + drawEffects(); + drawHud(); + ctx.restore(); + if (screen === 'pause') drawOverlay('Paused', () => text(`Level ${game.level} • Score ${game.score}`, W / 2, 280, 22, '#fff', 'center', 500), '#ffd54a'); + if (screen === 'win') drawOverlay('Hive full!', () => { + text('★'.repeat(game.stars) + '☆'.repeat(3 - game.stars), W / 2, 320, 64, '#ffd54a', 'center', 700, '#7a4400'); + text(`Score ${game.score}`, W / 2, 380, 28, '#fff', 'center', 700); + }, '#ffd54a'); + if (screen === 'lose') drawOverlay('Swarmed!', () => { + text('The flowers reached your bee.', W / 2, 310, 24, '#fff', 'center', 500); + text(`Score ${game.score}`, W / 2, 360, 28, '#fff', 'center', 700); + }, '#ff8a65'); + } + ctx.restore(); + } + + // ---------- main loop ---------- + let last = performance.now(); + function frame(now) { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + update(dt); + render(); + requestAnimationFrame(frame); + } + + addEventListener('resize', resize); + resize(); + game.grid = buildLevel(1).grid; + game.info = levelInfo(1); + setScreen('title'); + canvas.focus(); + if (document.fonts && document.fonts.load) document.fonts.load(`700 20px ${FONT}`).then(() => { skyCache = null; }); + requestAnimationFrame(frame); + + // The app's Back button, once per press however it arrives. + let lastBack = 0; + window.orbitBack = () => { const t = performance.now(); if (t - lastBack > 250) { lastBack = t; onAction('back'); } }; + // Small hook for automated checks. + window.orbitHive = { game, save, startLevel, fire, setScreen, get screen() { return screen; } }; +})(); diff --git a/android/app/src/main/assets/orbithive/index.html b/android/app/src/main/assets/orbithive/index.html new file mode 100644 index 0000000..382f8dc --- /dev/null +++ b/android/app/src/main/assets/orbithive/index.html @@ -0,0 +1,17 @@ + + + + + +OrbitHive + + + + + + + diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index a171797..0b47c10 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.78.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.79.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 1c6bceb..703b626 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -41,6 +41,9 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import android.webkit.JavascriptInterface +import android.webkit.WebView import androidx.tv.material3.Text import kotlinx.coroutines.delay import kotlin.math.cos @@ -252,17 +255,57 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { } } -/** The arcade: the game on the left, and a honeycomb of what's here, with room for more. */ +/** OrbitHive runs as a small web page from inside the app, with no network, and handles the remote itself. */ +@android.annotation.SuppressLint("SetJavaScriptEnabled") +@Composable +private fun OrbitHiveScreen(onLeave: () -> Unit) { + var web by remember { mutableStateOf(null) } + BackHandler { web?.evaluateJavascript("window.orbitBack()", null) } + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + WebView(context).apply { + setBackgroundColor(android.graphics.Color.rgb(7, 22, 50)) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.blockNetworkLoads = true + settings.mediaPlaybackRequiresUserGesture = false + isFocusable = true; isFocusableInTouchMode = true + addJavascriptInterface(object { + @JavascriptInterface fun leave() { post { onLeave() } } + }, "OrbitApp") + loadUrl("file:///android_asset/orbithive/index.html") + requestFocus() + web = this + } + }, onRelease = { it.destroy() }) +} + +private enum class ArcadeGame(val title: String, val blurb: String) { + Snake("SNAKE", "Eat, grow, and don't hit the walls or yourself."), + Hive("ORBITHIVE", "Fire flowers into the honeycomb, and clear the hive before it reaches you.") +} + +@Composable +private fun GameTile(game: ArcadeGame, focus: FocusRequester?, nav: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(135.dp).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }.clickable(onClick = onPlay), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) } + Text(if (game == ArcadeGame.Snake) "Snake" else "OrbitHive", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + } +} + +/** The arcade: the chosen game on the left, and a honeycomb of what's here, with room for more. */ @Composable internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { val context = LocalContext.current val settings = remember { OrbitSettings(context) } var best by remember { mutableIntStateOf(settings.snakeBest) } - var playing by remember { mutableStateOf(false) } - DisposableEffect(playing) { onPlaying(playing); onDispose { onPlaying(false) } } - if (playing) { - SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = false }) - return + var playing by remember { mutableStateOf(null) } + var chosen by remember { mutableStateOf(ArcadeGame.Snake) } + DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } } + when (playing) { + ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return } + ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return } + null -> {} } val first = remember { FocusRequester() } LaunchedEffect(Unit) { first.requestFocus() } @@ -272,27 +315,32 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) { Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) { Column(Modifier.weight(1f).fillMaxHeight().background(Panel, RoundedCornerShape(6.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Image(painterResource(R.drawable.orbit_arcade_logo), "Orbit Arcade", Modifier.width(256.dp).height(90.dp)) - Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) { SnakeField(preview, Modifier.fillMaxSize()) } - Text("SNAKE", color = Honey, fontFamily = Marquee, fontSize = 30.sp) - Text(if (best > 0) "Your best: $best" else "Eat, grow, and don't hit the walls or yourself.", color = Color.White, fontSize = 15.sp) - } - Column(Modifier.width(270.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { - var focused by remember { mutableStateOf(false) } - Box(Modifier.size(170.dp).focusRequester(first).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused }.clickable { playing = true }, contentAlignment = Alignment.Center) { - Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) } - Text("Snake", color = if (focused) Navy else Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) - } - Spacer(Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - repeat(2) { - Box(Modifier.size(110.dp), contentAlignment = Alignment.Center) { - Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) } - Text("More soon", color = Muted, fontSize = 12.sp) + Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) { + if (chosen == ArcadeGame.Snake) SnakeField(preview, Modifier.fillMaxSize()) + else Canvas(Modifier.fillMaxSize()) { + val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD)) + val r = size.height / 9 + for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) { + val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f) + drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size]) } } } + Text(chosen.title, color = Honey, fontFamily = Marquee, fontSize = 30.sp) + Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2) + } + Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GameTile(ArcadeGame.Snake, first, nav, { chosen = ArcadeGame.Snake }, { playing = ArcadeGame.Snake }) + GameTile(ArcadeGame.Hive, null, nav, { chosen = ArcadeGame.Hive }, { playing = ArcadeGame.Hive }) + } + Spacer(Modifier.height(4.dp)) + Box(Modifier.size(110.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) } + Text("More soon", color = Muted, fontSize = 12.sp) + } } } - Text("OK Play Back Main menu", color = Muted, fontSize = 11.sp) + Text("◄► Choose OK Play Back Main menu", color = Muted, fontSize = 11.sp) } } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 4aae5a8..8a34f89 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.78.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.79.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md index 47664e0..a2e04ac 100644 --- a/docs/refinement-notes.md +++ b/docs/refinement-notes.md @@ -33,6 +33,10 @@ Paul's approach, 2 November 2026: build in pieces, learn by using it, and note e - **Check them in motion.** Paul caught what stills hid: planets outside their rings, a burst covering a letter. - **Flat layers look blocky.** Real 3D in Blender is a big step up, and Blender's free. It takes about 25 minutes per ident on a mid-range graphics card. +## Games + +- **OrbitHive recreates Beehive Bedlam's rules.** The names are changed and the art is its own, but check this properly before any public release. + ## Size and upkeep Paul, 2 November 2026: keep an eye on optimisation, size and cache management.