feat(App, RealityOverridePage): add Reality Override page and integrate WebSocket message broadcasting

This commit is contained in:
Jaime Idolpx 2026-06-08 14:50:07 -04:00
parent ab5d9bb486
commit 0df2b9cae5
3 changed files with 325 additions and 8 deletions

View File

@ -10,6 +10,7 @@ import OtherPage from './components/OtherPage';
import ToolsPage from './components/ToolsPage'; import ToolsPage from './components/ToolsPage';
import SearchOverlay from './components/SearchOverlay'; import SearchOverlay from './components/SearchOverlay';
import MediaManager from './components/MediaManager'; import MediaManager from './components/MediaManager';
import RealityOverridePage from './components/RealityOverridePage';
import logoSvg from '../imports/logo.svg'; import logoSvg from '../imports/logo.svg';
import { useSettings } from './settings'; import { useSettings } from './settings';
@ -35,7 +36,8 @@ type AppId =
| 'charset-editor' | 'charset-editor'
| 'petscii-editor' | 'petscii-editor'
| 'idle-animation' | 'idle-animation'
| 'loading-animation'; | 'loading-animation'
| 'reality-override';
export default function App() { export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status'); const [currentPage, setCurrentPage] = useState<Page>('status');
@ -105,6 +107,7 @@ export default function App() {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Activity className="w-7 h-7" />} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} /> <AppCard icon={<Activity className="w-7 h-7" />} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} />
<AppCard icon={<Loader2 className="w-7 h-7" />} label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} /> <AppCard icon={<Loader2 className="w-7 h-7" />} label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} />
<AppCard icon={<Wifi className="w-7 h-7" />} label="Reality Override" onClick={() => setCurrentPage('reality-override')} />
</div> </div>
</div> </div>
</div> </div>
@ -140,8 +143,9 @@ export default function App() {
'sprite-editor': <AppPage title="Sprite Editor" onBack={() => setCurrentPage('apps')} />, 'sprite-editor': <AppPage title="Sprite Editor" onBack={() => setCurrentPage('apps')} />,
'charset-editor': <AppPage title="Character Set Editor" onBack={() => setCurrentPage('apps')} />, 'charset-editor': <AppPage title="Character Set Editor" onBack={() => setCurrentPage('apps')} />,
'petscii-editor': <AppPage title="Petscii Editor" onBack={() => setCurrentPage('apps')} />, 'petscii-editor': <AppPage title="Petscii Editor" onBack={() => setCurrentPage('apps')} />,
'idle-animation': <AppPage title="Idle Animation" onBack={() => setCurrentPage('apps')} />, 'idle-animation': <AppPage title="Idle Animation" onBack={() => setCurrentPage('apps')} />,
'loading-animation': <AppPage title="Loading Animation" onBack={() => setCurrentPage('apps')} /> 'loading-animation': <AppPage title="Loading Animation" onBack={() => setCurrentPage('apps')} />,
'reality-override': <RealityOverridePage onBack={() => setCurrentPage('apps')} />
}; };
// AppCard component for app grid // AppCard component for app grid

View File

