402 lines
13 KiB
HTML
402 lines
13 KiB
HTML
<!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>
|