Package OBS overlays for Docker
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# OBS Overlays Docker Stack
|
||||||
|
|
||||||
|
LAN-only browser-source overlays for OBS. Each overlay runs in its own Docker container and keeps editable JSON state in `./data`, outside Git, so Portainer redeploys do not wipe the active stream state.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Overlay | Container | Port | OBS URL | Editor URL | State file |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| Pokemon Party | `pokemon-party-overlay` | 8765 | `http://SERVER-IP:8765/overlay.html` | `http://SERVER-IP:8765/editor.html` | `./data/pokemon-party/party.json` |
|
||||||
|
| Gauntlet | `gauntlet-overlay` | 8770 | `http://SERVER-IP:8770/overlay.html` | `http://SERVER-IP:8770/editor.html` | `./data/gauntlet/gauntlet.json` |
|
||||||
|
| Blackout Bingo | `blackout-bingo-overlay` | 8780 | `http://SERVER-IP:8780/overlay.html` | `http://SERVER-IP:8780/editor.html` | `./data/blackout-bingo/bingo.json` |
|
||||||
|
|
||||||
|
## Portainer Git deployment
|
||||||
|
|
||||||
|
1. Push this repo to Gitea.
|
||||||
|
2. In Portainer, go to Stacks -> Add stack.
|
||||||
|
3. Choose Git Repository.
|
||||||
|
4. Paste the Gitea repo URL.
|
||||||
|
5. Set Compose path to `docker-compose.yml`.
|
||||||
|
6. Deploy the stack.
|
||||||
|
|
||||||
|
Redeploy the stack after Git changes to rebuild containers while keeping `./data` state files.
|
||||||
|
|
||||||
|
## Local test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
curl http://127.0.0.1:8765/overlay.html
|
||||||
|
curl http://127.0.0.1:8770/overlay.html
|
||||||
|
curl http://127.0.0.1:8780/api/bingo
|
||||||
|
```
|
||||||
|
|
||||||
|
## LAN-only note
|
||||||
|
|
||||||
|
This stack publishes plain HTTP ports on the Docker host. Do not port-forward these editors to the internet. If remote access is needed later, put them behind VPN or authenticated reverse proxy.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.zip
|
||||||
|
data/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8780
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/bingo.json ] || cp /app/defaults/bingo.json /data/bingo.json && ln -sf /data/bingo.json /app/bingo.json && exec python /app/server.py 8780"]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
Blackout Bingo OBS Overlay Prototype
|
||||||
|
|
||||||
|
Local Windows run:
|
||||||
|
|
||||||
|
1. Extract the bingo-overlay folder.
|
||||||
|
2. Double-click start-bingo.bat.
|
||||||
|
3. Open the editor:
|
||||||
|
http://localhost:8780/editor.html
|
||||||
|
4. Add up to 100 tasks, one per line.
|
||||||
|
5. Click Save Tasks.
|
||||||
|
6. Click Generate Board to randomly pick 25 unique tasks for the 5x5 card.
|
||||||
|
7. Add this OBS Browser Source:
|
||||||
|
http://localhost:8780/overlay.html
|
||||||
|
|
||||||
|
Recommended OBS Browser Source size:
|
||||||
|
|
||||||
|
Width: 1000
|
||||||
|
Height: 900
|
||||||
|
|
||||||
|
Server/Docker run:
|
||||||
|
|
||||||
|
This overlay can also run on a server, Docker host, or Dell20.
|
||||||
|
|
||||||
|
1. Copy the bingo-overlay folder to the Docker host.
|
||||||
|
2. From inside the folder, run:
|
||||||
|
docker compose up -d --build
|
||||||
|
3. Open the editor from another machine:
|
||||||
|
http://SERVER-IP:8780/editor.html
|
||||||
|
4. Add this OBS Browser Source on the streaming PC:
|
||||||
|
http://SERVER-IP:8780/overlay.html
|
||||||
|
|
||||||
|
For Dell20, replace SERVER-IP with Dell20's LAN IP or hostname. If Dell20 resolves on your network, this will usually be one of:
|
||||||
|
|
||||||
|
http://obin-core.wheelz.lab:8780/overlay.html
|
||||||
|
http://192.168.20.123:8780/overlay.html
|
||||||
|
|
||||||
|
Docker persistence:
|
||||||
|
|
||||||
|
- docker-compose.yml stores the saved bingo state under:
|
||||||
|
./data/bingo.json
|
||||||
|
- This keeps the board and task list through container restarts/rebuilds.
|
||||||
|
- To reset everything manually, stop the stack and remove data/bingo.json, then start it again.
|
||||||
|
|
||||||
|
How completion works:
|
||||||
|
|
||||||
|
- This is a blackout bingo card.
|
||||||
|
- Each square can be marked by Player 1, Player 2, or both.
|
||||||
|
- Player names are editable at the top of editor.html.
|
||||||
|
- Blue mark = Player 1.
|
||||||
|
- Pink mark = Player 2.
|
||||||
|
- Gold/mixed highlight = both players completed that square.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
server.py Save/API server
|
||||||
|
editor.html Task entry, board generation, and completion controls
|
||||||
|
overlay.html OBS overlay view
|
||||||
|
bingo.json Default/single-machine saved task pool, player names, and current board
|
||||||
|
start-bingo.bat Windows launcher
|
||||||
|
Dockerfile Container image definition
|
||||||
|
docker-compose.yml Docker deployment on a server
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- If running locally with the BAT file, keep the start-bingo.bat window open while streaming.
|
||||||
|
- If running with Docker, the container keeps running in the background until stopped.
|
||||||
|
- Generate Board clears completion marks because it creates a new card.
|
||||||
|
- Save Tasks preserves the current board and marks.
|
||||||
|
- The overlay auto-refreshes about every 1.5 seconds.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
{
|
||||||
|
"players": [
|
||||||
|
"Player 1",
|
||||||
|
"Player 2"
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
"Win a boss fight",
|
||||||
|
"Find a hidden item",
|
||||||
|
"Get a critical hit",
|
||||||
|
"Survive with 1 HP",
|
||||||
|
"Defeat a rival",
|
||||||
|
"Catch a new teammate",
|
||||||
|
"Lose a teammate",
|
||||||
|
"Win without healing",
|
||||||
|
"Find a rare item",
|
||||||
|
"Complete a side objective",
|
||||||
|
"Beat a dungeon",
|
||||||
|
"Unlock a shortcut",
|
||||||
|
"Get poisoned",
|
||||||
|
"Learn a new move",
|
||||||
|
"Buy something useful",
|
||||||
|
"Win first try",
|
||||||
|
"Win after a reset",
|
||||||
|
"Use a status move",
|
||||||
|
"Defeat an optional enemy",
|
||||||
|
"Reach a new town",
|
||||||
|
"Trigger a cutscene",
|
||||||
|
"Get ambushed",
|
||||||
|
"Upgrade equipment",
|
||||||
|
"Finish a challenge room",
|
||||||
|
"Chat picks a choice"
|
||||||
|
],
|
||||||
|
"board": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"task": "Buy something useful",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"task": "Defeat a rival",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"task": "Lose a teammate",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"task": "Reach a new town",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"task": "Defeat an optional enemy",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"task": "Get ambushed",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"task": "Chat picks a choice",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"task": "Finish a challenge room",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"task": "Beat a dungeon",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"task": "Survive with 1 HP",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"task": "Win first try",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"task": "Win without healing",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"task": "Trigger a cutscene",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"task": "Find a hidden item",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"task": "Complete a side objective",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"task": "Upgrade equipment",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"task": "Unlock a shortcut",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"task": "Win after a reset",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"task": "Find a rare item",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"task": "Get a critical hit",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"task": "Catch a new teammate",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"task": "Win a boss fight",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"task": "Learn a new move",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"task": "Use a status move",
|
||||||
|
"completedBy": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"task": "Get poisoned",
|
||||||
|
"completedBy": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Bingo Overlay Editor</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0b1020; --panel:#151d34; --line:#2d3857; --text:#f7f0dc; --muted:#9faccc; --gold:#ffe66d; --blue:#2f8cff; --green:#35d06f; --bad:#ff6b6b; --good:#62e884; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; background: radial-gradient(circle at top, #202b50, #0b1020 48%); color:var(--text); font-family:"Segoe UI", system-ui, sans-serif; }
|
||||||
|
.wrap { max-width:1220px; margin:0 auto; padding:28px 18px 60px; }
|
||||||
|
header { display:flex; justify-content:space-between; gap:18px; align-items:flex-end; margin-bottom:18px; }
|
||||||
|
h1 { margin:0; font-size:38px; letter-spacing:.06em; text-transform:uppercase; }
|
||||||
|
.hint { color:var(--muted); max-width:760px; line-height:1.45; }
|
||||||
|
.panel { background:rgba(21,29,52,.9); border:1px solid var(--line); border-radius:18px; padding:16px; box-shadow:0 18px 50px rgba(0,0,0,.3); margin-bottom:16px; }
|
||||||
|
.top { display:grid; grid-template-columns:1fr 1fr auto; gap:12px; align-items:end; }
|
||||||
|
label { display:block; color:var(--muted); font-size:13px; text-transform:uppercase; letter-spacing:.08em; margin-bottom:6px; }
|
||||||
|
input, textarea { width:100%; background:#090d1a; color:var(--text); border:1px solid var(--line); border-radius:12px; padding:10px 12px; font:inherit; }
|
||||||
|
textarea { min-height:300px; resize:vertical; line-height:1.35; }
|
||||||
|
button { border:0; border-radius:12px; padding:11px 14px; font-weight:800; cursor:pointer; color:#08101d; background:var(--gold); box-shadow:0 8px 20px rgba(0,0,0,.22); }
|
||||||
|
button.secondary { background:#253151; color:var(--text); border:1px solid var(--line); }
|
||||||
|
button.blue { background:var(--blue); }
|
||||||
|
button.green { background:var(--green); color:#041409; }
|
||||||
|
button.bad { background:var(--bad); color:white; }
|
||||||
|
button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.status { color:var(--muted); min-height:24px; margin-top:10px; }
|
||||||
|
.status.good { color:var(--good); } .status.bad { color:var(--bad); }
|
||||||
|
.columns { display:grid; grid-template-columns: 420px 1fr; gap:16px; }
|
||||||
|
.board { display:grid; grid-template-columns:repeat(5, 1fr); gap:8px; }
|
||||||
|
.cell { min-height:100px; border:1px solid var(--line); border-radius:13px; background:#0c1223; padding:8px; display:flex; flex-direction:column; justify-content:space-between; gap:8px; }
|
||||||
|
.task { font-weight:750; line-height:1.15; font-size:14px; }
|
||||||
|
.marks { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
|
||||||
|
.marks button { padding:7px 5px; font-size:12px; background:#253151; color:var(--text); border:1px solid var(--line); }
|
||||||
|
.marks button.on.p1 { background:var(--blue); color:#03101a; }
|
||||||
|
.marks button.on.p2 { background:var(--green); color:#041409; }
|
||||||
|
.count { font-weight:800; color:var(--gold); }
|
||||||
|
.small { color:var(--muted); font-size:13px; line-height:1.35; }
|
||||||
|
@media (max-width: 920px) { .columns, .top { grid-template-columns:1fr; } header { display:block; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Bingo Editor</h1>
|
||||||
|
<div class="hint">Add up to 100 possible tasks, one per line. Generate a 5x5 blackout card from 25 random unique tasks. Use the board controls to mark squares completed by either player.</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><a href="/overlay.html" target="_blank"><button class="secondary">Open Overlay</button></a></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel top">
|
||||||
|
<div><label>Player 1 name</label><input id="player1" value="Player 1"></div>
|
||||||
|
<div><label>Player 2 name</label><input id="player2" value="Player 2"></div>
|
||||||
|
<div class="actions"><button id="saveBtn">Save Tasks</button><button id="generateBtn" class="blue">Generate Board</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<section class="panel">
|
||||||
|
<label>Task pool <span class="count" id="taskCount">0 / 100</span></label>
|
||||||
|
<textarea id="tasks" placeholder="Enter one task per line..."></textarea>
|
||||||
|
<div class="actions" style="margin-top:12px">
|
||||||
|
<button id="saveBtn2">Save Tasks</button>
|
||||||
|
<button id="sampleBtn" class="secondary">Load Sample 30</button>
|
||||||
|
<button id="clearTasksBtn" class="bad">Clear Tasks</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
<p class="small">Tip: Generate Board resets all completion marks. Save Tasks keeps the current board if the board cells still exist.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="actions" style="justify-content:space-between;margin-bottom:12px">
|
||||||
|
<div><label style="margin:0">Current board</label><div class="small">Click player buttons under each square to toggle completion.</div></div>
|
||||||
|
<button id="clearMarksBtn" class="secondary">Clear Marks</button>
|
||||||
|
</div>
|
||||||
|
<div id="board" class="board"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let state = { players: ['Player 1', 'Player 2'], tasks: [], board: [] };
|
||||||
|
const sampleTasks = Array.from({length: 30}, (_, i) => [
|
||||||
|
'Win a boss fight','Find a hidden item','Get a critical hit','Survive with 1 HP','Defeat a rival','Catch a new teammate','Lose a teammate','Win without healing','Find a rare item','Complete a side objective','Beat a dungeon','Unlock a shortcut','Get poisoned','Learn a new move','Buy something useful','Win first try','Win after a reset','Use a status move','Defeat an optional enemy','Reach a new town','Trigger a cutscene','Get ambushed','Upgrade equipment','Finish a challenge room','Chat picks a choice','Complete a level','Find a secret door','Use only starter gear','Beat a timer','Make a risky choice'
|
||||||
|
][i]);
|
||||||
|
function getTasksFromBox() { return document.getElementById('tasks').value.split(/\r?\n/).map(t => t.trim()).filter(Boolean).slice(0, 100); }
|
||||||
|
function updateCount() { const n = document.getElementById('tasks').value.split(/\r?\n/).map(t => t.trim()).filter(Boolean).length; document.getElementById('taskCount').textContent = `${n} / 100`; document.getElementById('generateBtn').disabled = n < 25 || n > 100; }
|
||||||
|
function setStatus(msg, kind='') { const el = document.getElementById('status'); el.textContent = msg; el.className = 'status ' + kind; }
|
||||||
|
async function api(path, payload) { const res = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload || {}) }); const json = await res.json(); if (!json.ok) throw new Error(json.error || 'Request failed'); return json.data; }
|
||||||
|
async function load() { const res = await fetch('/api/bingo?ts=' + Date.now(), {cache:'no-store'}); const json = await res.json(); state = json.data; document.getElementById('player1').value = state.players?.[0] || 'Player 1'; document.getElementById('player2').value = state.players?.[1] || 'Player 2'; document.getElementById('tasks').value = (state.tasks || []).join('\n'); updateCount(); renderBoard(); }
|
||||||
|
async function save() { state.players = [document.getElementById('player1').value.trim() || 'Player 1', document.getElementById('player2').value.trim() || 'Player 2']; state.tasks = getTasksFromBox(); state = await api('/api/bingo', state); setStatus('Saved bingo.json directly.', 'good'); renderBoard(); updateCount(); }
|
||||||
|
async function generate() { await save(); state = await api('/api/generate', {}); setStatus('Generated a new 5x5 board from 25 unique tasks.', 'good'); renderBoard(); }
|
||||||
|
async function mark(id, player) { state = await api('/api/mark', { cellId:id, player }); renderBoard(); }
|
||||||
|
async function clearMarks() { state.board = (state.board || []).map(c => ({...c, completedBy: []})); state = await api('/api/bingo', state); renderBoard(); setStatus('Cleared completion marks.', 'good'); }
|
||||||
|
function renderBoard() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
const players = state.players || ['Player 1','Player 2'];
|
||||||
|
if (!state.board || state.board.length === 0) { board.innerHTML = '<div class="small">No board yet. Add at least 25 tasks and click Generate Board.</div>'; return; }
|
||||||
|
board.innerHTML = state.board.map(cell => {
|
||||||
|
const p1 = cell.completedBy?.includes(players[0]); const p2 = cell.completedBy?.includes(players[1]);
|
||||||
|
return `<div class="cell"><div class="task">${cell.task}</div><div class="marks"><button class="p1 ${p1?'on':''}" data-id="${cell.id}" data-player="${players[0]}">${players[0]}</button><button class="p2 ${p2?'on':''}" data-id="${cell.id}" data-player="${players[1]}">${players[1]}</button></div></div>`;
|
||||||
|
}).join('');
|
||||||
|
board.querySelectorAll('button[data-id]').forEach(btn => btn.addEventListener('click', () => mark(Number(btn.dataset.id), btn.dataset.player)));
|
||||||
|
}
|
||||||
|
document.getElementById('tasks').addEventListener('input', updateCount);
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', () => save().catch(e => setStatus(e.message, 'bad')));
|
||||||
|
document.getElementById('saveBtn2').addEventListener('click', () => save().catch(e => setStatus(e.message, 'bad')));
|
||||||
|
document.getElementById('generateBtn').addEventListener('click', () => generate().catch(e => setStatus(e.message, 'bad')));
|
||||||
|
document.getElementById('clearMarksBtn').addEventListener('click', () => clearMarks().catch(e => setStatus(e.message, 'bad')));
|
||||||
|
document.getElementById('sampleBtn').addEventListener('click', () => { document.getElementById('tasks').value = sampleTasks.join('\n'); updateCount(); setStatus('Loaded sample tasks. Click Save or Generate Board.', ''); });
|
||||||
|
document.getElementById('clearTasksBtn').addEventListener('click', () => { document.getElementById('tasks').value = ''; updateCount(); setStatus('Task box cleared. Click Save to commit.', ''); });
|
||||||
|
load().catch(e => setStatus(e.message, 'bad'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Blackout Bingo Overlay</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #050505;
|
||||||
|
--coal: #0b0b0a;
|
||||||
|
--charcoal: #151514;
|
||||||
|
--slate: #242424;
|
||||||
|
--stone: #3a3935;
|
||||||
|
--line: rgba(238, 235, 225, 0.18);
|
||||||
|
--line-strong: rgba(238, 235, 225, 0.36);
|
||||||
|
--text: #efede6;
|
||||||
|
--muted: #aaa69d;
|
||||||
|
--paper: #d9d6cc;
|
||||||
|
--p1: #2f8cff;
|
||||||
|
--p2: #35d06f;
|
||||||
|
--both: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 18%, rgba(100, 100, 94, .20), transparent 35%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(60, 60, 58, .22), transparent 42%),
|
||||||
|
linear-gradient(135deg, #000 0%, #090909 44%, #161614 100%);
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px, 46px 46px;
|
||||||
|
mask-image: linear-gradient(to bottom, rgba(0,0,0,.45), transparent 78%);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(94vw, 980px);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(34, 34, 32, .98), rgba(7, 7, 7, .99) 58%, rgba(18, 18, 17, .98)),
|
||||||
|
#0b0b0a;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 26px 80px rgba(0,0,0,.64), inset 0 0 0 1px rgba(255,255,255,.05);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: "BLACKOUT DOSSIER";
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
top: 14px;
|
||||||
|
color: rgba(238,235,225,.08);
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 54px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.05em;
|
||||||
|
line-height: .8;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 2px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: clamp(28px, 3.7vw, 46px);
|
||||||
|
line-height: .98;
|
||||||
|
letter-spacing: -.045em;
|
||||||
|
color: var(--paper);
|
||||||
|
text-transform: none;
|
||||||
|
text-shadow: 0 2px 0 #000, 0 20px 36px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: .22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.legend { display:flex; gap: 8px; align-items:center; color: var(--muted); font-size: 12px; flex-wrap: wrap; justify-content: end; max-width: 430px; }
|
||||||
|
.pill { border: 1px solid var(--line); border-radius: 999px; padding: 6px 10px; background: rgba(255,255,255,.045); box-shadow: inset 0 1px 0 rgba(255,255,255,.05); }
|
||||||
|
.p1 { color: var(--p1); } .p2 { color: var(--p2); } .both { color: white; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
min-height: clamp(78px, 11.6vh, 104px);
|
||||||
|
padding: 14px 10px 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(238,235,225,.14);
|
||||||
|
background:
|
||||||
|
linear-gradient(155deg, rgba(255,255,255,.07), rgba(255,255,255,.018) 42%, rgba(0,0,0,.18)),
|
||||||
|
#161615;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.05);
|
||||||
|
font-weight: 760;
|
||||||
|
font-size: clamp(12px, 1.45vw, 17px);
|
||||||
|
line-height: 1.12;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #e8e5dc;
|
||||||
|
}
|
||||||
|
.cell::before {
|
||||||
|
content: attr(data-index);
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 8px;
|
||||||
|
color: rgba(238,235,225,.22);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cell.p1done {
|
||||||
|
border-color: rgba(47,140,255,.86);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 24%, rgba(47,140,255,.34), transparent 45%),
|
||||||
|
linear-gradient(145deg, rgba(47,140,255,.22), rgba(34,34,32,.78));
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.cell.p2done {
|
||||||
|
border-color: rgba(53,208,111,.80);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(-45deg, rgba(53,208,111,.16) 0 5px, transparent 5px 13px),
|
||||||
|
linear-gradient(145deg, rgba(53,208,111,.25), rgba(16,16,15,.88));
|
||||||
|
color: #f1eee6;
|
||||||
|
}
|
||||||
|
.cell.bothdone {
|
||||||
|
border-color: rgba(115,236,255,.92);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 28%, rgba(47,140,255,.38), transparent 42%),
|
||||||
|
radial-gradient(circle at 72% 70%, rgba(53,208,111,.34), transparent 44%),
|
||||||
|
linear-gradient(145deg, rgba(210,207,197,.22), rgba(45,45,43,.74));
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 13px 32px rgba(0,0,0,.34), inset 0 0 0 1px rgba(115,236,255,.20);
|
||||||
|
}
|
||||||
|
.marks { position:absolute; left: 9px; right: 9px; bottom: 8px; display:flex; justify-content:center; gap: 6px; }
|
||||||
|
.mark { width: 24px; height: 7px; border-radius: 999px; opacity: .38; background: rgba(238,235,225,.26); border: 1px solid rgba(238,235,225,.15); }
|
||||||
|
.mark.on.p1m { opacity:1; background: #2f8cff; box-shadow: 0 0 16px rgba(47,140,255,.45); }
|
||||||
|
.mark.on.p2m { opacity:1; background: #35d06f; box-shadow: 0 0 16px rgba(53,208,111,.40); }
|
||||||
|
.empty { color: var(--muted); padding: 60px 20px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; background: rgba(255,255,255,.035); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="card">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="sub">Stream Challenge</div>
|
||||||
|
<h1>Blackout Bingo</h1>
|
||||||
|
</div>
|
||||||
|
<div class="legend" id="legend"></div>
|
||||||
|
</header>
|
||||||
|
<div id="board" class="grid" aria-label="Bingo board"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function loadBingo() {
|
||||||
|
const res = await fetch('/api/bingo?ts=' + Date.now(), { cache: 'no-store' });
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
function classFor(cell, players) {
|
||||||
|
const p1 = cell.completedBy?.includes(players[0]);
|
||||||
|
const p2 = cell.completedBy?.includes(players[1]);
|
||||||
|
if (p1 && p2) return 'bothdone';
|
||||||
|
if (p1) return 'p1done';
|
||||||
|
if (p2) return 'p2done';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function render(data) {
|
||||||
|
const players = data.players || ['Player 1', 'Player 2'];
|
||||||
|
document.getElementById('legend').innerHTML = `<span class="pill p1">${players[0]}</span><span class="pill p2">${players[1]}</span><span class="pill both">Both = blackout square</span>`;
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!data.board || data.board.length === 0) {
|
||||||
|
board.className = '';
|
||||||
|
board.innerHTML = '<div class="empty">No board generated yet. Open the editor, add at least 25 tasks, then generate a card.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
board.className = 'grid';
|
||||||
|
board.innerHTML = data.board.map((cell, i) => {
|
||||||
|
const p1 = cell.completedBy?.includes(players[0]);
|
||||||
|
const p2 = cell.completedBy?.includes(players[1]);
|
||||||
|
return `<div class="cell ${classFor(cell, players)}" data-index="${String(i + 1).padStart(2, '0')}"><span>${cell.task}</span><div class="marks"><i class="mark p1m ${p1 ? 'on' : ''}"></i><i class="mark p2m ${p2 ? 'on' : ''}"></i></div></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
async function refresh() { try { render(await loadBingo()); } catch (e) { console.error(e); } }
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 1500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
DATA_FILE = Path(os.environ.get("BINGO_DATA_FILE", Path(__file__).with_name("bingo.json"))).expanduser()
|
||||||
|
MAX_TASKS = 100
|
||||||
|
BOARD_SIZE = 25
|
||||||
|
|
||||||
|
|
||||||
|
def default_data():
|
||||||
|
return {"players": ["Player 1", "Player 2"], "tasks": [], "board": []}
|
||||||
|
|
||||||
|
|
||||||
|
def load_data():
|
||||||
|
if not DATA_FILE.exists():
|
||||||
|
save_data(default_data())
|
||||||
|
with DATA_FILE.open("r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
data.setdefault("players", ["Player 1", "Player 2"])
|
||||||
|
data.setdefault("tasks", [])
|
||||||
|
data.setdefault("board", [])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def clean_data(data):
|
||||||
|
tasks = [str(t).strip() for t in data.get("tasks", []) if str(t).strip()]
|
||||||
|
if len(tasks) > MAX_TASKS:
|
||||||
|
raise ValueError("You can save up to 100 tasks.")
|
||||||
|
players = data.get("players") or ["Player 1", "Player 2"]
|
||||||
|
players = [str(p).strip() or f"Player {i + 1}" for i, p in enumerate(players[:2])]
|
||||||
|
while len(players) < 2:
|
||||||
|
players.append(f"Player {len(players) + 1}")
|
||||||
|
board = []
|
||||||
|
for i, cell in enumerate(data.get("board", [])[:BOARD_SIZE]):
|
||||||
|
task = str(cell.get("task", "")).strip()
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
completed = []
|
||||||
|
for player in cell.get("completedBy", []):
|
||||||
|
player = str(player).strip()
|
||||||
|
if player and player in players and player not in completed:
|
||||||
|
completed.append(player)
|
||||||
|
board.append({"id": int(cell.get("id", i)), "task": task, "completedBy": completed})
|
||||||
|
return {"players": players, "tasks": tasks, "board": board}
|
||||||
|
|
||||||
|
|
||||||
|
def save_data(data):
|
||||||
|
clean = clean_data(data)
|
||||||
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = DATA_FILE.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(clean, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(DATA_FILE)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def generate_board(seed=None):
|
||||||
|
data = load_data()
|
||||||
|
tasks = list(dict.fromkeys([t.strip() for t in data.get("tasks", []) if t.strip()]))
|
||||||
|
if len(tasks) < BOARD_SIZE:
|
||||||
|
raise ValueError("Add at least 25 unique tasks before generating a board.")
|
||||||
|
rng = random.Random(seed) if seed else random.SystemRandom()
|
||||||
|
selected = rng.sample(tasks, BOARD_SIZE)
|
||||||
|
data["board"] = [{"id": i, "task": task, "completedBy": []} for i, task in enumerate(selected)]
|
||||||
|
return save_data(data)
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_mark(cell_id, player):
|
||||||
|
data = load_data()
|
||||||
|
players = data.get("players", ["Player 1", "Player 2"])
|
||||||
|
if player not in players:
|
||||||
|
raise ValueError("Unknown player.")
|
||||||
|
for cell in data.get("board", []):
|
||||||
|
if int(cell.get("id", -1)) == int(cell_id):
|
||||||
|
completed = cell.setdefault("completedBy", [])
|
||||||
|
if player in completed:
|
||||||
|
completed.remove(player)
|
||||||
|
else:
|
||||||
|
completed.append(player)
|
||||||
|
return save_data(data)
|
||||||
|
raise ValueError("Unknown board cell.")
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def read_json(self):
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||||
|
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
|
||||||
|
return json.loads(raw or "{}")
|
||||||
|
|
||||||
|
def send_json(self, status, payload):
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if urlparse(self.path).path == "/api/bingo":
|
||||||
|
self.send_json(200, {"ok": True, "data": load_data()})
|
||||||
|
return
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
try:
|
||||||
|
payload = self.read_json()
|
||||||
|
if path == "/api/bingo":
|
||||||
|
self.send_json(200, {"ok": True, "data": save_data(payload)})
|
||||||
|
return
|
||||||
|
if path == "/api/generate":
|
||||||
|
self.send_json(200, {"ok": True, "data": generate_board(payload.get("seed"))})
|
||||||
|
return
|
||||||
|
if path == "/api/mark":
|
||||||
|
self.send_json(200, {"ok": True, "data": toggle_mark(payload.get("cellId"), payload.get("player"))})
|
||||||
|
return
|
||||||
|
self.send_json(404, {"ok": False, "error": "Not found"})
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_json(400, {"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8780
|
||||||
|
print(f"Bingo overlay server running at http://localhost:{port}/")
|
||||||
|
print(f"Editor: http://localhost:{port}/editor.html")
|
||||||
|
print(f"Overlay: http://localhost:{port}/overlay.html")
|
||||||
|
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo Starting Blackout Bingo overlay server...
|
||||||
|
echo.
|
||||||
|
echo Editor: http://localhost:8780/editor.html
|
||||||
|
echo Overlay: http://localhost:8780/overlay.html
|
||||||
|
echo.
|
||||||
|
echo Keep this window open while streaming. Closing it stops the overlay.
|
||||||
|
echo.
|
||||||
|
py server.py 8780
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Python launcher failed. Trying python instead...
|
||||||
|
python server.py 8780
|
||||||
|
)
|
||||||
|
pause
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def start_server(tmp_path):
|
||||||
|
src = Path(__file__).resolve().parents[1]
|
||||||
|
work = tmp_path / "app"
|
||||||
|
work.mkdir()
|
||||||
|
for name in ["server.py", "overlay.html", "editor.html"]:
|
||||||
|
(work / name).write_text((src / name).read_text(), encoding="utf-8")
|
||||||
|
(work / "bingo.json").write_text((src / "defaults" / "bingo.json").read_text(), encoding="utf-8")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "server.py", "8780"],
|
||||||
|
cwd=work,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("http://127.0.0.1:8780/api/bingo", timeout=0.25).read()
|
||||||
|
return proc, work
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.1)
|
||||||
|
output = proc.stdout.read() if proc.stdout else ""
|
||||||
|
proc.kill()
|
||||||
|
raise AssertionError(f"server did not start: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(url, method="GET", payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_rejects_more_than_100_tasks(tmp_path):
|
||||||
|
proc, work = start_server(tmp_path)
|
||||||
|
try:
|
||||||
|
payload = {"tasks": [f"Task {i}" for i in range(101)], "board": [], "players": ["P1", "P2"]}
|
||||||
|
try:
|
||||||
|
request_json("http://127.0.0.1:8780/api/bingo", "POST", payload)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
assert exc.code == 400
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected 400 for too many tasks")
|
||||||
|
finally:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_generate_25_unique_board_cells(tmp_path):
|
||||||
|
proc, work = start_server(tmp_path)
|
||||||
|
try:
|
||||||
|
tasks = [f"Challenge {i:02d}" for i in range(30)]
|
||||||
|
saved = request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Ash", "Misty"]})
|
||||||
|
assert saved["ok"] is True
|
||||||
|
generated = request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "demo"})
|
||||||
|
board = generated["data"]["board"]
|
||||||
|
assert len(board) == 25
|
||||||
|
assert len({cell["task"] for cell in board}) == 25
|
||||||
|
assert all(cell["completedBy"] == [] for cell in board)
|
||||||
|
assert (work / "bingo.json").exists()
|
||||||
|
finally:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_cell_toggles_two_players_independently(tmp_path):
|
||||||
|
proc, work = start_server(tmp_path)
|
||||||
|
try:
|
||||||
|
tasks = [f"Task {i}" for i in range(25)]
|
||||||
|
request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Blue", "Red"]})
|
||||||
|
request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "fixed"})
|
||||||
|
one = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"})
|
||||||
|
assert one["data"]["board"][0]["completedBy"] == ["Blue"]
|
||||||
|
two = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Red"})
|
||||||
|
assert set(two["data"]["board"][0]["completedBy"]) == {"Blue", "Red"}
|
||||||
|
off = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"})
|
||||||
|
assert off["data"]["board"][0]["completedBy"] == ["Red"]
|
||||||
|
finally:
|
||||||
|
proc.kill()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
pokemon-party-overlay:
|
||||||
|
build:
|
||||||
|
context: ./pokemon-party
|
||||||
|
container_name: pokemon-party-overlay
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8765:8765"
|
||||||
|
volumes:
|
||||||
|
- ./data/pokemon-party:/data
|
||||||
|
|
||||||
|
gauntlet-overlay:
|
||||||
|
build:
|
||||||
|
context: ./gauntlet
|
||||||
|
container_name: gauntlet-overlay
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8770:8770"
|
||||||
|
volumes:
|
||||||
|
- ./data/gauntlet:/data
|
||||||
|
|
||||||
|
blackout-bingo-overlay:
|
||||||
|
build:
|
||||||
|
context: ./blackout-bingo
|
||||||
|
container_name: blackout-bingo-overlay
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8780:8780"
|
||||||
|
volumes:
|
||||||
|
- ./data/blackout-bingo:/data
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.zip
|
||||||
|
data/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8770
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/gauntlet.json ] || cp /app/defaults/gauntlet.json /data/gauntlet.json && ln -sf /data/gauntlet.json /app/gauntlet.json && exec python /app/server.py --host 0.0.0.0 --port 8770 --directory /app"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Gauntlet OBS Overlay
|
||||||
|
====================
|
||||||
|
|
||||||
|
This is a local browser-source overlay for OBS.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
overlay.html The OBS overlay page
|
||||||
|
editor.html The editing page for up to 10 games
|
||||||
|
gauntlet.json The saved game list and statuses
|
||||||
|
server.py Local server with save/advance/reset endpoints
|
||||||
|
start-gauntlet.bat Double-click this to start the server on Windows
|
||||||
|
advance-game.bat Clears the active game and moves to the next game
|
||||||
|
reset-gauntlet.bat Resets the run to the first game
|
||||||
|
|
||||||
|
Fast start on your streaming PC:
|
||||||
|
1. Extract the zip.
|
||||||
|
2. Double-click start-gauntlet.bat.
|
||||||
|
3. Open the editor: http://localhost:8770/editor.html
|
||||||
|
4. Add your games and click Save gauntlet.json directly.
|
||||||
|
5. Add an OBS Browser Source pointed to: http://localhost:8770/overlay.html
|
||||||
|
|
||||||
|
Recommended OBS Browser Source size:
|
||||||
|
Width: 1120
|
||||||
|
Height: 350
|
||||||
|
|
||||||
|
How the gauntlet works:
|
||||||
|
- You can add up to 10 games.
|
||||||
|
- Blank title rows do not show on the overlay.
|
||||||
|
- Exactly one game should be Active. The active game is highlighted.
|
||||||
|
- advance-game.bat marks the active game Cleared and activates the next non-cleared game.
|
||||||
|
- reset-gauntlet.bat sets the first game Active and all other games Queued.
|
||||||
|
|
||||||
|
Keep the start-gauntlet.bat window open while streaming. Closing it stops the local server.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Clears the active Gauntlet game and moves the highlight to the next game.
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/advance' | Out-Null; Write-Host 'Gauntlet advanced: current game cleared, next game active.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }"
|
||||||
|
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"title": "Super Mario World",
|
||||||
|
"subtitle": "96 exits optional",
|
||||||
|
"icon": "✅",
|
||||||
|
"status": "cleared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Metroid Fusion",
|
||||||
|
"subtitle": "Any% clear",
|
||||||
|
"icon": "✅",
|
||||||
|
"status": "cleared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pokémon SoulSilver",
|
||||||
|
"subtitle": "Nuzlocke ruleset",
|
||||||
|
"icon": "⚡",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mega Man X",
|
||||||
|
"subtitle": "Boss order TBD",
|
||||||
|
"icon": "🌙",
|
||||||
|
"status": "queued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Zelda: Minish Cap",
|
||||||
|
"subtitle": "Casual clear",
|
||||||
|
"icon": "🗡️",
|
||||||
|
"status": "queued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Castlevania",
|
||||||
|
"subtitle": "Finale candidate",
|
||||||
|
"icon": "👑",
|
||||||
|
"status": "queued"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Gauntlet Overlay Editor</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}body{min-height:100vh;background:#110d09;color:#f8ead2;font-family:Segoe UI,Arial,sans-serif;padding:26px}.shell{max-width:1120px;margin:0 auto}.header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:18px}.eyebrow{text-transform:uppercase;letter-spacing:.18em;color:#e1b15f;font-size:12px;font-weight:800}.header h1{font-family:Georgia,'Times New Roman',serif;font-size:42px;line-height:1;margin-top:6px}.header p{color:#d9bd88;margin-top:8px;max-width:650px}.actions{display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end}.btn{border:1px solid rgba(255,226,170,.25);background:#2b1b12;color:#fff3d8;border-radius:10px;padding:11px 13px;font-weight:800;cursor:pointer;box-shadow:0 8px 20px rgba(0,0,0,.22)}.btn.primary{background:#e1b15f;color:#1b110b;border-color:#ffe0a0}.btn.danger{background:#452020}.btn:hover{filter:brightness(1.08)}.panel{background:linear-gradient(120deg,#3d2819,#1f1711);border:1px solid rgba(255,226,170,.22);border-radius:16px;padding:16px;box-shadow:0 20px 60px rgba(0,0,0,.42)}.grid{display:grid;gap:10px}.row{display:grid;grid-template-columns:42px 2fr 2fr 70px 126px 72px;gap:8px;align-items:center;padding:10px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.12)}.row.active{border-color:#e1b15f;box-shadow:0 0 20px rgba(218,153,61,.15)}label{font-size:11px;color:#d9bd88;text-transform:uppercase;letter-spacing:.1em;display:block;margin-bottom:5px}input,select{width:100%;background:#17110d;color:#f8ead2;border:1px solid rgba(255,226,170,.22);border-radius:9px;padding:10px;font:inherit}input::placeholder{color:#806d50}.slot{width:34px;height:34px;border-radius:9px;background:#2f2117;display:grid;place-items:center;color:#e1b15f;font-weight:900}.small{font-size:13px;color:#d9bd88;margin:12px 2px}.status{margin-top:12px;color:#f0c36d;min-height:22px}.fallbacks{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}textarea{width:100%;height:220px;background:#0b0806;color:#f8ead2;border:1px solid rgba(255,226,170,.22);border-radius:12px;padding:12px;font-family:Consolas,monospace;margin-top:10px;display:none}.preview-link{color:#f0c36d;text-decoration:none;font-weight:800}.preview-link:hover{text-decoration:underline}@media(max-width:920px){.header{flex-direction:column}.actions{justify-content:flex-start}.row{grid-template-columns:38px 1fr}.field{grid-column:auto}.row .wide{grid-column:2}.row .btn{grid-column:2;width:max-content}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<div class="header"><div><div class="eyebrow">OBS Browser Source Tool</div><h1>The Gauntlet Editor</h1><p>Add up to 10 games. Blank title rows are hidden from the overlay. Set one game as Active so the overlay highlights your current run.</p></div><div class="actions"><button class="btn primary" onclick="saveDirect()">Save gauntlet.json directly</button><button class="btn" onclick="advanceRun()">Clear current + next</button><button class="btn danger" onclick="resetRun()">Reset to beginning</button></div></div>
|
||||||
|
<div class="panel"><div class="grid" id="rows"></div><div class="small">Overlay: <a class="preview-link" href="overlay.html" target="_blank">open overlay.html</a>. Batch controls use the same advance/reset actions as this editor.</div><div class="fallbacks"><button class="btn" onclick="copyJson()">Copy JSON</button><button class="btn" onclick="downloadJson()">Download gauntlet.json</button><button class="btn" onclick="toggleJson()">Show/hide JSON</button></div><textarea id="jsonBox" spellcheck="false"></textarea><div class="status" id="status"></div></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const rows = document.getElementById('rows'); const statusEl = document.getElementById('status'); const jsonBox = document.getElementById('jsonBox');
|
||||||
|
const MAX = 10; const statuses = ['active','queued','cleared','paused','failed'];
|
||||||
|
function setStatus(msg, bad=false){statusEl.textContent = msg; statusEl.style.color = bad ? '#ffb2a8' : '#f0c36d';}
|
||||||
|
function rowTemplate(i, game={}){const row=document.createElement('div'); row.className='row'; row.dataset.index=i; row.innerHTML=`<div class="slot">${i+1}</div><div class="field wide"><label>Game title</label><input data-field="title" placeholder="Game ${i+1}" value="${esc(game.title||'')}"></div><div class="field wide"><label>Subtitle / rules</label><input data-field="subtitle" placeholder="Any%, Nuzlocke, casual clear..." value="${esc(game.subtitle||'')}"></div><div class="field"><label>Icon</label><input data-field="icon" placeholder="◆" maxlength="8" value="${esc(game.icon||'')}"></div><div class="field"><label>Status</label><select data-field="status">${statuses.map(s=>`<option value="${s}" ${s===(game.status||'queued')?'selected':''}>${s}</option>`).join('')}</select></div><button class="btn" type="button" onclick="setActive(${i})">Active</button>`; rows.appendChild(row);}
|
||||||
|
function esc(s){return String(s).replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));}
|
||||||
|
function buildRows(games=[]){rows.innerHTML=''; for(let i=0;i<MAX;i++) rowTemplate(i, games[i]||{}); refreshActiveStyle();}
|
||||||
|
function getData(){const games=[]; for(const row of rows.children){const title=row.querySelector('[data-field="title"]').value.trim(); if(!title) continue; const game={title, subtitle:row.querySelector('[data-field="subtitle"]').value.trim(), icon:row.querySelector('[data-field="icon"]').value.trim()||'◆', status:row.querySelector('[data-field="status"]').value}; games.push(game);} let activeSeen=false; for(const g of games){if(g.status==='active'){if(activeSeen)g.status='queued'; activeSeen=true;}} if(games.length && !activeSeen){const first=games.find(g=>g.status!=='cleared')||games[0]; first.status='active';} return {games};}
|
||||||
|
function applyData(data){buildRows((data&&Array.isArray(data.games))?data.games:[]);}
|
||||||
|
function refreshActiveStyle(){for(const row of rows.children){row.classList.toggle('active', row.querySelector('[data-field="status"]').value==='active'); row.querySelector('[data-field="status"]').onchange=refreshActiveStyle;}}
|
||||||
|
function setActive(index){for(const row of rows.children){row.querySelector('[data-field="status"]').value = Number(row.dataset.index)===index ? 'active' : (row.querySelector('[data-field="status"]').value==='active'?'queued':row.querySelector('[data-field="status"]').value);} refreshActiveStyle();}
|
||||||
|
async function load(){try{const res=await fetch('gauntlet.json?cache='+Date.now(),{cache:'no-store'}); applyData(await res.json()); setStatus('Loaded gauntlet.json');}catch(e){buildRows(); setStatus('Could not load gauntlet.json; starting blank.', true);}}
|
||||||
|
async function saveDirect(){try{const data=getData(); const res=await fetch('/api/gauntlet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Save failed'); applyData(out); setStatus('Saved gauntlet.json directly. OBS will update within about 2 seconds.');}catch(e){setStatus('Direct save failed. Start with py server.py, not python -m http.server. ' + e.message, true);}}
|
||||||
|
async function advanceRun(){try{const res=await fetch('/api/advance',{method:'POST'}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Advance failed'); applyData(out); setStatus('Current game cleared; next game is active.');}catch(e){setStatus('Advance failed: '+e.message, true);}}
|
||||||
|
async function resetRun(){if(!confirm('Reset the run to the beginning? First game becomes active and all others become queued.'))return; try{const res=await fetch('/api/reset',{method:'POST'}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Reset failed'); applyData(out); setStatus('Run reset to the beginning.');}catch(e){setStatus('Reset failed: '+e.message, true);}}
|
||||||
|
function updateJsonBox(){jsonBox.value=JSON.stringify(getData(), null, 2);} function toggleJson(){updateJsonBox(); jsonBox.style.display=jsonBox.style.display==='block'?'none':'block';}
|
||||||
|
async function copyJson(){updateJsonBox(); await navigator.clipboard.writeText(jsonBox.value); setStatus('Copied JSON to clipboard.');}
|
||||||
|
function downloadJson(){updateJsonBox(); const blob=new Blob([jsonBox.value+'\n'],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='gauntlet.json'; a.click(); URL.revokeObjectURL(a.href); setStatus('Downloaded gauntlet.json.');}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>The Gauntlet Overlay</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{width:100%;min-height:100%;background:transparent;color:#f8ead2;font-family:Georgia,'Times New Roman',serif;overflow:hidden}
|
||||||
|
body{display:grid;place-items:center;padding:18px}
|
||||||
|
.ledger{width:min(1120px,96vw);min-height:142px;max-height:350px;border-radius:18px;background:linear-gradient(90deg,#2b1b12,#49301c 48%,#2a1b13);box-shadow:0 26px 80px rgba(0,0,0,.75),inset 0 0 60px rgba(255,214,148,.08);padding:16px;border:1px solid rgba(255,222,162,.28);position:relative;overflow:hidden}
|
||||||
|
.ledger:before{content:"";position:absolute;inset:10px;border:1px solid rgba(255,226,170,.18);border-radius:12px;pointer-events:none}.paper{height:100%;border-radius:12px;background:radial-gradient(circle at 30% 5%,rgba(255,248,213,.18),transparent 25%),linear-gradient(120deg,#3d2819,#1f1711);padding:22px;position:relative;overflow:hidden}.paper:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025),rgba(255,255,255,.025) 1px,transparent 1px,transparent 5px);mix-blend-mode:screen;pointer-events:none}
|
||||||
|
.quests{position:relative;z-index:1;height:100%;display:flex;flex-direction:column}.top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(255,226,170,.22);padding-bottom:10px;margin-bottom:12px;gap:16px}.top h2{font-size:30px;letter-spacing:.01em;white-space:nowrap}.rule{font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;font-size:13px;text-align:right}.list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;flex:1}.quest{min-height:66px;display:grid;grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.16);font-family:Segoe UI,Arial,sans-serif;transition:opacity .2s ease, transform .2s ease, box-shadow .2s ease}.quest.active{background:linear-gradient(90deg,rgba(182,43,43,.60),rgba(192,139,56,.24));border-color:#e1b15f;box-shadow:0 0 24px rgba(218,153,61,.20),inset 0 0 22px rgba(255,218,141,.08);transform:translateY(-1px)}.icon{width:42px;height:42px;border-radius:10px;background:#2f2117;display:grid;place-items:center;font-size:22px;box-shadow:inset 0 0 12px rgba(255,222,162,.08)}.name{font-weight:850;color:#fff3d8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:16px}.sub{font-size:12px;color:#d7c098;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tag{font-size:11px;text-transform:uppercase;letter-spacing:.12em;border:1px solid rgba(255,226,170,.22);padding:5px 8px;border-radius:999px;color:#d8bf91;white-space:nowrap}.active .tag{color:#1b110b;background:#f0c36d;border-color:#ffe0a0;font-weight:900}.cleared{opacity:.62}.cleared .tag{background:#28351f;color:#bbdea4}.failed .tag{background:#452020;color:#ffb2a8}.paused .tag{background:#2d3145;color:#bcc9ff}.empty-state{padding:22px;font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;text-align:center}
|
||||||
|
@media (max-width:760px){body{padding:10px}.top{align-items:flex-start;flex-direction:column}.rule{text-align:left}.list{grid-template-columns:1fr}.ledger{max-height:95vh}.top h2{font-size:25px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ledger"><div class="paper"><main class="quests"><div class="top"><h2>The Gauntlet Ledger</h2><div class="rule" id="summary">Loading gauntlet...</div></div><div class="list" id="list"></div></main></div></div>
|
||||||
|
<script>
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
const summary = document.getElementById('summary');
|
||||||
|
const labels = {active:'Active', queued:'Queued', cleared:'Cleared', paused:'Paused', failed:'Failed'};
|
||||||
|
const statusSub = {active:'Current game', queued:'In the gauntlet', cleared:'Cleared', paused:'Paused', failed:'Failed'};
|
||||||
|
function escapeHtml(s){return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));}
|
||||||
|
function render(data){
|
||||||
|
const games = Array.isArray(data.games) ? data.games.filter(g => g && g.title) : [];
|
||||||
|
list.innerHTML = '';
|
||||||
|
if(!games.length){list.innerHTML = '<div class="empty-state">No games added yet. Use the editor to build your gauntlet.</div>'; summary.textContent = 'Waiting for games'; return;}
|
||||||
|
const active = games.find(g => g.status === 'active');
|
||||||
|
const cleared = games.filter(g => g.status === 'cleared').length;
|
||||||
|
summary.textContent = `${cleared}/${games.length} cleared${active ? ' · Active: ' + active.title : ' · No active game'}`;
|
||||||
|
for(const game of games){
|
||||||
|
const status = labels[game.status] ? game.status : 'queued';
|
||||||
|
const subtitle = game.subtitle || statusSub[status] || '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `quest ${status}`;
|
||||||
|
div.innerHTML = `<div class="icon">${escapeHtml(game.icon || '◆')}</div><div><div class="name">${escapeHtml(game.title)}</div><div class="sub">${escapeHtml(subtitle)}</div></div><div class="tag">${labels[status]}</div>`;
|
||||||
|
list.appendChild(div);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function load(){
|
||||||
|
try{const res = await fetch('gauntlet.json?cache=' + Date.now(), {cache:'no-store'}); render(await res.json());}
|
||||||
|
catch(err){summary.textContent = 'Could not load gauntlet.json';}
|
||||||
|
}
|
||||||
|
load(); setInterval(load, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Resets the Gauntlet: first game active, all other games queued.
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/reset' | Out-Null; Write-Host 'Gauntlet reset to the beginning.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }"
|
||||||
|
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tiny local server for the Gauntlet OBS overlay.
|
||||||
|
|
||||||
|
Serves overlay.html/editor.html and adds local control endpoints:
|
||||||
|
POST /api/gauntlet -> validates/writes gauntlet.json
|
||||||
|
POST /api/advance -> mark active game cleared, activate next queued game
|
||||||
|
POST /api/reset -> first game active, remaining games queued
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VALID_STATUSES = {"active", "queued", "cleared", "paused", "failed"}
|
||||||
|
MAX_GAMES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(value: Any, max_len: int = 120) -> str:
|
||||||
|
return str(value or "").strip()[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_game(game: Any, fallback_status: str = "queued") -> dict[str, str] | None:
|
||||||
|
if not isinstance(game, dict):
|
||||||
|
return None
|
||||||
|
title = clean_text(game.get("title"), 80)
|
||||||
|
subtitle = clean_text(game.get("subtitle"), 120)
|
||||||
|
icon = clean_text(game.get("icon"), 8)
|
||||||
|
status = clean_text(game.get("status"), 20).lower()
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
status = fallback_status
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return {"title": title, "subtitle": subtitle, "icon": icon or "◆", "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_gauntlet(data: Any) -> dict[str, list[dict[str, str]]]:
|
||||||
|
games = data.get("games") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(games, list):
|
||||||
|
raise ValueError("Expected JSON object with a games array, or a games array.")
|
||||||
|
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for item in games[:MAX_GAMES]:
|
||||||
|
game = normalize_game(item)
|
||||||
|
if game:
|
||||||
|
normalized.append(game)
|
||||||
|
|
||||||
|
active_seen = False
|
||||||
|
for game in normalized:
|
||||||
|
if game["status"] == "active":
|
||||||
|
if active_seen:
|
||||||
|
game["status"] = "queued"
|
||||||
|
else:
|
||||||
|
active_seen = True
|
||||||
|
|
||||||
|
if normalized and not active_seen:
|
||||||
|
first_not_cleared = next((g for g in normalized if g["status"] != "cleared"), normalized[0])
|
||||||
|
first_not_cleared["status"] = "active"
|
||||||
|
|
||||||
|
return {"games": normalized}
|
||||||
|
|
||||||
|
|
||||||
|
def load_gauntlet(directory: str) -> dict[str, list[dict[str, str]]]:
|
||||||
|
path = Path(directory).resolve() / "gauntlet.json"
|
||||||
|
if not path.exists():
|
||||||
|
return {"games": []}
|
||||||
|
return normalize_gauntlet(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def save_gauntlet(directory: str, data: dict[str, list[dict[str, str]]]) -> None:
|
||||||
|
path = Path(directory).resolve() / "gauntlet.json"
|
||||||
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def advance_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]:
|
||||||
|
games = data.get("games", [])
|
||||||
|
active_index = next((i for i, g in enumerate(games) if g.get("status") == "active"), None)
|
||||||
|
if active_index is None:
|
||||||
|
if games:
|
||||||
|
games[0]["status"] = "active"
|
||||||
|
return {"games": games}
|
||||||
|
|
||||||
|
games[active_index]["status"] = "cleared"
|
||||||
|
for i in range(active_index + 1, len(games)):
|
||||||
|
if games[i].get("status") != "cleared":
|
||||||
|
games[i]["status"] = "active"
|
||||||
|
break
|
||||||
|
return {"games": games}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]:
|
||||||
|
games = data.get("games", [])
|
||||||
|
for index, game in enumerate(games):
|
||||||
|
game["status"] = "active" if index == 0 else "queued"
|
||||||
|
return {"games": games}
|
||||||
|
|
||||||
|
|
||||||
|
class GauntletHandler(SimpleHTTPRequestHandler):
|
||||||
|
server_version = "GauntletOverlay/1.0"
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
route = self.path.split("?", 1)[0]
|
||||||
|
try:
|
||||||
|
if route == "/api/gauntlet":
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length).decode("utf-8")
|
||||||
|
data = normalize_gauntlet(json.loads(raw))
|
||||||
|
save_gauntlet(self.directory, data)
|
||||||
|
self.send_json({"ok": True, "message": "gauntlet.json saved", **data})
|
||||||
|
return
|
||||||
|
if route == "/api/advance":
|
||||||
|
data = advance_gauntlet(load_gauntlet(self.directory))
|
||||||
|
save_gauntlet(self.directory, data)
|
||||||
|
self.send_json({"ok": True, "message": "advanced to next game", **data})
|
||||||
|
return
|
||||||
|
if route == "/api/reset":
|
||||||
|
data = reset_gauntlet(load_gauntlet(self.directory))
|
||||||
|
save_gauntlet(self.directory, data)
|
||||||
|
self.send_json({"ok": True, "message": "gauntlet reset", **data})
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_json({"ok": False, "error": str(exc)}, status=400)
|
||||||
|
return
|
||||||
|
self.send_json({"ok": False, "error": "Not found"}, status=404)
|
||||||
|
|
||||||
|
def send_json(self, payload: dict[str, Any], status: int = 200) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Run the Gauntlet OBS overlay server.")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8770, help="Port to bind. Default: 8770")
|
||||||
|
parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
handler = lambda *handler_args, **kwargs: GauntletHandler(*handler_args, directory=args.directory, **kwargs) # noqa: E731
|
||||||
|
with ThreadingHTTPServer((args.host, args.port), handler) as httpd:
|
||||||
|
print(f"Gauntlet Overlay server running at http://{args.host}:{args.port}/")
|
||||||
|
print(f"Editor: http://{args.host}:{args.port}/editor.html")
|
||||||
|
print(f"Overlay: http://{args.host}:{args.port}/overlay.html")
|
||||||
|
print("Controls: run advance-game.bat to clear the current game and activate the next one.")
|
||||||
|
print("Keep this window open while streaming. Press Ctrl+C to stop.")
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Variant: Arcade Marquee
|
||||||
|
|
||||||
|
### Design stance
|
||||||
|
High-energy stream overlay that treats the Gauntlet like an arcade event marquee.
|
||||||
|
|
||||||
|
### Key choices
|
||||||
|
- Layout: big active-game hero on the left, queue cards across the right.
|
||||||
|
- Typography: bold uppercase, compact metadata chips.
|
||||||
|
- Color: neon magenta/cyan on dark glass.
|
||||||
|
- Interaction: hover lift on game cards for review.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
- Strong at: stream energy, quick active-state readability, sponsor/OBS-style polish.
|
||||||
|
- Weak at: quieter streams, long game titles, large queue lists.
|
||||||
|
|
||||||
|
### Best for
|
||||||
|
A main overlay shown between segments or in a webcam/sidebar area where the active game needs to pop.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Gauntlet Overlay Sketch — Arcade Marquee</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0} body{min-height:100vh;background:#05040a;color:#fff;font-family:Inter,Segoe UI,Arial,sans-serif;display:grid;place-items:center;padding:28px;overflow:hidden}
|
||||||
|
.frame{width:min(1600px,96vw);height:360px;position:relative;border-radius:34px;background:linear-gradient(135deg,rgba(33,13,64,.92),rgba(7,10,25,.86));border:2px solid rgba(255,255,255,.16);box-shadow:0 0 0 1px rgba(255,0,179,.22),0 28px 90px rgba(0,0,0,.65),0 0 80px rgba(0,224,255,.15) inset;overflow:hidden;padding:24px;display:grid;grid-template-columns:430px 1fr;gap:22px}
|
||||||
|
.frame:before{content:"";position:absolute;inset:-50%;background:radial-gradient(circle at 20% 30%,rgba(255,31,200,.32),transparent 24%),radial-gradient(circle at 78% 20%,rgba(0,235,255,.24),transparent 26%),linear-gradient(90deg,transparent 0 48%,rgba(255,255,255,.04) 49% 51%,transparent 52%);animation:drift 12s linear infinite;z-index:0}.frame>*{position:relative;z-index:1}@keyframes drift{to{transform:translateX(8%) rotate(6deg)}}
|
||||||
|
.active{border-radius:26px;padding:22px;background:linear-gradient(180deg,rgba(255,255,255,.16),rgba(255,255,255,.06));border:1px solid rgba(255,255,255,.22);box-shadow:0 0 35px rgba(255,0,204,.22);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font-size:13px;letter-spacing:.22em;text-transform:uppercase;color:#63f4ff;text-shadow:0 0 12px #00d9ff}.active h1{font-size:42px;line-height:.96;text-transform:uppercase;letter-spacing:-.04em;margin:12px 0 8px;text-shadow:0 0 18px rgba(255,43,205,.5)}.meta{display:flex;gap:8px;flex-wrap:wrap}.pill{font-size:13px;border-radius:999px;padding:7px 10px;background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.16);color:#eee}.meter{height:12px;border-radius:99px;background:#17111f;overflow:hidden;border:1px solid rgba(255,255,255,.16);margin-top:12px}.fill{height:100%;width:42%;background:linear-gradient(90deg,#ff2bd1,#00eaff);box-shadow:0 0 18px #ff2bd1}
|
||||||
|
.queue{display:grid;grid-template-rows:auto 1fr;gap:14px}.bar{display:flex;justify-content:space-between;align-items:end}.title{font-size:24px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.count{color:#aaa;font-size:14px}.games{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.card{min-height:220px;border-radius:20px;padding:14px;background:rgba(8,12,28,.74);border:1px solid rgba(255,255,255,.13);position:relative;overflow:hidden;transition:.2s transform,.2s border-color}.card:hover{transform:translateY(-5px);border-color:#63f4ff}.card.active-card{background:linear-gradient(180deg,rgba(255,43,209,.35),rgba(0,234,255,.12));border-color:#ff43d6}.num{font-size:11px;color:#aaa;letter-spacing:.18em}.cover{height:92px;border-radius:15px;margin:10px 0;background:linear-gradient(135deg,var(--a),var(--b));display:grid;place-items:center;font-size:34px;box-shadow:0 12px 35px rgba(0,0,0,.35)}.game{font-size:17px;font-weight:850;line-height:1.05}.status{position:absolute;left:14px;bottom:14px;font-size:12px;color:#cfd6ff}.next{position:absolute;right:12px;top:12px;background:#0cf;color:#001018;border-radius:99px;padding:4px 7px;font-size:10px;font-weight:900}.active-card .next{background:#ff43d6;color:white}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<section class="active">
|
||||||
|
<div><div class="eyebrow">Now Running The Gauntlet</div><h1>Pokémon SoulSilver</h1><div class="meta"><span class="pill">Active Game</span><span class="pill">Nuzlocke</span><span class="pill">Run 01</span></div></div>
|
||||||
|
<div><div class="eyebrow">Gauntlet Progress</div><div class="meter"><div class="fill"></div></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="queue">
|
||||||
|
<div class="bar"><div class="title">Game Queue</div><div class="count">3 cleared · 4 waiting</div></div>
|
||||||
|
<div class="games">
|
||||||
|
<article class="card active-card"><span class="next">ACTIVE</span><div class="num">#04</div><div class="cover" style="--a:#e6262f;--b:#f5ce3e">⚡</div><div class="game">Pokémon SoulSilver</div><div class="status">Johto badge chase</div></article>
|
||||||
|
<article class="card"><span class="next">NEXT</span><div class="num">#05</div><div class="cover" style="--a:#5b3df5;--b:#00e0ff">🌙</div><div class="game">Mega Man X</div><div class="status">Queued</div></article>
|
||||||
|
<article class="card"><div class="num">#06</div><div class="cover" style="--a:#1d9f58;--b:#b5ff5e">🗡️</div><div class="game">Zelda: Minish Cap</div><div class="status">Queued</div></article>
|
||||||
|
<article class="card"><div class="num">#07</div><div class="cover" style="--a:#ff7a18;--b:#af002d">🏎️</div><div class="game">Mario Kart DS</div><div class="status">Challenge run</div></article>
|
||||||
|
<article class="card"><div class="num">#08</div><div class="cover" style="--a:#3949ab;--b:#101425">👑</div><div class="game">Castlevania</div><div class="status">Final slot</div></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Variant: RPG Quest Ledger
|
||||||
|
|
||||||
|
### Design stance
|
||||||
|
Frames the Gauntlet as a compact quest log where the active game is highlighted directly in the ledger list.
|
||||||
|
|
||||||
|
### Key choices
|
||||||
|
- Layout: two-column ledger rows without the separate current-trial panel.
|
||||||
|
- Typography: fantasy serif headings with readable UI-body details.
|
||||||
|
- Color: parchment, leather, gold, and dark red.
|
||||||
|
- Interaction: active row status styling shows state clearly.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
- Strong at: theme, storytelling, cleared/queued status clarity.
|
||||||
|
- Weak at: less modern/techy, may need simplification for tiny OBS placement.
|
||||||
|
|
||||||
|
### Best for
|
||||||
|
A Gauntlet brand identity where the list feels like a challenge board or campaign log.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Gauntlet Overlay Sketch — RPG Quest Ledger</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0} body{min-height:100vh;background:#0d0b08;color:#f8ead2;font-family:Georgia,'Times New Roman',serif;display:grid;place-items:center;padding:30px;overflow:hidden}
|
||||||
|
.ledger{width:min(1120px,96vw);height:350px;border-radius:18px;background:linear-gradient(90deg,#2b1b12,#49301c 48%,#2a1b13);box-shadow:0 26px 80px rgba(0,0,0,.75),inset 0 0 60px rgba(255,214,148,.08);padding:16px;border:1px solid rgba(255,222,162,.28);position:relative;overflow:hidden}.ledger:before{content:"";position:absolute;inset:10px;border:1px solid rgba(255,226,170,.18);border-radius:12px;pointer-events:none}.paper{height:100%;border-radius:12px;background:radial-gradient(circle at 30% 5%,rgba(255,248,213,.18),transparent 25%),linear-gradient(120deg,#3d2819,#1f1711);padding:24px;position:relative}.paper:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025),rgba(255,255,255,.025) 1px,transparent 1px,transparent 5px);mix-blend-mode:screen;pointer-events:none}
|
||||||
|
.quests{position:relative;z-index:1;height:100%;display:flex;flex-direction:column}.top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(255,226,170,.22);padding-bottom:10px;margin-bottom:12px}.top h2{font-size:30px}.rule{font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;font-size:13px}.list{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;flex:1}.quest{height:70px;display:grid;grid-template-columns:46px 1fr auto;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.16);font-family:Segoe UI,Arial,sans-serif}.quest.active{background:linear-gradient(90deg,rgba(182,43,43,.55),rgba(192,139,56,.2));border-color:#e1b15f;box-shadow:0 0 22px rgba(218,153,61,.16)}.icon{width:42px;height:42px;border-radius:10px;background:#2f2117;display:grid;place-items:center;font-size:22px}.name{font-weight:800;color:#fff3d8}.sub{font-size:12px;color:#d7c098;margin-top:2px}.tag{font-size:11px;text-transform:uppercase;letter-spacing:.12em;border:1px solid rgba(255,226,170,.22);padding:5px 8px;border-radius:999px;color:#d8bf91}.active .tag{color:#1b110b;background:#f0c36d;border-color:#ffe0a0}.done{opacity:.62;text-decoration-thickness:2px}.done .tag{background:#28351f;color:#bbdea4}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ledger"><div class="paper">
|
||||||
|
<main class="quests"><div class="top"><h2>The Gauntlet Ledger</h2><div class="rule">Set one quest as ACTIVE · track cleared and queued games</div></div><div class="list">
|
||||||
|
<div class="quest done"><div class="icon">✅</div><div><div class="name">Super Mario World</div><div class="sub">Cleared · 96 exits optional</div></div><div class="tag">Cleared</div></div>
|
||||||
|
<div class="quest done"><div class="icon">✅</div><div><div class="name">Metroid Fusion</div><div class="sub">Cleared · any%</div></div><div class="tag">Cleared</div></div>
|
||||||
|
<div class="quest active"><div class="icon">⚡</div><div><div class="name">Pokémon SoulSilver</div><div class="sub">Active · Nuzlocke ruleset</div></div><div class="tag">Active</div></div>
|
||||||
|
<div class="quest"><div class="icon">🌙</div><div><div class="name">Mega Man X</div><div class="sub">Queued · boss order TBD</div></div><div class="tag">Next</div></div>
|
||||||
|
<div class="quest"><div class="icon">🗡️</div><div><div class="name">Zelda: Minish Cap</div><div class="sub">Queued · casual clear</div></div><div class="tag">Queued</div></div>
|
||||||
|
<div class="quest"><div class="icon">👑</div><div><div class="name">Castlevania</div><div class="sub">Queued · finale candidate</div></div><div class="tag">Queued</div></div>
|
||||||
|
</div></main>
|
||||||
|
</div></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Gauntlet Overlay Sketches</title><style>*{box-sizing:border-box}body{margin:0;background:#10131c;color:#f8fafc;font-family:Segoe UI,Arial,sans-serif;padding:40px}h1{font-size:42px;margin:0 0 8px}p{color:#aab4c8}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;margin-top:28px}.card{background:#171c29;border:1px solid #2b3448;border-radius:18px;padding:20px;color:inherit;text-decoration:none;transition:.2s}.card:hover{transform:translateY(-3px);border-color:#5ddcff}.thumb{height:180px;border-radius:14px;margin-bottom:16px;display:grid;place-items:center;font-size:52px}.a{background:linear-gradient(135deg,#35104e,#071628)}.b{background:linear-gradient(135deg,#4a2f1d,#16100c)}.open{color:#7ee7ff;font-weight:800}</style></head><body><h1>Gauntlet Overlay Design Sketches</h1><p>Two visual directions for the stream overlay. These are design-only mockups with sample games and an active state.</p><div class="grid"><a class="card" href="001-arcade-marquee/index.html"><div class="thumb a">🕹️</div><h2>Arcade Marquee</h2><p>Neon, energetic, stream-forward. Active game gets a big hero panel with a horizontal queue.</p><div class="open">Open prototype →</div></a><a class="card" href="002-rpg-quest-ledger/index.html"><div class="thumb b">📜</div><h2>RPG Quest Ledger</h2><p>Gauntlet as a quest book. Strong active game spotlight with cleared/queued quest rows.</p><div class="open">Open prototype →</div></a></div></body></html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Gauntlet Overlay launcher
|
||||||
|
REM This starts the local server used by OBS, the editor, and batch controls.
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo Starting Gauntlet Overlay server...
|
||||||
|
echo.
|
||||||
|
echo Editor: http://localhost:8770/editor.html
|
||||||
|
echo Overlay: http://localhost:8770/overlay.html
|
||||||
|
echo.
|
||||||
|
echo Keep this window open while streaming.
|
||||||
|
echo Press Ctrl+C to stop the server.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where py >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
py server.py
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
where python >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
python server.py
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ERROR: Python was not found.
|
||||||
|
echo Install Python from https://www.python.org/downloads/
|
||||||
|
echo During install, check "Add Python to PATH".
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
|
:end
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.zip
|
||||||
|
data/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8765
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/party.json ] || cp /app/defaults/party.json /data/party.json && ln -sf /data/party.json /app/party.json && exec python /app/server.py --host 0.0.0.0 --port 8765 --directory /app"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Pokémon Party OBS Overlay
|
||||||
|
|
||||||
|
This is the simple Option 2 version: a static overlay page plus a party.json file.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
overlay.html - the OBS/browser overlay
|
||||||
|
editor.html - editor for the six party slots
|
||||||
|
guide.html - visual setup guide you can open in your browser
|
||||||
|
server.py - local server that lets editor.html save party.json directly
|
||||||
|
party.json - your six party slots
|
||||||
|
sprites/ - put your Pokémon sprite PNG/GIF files here
|
||||||
|
|
||||||
|
How to run locally:
|
||||||
|
1. Open a terminal in this folder.
|
||||||
|
2. Run:
|
||||||
|
py server.py
|
||||||
|
|
||||||
|
On Mac/Linux, use:
|
||||||
|
python3 server.py
|
||||||
|
3. In OBS, add a Browser Source with:
|
||||||
|
http://localhost:8765/overlay.html
|
||||||
|
4. Open the visual guide if you want setup help:
|
||||||
|
http://localhost:8765/guide.html
|
||||||
|
5. Recommended OBS browser size:
|
||||||
|
Width: 1400
|
||||||
|
Height: 230
|
||||||
|
6. Keep the server terminal open while streaming.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
Use server.py if you want the editor to save party.json directly.
|
||||||
|
Plain python -m http.server still works for viewing the overlay, but it cannot save files.
|
||||||
|
|
||||||
|
Changing your party:
|
||||||
|
Option A, recommended direct-save workflow:
|
||||||
|
1. Start the server with:
|
||||||
|
py server.py
|
||||||
|
2. Open this in your normal browser:
|
||||||
|
http://localhost:8765/editor.html
|
||||||
|
3. Fill in the six slots.
|
||||||
|
4. Click Save party.json directly.
|
||||||
|
5. OBS should update within 2 seconds.
|
||||||
|
|
||||||
|
Option B, backup static workflow:
|
||||||
|
1. Open this in your normal browser:
|
||||||
|
http://localhost:8765/editor.html
|
||||||
|
2. Fill in the six slots.
|
||||||
|
3. Click Copy JSON and paste it over the contents of party.json, or click Download party.json and replace the old file.
|
||||||
|
4. Save party.json. The overlay checks for changes every 2 seconds.
|
||||||
|
|
||||||
|
Option B, browser-storage preview workflow:
|
||||||
|
1. Open editor.html in the same browser/profile that will view the overlay.
|
||||||
|
2. Click Save to browser preview.
|
||||||
|
3. Use this overlay URL:
|
||||||
|
http://localhost:8765/overlay.html?source=browser
|
||||||
|
|
||||||
|
For OBS, Option A is more reliable because OBS may not share browser storage with your regular browser.
|
||||||
|
|
||||||
|
Each slot looks like this:
|
||||||
|
{
|
||||||
|
"species": "cyndaquil",
|
||||||
|
"nickname": "Toast",
|
||||||
|
"sprite": "sprites/cyndaquil.png",
|
||||||
|
"status": "alive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Status options:
|
||||||
|
alive - normal
|
||||||
|
boxed - yellow tag
|
||||||
|
dead - grayscale sprite and crossed text
|
||||||
|
empty - empty slot
|
||||||
|
|
||||||
|
Sprite naming:
|
||||||
|
You can name files however you want, as long as party.json points to the right path.
|
||||||
|
Examples:
|
||||||
|
sprites/cyndaquil.png
|
||||||
|
sprites/155.png
|
||||||
|
sprites/toast.gif
|
||||||
|
|
||||||
|
Layout options:
|
||||||
|
Horizontal party bar:
|
||||||
|
http://localhost:8765/overlay.html
|
||||||
|
|
||||||
|
Vertical sidebar:
|
||||||
|
http://localhost:8765/overlay.html?layout=vertical
|
||||||
|
|
||||||
|
2x3 grid:
|
||||||
|
http://localhost:8765/overlay.html?layout=grid
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The page has a transparent background for OBS.
|
||||||
|
If a sprite file is missing, the overlay shows a Poké Ball placeholder instead of breaking.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"party": [
|
||||||
|
{
|
||||||
|
"species": "cyndaquil",
|
||||||
|
"nickname": "Toast",
|
||||||
|
"sprite": "sprites/cyndaquil.png",
|
||||||
|
"status": "alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"species": "pidgey",
|
||||||
|
"nickname": "Gale",
|
||||||
|
"sprite": "sprites/pidgey.png",
|
||||||
|
"status": "alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"species": "mareep",
|
||||||
|
"nickname": "Static",
|
||||||
|
"sprite": "sprites/mareep.png",
|
||||||
|
"status": "alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"species": "",
|
||||||
|
"nickname": "",
|
||||||
|
"sprite": "",
|
||||||
|
"status": "empty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"species": "",
|
||||||
|
"nickname": "",
|
||||||
|
"sprite": "",
|
||||||
|
"status": "empty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"species": "",
|
||||||
|
"nickname": "",
|
||||||
|
"sprite": "",
|
||||||
|
"status": "empty"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Pokémon Party Editor</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #07111f;
|
||||||
|
--panel: #101d33;
|
||||||
|
--panel-2: #152642;
|
||||||
|
--line: rgba(255,255,255,.16);
|
||||||
|
--text: #f8fbff;
|
||||||
|
--muted: #b8c7df;
|
||||||
|
--accent: #f6d35b;
|
||||||
|
--blue: #79d2ff;
|
||||||
|
--green: #80ffaa;
|
||||||
|
--red: #ff8e8e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(121,210,255,.18), transparent 32rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(246,211,91,.14), transparent 30rem),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin: 0 0 8px; font-size: 30px; }
|
||||||
|
p { color: var(--muted); line-height: 1.5; }
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.slot,
|
||||||
|
.output {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.06), transparent), var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 18px 45px rgba(0,0,0,.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 18px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #06101f;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary { background: #273b5d; color: var(--text); border: 1px solid var(--line); }
|
||||||
|
button.good { background: var(--green); }
|
||||||
|
button.warn { background: var(--accent); }
|
||||||
|
button.danger { background: var(--red); }
|
||||||
|
|
||||||
|
button:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview img {
|
||||||
|
max-width: 78px;
|
||||||
|
max-height: 78px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ball {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, #fff 0 5px, #111 6px 9px, transparent 10px),
|
||||||
|
linear-gradient(#dc3144 0 46%, #161616 47% 53%, #f4f4f4 54% 100%);
|
||||||
|
border: 3px solid #111;
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #081426;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 210px;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(246,211,91,.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
body { padding: 14px; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.slot { grid-template-columns: 1fr; }
|
||||||
|
.preview { width: 100%; }
|
||||||
|
.fields { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<h1>Pokémon Party Editor</h1>
|
||||||
|
<p>Use this page to build your six-slot party and save it directly to party.json for the OBS overlay.</p>
|
||||||
|
|
||||||
|
<section class="topbar">
|
||||||
|
<button id="loadJson">Load current party.json</button>
|
||||||
|
<button id="saveFile" class="good">Save party.json directly</button>
|
||||||
|
<button id="saveBrowser" class="secondary">Save to browser preview</button>
|
||||||
|
<button id="copyJson" class="warn">Copy JSON</button>
|
||||||
|
<button id="downloadJson" class="secondary">Download party.json</button>
|
||||||
|
<button id="clearParty" class="danger">Clear all slots</button>
|
||||||
|
<div class="hint">Direct save requires running this folder with server.py, not plain python -m http.server. If direct save fails, Copy JSON and Download party.json still work as backups.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="note">Sprite path examples: sprites/cyndaquil.png, sprites/hgss/155.png, or a full PokeAPI URL. If you enter a National Dex number and leave Sprite blank, the helper buttons can fill it for you.</p>
|
||||||
|
|
||||||
|
<section id="slots" class="grid" aria-label="Party slots"></section>
|
||||||
|
|
||||||
|
<section class="output">
|
||||||
|
<h2>Generated party.json</h2>
|
||||||
|
<textarea id="jsonOutput" spellcheck="false"></textarea>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const STORAGE_KEY = 'pokemonPartyOverlay.party';
|
||||||
|
const emptySlot = () => ({ species: '', nickname: '', sprite: '', status: 'empty' });
|
||||||
|
const sample = [
|
||||||
|
{ species: 'cyndaquil', nickname: 'Toast', sprite: 'sprites/cyndaquil.png', status: 'alive' },
|
||||||
|
{ species: 'pidgey', nickname: 'Gale', sprite: 'sprites/pidgey.png', status: 'alive' },
|
||||||
|
{ species: 'mareep', nickname: 'Static', sprite: 'sprites/mareep.png', status: 'alive' },
|
||||||
|
emptySlot(), emptySlot(), emptySlot()
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeParty(data) {
|
||||||
|
const party = Array.isArray(data) ? data : data?.party;
|
||||||
|
return Array.from({ length: 6 }, (_, i) => ({ ...emptySlot(), ...(party?.[i] || {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceSpecies(value) {
|
||||||
|
return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentParty() {
|
||||||
|
return Array.from(document.querySelectorAll('.slot')).map(slot => {
|
||||||
|
const species = niceSpecies(slot.querySelector('[data-field="species"]').value);
|
||||||
|
const nickname = slot.querySelector('[data-field="nickname"]').value.trim();
|
||||||
|
const dex = slot.querySelector('[data-field="dex"]').value.trim();
|
||||||
|
let sprite = slot.querySelector('[data-field="sprite"]').value.trim();
|
||||||
|
const status = slot.querySelector('[data-field="status"]').value;
|
||||||
|
|
||||||
|
if (!sprite && species) sprite = `sprites/${species}.png`;
|
||||||
|
if (!species && !nickname && !sprite) return emptySlot();
|
||||||
|
return { species, nickname, sprite, status: status || 'alive' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyJson() {
|
||||||
|
return JSON.stringify({ party: currentParty() }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutput() {
|
||||||
|
document.getElementById('jsonOutput').value = partyJson();
|
||||||
|
updatePreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreviews() {
|
||||||
|
document.querySelectorAll('.slot').forEach(slot => {
|
||||||
|
const sprite = slot.querySelector('[data-field="sprite"]').value.trim();
|
||||||
|
const species = slot.querySelector('[data-field="species"]').value.trim();
|
||||||
|
const preview = slot.querySelector('.preview');
|
||||||
|
preview.innerHTML = '';
|
||||||
|
if (sprite || species) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = species || 'Pokémon sprite';
|
||||||
|
img.src = sprite || `sprites/${niceSpecies(species)}.png`;
|
||||||
|
img.onerror = () => {
|
||||||
|
preview.innerHTML = '<div class="ball" title="Sprite not found"></div>';
|
||||||
|
};
|
||||||
|
preview.appendChild(img);
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = '<div class="ball" title="Empty slot"></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setParty(party) {
|
||||||
|
normalizeParty(party).forEach((mon, index) => {
|
||||||
|
const slot = document.querySelector(`.slot[data-index="${index}"]`);
|
||||||
|
slot.querySelector('[data-field="species"]').value = mon.species || '';
|
||||||
|
slot.querySelector('[data-field="nickname"]').value = mon.nickname || '';
|
||||||
|
slot.querySelector('[data-field="sprite"]').value = mon.sprite || '';
|
||||||
|
slot.querySelector('[data-field="status"]').value = mon.status || (mon.species ? 'alive' : 'empty');
|
||||||
|
});
|
||||||
|
updateOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSlot(index) {
|
||||||
|
const slot = document.createElement('article');
|
||||||
|
slot.className = 'slot';
|
||||||
|
slot.dataset.index = index;
|
||||||
|
slot.innerHTML = `
|
||||||
|
<div class="preview"><div class="ball"></div></div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="slot-title">Slot ${index + 1}</div>
|
||||||
|
<label>Species
|
||||||
|
<input data-field="species" placeholder="cyndaquil" />
|
||||||
|
</label>
|
||||||
|
<label>Nickname
|
||||||
|
<input data-field="nickname" placeholder="Toast" />
|
||||||
|
</label>
|
||||||
|
<label>National Dex #
|
||||||
|
<input data-field="dex" inputmode="numeric" placeholder="155" />
|
||||||
|
</label>
|
||||||
|
<label>Status
|
||||||
|
<select data-field="status">
|
||||||
|
<option value="alive">alive</option>
|
||||||
|
<option value="dead">dead</option>
|
||||||
|
<option value="boxed">boxed</option>
|
||||||
|
<option value="empty">empty</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">Sprite path or URL
|
||||||
|
<input data-field="sprite" placeholder="sprites/cyndaquil.png" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" data-action="localHgss">Use local HGSS path</button>
|
||||||
|
<button type="button" class="secondary" data-action="pokeapiHgss">Use PokeAPI HGSS URL</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
slot.addEventListener('input', updateOutput);
|
||||||
|
slot.addEventListener('change', updateOutput);
|
||||||
|
slot.addEventListener('click', event => {
|
||||||
|
const action = event.target.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
const dex = slot.querySelector('[data-field="dex"]').value.trim();
|
||||||
|
const sprite = slot.querySelector('[data-field="sprite"]');
|
||||||
|
if (!dex) {
|
||||||
|
alert('Enter the National Dex number first. Example: Cyndaquil is 155.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'localHgss') sprite.value = `sprites/hgss/${dex}.png`;
|
||||||
|
if (action === 'pokeapiHgss') sprite.value = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/${dex}.png`;
|
||||||
|
updateOutput();
|
||||||
|
});
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentJson() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`party.json?cacheBust=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
setParty(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Could not load party.json. Make sure this folder is being served with python -m http.server.\n\n${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFileDirectly() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/party', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: partyJson()
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !result.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
||||||
|
alert('Saved directly to party.json. OBS should update within 2 seconds.');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Could not save directly. Start the overlay with server.py instead of python -m http.server.\n\nWindows command: py server.py\n\nBackup option: click Copy JSON or Download party.json.\n\n${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBrowserPreview() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, partyJson());
|
||||||
|
alert('Saved to this browser. To make OBS use this without replacing party.json, set the OBS URL to overlay.html?source=browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyJson() {
|
||||||
|
await navigator.clipboard.writeText(partyJson());
|
||||||
|
alert('Copied. Paste this over the contents of party.json, then save.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJson() {
|
||||||
|
const blob = new Blob([partyJson() + '\n'], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'party.json';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loadJson').addEventListener('click', loadCurrentJson);
|
||||||
|
document.getElementById('saveFile').addEventListener('click', saveFileDirectly);
|
||||||
|
document.getElementById('saveBrowser').addEventListener('click', saveBrowserPreview);
|
||||||
|
document.getElementById('copyJson').addEventListener('click', copyJson);
|
||||||
|
document.getElementById('downloadJson').addEventListener('click', downloadJson);
|
||||||
|
document.getElementById('clearParty').addEventListener('click', () => setParty(Array.from({ length: 6 }, emptySlot)));
|
||||||
|
|
||||||
|
const slots = document.getElementById('slots');
|
||||||
|
for (let i = 0; i < 6; i++) slots.appendChild(makeSlot(i));
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
try { setParty(JSON.parse(saved)); }
|
||||||
|
catch { setParty(sample); }
|
||||||
|
} else {
|
||||||
|
setParty(sample);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Pokémon Party OBS Overlay Guide</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #101827;
|
||||||
|
--panel: #172238;
|
||||||
|
--panel-2: #1e2d4a;
|
||||||
|
--ink: #f7fbff;
|
||||||
|
--muted: #aebbd3;
|
||||||
|
--line: rgba(255,255,255,.14);
|
||||||
|
--gold: #ffd76a;
|
||||||
|
--red: #ff5f6d;
|
||||||
|
--blue: #60d5ff;
|
||||||
|
--green: #72ee9b;
|
||||||
|
--shadow: 0 24px 70px rgba(0,0,0,.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, rgba(96,213,255,.22), transparent 30rem),
|
||||||
|
radial-gradient(circle at 82% 4%, rgba(255,215,106,.18), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #0b1120 0%, #101827 48%, #18243b 100%);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 44px 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: clamp(28px, 5vw, 58px);
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(255,255,255,.10), rgba(255,255,255,.03)),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
right: -80px;
|
||||||
|
top: -80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 34px solid rgba(255,255,255,.07);
|
||||||
|
box-shadow: inset 0 0 0 28px rgba(255,255,255,.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,215,106,.13);
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 { line-height: 1.08; margin: 0; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 850px;
|
||||||
|
font-size: clamp(2.3rem, 6vw, 5rem);
|
||||||
|
letter-spacing: -.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.17rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 17px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
border-color: rgba(255,215,106,.5);
|
||||||
|
background: linear-gradient(180deg, #ffe58d, #f5b83b);
|
||||||
|
color: #1b1606;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
grid-column: span 6;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(23,34,56,.82);
|
||||||
|
box-shadow: 0 16px 45px rgba(0,0,0,.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.full { grid-column: 1 / -1; }
|
||||||
|
.card.third { grid-column: span 4; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: clamp(1.55rem, 3vw, 2.25rem);
|
||||||
|
letter-spacing: -.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0 0 12px; color: var(--muted); }
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
ol, ul { margin: 10px 0 0; padding-left: 1.25rem; color: var(--muted); }
|
||||||
|
li { margin: 7px 0; }
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255,255,255,.09);
|
||||||
|
color: #dff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
background: #07101f;
|
||||||
|
color: #e8f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
counter-reset: step;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,.055);
|
||||||
|
border: 1px solid rgba(255,255,255,.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:before {
|
||||||
|
counter-increment: step;
|
||||||
|
content: counter(step);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: var(--gold);
|
||||||
|
color: #1b1606;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(96,213,255,.11);
|
||||||
|
border: 1px solid rgba(96,213,255,.28);
|
||||||
|
color: #daf7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,.16);
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alive { color: var(--green); }
|
||||||
|
.boxed { color: var(--gold); }
|
||||||
|
.dead { color: var(--red); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 26px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.page { width: min(100% - 24px, 1180px); padding-top: 18px; }
|
||||||
|
.card, .card.third { grid-column: 1 / -1; }
|
||||||
|
.hero { border-radius: 22px; }
|
||||||
|
.step { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="kicker">SoulSilver Nuzlocke stream tool</div>
|
||||||
|
<h1>OBS party overlay setup guide</h1>
|
||||||
|
<p>This guide walks you through running the Pokémon party overlay locally, editing your six active slots, adding HeartGold/SoulSilver sprites, and connecting everything to OBS.</p>
|
||||||
|
<nav class="quick-links" aria-label="Guide shortcuts">
|
||||||
|
<a class="button primary" href="editor.html">Open Editor</a>
|
||||||
|
<a class="button" href="overlay.html">Preview Overlay</a>
|
||||||
|
<a class="button" href="overlay.html?layout=grid">Grid Preview</a>
|
||||||
|
<a class="button" href="overlay.html?layout=vertical">Vertical Preview</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Setup guide sections">
|
||||||
|
<article class="card full">
|
||||||
|
<h2>Fast start</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div><h3>Extract the folder</h3><p>Unzip <code>pokemon-party-overlay.zip</code> somewhere easy, like <code>C:\StreamingTools\pokemon-party-overlay</code>.</p></div></div>
|
||||||
|
<div class="step"><div><h3>Start the local server</h3><p>Double-click <code>start-overlay.bat</code>. Keep that window open while streaming.</p></div></div>
|
||||||
|
<div class="step"><div><h3>Open the editor</h3><p>Go to <code>http://localhost:8765/editor.html</code>, set your Pokémon, nicknames, sprite paths, and statuses, then click <strong>Save party.json directly</strong>.</p></div></div>
|
||||||
|
<div class="step"><div><h3>Add the OBS source</h3><p>In OBS, add a Browser Source pointing to <code>http://localhost:8765/overlay.html</code>. Recommended size: <code>1400 x 230</code>.</p></div></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h2>What each file does</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>start-overlay.bat</code> starts everything on Windows.</li>
|
||||||
|
<li><code>server.py</code> serves the pages and lets the editor save directly.</li>
|
||||||
|
<li><code>editor.html</code> is your control page for changing the party.</li>
|
||||||
|
<li><code>overlay.html</code> is the OBS Browser Source page.</li>
|
||||||
|
<li><code>party.json</code> stores the six slots.</li>
|
||||||
|
<li><code>sprites/</code> stores your local PNG or GIF sprites.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h2>OBS settings</h2>
|
||||||
|
<p>Add a new Browser Source and use:</p>
|
||||||
|
<pre>URL: http://localhost:8765/overlay.html
|
||||||
|
Width: 1400
|
||||||
|
Height: 230</pre>
|
||||||
|
<p class="callout">The overlay background is transparent, so you can place it above your game capture. If you use a different layout, resize the Browser Source to fit.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card full">
|
||||||
|
<h2>Adding sprites</h2>
|
||||||
|
<p>The easiest local setup is to put HeartGold/SoulSilver sprites in <code>sprites/hgss/</code> and use National Dex numbers as filenames.</p>
|
||||||
|
<pre>pokemon-party-overlay/
|
||||||
|
sprites/
|
||||||
|
hgss/
|
||||||
|
152.png
|
||||||
|
155.png
|
||||||
|
158.png
|
||||||
|
25.png</pre>
|
||||||
|
<p>Then use paths like this in the editor:</p>
|
||||||
|
<pre>sprites/hgss/155.png</pre>
|
||||||
|
<p>Good source: <code>https://github.com/PokeAPI/sprites</code>. The HGSS folder is:</p>
|
||||||
|
<pre>sprites/pokemon/versions/generation-iv/heartgold-soulsilver/</pre>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card third">
|
||||||
|
<h2>Layouts</h2>
|
||||||
|
<p>Horizontal party bar:</p>
|
||||||
|
<pre>overlay.html</pre>
|
||||||
|
<p>Vertical sidebar:</p>
|
||||||
|
<pre>overlay.html?layout=vertical</pre>
|
||||||
|
<p>2x3 grid:</p>
|
||||||
|
<pre>overlay.html?layout=grid</pre>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card third">
|
||||||
|
<h2>Status tags</h2>
|
||||||
|
<p>Use these status values in the editor:</p>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="pill alive">alive</span>
|
||||||
|
<span class="pill boxed">boxed</span>
|
||||||
|
<span class="pill dead">dead</span>
|
||||||
|
<span class="pill">empty</span>
|
||||||
|
</div>
|
||||||
|
<p class="callout">Dead Pokémon show with grayscale styling, which is useful for Nuzlocke tracking.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card third">
|
||||||
|
<h2>If something breaks</h2>
|
||||||
|
<ul>
|
||||||
|
<li>If OBS is blank, confirm <code>start-overlay.bat</code> is still open.</li>
|
||||||
|
<li>If saving fails, make sure you started with <code>server.py</code>, not <code>python -m http.server</code>.</li>
|
||||||
|
<li>If a sprite is missing, check the path and filename spelling.</li>
|
||||||
|
<li>If port 8765 is busy, close the old server window and start again.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card full">
|
||||||
|
<h2>Example party.json slot</h2>
|
||||||
|
<p>You normally edit this through the editor, but this is what one Pokémon slot looks like under the hood:</p>
|
||||||
|
<pre>{
|
||||||
|
"species": "cyndaquil",
|
||||||
|
"nickname": "Toast",
|
||||||
|
"sprite": "sprites/hgss/155.png",
|
||||||
|
"status": "alive"
|
||||||
|
}</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Keep the server window open during stream. Update the party in the editor; OBS refreshes automatically within a couple seconds.
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Pokémon Party Overlay</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--slot-width: 190px;
|
||||||
|
--slot-height: 170px;
|
||||||
|
--accent: #f6d35b;
|
||||||
|
--accent-2: #79d2ff;
|
||||||
|
--panel: rgba(13, 23, 42, 0.78);
|
||||||
|
--panel-strong: rgba(6, 12, 28, 0.9);
|
||||||
|
--text: #fff7d6;
|
||||||
|
--muted: #bdd7ff;
|
||||||
|
--dead: #8b94a7;
|
||||||
|
--empty: rgba(255,255,255,0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", Arial, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, var(--slot-width));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
width: max-content;
|
||||||
|
filter: drop-shadow(0 12px 18px rgba(0,0,0,.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
position: relative;
|
||||||
|
width: var(--slot-width);
|
||||||
|
min-height: var(--slot-height);
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
border-radius: 22px 22px 14px 14px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.18), transparent 22%),
|
||||||
|
linear-gradient(180deg, var(--panel), var(--panel-strong));
|
||||||
|
border: 3px solid rgba(255,255,255,.32);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px rgba(0,0,0,.24),
|
||||||
|
0 8px 0 rgba(0,0,0,.28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 7px;
|
||||||
|
border-radius: 17px 17px 10px 10px;
|
||||||
|
border: 1px solid rgba(121, 210, 255, .35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.empty {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg, rgba(255,255,255,.08), rgba(255,255,255,.08) 10px, transparent 10px, transparent 20px),
|
||||||
|
linear-gradient(180deg, rgba(13,23,42,.45), rgba(6,12,28,.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.dead .sprite {
|
||||||
|
filter: grayscale(1) opacity(.58);
|
||||||
|
transform: translateY(4px) scale(.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.dead .nickname,
|
||||||
|
.slot.dead .species {
|
||||||
|
color: var(--dead);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 104px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-wrap::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 112px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
bottom: 4px;
|
||||||
|
background: radial-gradient(ellipse, rgba(0,0,0,.38), rgba(0,0,0,0) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
max-width: 122px;
|
||||||
|
max-height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokeball {
|
||||||
|
width: 78px;
|
||||||
|
height: 78px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, #fff 0 8px, #111 9px 13px, transparent 14px),
|
||||||
|
linear-gradient(#dc3144 0 46%, #161616 47% 53%, #f4f4f4 54% 100%);
|
||||||
|
opacity: .42;
|
||||||
|
border: 4px solid #111;
|
||||||
|
box-shadow: inset 0 -7px 0 rgba(0,0,0,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1.05;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 3px 0 rgba(0,0,0,.58), 0 0 10px rgba(0,0,0,.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.species {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 3px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: .92;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, var(--accent), #cf8e24);
|
||||||
|
color: #241400;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 1000;
|
||||||
|
border: 2px solid rgba(255,255,255,.68);
|
||||||
|
box-shadow: 0 3px 0 rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid rgba(255,255,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.alive { color: #8effa9; }
|
||||||
|
.status.dead { color: #ff8e8e; }
|
||||||
|
.status.boxed { color: #ffd37a; }
|
||||||
|
|
||||||
|
.party.vertical {
|
||||||
|
grid-template-columns: var(--slot-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.party.grid {
|
||||||
|
grid-template-columns: repeat(3, var(--slot-width));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="party" class="party" aria-label="Current Pokémon party"></main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const DEFAULT_PARTY = [
|
||||||
|
{ "species": "cyndaquil", "nickname": "Toast", "sprite": "sprites/cyndaquil.png", "status": "alive" },
|
||||||
|
{ "species": "totodile", "nickname": "Chomps", "sprite": "sprites/totodile.png", "status": "alive" },
|
||||||
|
{ "species": "pidgey", "nickname": "Gale", "sprite": "sprites/pidgey.png", "status": "alive" },
|
||||||
|
{ "species": "mareep", "nickname": "Static", "sprite": "sprites/mareep.png", "status": "alive" },
|
||||||
|
{ "species": "", "nickname": "", "sprite": "", "status": "empty" },
|
||||||
|
{ "species": "", "nickname": "", "sprite": "", "status": "empty" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const STORAGE_KEY = 'pokemonPartyOverlay.party';
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
refreshMs: 2000,
|
||||||
|
layout: params.get('layout') || 'horizontal',
|
||||||
|
source: params.get('source') || 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
function niceName(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/[-_]/g, ' ')
|
||||||
|
.replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParty(data) {
|
||||||
|
const party = Array.isArray(data) ? data : data.party;
|
||||||
|
return Array.from({ length: 6 }, (_, i) => party?.[i] || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(party) {
|
||||||
|
const root = document.getElementById('party');
|
||||||
|
root.className = `party ${settings.layout}`;
|
||||||
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
normalizeParty(party).forEach((mon, index) => {
|
||||||
|
const status = (mon.status || (mon.species ? 'alive' : 'empty')).toLowerCase();
|
||||||
|
const hasMon = Boolean(mon.species || mon.nickname || mon.sprite);
|
||||||
|
const slot = document.createElement('section');
|
||||||
|
slot.className = `slot ${hasMon ? status : 'empty'}`;
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'badge';
|
||||||
|
badge.textContent = index + 1;
|
||||||
|
slot.appendChild(badge);
|
||||||
|
|
||||||
|
if (hasMon && status !== 'empty') {
|
||||||
|
const statusEl = document.createElement('div');
|
||||||
|
statusEl.className = `status ${status}`;
|
||||||
|
statusEl.textContent = status;
|
||||||
|
slot.appendChild(statusEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spriteWrap = document.createElement('div');
|
||||||
|
spriteWrap.className = 'sprite-wrap';
|
||||||
|
|
||||||
|
if (mon.sprite) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'sprite';
|
||||||
|
img.alt = mon.species || mon.nickname || `Party slot ${index + 1}`;
|
||||||
|
img.src = mon.sprite;
|
||||||
|
img.onerror = () => {
|
||||||
|
img.remove();
|
||||||
|
const ball = document.createElement('div');
|
||||||
|
ball.className = 'pokeball';
|
||||||
|
spriteWrap.appendChild(ball);
|
||||||
|
};
|
||||||
|
spriteWrap.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const ball = document.createElement('div');
|
||||||
|
ball.className = 'pokeball';
|
||||||
|
spriteWrap.appendChild(ball);
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.appendChild(spriteWrap);
|
||||||
|
|
||||||
|
const nickname = document.createElement('div');
|
||||||
|
nickname.className = 'nickname';
|
||||||
|
nickname.textContent = mon.nickname || (hasMon ? niceName(mon.species) : 'Empty');
|
||||||
|
slot.appendChild(nickname);
|
||||||
|
|
||||||
|
const species = document.createElement('div');
|
||||||
|
species.className = 'species';
|
||||||
|
species.textContent = hasMon ? niceName(mon.species) : 'Party Slot';
|
||||||
|
slot.appendChild(species);
|
||||||
|
|
||||||
|
root.appendChild(slot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadParty() {
|
||||||
|
try {
|
||||||
|
if (settings.source === 'browser') {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!saved) throw new Error('No party saved in browser storage yet. Open editor.html and click Save to browser preview.');
|
||||||
|
render(JSON.parse(saved));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`party.json?cacheBust=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error(`party.json returned ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
render(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Using built-in sample party. Serve this folder over HTTP to load party.json.', error);
|
||||||
|
render(DEFAULT_PARTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadParty();
|
||||||
|
setInterval(loadParty, settings.refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tiny local server for the Pokémon Party OBS overlay.
|
||||||
|
|
||||||
|
It serves overlay.html/editor.html like `python -m http.server`, plus adds:
|
||||||
|
POST /api/party -> validates JSON and writes party.json in this folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EMPTY_SLOT = {"species": "", "nickname": "", "sprite": "", "status": "empty"}
|
||||||
|
VALID_STATUSES = {"alive", "dead", "boxed", "empty"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_slot(slot: Any) -> dict[str, str]:
|
||||||
|
if not isinstance(slot, dict):
|
||||||
|
return dict(EMPTY_SLOT)
|
||||||
|
|
||||||
|
species = str(slot.get("species", "") or "").strip().lower().replace(" ", "-")
|
||||||
|
nickname = str(slot.get("nickname", "") or "").strip()
|
||||||
|
sprite = str(slot.get("sprite", "") or "").strip()
|
||||||
|
status = str(slot.get("status", "") or "").strip().lower()
|
||||||
|
|
||||||
|
if not status or status not in VALID_STATUSES:
|
||||||
|
status = "alive" if (species or nickname or sprite) else "empty"
|
||||||
|
|
||||||
|
if not (species or nickname or sprite):
|
||||||
|
return dict(EMPTY_SLOT)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"species": species,
|
||||||
|
"nickname": nickname,
|
||||||
|
"sprite": sprite,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_party(data: Any) -> dict[str, list[dict[str, str]]]:
|
||||||
|
party = data.get("party") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(party, list):
|
||||||
|
raise ValueError("Expected JSON object with a party array, or a party array.")
|
||||||
|
|
||||||
|
slots = [normalize_slot(slot) for slot in party[:6]]
|
||||||
|
while len(slots) < 6:
|
||||||
|
slots.append(dict(EMPTY_SLOT))
|
||||||
|
return {"party": slots}
|
||||||
|
|
||||||
|
|
||||||
|
class PartyOverlayHandler(SimpleHTTPRequestHandler):
|
||||||
|
server_version = "PokemonPartyOverlay/1.0"
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802 - http.server method name
|
||||||
|
if self.path.split("?", 1)[0] != "/api/party":
|
||||||
|
self.send_json({"ok": False, "error": "Not found"}, status=404)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length).decode("utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
normalized = normalize_party(data)
|
||||||
|
party_path = Path(self.directory).resolve() / "party.json"
|
||||||
|
party_path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
|
||||||
|
except Exception as exc: # Keep this friendly for local streamer use.
|
||||||
|
self.send_json({"ok": False, "error": str(exc)}, status=400)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json({"ok": True, "message": "party.json saved"})
|
||||||
|
|
||||||
|
def send_json(self, payload: dict[str, Any], status: int = 200) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Run the Pokémon Party OBS overlay server.")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765, help="Port to bind. Default: 8765")
|
||||||
|
parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
handler = lambda *handler_args, **kwargs: PartyOverlayHandler( # noqa: E731
|
||||||
|
*handler_args,
|
||||||
|
directory=args.directory,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
with ThreadingHTTPServer((args.host, args.port), handler) as httpd:
|
||||||
|
print(f"Pokémon Party Overlay server running at http://{args.host}:{args.port}/")
|
||||||
|
print(f"Editor: http://{args.host}:{args.port}/editor.html")
|
||||||
|
print(f"Overlay: http://{args.host}:{args.port}/overlay.html")
|
||||||
|
print("Keep this window open while streaming. Press Ctrl+C to stop.")
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Drop sprite files in this folder.
|
||||||
|
|
||||||
|
Recommended formats:
|
||||||
|
PNG for still sprites
|
||||||
|
GIF for animated sprites
|
||||||
|
|
||||||
|
Then reference them from party.json like:
|
||||||
|
"sprite": "sprites/cyndaquil.png"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Pokemon Party Overlay launcher
|
||||||
|
REM This starts the local server used by OBS and the editor.
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo Starting Pokemon Party Overlay server...
|
||||||
|
echo.
|
||||||
|
echo Editor: http://localhost:8765/editor.html
|
||||||
|
echo Overlay: http://localhost:8765/overlay.html
|
||||||
|
echo.
|
||||||
|
echo Keep this window open while streaming.
|
||||||
|
echo Press Ctrl+C to stop the server.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where py >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
py server.py
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
where python >nul 2>nul
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
python server.py
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ERROR: Python was not found.
|
||||||
|
echo Install Python from https://www.python.org/downloads/
|
||||||
|
echo During install, check "Add Python to PATH".
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
|
:end
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
class PartyServerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from server import PartyOverlayHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
(self.root / "party.json").write_text('{"party": []}\n', encoding="utf-8")
|
||||||
|
(self.root / "editor.html").write_text("editor", encoding="utf-8")
|
||||||
|
|
||||||
|
handler = lambda *args, **kwargs: PartyOverlayHandler(*args, directory=str(self.root), **kwargs)
|
||||||
|
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.host, self.port = self.httpd.server_address
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.httpd.shutdown()
|
||||||
|
self.thread.join(timeout=2)
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def request(self, method, path, body=None, headers=None):
|
||||||
|
conn = HTTPConnection(self.host, self.port, timeout=5)
|
||||||
|
conn.request(method, path, body=body, headers=headers or {})
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = response.read().decode("utf-8")
|
||||||
|
conn.close()
|
||||||
|
return response.status, data, dict(response.getheaders())
|
||||||
|
|
||||||
|
def test_post_api_party_writes_normalized_party_json(self):
|
||||||
|
payload = {
|
||||||
|
"party": [
|
||||||
|
{"species": "cyndaquil", "nickname": "Toast", "sprite": "sprites/hgss/155.png", "status": "alive"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
status, body, _headers = self.request(
|
||||||
|
"POST",
|
||||||
|
"/api/party",
|
||||||
|
body=json.dumps(payload),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(json.loads(body)["ok"], True)
|
||||||
|
saved = json.loads((self.root / "party.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(len(saved["party"]), 6)
|
||||||
|
self.assertEqual(saved["party"][0]["species"], "cyndaquil")
|
||||||
|
self.assertEqual(saved["party"][1]["status"], "empty")
|
||||||
|
|
||||||
|
def test_post_api_party_rejects_invalid_json_without_overwriting_file(self):
|
||||||
|
before = (self.root / "party.json").read_text(encoding="utf-8")
|
||||||
|
status, body, _headers = self.request(
|
||||||
|
"POST",
|
||||||
|
"/api/party",
|
||||||
|
body="not json",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertIn("error", json.loads(body))
|
||||||
|
self.assertEqual((self.root / "party.json").read_text(encoding="utf-8"), before)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user