Created
June 30, 2026 11:29
-
-
Save gabonator/1ba8e4fdca2f3fae02862af1f728d2f6 to your computer and use it in GitHub Desktop.
Lego sumo tournament
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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">←</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()">←</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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| // ─── 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