@ -0,0 +1,296 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-react';
// ── Rocket sprite (6w × 10h, nose pointing UP) ───────────────────────────────
// Type: 0=hull, 1=trim, 2=porthole
const HULL: [number, number, 0 | 1 | 2][] = [
[2,0,0],[3,0,0],
[1,1,0],[2,1,0],[3,1,0],[4,1,0],
[0,2,0],[1,2,0],[2,2,2],[3,2,2],[4,2,0],[5,2,0],
[0,3,0],[1,3,0],[2,3,0],[3,3,0],[4,3,0],[5,3,0],
[0,4,0],[1,4,0],[2,4,0],[3,4,0],[4,4,0],[5,4,0],
[1,5,0],[2,5,0],[3,5,0],[4,5,0],
[1,6,1],[2,6,0],[3,6,0],[4,6,1],
];
// Alternating flame patterns
const FLAMES: [number, number][][] = [
[[2,7],[3,7],[1,8],[2,8],[3,8],[4,8],[2,9],[3,9]],
[[2,7],[3,7],[2,8],[3,8],[1,9],[4,9]],
];
const S = 2; // sprite cell size in CSS pixels
// ── Types ─────────────────────────────────────────────────────────────────────
interface Star { x:number; y:number; sz:number; ph:number; spd:number; col:[number,number,number]; }
interface Rocket { x:number; y:number; vx:number; vy:number; rot:number; color:string; ff:number; tick:number; }
interface Msg { text:string; id:number; }
// ── Helpers ───────────────────────────────────────────────────────────────────
const STAR_PALETTE: [number,number,number][] = [
[255,255,255],[255,240,210],[210,220,255],[255,210,210],[200,255,230],
];
const ROCKET_COLORS = ['#C0C0C0','#FF9090','#88AAFF','#AAFFAA','#FFDDAA','#FFAAFF'];
function mkStar(W: number, H: number): Star {
return {
x: Math.random()*W|0, y: Math.random()*H|0,
sz: Math.random()<0.8 ? 1 : Math.random()<0.6 ? 2 : 3,
ph: Math.random()*Math.PI*2,
spd: Math.random()*0.04+0.005,
col: STAR_PALETTE[Math.random()*STAR_PALETTE.length|0],
};
}
function mkRocket(W: number, H: number, initialX?: number): Rocket {
const dir = Math.random()<0.5 ? 1 : -1;
const speed = Math.random()*1.5+0.8;
return {
x: initialX ?? (dir>0 ? -20 : W+20),
y: Math.random()*H*0.75+H*0.1,
vx: dir*speed,
vy: (Math.random()-0.5)*0.5,
rot: dir>0 ? Math.PI/2 : -Math.PI/2,
color: ROCKET_COLORS[Math.random()*ROCKET_COLORS.length|0],
ff: 0, tick: 0,
};
}
function buildNebula(W: number, H: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = W; c.height = H;
const ctx = c.getContext('2d')!;
const blobs = [
{ cx:W*.25, cy:H*.35, sx:W*.2, sy:H*.2, r:80, g:0, b:170 },
{ cx:W*.72, cy:H*.22, sx:W*.16, sy:H*.2, r:0, g:40, b:155 },
{ cx:W*.55, cy:H*.65, sx:W*.22, sy:H*.2, r:65, g:0, b:115 },
{ cx:W*.12, cy:H*.75, sx:W*.14, sy:H*.15, r:0, g:55, b:90 },
{ cx:W*.88, cy:H*.6, sx:W*.13, sy:H*.15, r:90, g:15, b:70 },
];
for (const { cx,cy,sx,sy,r,g,b } of blobs) {
for (let i = 0; i < 600; i++) {
const u = Math.random()+1e-9, v = Math.random();
const z1 = Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v);
const z2 = Math.sqrt(-2*Math.log(u))*Math.sin(2*Math.PI*v);
const a = Math.min((0.04+Math.random()*0.12)/(1+(z1*z1+z2*z2)*0.12), 0.18);
if (a < 0.01) continue;
const sz = (Math.random()*4+2)|0;
ctx.fillStyle = `rgba(${r},${g},${b},${a})`;
ctx.fillRect((cx+z1*sx*.5)|0, (cy+z2*sy*.5)|0, sz, sz);
}
}
return c;
}
// Pivot is sprite center (3*S, 5*S). rotate(±π/2) to aim left/right.
function drawRocket(ctx: CanvasRenderingContext2D, rk: Rocket) {
ctx.save();
ctx.translate(rk.x, rk.y);
ctx.rotate(rk.rot);
ctx.translate(-3*S, -5*S);
for (const [dc, dr, t] of HULL) {
ctx.fillStyle = t===2 ? '#44DDFF' : t===1 ? '#555' : rk.color;
ctx.fillRect(dc*S, dr*S, S, S);
}
const flames = FLAMES[rk.ff%2];
for (let i = 0; i < flames.length; i++) {
ctx.fillStyle = i%2===0 ? '#FF6600' : '#FFCC00';
ctx.fillRect(flames[i][0]*S, flames[i][1]*S, S, S);
}
ctx.restore();
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function RealityOverridePage({ onBack }: { onBack: () => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animRef = useRef<number>(0);
const starsRef = useRef<Star[]>([]);
const rocketsRef = useRef<Rocket[]>([]);
const nebulaRef = useRef<HTMLCanvasElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const msgIdRef = useRef(0);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
const [history, setHistory] = useState<Msg[]>([]);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// ── Canvas animation loop ───────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let prevW = 0, prevH = 0;
const loop = () => {
const W = canvas.clientWidth, H = canvas.clientHeight;
if (!W || !H) { animRef.current = requestAnimationFrame(loop); return; }
if (W !== prevW || H !== prevH) {
canvas.width = W; canvas.height = H;
prevW = W; prevH = H;
nebulaRef.current = buildNebula(W, H);
starsRef.current = Array.from({ length: 200 }, () => mkStar(W, H));
// Spread initial rockets across the screen
rocketsRef.current = [0,1,2].map(i => mkRocket(W, H, (i+0.5)*W/3));
}
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingEnabled = false;
// Background
ctx.fillStyle = '#000008';
ctx.fillRect(0, 0, W, H);
// Nebula
if (nebulaRef.current) ctx.drawImage(nebulaRef.current, 0, 0);
// Stars
for (const st of starsRef.current) {
st.ph += st.spd;
const br = Math.sin(st.ph)*0.4+0.6;
const [r,g,b] = st.col;
ctx.fillStyle = `rgba(${r},${g},${b},${br})`;
ctx.fillRect(st.x, st.y, st.sz, st.sz);
// Sparkle cross on bright large stars
if (st.sz >= 3 && br > 0.85) {
ctx.fillStyle = `rgba(${r},${g},${b},${br*0.35})`;
ctx.fillRect(st.x-2, st.y+1, st.sz+4, 1);
ctx.fillRect(st.x+1, st.y-2, 1, st.sz+4);
}
}
// Rockets
const PAD = 60;
for (let i = rocketsRef.current.length-1; i >= 0; i--) {
const rk = rocketsRef.current[i];
rk.x += rk.vx; rk.y += rk.vy;
rk.tick++;
if (rk.tick >= 8) { rk.tick = 0; rk.ff++; }
drawRocket(ctx, rk);
if (rk.x < -PAD || rk.x > W+PAD || rk.y < -PAD || rk.y > H+PAD) {
rocketsRef.current.splice(i, 1);
setTimeout(() => {
const cv = canvasRef.current;
if (cv) rocketsRef.current.push(mkRocket(cv.clientWidth, cv.clientHeight));
}, Math.random()*4000+500);
}
}
animRef.current = requestAnimationFrame(loop);
};
animRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animRef.current);
}, []);
// ── WebSocket ───────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
let ws: WebSocket | null = null;
const connect = () => {
if (cancelled) return;
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
wsRef.current = ws;
setWsStatus('connecting');
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
ws.onmessage = (e) => {
if (cancelled) return;
const id = msgIdRef.current++;
const msg: Msg = { text: String(e.data), id };
setCurrentCmd(msg);
setHistory(prev => [...prev.slice(-9), msg]);
clearTimeout(fadeTimer.current);
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
};
ws.onclose = () => {
if (cancelled) return;
setWsStatus('disconnected');
setTimeout(connect, 3000);
};
ws.onerror = () => ws?.close();
};
connect();
return () => { cancelled = true; ws?.close(); };
}, []);
return (
<div className="relative w-full h-full bg-black overflow-hidden">
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
style={{ imageRendering: 'pixelated' }}
/>
{/* Back */}
<button
onClick={onBack}
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-white/50 hover:text-white text-xs px-2 py-1 rounded bg-black/40 border border-white/10"
>
<ChevronLeft className="w-3.5 h-3.5" /> Back
</button>
{/* WS status */}
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 text-xs px-2 py-1 rounded bg-black/40 border border-white/10">
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting</span></>}
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting</span></>}
</div>
{/* CSS keyframe for command flash */}
<style>{`
@keyframes cmd-flash {
0% { opacity: 0; transform: translate(-50%,-50%) scale(0.92); }
10% { opacity: 1; transform: translate(-50%,-50%) scale(1); }
70% { opacity: 1; transform: translate(-50%,-50%) scale(1); }
100% { opacity: 0; transform: translate(-50%,-50%) scale(1); }
}
.cmd-flash { animation: cmd-flash 5s ease-out forwards; }
`}</style>
{/* Idle title — shown only before first message */}
{history.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 pointer-events-none select-none">
<div
className="text-white/20 text-xl font-mono tracking-[0.3em] uppercase"
style={{ textShadow: '0 0 30px rgba(120,60,255,0.7)' }}
>
Reality Override
</div>
<div className="text-white/10 text-xs font-mono tracking-widest mt-2 uppercase">
Awaiting Commands<span className="animate-pulse">_</span>
</div>
</div>
)}
{/* Centered current command */}
{currentCmd && (
<div
key={currentCmd.id}
className="cmd-flash absolute z-10 pointer-events-none select-none text-center"
style={{ top: '50%', left: '50%', transform: 'translate(-50%,-50%)', maxWidth: '80%' }}
>
<div
className="font-mono text-2xl font-bold tracking-wide text-white break-words"
style={{ textShadow: '0 0 24px rgba(80,200,255,0.9), 0 0 60px rgba(80,200,255,0.4)' }}
>
{currentCmd.text}
</div>
</div>
)}
{/* History log */}
{history.length > 0 && (
<div className="absolute bottom-4 left-4 right-4 z-10">
<div className="bg-black/70 border border-green-900/40 rounded px-3 py-2 space-y-0.5">
{history.map(m => (
<div key={m.id} className="text-green-400 text-xs font-mono truncate">
<span className="text-green-800 mr-2">&gt;</span>{m.text}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -43,7 +43,10 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
import urllib.request, urllib.parse, urllib.error, urllib.parse import urllib.request, urllib.parse, urllib.error, urllib.parse
from time import timezone, strftime, localtime, gmtime from time import timezone, strftime, localtime, gmtime
import os, sys, re, shutil, struct, uuid, hashlib, mimetypes, base64, socket import os, sys, re, shutil, struct, threading, uuid, hashlib, mimetypes, base64, socket
_ws_lock = threading.Lock()
_ws_clients: set = set() # connected WebSocket sockets
# Debug message ( True / False ) # Debug message ( True / False )
sys_debug = False sys_debug = False
@ -697,9 +700,18 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
return data return data
def ws_process_message(self, message: str) -> str: def ws_process_message(self, message: str) -> str:
"""Override this to handle incoming WebSocket messages. Return the response string.""" """Override this to handle incoming WebSocket messages. Return the response to broadcast."""
return message # echo by default return message # echo by default
def _ws_broadcast(self, payload: bytes):
with _ws_lock:
clients = set(_ws_clients)
for sock in clients:
try:
self._ws_send(sock, payload)
except Exception:
pass
def _ws_send(self, sock, payload: bytes): def _ws_send(self, sock, payload: bytes):
length = len(payload) length = len(payload)
if length < 126: if length < 126:
@ -721,9 +733,11 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
self.send_header('Sec-WebSocket-Accept', accept) self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers() self.end_headers()
client = f'{self.client_address[0]}:{self.client_address[1]}' client = f'{self.client_address[0]}:{self.client_address[1]}'
print(f'[WS] {client} connected')
sock = self.connection sock = self.connection
sock.settimeout(None) sock.settimeout(None)
with _ws_lock:
_ws_clients.add(sock)
print(f'[WS] {client} connected (total: {len(_ws_clients)})')
try: try:
while True: while True:
b0, b1 = self._ws_recv(sock, 2) b0, b1 = self._ws_recv(sock, 2)
@ -749,10 +763,13 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
print(f'[WS] {client} recv ({length}b): {message}') print(f'[WS] {client} recv ({length}b): {message}')
response = self.ws_process_message(message) response = self.ws_process_message(message)
if response is not None: if response is not None:
self._ws_send(sock, response.encode('utf-8')) self._ws_broadcast(response.encode('utf-8'))
except Exception: except Exception:
pass pass
print(f'[WS] {client} disconnected') finally:
with _ws_lock:
_ws_clients.discard(sock)
print(f'[WS] {client} disconnected (total: {len(_ws_clients)})')
def do_GET(self, onlyhead=False): def do_GET(self, onlyhead=False):
if (self.path == '/ws' if (self.path == '/ws'