Skip to content

Instantly share code, notes, and snippets.

@gabonator
Created June 30, 2026 11:29
Show Gist options
  • Select an option

  • Save gabonator/1ba8e4fdca2f3fae02862af1f728d2f6 to your computer and use it in GitHub Desktop.

Select an option

Save gabonator/1ba8e4fdca2f3fae02862af1f728d2f6 to your computer and use it in GitHub Desktop.
Lego sumo tournament
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEGO Robot Cup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#1a1a2e;color:#eee;min-height:100vh}
.screen{display:none;min-height:100vh}
.screen.active{display:flex;flex-direction:column}
/* ── Intro screen ── */
#screen-intro{position:relative;cursor:pointer;background:#0d0d1f}
#intro-a,#intro-b{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
#intro-a{animation:introShowA 25s ease-in-out infinite}
#intro-b{animation:introShowB 25s ease-in-out infinite;opacity:0}
#intro-logos{position:relative;width:clamp(180px,55vw,700px);height:clamp(80px,30vh,380px);margin-top:3vh}
#intro-logo1,#intro-logo2{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
#intro-logo1{animation:introLogo1 25s ease-in-out infinite}
#intro-logo2{animation:introLogo2 25s ease-in-out infinite;opacity:0}
@keyframes introLogo1{0%,17%{opacity:1}20%,100%{opacity:0}}
@keyframes introLogo2{0%,17%{opacity:0}20%,100%{opacity:1}}
.rules-panel{width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;padding:3vh 5vw;box-sizing:border-box}
.rules-title{font-size:clamp(3rem,7vw,6rem);font-weight:900;color:#fff;margin-bottom:3vh;letter-spacing:3px;text-align:center}
.rules-list{list-style:none;padding:0;display:flex;flex-direction:column;justify-content:space-evenly;flex:1}
.rules-list li{font-size:clamp(1.2rem,2.8vw,2.4rem);font-weight:600;color:#ddd;padding-left:2em;position:relative;line-height:1.3}
.rules-list li::before{content:'▸';position:absolute;left:0;color:#555}
.rules-list li.rule-orange{color:#f5a623}
.rules-list li.rule-orange::before{color:#f5a623}
.rules-list li.rule-red{color:#e94560}
.rules-list li.rule-red::before{color:#e94560}
@keyframes introShowA{0%,38%{opacity:1}40%,97%{opacity:0}100%{opacity:1}}
@keyframes introShowB{0%,38%{opacity:0}40%,97%{opacity:1}100%{opacity:0}}
#screen-intro .intro-title{
font-size:clamp(2.8rem,7vw,7rem);font-weight:900;color:#e94560;
text-align:center;letter-spacing:4px;
text-shadow:0 0 60px rgba(233,69,96,.5),0 0 120px rgba(233,69,96,.2);
animation:introPulse 3s ease-in-out infinite;
padding:0 24px;line-height:1.15;
}
@keyframes introPulse{
0%,100%{text-shadow:0 0 60px rgba(233,69,96,.5),0 0 120px rgba(233,69,96,.2)}
50%{text-shadow:0 0 80px rgba(233,69,96,.9),0 0 160px rgba(233,69,96,.4)}
}
@keyframes hintBlink{0%,100%{opacity:.35}50%{opacity:.9}}
@keyframes introFadeIn{from{opacity:0}to{opacity:1}}
#screen-intro.active{animation:introFadeIn .7s ease}
/* ── Lang switch ── */
.lang-switch{display:flex;align-items:center;gap:0;border:2px solid #0f3460;border-radius:8px;overflow:hidden;flex-shrink:0}
.lang-btn{padding:8px 14px;border:none;background:transparent;color:#888;cursor:pointer;font-size:0.85em;font-weight:700;transition:all .15s}
.lang-btn.active{background:#0f3460;color:#eee}
.lang-btn:hover:not(.active){background:rgba(255,255,255,.06);color:#ccc}
/* ── Setup screen ── */
#screen-setup{padding:20px}
.setup-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;gap:10px}
.setup-header h1{font-size:2em;color:#e94560;letter-spacing:2px;flex:1}
.header-actions{display:flex;align-items:center;gap:12px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.c-card{border-radius:12px;padding:16px;border:2px solid #0f3460;transition:border-color .2s;position:relative}
.c-card:hover{border-color:#e94560}
.c-card-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.c-dot{width:40px;height:40px;border-radius:50%;border:3px solid #fff;cursor:pointer;flex-shrink:0;transition:transform .2s}
.c-dot:hover{transform:scale(1.15)}
.c-slot{font-size:1.0em;color:#888;font-weight:700;letter-spacing:1px;text-transform:uppercase}
.c-field{margin-bottom:6px;cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s}
.c-field:hover{background:rgba(255,255,255,0.07)}
.c-field .lbl{font-size:0.9em;color:#666;text-transform:uppercase;letter-spacing:1px;min-height:0.9em}
.c-field .val{font-size:1.35em;font-weight:600;min-height:1.2em}
.c-robot{color:#e94560;font-size:1.5em}
/* ── Buttons ── */
.btn{display:inline-flex;align-items:center;gap:6px;padding:10px 22px;border-radius:8px;border:none;cursor:pointer;font-size:1em;font-weight:700;transition:all .2s}
.btn-primary{background:#e94560;color:#fff}
.btn-primary:hover{background:#c73652;transform:scale(1.03)}
.btn-secondary{background:#0f3460;color:#fff}
.btn-secondary:hover{background:#1a4a8a}
/* ── Modal ── */
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:center;justify-content:center}
.overlay.active{display:flex}
.modal{background:#16213e;border-radius:16px;padding:28px;width:380px;max-width:92vw;border:1px solid #0f3460}
.modal h2{margin-bottom:18px;color:#e94560;font-size:1.4em}
.modal label{display:block;font-size:0.75em;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;margin-top:12px}
.modal input[type=text],.modal input[type=color]{width:100%;padding:10px;background:#0f3460;border:1px solid #1a4a8a;border-radius:8px;color:#fff;font-size:1em}
.modal input[type=color]{height:44px;padding:4px;cursor:pointer}
.modal-btns{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
/* ── Bracket screen ── */
#screen-bracket{padding:20px}
.bracket-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:10px}
.bracket-header h1{font-size:1.8em;color:#e94560;flex:1}
.bracket-wrap{display:flex;gap:0;overflow-x:auto;overflow-y:auto;padding-bottom:12px;align-items:flex-start}
.b-round{display:flex;flex-direction:column;min-width:200px;position:relative}
.b-round-title{font-size:1.44em;color:#888;text-transform:uppercase;letter-spacing:1px;text-align:center;padding:3px 0 6px}
.match-card{background:#16213e;border:2px solid #0f3460;border-radius:10px;padding:8px 12px;cursor:pointer;transition:all .2s;width:100%;position:relative}
.match-card:hover{border-color:#e94560;transform:scale(1.02)}
.match-card.current{border-color:#f5a623;box-shadow:0 0 18px rgba(245,166,35,.45)}
.match-card.done{border-color:#27ae60}
.match-card.unavail{opacity:.45;cursor:default}
.match-card.unavail:hover{transform:none;border-color:#0f3460}
.mp{display:flex;align-items:center;gap:9px;padding:3px 0}
.mp-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0}
.mp-name{font-size:1.76em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
.mp.winner .mp-name{font-weight:800;color:#f5a623}
.mp-divider{border:none;border-top:1px solid #0f3460;margin:2px 0}
.mp-tbd{color:#444;font-style:italic;font-size:1.64em;padding:2px 0}
.match-tag{font-size:1.36em;color:#555;text-align:right;margin-top:2px}
/* reset X button on match cards */
.match-reset{position:absolute;top:5px;right:6px;width:18px;height:18px;border:none;background:transparent;color:#555;cursor:pointer;font-size:0.85em;line-height:1;border-radius:4px;opacity:0.25;transition:opacity .15s,color .15s;z-index:5;padding:0;display:flex;align-items:center;justify-content:center}
.match-card:hover .match-reset{opacity:0.6}
.match-reset:hover{opacity:1!important;color:#e94560}
/* champion box */
.champion-box{background:#16213e;border:3px solid #f5a623;border-radius:12px;padding:24px 20px;text-align:center;min-width:220px;margin:40px 0 0 0}
.champion-box .champ-label{font-size:1.5em;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
.champion-box .champ-name{font-size:2.8em;font-weight:900;color:#f5a623}
.champion-box .champ-robot{font-size:1.8em;color:#e94560;margin-top:6px}
.champion-box .champ-dot{width:60px;height:60px;border-radius:50%;border:3px solid #f5a623;margin:0 auto 12px}
/* ── Timer screen — fullscreen, projector-sized ── */
#screen-timer{
align-items:center;justify-content:center;flex-direction:column;
position:fixed;inset:0;z-index:50;overflow:hidden;transition:background .6s;
}
.t-vs{
display:flex;gap:clamp(30px,6vw,100px);justify-content:center;
margin-bottom:clamp(10px,2vh,30px);z-index:2;position:relative;
flex-wrap:wrap;
}
.t-player{text-align:center}
.t-pdot{
width:clamp(60px,8vw,110px);height:clamp(60px,8vw,110px);
border-radius:50%;border:4px solid #fff;margin:0 auto clamp(8px,1.5vh,16px);
}
.t-pname{font-size:clamp(1.4rem,3.5vw,3.2rem);font-weight:800;line-height:1.1}
.t-probot{font-size:clamp(0.9rem,2vw,1.8rem);color:#bbb;margin-top:4px}
.t-vs-sep{
display:flex;align-items:center;color:#444;
font-size:clamp(2.5rem,5vw,5rem);font-weight:900;
}
.t-time{
font-size:clamp(7rem,22vw,21rem);
font-weight:900;font-variant-numeric:tabular-nums;
letter-spacing:clamp(2px,1vw,12px);
z-index:2;position:relative;transition:color .4s;line-height:1;
}
.t-subtitle{
font-size:clamp(1.4rem,3.5vw,3.5rem);
font-weight:800;margin-top:clamp(6px,1.5vh,18px);
min-height:1.6em;text-align:center;z-index:2;position:relative;
}
.t-buttons{
display:flex;gap:clamp(12px,3vw,40px);
margin-top:clamp(20px,3vh,40px);
z-index:2;position:relative;flex-wrap:wrap;justify-content:center;
}
.winner-btn{
padding:clamp(12px,2vh,22px) clamp(24px,4vw,56px);
border-radius:12px;border:3px solid;
background:rgba(0,0,0,.4);color:#fff;
font-size:clamp(1rem,2.2vw,2rem);font-weight:800;
cursor:pointer;transition:all .2s;
}
.winner-btn:hover{background:rgba(255,255,255,.15);transform:scale(1.06)}
.t-back{position:absolute;top:16px;left:16px;z-index:10}
.skull{position:absolute;animation:floatUp 3.2s ease-in forwards;z-index:1;pointer-events:none;user-select:none}
@keyframes floatUp{
0%{transform:translateY(0) rotate(0deg) scale(1);opacity:1}
100%{transform:translateY(-95vh) rotate(520deg) scale(1.4);opacity:0}
}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.15}}
.pulsing{animation:pulse .75s ease-in-out infinite}
/* ── Dice overlay ── */
#dice-overlay{position:fixed;inset:0;z-index:150;background:#0a0a1a;display:none;align-items:center;justify-content:center;flex-direction:column;overflow:hidden}
#dice-overlay.active{display:flex}
.dice-main-text{font-size:clamp(2rem,5vw,4.5rem);font-weight:900;color:#e94560;z-index:2;text-align:center;letter-spacing:4px;text-shadow:0 0 40px rgba(233,69,96,.7);animation:fadeIn .6s ease}
.dice-sub-text{font-size:clamp(1rem,2.5vw,2rem);color:#aaa;margin-top:16px;z-index:2;min-height:1.8em;text-align:center;transition:opacity .4s}
.d-el{position:absolute;pointer-events:none;user-select:none;line-height:1}
@keyframes diceSpin{0%{transform:rotate(0deg) scale(1)}50%{transform:rotate(180deg) scale(1.25)}100%{transform:rotate(360deg) scale(1)}}
@keyframes diceFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-28px) rotate(15deg)}}
@keyframes fadeIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}
@keyframes fadeInUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
#dice-overlay.fading{animation:overlayFadeOut .6s ease forwards}
@keyframes overlayFadeOut{to{opacity:0}}
/* ── Fireworks overlay + canvas ── */
#fw-overlay{position:fixed;inset:0;z-index:7;background:rgba(0,0,0,.5);display:none;cursor:pointer}
@keyframes fwOverlayIn{from{opacity:0}to{opacity:1}}
@keyframes fwOverlayOut{from{opacity:1}to{opacity:0}}
#fw-canvas{position:fixed;inset:0;pointer-events:none;z-index:8;display:none}
/* responsive */
@media(max-width:700px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:450px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<!-- ══ SCREEN 0: INTRO ══ -->
<div id="screen-intro" class="screen active" onclick="showScreen('setup')">
<div id="intro-a">
<div class="intro-title" id="intro-title"></div>
<div id="intro-logos">
<img id="intro-logo1" src="logo1.png" alt="">
<img id="intro-logo2" src="logo2.png" alt="">
</div>
</div>
<div id="intro-b">
<div class="rules-panel">
<div class="rules-title">Pravidlá</div>
<ul class="rules-list">
<li>Ak robot neopustí štartovaciu zónu do 30 sekúnd, prehráva</li>
<li>Ak z robota odpadne ľubovolný komponent, prehráva</li>
<li>Ak robot opustí herný plán celou svojou konštrukciou, prehráva</li>
<li class="rule-orange">Ak robot opustí herný plán celou hlavnou nápravou po 3 minútach, prehráva</li>
<li class="rule-red">Ak robot opustí herný plán jedným kolesom nápravy po 4 minútach, prehráva</li>
<li class="rule-red">Pri remíze určuje víťaza publikum, hodnotí vzhľad robota</li>
</ul>
</div>
</div>
</div>
<!-- ══ SCREEN 1: SETUP ══ -->
<div id="screen-setup" class="screen">
<div class="setup-header">
<h1 id="setup-title"></h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="showScreen('intro')" id="btn-home">&#8592;</button>
<div class="lang-switch">
<button class="lang-btn" id="lang-en-setup" onclick="setLang('en')">EN</button>
<button class="lang-btn" id="lang-sk-setup" onclick="setLang('sk')">SK</button>
</div>
<button class="btn btn-primary" id="btn-next" onclick="goToBracket()"></button>
</div>
</div>
<div class="grid" id="setup-grid"></div>
</div>
<!-- ══ SCREEN 2: BRACKET ══ -->
<div id="screen-bracket" class="screen">
<div class="bracket-header">
<h1 id="bracket-title"></h1>
<div class="header-actions">
<div class="lang-switch">
<button class="lang-btn" id="lang-en-bracket" onclick="setLang('en')">EN</button>
<button class="lang-btn" id="lang-sk-bracket" onclick="setLang('sk')">SK</button>
</div>
<button class="btn btn-secondary" id="btn-back" onclick="showScreen('setup')"></button>
</div>
</div>
<div class="bracket-wrap" id="bracket-wrap"></div>
</div>
<!-- ══ SCREEN 3: TIMER ══ -->
<div id="screen-timer" class="screen">
<button class="btn btn-secondary t-back" onclick="backFromTimer()">&#8592;</button>
<div class="t-vs" id="t-vs"></div>
<div class="t-time" id="t-time">5:00</div>
<div class="t-subtitle" id="t-subtitle"></div>
<div class="t-buttons" id="t-buttons"></div>
</div>
<!-- ══ DICE OVERLAY ══ -->
<div id="dice-overlay">
<div class="dice-main-text" id="dice-main"></div>
<div class="dice-sub-text" id="dice-sub"></div>
</div>
<!-- ══ FIREWORKS ══ -->
<div id="fw-overlay"></div>
<canvas id="fw-canvas"></canvas>
<!-- ══ MODAL ══ -->
<div class="overlay" id="modal-overlay">
<div class="modal" id="modal">
<h2 id="modal-title"></h2>
<label id="lbl-color"></label>
<input type="color" id="m-color">
<label id="lbl-fname"></label>
<input type="text" id="m-fname">
<label id="lbl-lname"></label>
<input type="text" id="m-lname">
<label id="lbl-robot"></label>
<input type="text" id="m-robot">
<div class="modal-btns">
<button class="btn btn-secondary" id="btn-cancel" onclick="closeModal()"></button>
<button class="btn btn-primary" id="btn-save" onclick="saveModal()"></button>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════
// I18N
// ═══════════════════════════════════════════════
const I18N = {
en: {
appTitle: 'LEGO Robot Cup',
contestTree: 'Contest Tree',
next: 'Next \u2192',
back: '\u2190 Back',
contestant: 'Contestant',
firstName: 'First Name',
lastName: 'Last Name',
robotName: 'Robot Name',
editTitle: 'Edit Contestant',
color: 'Color',
cancel: 'Cancel',
save: 'Save',
preliminary: 'Preliminary',
qf: 'Quarterfinals',
qfN: ['QF 1','QF 2','QF 3','QF 4'],
sf: 'Semifinals',
sfN: ['Semifinal 1','Semifinal 2'],
final: 'Final',
champion: 'Champion',
tbd: 'TBD',
wins: 'Wins',
deathMode: '\u2620 DEATH MODE \u2620',
finalMin: '\u2620 FINAL MINUTE \u2620',
drawing: 'DRAWING LOTS\u2026',
letsgo: "LET'S GO!",
clickHint: 'tap to continue',
},
sk: {
appTitle: 'OKND Lego Sumo súboj',
contestTree: 'Z\u00e1pasy',
next: 'Dalej \u2192',
back: '\u2190 Naspäť',
contestant: 'Sut\u00e1\u017eiaci',
firstName: 'Meno',
lastName: '',
robotName: 'Robot',
editTitle: 'Upravi\u0165 sut\u00e1\u017eiaceho',
color: 'Farba',
cancel: 'Zru\u0161i\u0165',
save: 'Ulo\u017ei\u0165',
preliminary: 'Vyraďovačka',
qf: '\u0160tvrtfin\u00e1le',
qfN: ['\u0160F 1','\u0160F 2','\u0160F 3','\u0160F 4'],
sf: 'Semifin\u00e1le',
sfN: ['Semifin\u00e1le 1','Semifin\u00e1le 2'],
final: 'Fin\u00e1le',
champion: 'V\u00ed\u0165az',
tbd: 'TBD',
wins: 'V\u00ed\u0165az\u00ed',
deathMode: '\u2620 DEATH MODE \u2620',
finalMin: '\u2620 POSLEDN\u00c1 MIN\u00daTA \u2620',
drawing: 'LOSOVANIE\u2026',
letsgo: 'POĎME!',
clickHint: 'klikni pre pokra\u010dovanie',
}
};
function t(key) { return I18N[lang][key]; }
// ═══════════════════════════════════════════════
// DEFAULT DATA — edit this JSON to preset values
// ═══════════════════════════════════════════════
/*
const DEFAULT_CONTESTANTS = [
{color:"#e74c3c", firstName:"Alice", lastName:"Novak", robotName:"Thunderbolt"},
{color:"#3498db", firstName:"Boris", lastName:"Kral", robotName:"IronClaw"},
{color:"#2ecc71", firstName:"Clara", lastName:"Horak", robotName:"SwiftFire"},
{color:"#f39c12", firstName:"David", lastName:"Blaho", robotName:"TitanX"},
{color:"#9b59b6", firstName:"Elena", lastName:"Mraz", robotName:"VortexX"},
{color:"#1abc9c", firstName:"Filip", lastName:"Cerny", robotName:"CyberFang"},
{color:"#e67e22", firstName:"Gabika", lastName:"Valky", robotName:"SteelWind"},
{color:"#e91e63", firstName:"Hana", lastName:"Kovac", robotName:"PhoenixX"},
{color:"#00bcd4", firstName:"Igor", lastName:"Polak", robotName:"NovaStar"},
];
*/
const DEFAULT_CONTESTANTS = [
{
"color": "#45df3a",
"firstName": "Terezka",
"lastName": "Hricková",
"robotName": "Thboot"
},
{
"color": "#db33c5",
"firstName": "Richard",
"lastName": "Bittner",
"robotName": "Neviem"
},
{
"color": "#c12ecc",
"firstName": "Samuel",
"lastName": "Lípa",
"robotName": "Strašpytel"
},
{
"color": "#712dd7",
"firstName": "Juraj",
"lastName": "Pokrivčák",
"robotName": "Alexo"
},
{
"color": "#9b59b6",
"firstName": "Rasťo",
"lastName": "Papp",
"robotName": "Blbeček"
},
{
"color": "#1abc9c",
"firstName": "Leonard",
"lastName": "Lilko",
"robotName": "Vrták3"
},
{
"color": "#df0c0c",
"firstName": "Oskar",
"lastName": "Mojžiš",
"robotName": "zetal|smooth"
},
{
"color": "#d1eb0f",
"firstName": "Marián",
"lastName": "Bušinský",
"robotName": "Autobus"
},
{
"color": "#8f2791",
"firstName": "Patrik",
"lastName": "Šovár",
"robotName": "vortex"
}
];
// Quasi-random slot assignment (fixed lookup table, indices 0-8)
let SLOT_ORDER = [3, 7, 1, 5, 0, 8, 2, 6, 4];
SLOT_ORDER.reverse();
//const SLOT_ORDER = [3, 7, 1, 5, 0, 8, 2, 6, 4];
// ═══════════════════════════════════════════════
// BRACKET DEFINITION
// p1/p2: {type:'slot'|'match', idx:N}
// ═══════════════════════════════════════════════
const MATCHES = [
{id:0, labelKey:'preliminary', round:0, p1:{type:'slot',idx:0}, p2:{type:'slot',idx:1}},
{id:1, labelKey:'qf0', round:1, p1:{type:'slot',idx:2}, p2:{type:'slot',idx:3}},
{id:2, labelKey:'qf1', round:1, p1:{type:'slot',idx:4}, p2:{type:'slot',idx:5}},
{id:3, labelKey:'qf2', round:1, p1:{type:'slot',idx:6}, p2:{type:'slot',idx:7}},
{id:4, labelKey:'qf3', round:1, p1:{type:'slot',idx:8}, p2:{type:'match',idx:0}},
{id:5, labelKey:'sf0', round:2, p1:{type:'match',idx:1}, p2:{type:'match',idx:2}},
{id:6, labelKey:'sf1', round:2, p1:{type:'match',idx:3}, p2:{type:'match',idx:4}},
{id:7, labelKey:'final', round:3, p1:{type:'match',idx:5}, p2:{type:'match',idx:6}},
];
function matchLabel(m) {
const k = m.labelKey;
if (k === 'preliminary') return t('preliminary');
if (k.startsWith('qf')) return t('qfN')[parseInt(k.slice(2))];
if (k.startsWith('sf')) return t('sfN')[parseInt(k.slice(2))];
if (k === 'final') return t('final');
return k;
}
const ROUND_NAME_KEYS = ['preliminary','qf','sf','final'];
// ═══════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════
let contestants, results, lang;
function loadState() {
try {
const saved = localStorage.getItem('legocup_state');
if (saved) {
const d = JSON.parse(saved);
contestants = d.contestants || JSON.parse(JSON.stringify(DEFAULT_CONTESTANTS));
results = d.results || {};
lang = d.lang || 'sk';
} else {
contestants = JSON.parse(JSON.stringify(DEFAULT_CONTESTANTS));
results = {};
lang = 'sk';
}
} catch(e) {
contestants = JSON.parse(JSON.stringify(DEFAULT_CONTESTANTS));
results = {};
lang = 'sk';
}
}
function saveState() {
const data = {contestants, results, lang};
localStorage.setItem('legocup_state', JSON.stringify(data));
console.log('%c[LEGO Cup State]', 'color:#e94560;font-weight:bold', JSON.stringify({contestants, results}, null, 2));
}
// ═══════════════════════════════════════════════
// LANGUAGE
// ═══════════════════════════════════════════════
function setLang(l) {
lang = l;
saveState();
applyLang();
renderSetup();
// re-render bracket if visible
if (document.getElementById('screen-bracket').classList.contains('active')) renderBracket();
}
function applyLang() {
// update all lang buttons
['setup','bracket'].forEach(screen => {
const en = document.getElementById('lang-en-'+screen);
const sk = document.getElementById('lang-sk-'+screen);
if (en) { en.classList.toggle('active', lang==='en'); sk.classList.toggle('active', lang==='sk'); }
});
// static text
document.getElementById('intro-title').textContent = t('appTitle');
document.getElementById('setup-title').textContent = t('appTitle');
document.getElementById('bracket-title').textContent = t('contestTree');
document.getElementById('btn-next').textContent = t('next');
document.getElementById('btn-back').textContent = t('back');
document.getElementById('modal-title').textContent = t('editTitle');
document.getElementById('lbl-color').textContent = t('color');
document.getElementById('lbl-fname').textContent = t('firstName');
document.getElementById('lbl-lname').textContent = t('lastName');
document.getElementById('lbl-robot').textContent = t('robotName');
document.getElementById('btn-cancel').textContent = t('cancel');
document.getElementById('btn-save').textContent = t('save');
document.getElementById('m-fname').placeholder = t('firstName');
document.getElementById('m-robot').placeholder = t('robotName');
}
// ═══════════════════════════════════════════════
// CONTESTANT HELPERS
// ═══════════════════════════════════════════════
function slottedContestant(slotIdx) {
return contestants[SLOT_ORDER[slotIdx]];
}
function contestantInMatch(matchDef, side) {
const ref = matchDef[side];
if (ref.type === 'slot') return slottedContestant(ref.idx);
const winnerId = results[ref.idx];
if (winnerId == null) return null;
return contestants[winnerId];
}
function resolveParticipants(matchId) {
const m = MATCHES[matchId];
return { p1: contestantInMatch(m,'p1'), p2: contestantInMatch(m,'p2') };
}
function contestantIndex(c) { return contestants.indexOf(c); }
function nextMatchId() {
for (let i = 0; i < MATCHES.length; i++) {
const {p1,p2} = resolveParticipants(i);
if (p1 && p2 && results[i] == null) return i;
}
return null;
}
// ═══════════════════════════════════════════════
// RESET MATCH (cascade)
// ═══════════════════════════════════════════════
function getDependents(matchId) {
const deps = new Set();
for (const m of MATCHES) {
if ((m.p1.type==='match' && m.p1.idx===matchId) ||
(m.p2.type==='match' && m.p2.idx===matchId)) {
deps.add(m.id);
getDependents(m.id).forEach(d => deps.add(d));
}
}
return deps;
}
function resetMatch(id, evt) {
if (evt) evt.stopPropagation();
const toReset = new Set([id, ...getDependents(id)]);
if (toReset.has(7)) stopFireworks();
toReset.forEach(mid => delete results[mid]);
saveState();
renderBracket();
}
// ═══════════════════════════════════════════════
// SCREENS
// ═══════════════════════════════════════════════
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-'+id).classList.add('active');
}
// ═══════════════════════════════════════════════
// SETUP SCREEN
// ═══════════════════════════════════════════════
function renderSetup() {
const grid = document.getElementById('setup-grid');
grid.innerHTML = '';
contestants.forEach((c, i) => {
const card = document.createElement('div');
card.className = 'c-card';
card.style.background = hexToRgba(c.color, 0.12);
card.style.borderColor = hexToRgba(c.color, 0.5);
const lnLabel = t('lastName');
card.innerHTML = `
<div class="c-card-header">
<div class="c-dot" style="background:${c.color}" title="${t('color')}" onclick="openModal(${i},'color')"></div>
<span class="c-slot">${t('contestant')} ${i+1}</span>
</div>
<div class="c-field" onclick="openModal(${i},'fname')">
<div class="lbl">${t('firstName')}</div>
<div class="val">${esc(c.firstName) || '<em style="color:#555">—</em>'}</div>
</div>
<div class="c-field" onclick="openModal(${i},'lname')">
<div class="lbl">${lnLabel}</div>
<div class="val">${esc(c.lastName) || '<em style="color:#555">—</em>'}</div>
</div>
<div class="c-field" onclick="openModal(${i},'robot')">
<div class="lbl">${t('robotName')}</div>
<div class="val c-robot">${esc(c.robotName) || '<em style="color:#555">—</em>'}</div>
</div>
`;
grid.appendChild(card);
});
}
function hexToRgba(hex, alpha) {
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${alpha})`;
}
function esc(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Modal ───
let modalIdx = null;
function openModal(idx, focus) {
modalIdx = idx;
const c = contestants[idx];
document.getElementById('m-color').value = c.color;
document.getElementById('m-fname').value = c.firstName;
document.getElementById('m-lname').value = c.lastName;
document.getElementById('m-robot').value = c.robotName;
document.getElementById('modal-overlay').classList.add('active');
setTimeout(() => {
const map = {color:'m-color',fname:'m-fname',lname:'m-lname',robot:'m-robot'};
document.getElementById(map[focus]||'m-fname').focus();
}, 80);
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('active');
modalIdx = null;
}
function saveModal() {
if (modalIdx == null) return;
contestants[modalIdx] = {
color: document.getElementById('m-color').value,
firstName: document.getElementById('m-fname').value.trim(),
lastName: document.getElementById('m-lname').value.trim(),
robotName: document.getElementById('m-robot').value.trim(),
};
saveState();
closeModal();
renderSetup();
}
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target===document.getElementById('modal-overlay')) closeModal();
});
document.getElementById('modal').addEventListener('keydown', e => {
if (e.key==='Enter') saveModal();
if (e.key==='Escape') closeModal();
});
function goToBracket() {
showDiceAnimation(() => {
renderBracket();
showScreen('bracket');
});
}
// ═══════════════════════════════════════════════
// BRACKET SCREEN
// ═══════════════════════════════════════════════
function renderBracket() {
const wrap = document.getElementById('bracket-wrap');
wrap.innerHTML = '';
const next = nextMatchId();
const champion = results[7] != null ? contestants[results[7]] : null;
const rounds = [0,1,2,3].map(r => MATCHES.filter(m => m.round===r));
const SLOT_H = 100;
const TOTAL_H = 8 * SLOT_H;
// [top-slot, span-slots] per match id
const matchPositions = [
{top:0,span:2}, // 0 prelim
{top:0,span:2}, // 1 qf1
{top:2,span:2}, // 2 qf2
{top:4,span:2}, // 3 qf3
{top:6,span:2}, // 4 qf4
{top:0,span:4}, // 5 sf1
{top:4,span:4}, // 6 sf2
{top:0,span:8}, // 7 final
];
rounds.forEach((roundMatches, ri) => {
const col = document.createElement('div');
col.className = 'b-round';
col.style.cssText = `height:${TOTAL_H}px;position:relative;width:300px`;
const titleEl = document.createElement('div');
titleEl.className = 'b-round-title';
titleEl.style.cssText = 'position:absolute;top:0;left:0;right:0';
titleEl.textContent = t(ROUND_NAME_KEYS[ri]);
col.appendChild(titleEl);
roundMatches.forEach(m => {
const pos = matchPositions[m.id];
const topPx = 30 + pos.top * SLOT_H;
const heightPx = pos.span * SLOT_H - 10;
const mwrap = document.createElement('div');
mwrap.style.cssText = `position:absolute;top:${topPx}px;left:8px;right:8px;height:${heightPx}px;display:flex;align-items:center`;
const {p1,p2} = resolveParticipants(m.id);
const isAvail = p1 && p2;
const isDone = results[m.id] != null;
const isCurrent = m.id === next;
const winnerIdx = results[m.id];
const card = document.createElement('div');
card.className = 'match-card'
+ (isCurrent ? ' current' : '')
+ (isDone ? ' done' : '')
+ (!isAvail ? ' unavail' : '');
if (isAvail) {
card.addEventListener('click', () => startTimer(m.id));
}
// ── Reset button (only on completed matches) ──
if (isDone) {
const resetBtn = document.createElement('button');
resetBtn.className = 'match-reset';
resetBtn.title = 'Reset';
resetBtn.textContent = '✕';
resetBtn.addEventListener('click', e => resetMatch(m.id, e));
card.appendChild(resetBtn);
}
// ── Render each participant independently ──
const p1IsWinner = isDone && p1 && winnerIdx===contestantIndex(p1);
const p2IsWinner = isDone && p2 && winnerIdx===contestantIndex(p2);
function mpRow(c, isWinner) {
if (c) return `<div class="mp ${isWinner?'winner':''}">
<div class="mp-dot" style="background:${c.color}"></div>
<span class="mp-name">${esc(c.firstName)} ${esc(c.lastName)}</span>
</div>`;
return `<div class="mp"><span class="mp-tbd">${t('tbd')}</span></div>`;
}
const inner = document.createElement('div');
inner.innerHTML = mpRow(p1, p1IsWinner)
+ '<hr class="mp-divider">'
+ mpRow(p2, p2IsWinner)
+ `<div class="match-tag">${matchLabel(m)}${isCurrent?' ▶':''}</div>`;
card.appendChild(inner);
mwrap.appendChild(card);
col.appendChild(mwrap);
});
wrap.appendChild(col);
// SVG connector between rounds
if (ri < 3) {
const conn = document.createElement('div');
conn.style.cssText = `width:30px;height:${TOTAL_H}px;flex-shrink:0;position:relative`;
const pairs = getConnectorPairs(ri);
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('width','30');
svg.setAttribute('height',String(TOTAL_H));
svg.style.cssText = 'position:absolute;top:0;left:0';
pairs.forEach(([ft,fh,tt,th]) => {
const y1 = 30 + ft*SLOT_H + fh*SLOT_H/2;
const y2 = 30 + tt*SLOT_H + th*SLOT_H/2;
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('d',`M0,${y1} H15 V${y2} H30`);
path.setAttribute('stroke','#1a4a8a');
path.setAttribute('stroke-width','2');
path.setAttribute('fill','none');
svg.appendChild(path);
});
conn.appendChild(svg);
wrap.appendChild(conn);
}
});
// Champion box
if (champion) {
const chbox = document.createElement('div');
chbox.style.cssText = `margin-top:${30+3.5*SLOT_H}px;margin-left:8px`;
chbox.className = 'champion-box';
chbox.innerHTML = `
<div class="champ-label">${t('champion')}</div>
<div class="champ-dot" style="background:${champion.color}"></div>
<div class="champ-name">${esc(champion.firstName)} ${esc(champion.lastName)}</div>
<div class="champ-robot">${esc(champion.robotName)}</div>
`;
wrap.appendChild(chbox);
if (newChampionJustDeclared) {
newChampionJustDeclared = false;
startFireworks();
}
} else {
stopFireworks();
}
}
function getConnectorPairs(fromRound) {
if (fromRound===0) return [[0,2, 6,2]];
if (fromRound===1) return [[0,2,0,4],[2,2,0,4],[4,2,4,4],[6,2,4,4]];
if (fromRound===2) return [[0,4,0,8],[4,4,0,8]];
return [];
}
// ═══════════════════════════════════════════════
// TIMER SCREEN
// ═══════════════════════════════════════════════
let timerInterval = null;
let timerMatchId = null;
let timerSeconds = 0;
function startTimer(matchId) {
timerMatchId = matchId;
const {p1,p2} = resolveParticipants(matchId);
document.getElementById('t-vs').innerHTML = `
<div class="t-player">
<div class="t-pdot" style="background:${p1.color}"></div>
<div class="t-pname">${esc(p1.firstName)} ${esc(p1.lastName)}</div>
<div class="t-probot">${esc(p1.robotName)}</div>
</div>
<div class="t-vs-sep">VS</div>
<div class="t-player">
<div class="t-pdot" style="background:${p2.color}"></div>
<div class="t-pname">${esc(p2.firstName)} ${esc(p2.lastName)}</div>
<div class="t-probot">${esc(p2.robotName)}</div>
</div>
`;
document.getElementById('t-buttons').innerHTML = `
<button class="winner-btn" style="border-color:${p1.color}"
onclick="declareWinner(${contestantIndex(p1)})">
${esc(p1.firstName)} ${t('wins')}
</button>
<button class="winner-btn" style="border-color:${p2.color}"
onclick="declareWinner(${contestantIndex(p2)})">
${esc(p2.firstName)} ${t('wins')}
</button>
`;
stopTimer();
clearSkulls();
timerSeconds = 5*60;
const screen = document.getElementById('screen-timer');
screen.style.background = '#1a1a2e';
const timeEl = document.getElementById('t-time');
timeEl.className = 't-time';
timeEl.style.color = '#ffffff';
document.getElementById('t-subtitle').textContent = '';
updateTimerDisplay();
showScreen('timer');
timerInterval = setInterval(tickTimer, 1000);
}
function tickTimer() {
timerSeconds--;
if (timerSeconds < 0) timerSeconds = 0;
updateTimerDisplay();
applyTimerEffects();
if (timerSeconds===0) stopTimer();
}
function updateTimerDisplay() {
const m = Math.floor(timerSeconds/60);
const s = timerSeconds%60;
document.getElementById('t-time').textContent = `${m}:${s.toString().padStart(2,'0')}`;
}
function applyTimerEffects() {
const screen = document.getElementById('screen-timer');
const timeEl = document.getElementById('t-time');
const sub = document.getElementById('t-subtitle');
if (timerSeconds <= 60) {
screen.style.background = '#2a0000';
timeEl.style.color = '#ff2222';
timeEl.classList.add('pulsing');
sub.textContent = t('finalMin');
sub.style.color = '#ff4444';
spawnSkulls(3);
} else if (timerSeconds <= 120) {
screen.style.background = '#1a1500';
timeEl.style.color = '#f5c623';
timeEl.classList.remove('pulsing');
sub.textContent = t('deathMode');
sub.style.color = '#f5c623';
spawnSkulls(1);
} else {
screen.style.background = '#1a1a2e';
timeEl.style.color = '#ffffff';
timeEl.classList.remove('pulsing');
sub.textContent = '';
}
}
function spawnSkulls(count) {
const container = document.getElementById('screen-timer');
for (let i = 0; i < count; i++) {
setTimeout(() => {
const el = document.createElement('div');
el.className = 'skull';
el.textContent = Math.random() < 0.5 ? '\uD83D\uDC80' : '\u2620';
el.style.left = (Math.random()*90)+'vw';
el.style.bottom = '-80px';
el.style.fontSize = (2+Math.random()*3)+'em';
el.style.animationDuration = (2.5+Math.random()*2)+'s';
el.style.animationDelay = (Math.random()*0.4)+'s';
container.appendChild(el);
setTimeout(()=>el.remove(), 5500);
}, i*180);
}
}
function clearSkulls() {
document.querySelectorAll('#screen-timer .skull').forEach(s=>s.remove());
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval=null; }
}
function declareWinner(contestantIdx) {
stopTimer();
clearSkulls();
results[timerMatchId] = contestantIdx;
if (timerMatchId === 7) newChampionJustDeclared = true;
saveState();
renderBracket();
showScreen('bracket');
}
function backFromTimer() {
stopTimer();
clearSkulls();
renderBracket();
showScreen('bracket');
}
document.addEventListener('keydown', e => {
if (e.code === 'Space' && timerInterval !== null) {
e.preventDefault();
timerSeconds = Math.max(0, timerSeconds - 30);
updateTimerDisplay();
applyTimerEffects();
if (timerSeconds === 0) stopTimer();
}
});
// ═══════════════════════════════════════════════
// DICE ANIMATION
// ═══════════════════════════════════════════════
const DICE_POSITIONS = [
{top:'8%', left:'8%'}, {top:'6%', left:'45%'}, {top:'10%', right:'9%'},
{top:'42%',left:'3%'}, {top:'45%', right:'3%'},
{bottom:'12%',left:'12%'}, {bottom:'8%', left:'42%'}, {bottom:'10%', right:'10%'},
{top:'25%', left:'22%'},
];
function showDiceAnimation(callback) {
const overlay = document.getElementById('dice-overlay');
const mainEl = document.getElementById('dice-main');
const subEl = document.getElementById('dice-sub');
// Clear old dice
overlay.querySelectorAll('.d-el').forEach(d => d.remove());
overlay.classList.remove('fading');
overlay.style.opacity = '';
mainEl.textContent = t('drawing');
subEl.textContent = '';
subEl.style.opacity = '1';
// Spawn dice
const faces = ['\u2680','\u2681','\u2682','\u2683','\u2684','\u2685'];
DICE_POSITIONS.forEach((pos, i) => {
const el = document.createElement('div');
el.className = 'd-el';
el.textContent = faces[i % 6];
const sz = 3.5 + (i % 3) * 1.8;
Object.assign(el.style, {
fontSize: sz + 'em',
opacity: 0.18 + (i % 4) * 0.1,
animationName: i % 2 === 0 ? 'diceSpin' : 'diceFloat',
animationDuration: (1.4 + i * 0.35) + 's',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
...pos,
});
overlay.appendChild(el);
});
overlay.classList.add('active');
// "Let's go!" at 3.6s
const t1 = setTimeout(() => {
subEl.style.opacity = '0';
setTimeout(() => {
mainEl.style.animation = 'fadeIn .5s ease';
mainEl.textContent = t('letsgo');
mainEl.style.color = '#f5a623';
mainEl.style.textShadow = '0 0 40px rgba(245,166,35,.8)';
subEl.style.opacity = '1';
}, 400);
}, 3600);
// Fade out and reveal bracket at 5s
const t2 = setTimeout(() => {
overlay.classList.add('fading');
setTimeout(() => {
overlay.classList.remove('active','fading');
overlay.style.opacity = '';
mainEl.style.color = '';
mainEl.style.textShadow = '';
mainEl.style.animation = '';
callback();
}, 600);
}, 5000);
// Allow skip by clicking
overlay.onclick = () => {
clearTimeout(t1); clearTimeout(t2);
overlay.onclick = null;
overlay.classList.remove('active','fading');
overlay.style.opacity = '';
mainEl.style.color = ''; mainEl.style.textShadow = ''; mainEl.style.animation = '';
callback();
};
}
// ═══════════════════════════════════════════════
// FIREWORKS
// ═══════════════════════════════════════════════
let fwRaf = null;
let fwLaunchTimer = null;
let newChampionJustDeclared = false;
const fwRockets = [];
const fwParticles = [];
class FwParticle {
constructor(x, y, color) {
this.x = x; this.y = y; this.color = color;
this.vx = (Math.random() - 0.5) * 9;
this.vy = (Math.random() - 0.5) * 9;
this.alpha = 1;
this.decay = 0.013 + Math.random() * 0.012;
this.r = 1.8 + Math.random() * 2;
this.trail = [];
}
update() {
this.trail.push({x: this.x, y: this.y});
if (this.trail.length > 5) this.trail.shift();
this.x += this.vx; this.y += this.vy;
this.vy += 0.12;
this.vx *= 0.98;
this.alpha -= this.decay;
}
draw(ctx) {
ctx.save();
this.trail.forEach((pt, i) => {
ctx.globalAlpha = this.alpha * (i / this.trail.length) * 0.4;
ctx.fillStyle = this.color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, this.r * 0.7, 0, Math.PI * 2); ctx.fill();
});
ctx.globalAlpha = this.alpha;
ctx.fillStyle = this.color;
ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
class FwRocket {
constructor(canvas) {
this.x = canvas.width * (0.15 + Math.random() * 0.7);
this.y = canvas.height + 5;
this.vy = -(12 + Math.random() * 8);
this.targetY = canvas.height * (0.08 + Math.random() * 0.38);
this.exploded = false;
this.color = `hsl(${Math.floor(Math.random() * 360)},100%,68%)`;
this.trail = [];
}
update() {
if (this.exploded) return;
this.trail.push({x: this.x, y: this.y});
if (this.trail.length > 10) this.trail.shift();
this.y += this.vy;
this.vy += 0.28;
if (this.y <= this.targetY || this.vy >= 0) this.exploded = true;
}
explode() {
const count = 90 + Math.floor(Math.random() * 50);
for (let i = 0; i < count; i++) fwParticles.push(new FwParticle(this.x, this.y, this.color));
// Ring burst in second color
const c2 = `hsl(${Math.floor(Math.random()*360)},100%,80%)`;
for (let i = 0; i < 40; i++) {
const p = new FwParticle(this.x, this.y, c2);
const a = (i / 40) * Math.PI * 2;
p.vx = Math.cos(a) * (4 + Math.random() * 2);
p.vy = Math.sin(a) * (4 + Math.random() * 2);
fwParticles.push(p);
}
}
draw(ctx) {
if (this.exploded) return;
ctx.save();
this.trail.forEach((pt, i) => {
ctx.globalAlpha = (i / this.trail.length) * 0.7;
ctx.fillStyle = this.color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2); ctx.fill();
});
ctx.restore();
}
}
function fwLoop() {
const canvas = document.getElementById('fw-canvas');
if (!canvas || canvas.style.display === 'none') return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = fwRockets.length - 1; i >= 0; i--) {
const r = fwRockets[i];
r.update();
r.draw(ctx);
if (r.exploded) { r.explode(); fwRockets.splice(i, 1); }
}
for (let i = fwParticles.length - 1; i >= 0; i--) {
const p = fwParticles[i];
p.update(); p.draw(ctx);
if (p.alpha <= 0) fwParticles.splice(i, 1);
}
fwRaf = requestAnimationFrame(fwLoop);
}
function fwLaunch() {
const canvas = document.getElementById('fw-canvas');
if (!canvas || canvas.style.display === 'none') return;
fwRockets.push(new FwRocket(canvas));
const delay = 700 + Math.random() * 900;
fwLaunchTimer = setTimeout(fwLaunch, delay);
}
function startFireworks() {
const canvas = document.getElementById('fw-canvas');
const overlay = document.getElementById('fw-overlay');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = 'block';
fwParticles.length = 0; fwRockets.length = 0;
fwLoop();
fwLaunch();
fwLaunch();
// Animate overlay in
overlay.style.animation = 'none';
overlay.style.opacity = '0';
overlay.style.display = 'block';
void overlay.offsetHeight; // force reflow
overlay.style.animation = 'fwOverlayIn .8s ease forwards';
overlay.onclick = () => stopFireworks();
}
function stopFireworks() {
if (fwRaf) { cancelAnimationFrame(fwRaf); fwRaf = null; }
if (fwLaunchTimer) { clearTimeout(fwLaunchTimer); fwLaunchTimer = null; }
fwParticles.length = 0; fwRockets.length = 0;
const canvas = document.getElementById('fw-canvas');
canvas.style.display = 'none';
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const overlay = document.getElementById('fw-overlay');
overlay.onclick = null;
overlay.style.animation = 'fwOverlayOut .5s ease forwards';
setTimeout(() => { overlay.style.display = 'none'; overlay.style.animation = ''; }, 520);
}
window.addEventListener('resize', () => {
const canvas = document.getElementById('fw-canvas');
if (canvas.style.display !== 'none') {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
});
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
loadState();
applyLang();
renderSetup();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment