| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>1000 Rooms</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| <link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&family=Cinzel:wght@400;600&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet" /> |
|
|
| <style> |
| |
| :root { |
| --stone-darkest: #0d0b09; |
| --stone-dark: #1a1712; |
| --stone-mid: #2c2720; |
| --stone-light: #3d3730; |
| --stone-pale: #6b6055; |
| --amber-deep: #7a4a0e; |
| --amber: #c47c1a; |
| --amber-glow: #e8a832; |
| --amber-pale: #f0c870; |
| --parchment: #d4b896; |
| --parchment-dark: #a88c6e; |
| --blood: #8b1a1a; |
| --blood-bright: #c0392b; |
| --text-body: #c9b89e; |
| --text-dim: #7a6e60; |
| --text-faint: #4a4338; |
| |
| --font-title: 'Cinzel Decorative', serif; |
| --font-head: 'Cinzel', serif; |
| --font-body: 'IM Fell English', serif; |
| |
| --radius: 4px; |
| --glow-amber: 0 0 18px rgba(196,124,26,0.45), 0 0 40px rgba(196,124,26,0.18); |
| --glow-choice: 0 0 0 1px var(--amber-deep), 0 0 20px rgba(196,124,26,0.30); |
| --transition: 200ms ease; |
| } |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| html, body { |
| height: 100%; |
| background: var(--stone-darkest); |
| color: var(--text-body); |
| font-family: var(--font-body); |
| font-size: 17px; |
| line-height: 1.7; |
| overflow-x: hidden; |
| } |
| |
| |
| body::before { |
| content: ''; |
| position: fixed; inset: 0; z-index: 0; pointer-events: none; |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); |
| opacity: 0.6; |
| } |
| |
| |
| #app { |
| position: relative; z-index: 1; |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| padding: 0 1rem 4rem; |
| } |
| |
| |
| header { |
| width: 100%; |
| max-width: 780px; |
| padding: 3rem 0 2rem; |
| text-align: center; |
| border-bottom: 1px solid var(--stone-light); |
| margin-bottom: 2.5rem; |
| } |
| |
| .title-lockup { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| |
| .title-eyebrow { |
| font-family: var(--font-head); |
| font-size: 0.7rem; |
| letter-spacing: 0.35em; |
| text-transform: uppercase; |
| color: var(--amber); |
| opacity: 0.8; |
| } |
| |
| h1 { |
| font-family: var(--font-title); |
| font-size: clamp(2rem, 7vw, 3.4rem); |
| color: var(--amber-pale); |
| text-shadow: var(--glow-amber); |
| letter-spacing: 0.04em; |
| line-height: 1.1; |
| } |
| |
| .title-sub { |
| font-family: var(--font-body); |
| font-style: italic; |
| font-size: 0.95rem; |
| color: var(--text-dim); |
| margin-top: 0.25rem; |
| } |
| |
| |
| #stage { |
| width: 100%; |
| max-width: 780px; |
| display: flex; |
| flex-direction: column; |
| gap: 2rem; |
| } |
| |
| |
| #screen-loading { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 1.5rem; |
| padding: 4rem 1rem; |
| text-align: center; |
| } |
| |
| .flame-wrap { |
| position: relative; |
| width: 48px; height: 72px; |
| } |
| |
| .flame { |
| position: absolute; |
| bottom: 0; left: 50%; |
| transform: translateX(-50%); |
| width: 18px; height: 36px; |
| border-radius: 50% 50% 30% 30% / 60% 60% 40% 40%; |
| background: radial-gradient(ellipse at 50% 80%, var(--amber-glow) 0%, var(--amber) 50%, var(--blood) 100%); |
| animation: flicker 1.6s ease-in-out infinite alternate; |
| filter: blur(1px); |
| } |
| .flame::after { |
| content: ''; |
| position: absolute; |
| top: 30%; left: 20%; right: 20%; bottom: 10%; |
| border-radius: 50%; |
| background: rgba(240,200,112,0.5); |
| filter: blur(2px); |
| animation: flicker 1.1s ease-in-out infinite alternate-reverse; |
| } |
| .candle-body { |
| position: absolute; |
| bottom: 0; left: 50%; |
| transform: translateX(-50%); |
| width: 14px; height: 30px; |
| background: linear-gradient(to right, #e8e0d0, #f5f0e8 40%, #d0c8b8); |
| border-radius: 2px 2px 0 0; |
| } |
| |
| @keyframes flicker { |
| 0% { transform: translateX(-50%) scaleX(1) scaleY(1) rotate(-1deg); opacity: 0.9; } |
| 30% { transform: translateX(-52%) scaleX(0.9) scaleY(1.05) rotate(1deg); } |
| 60% { transform: translateX(-48%) scaleX(1.05) scaleY(0.97) rotate(-2deg); } |
| 100% { transform: translateX(-50%) scaleX(0.95) scaleY(1.03) rotate(0deg); opacity: 1; } |
| } |
| |
| .loading-text { |
| font-family: var(--font-head); |
| font-size: 0.85rem; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| color: var(--amber); |
| animation: pulse-text 2s ease-in-out infinite; |
| } |
| @keyframes pulse-text { |
| 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } |
| } |
| |
| .loading-hint { |
| font-style: italic; |
| color: var(--text-dim); |
| font-size: 0.9rem; |
| max-width: 340px; |
| } |
| |
| |
| #screen-game { display: none; flex-direction: column; gap: 1.75rem; } |
| |
| .room-header { |
| background: var(--stone-dark); |
| border: 1px solid var(--stone-light); |
| border-radius: var(--radius); |
| padding: 1.5rem 1.75rem; |
| position: relative; |
| overflow: hidden; |
| } |
| .room-header::before { |
| content: ''; |
| position: absolute; top: 0; left: 0; right: 0; height: 2px; |
| background: linear-gradient(to right, transparent, var(--amber), transparent); |
| } |
| |
| .room-name { |
| font-family: var(--font-head); |
| font-size: 1.35rem; |
| font-weight: 600; |
| color: var(--amber-pale); |
| margin-bottom: 0.6rem; |
| text-shadow: 0 0 12px rgba(196,124,26,0.3); |
| } |
| |
| .room-story { |
| font-style: italic; |
| color: var(--text-body); |
| line-height: 1.75; |
| font-size: 0.98rem; |
| } |
| |
| |
| .narrative { |
| background: var(--stone-dark); |
| border-left: 3px solid var(--amber-deep); |
| border-radius: 0 var(--radius) var(--radius) 0; |
| padding: 1.1rem 1.4rem; |
| font-style: italic; |
| color: var(--text-body); |
| font-size: 0.97rem; |
| line-height: 1.75; |
| animation: fade-in 0.5s ease; |
| } |
| @keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } |
| |
| |
| .section-label { |
| font-family: var(--font-head); |
| font-size: 0.68rem; |
| letter-spacing: 0.3em; |
| text-transform: uppercase; |
| color: var(--text-dim); |
| margin-bottom: 0.75rem; |
| display: flex; |
| align-items: center; |
| gap: 0.75rem; |
| } |
| .section-label::after { |
| content: ''; |
| flex: 1; |
| height: 1px; |
| background: var(--stone-light); |
| } |
| |
| |
| .choices-grid { |
| display: grid; |
| grid-template-columns: 1fr; |
| gap: 0.75rem; |
| } |
| |
| .choice-btn { |
| background: var(--stone-dark); |
| border: 1px solid var(--stone-light); |
| border-radius: var(--radius); |
| padding: 1rem 1.25rem; |
| cursor: pointer; |
| text-align: left; |
| color: var(--text-body); |
| font-family: var(--font-body); |
| font-size: 0.97rem; |
| line-height: 1.6; |
| transition: border-color var(--transition), box-shadow var(--transition), background var(--transition); |
| position: relative; |
| overflow: hidden; |
| } |
| |
| |
| .choice-btn::before { |
| content: ''; |
| position: absolute; |
| inset: 0; |
| background: radial-gradient(ellipse at 50% 120%, rgba(196,124,26,0.18) 0%, transparent 70%); |
| opacity: 0; |
| transition: opacity var(--transition); |
| pointer-events: none; |
| } |
| |
| .choice-btn:hover { |
| border-color: var(--amber); |
| box-shadow: var(--glow-choice); |
| background: var(--stone-mid); |
| } |
| .choice-btn:hover::before { opacity: 1; } |
| |
| .choice-btn:active { |
| background: var(--stone-light); |
| transform: scale(0.99); |
| } |
| |
| .choice-btn.selected { |
| border-color: var(--amber-glow); |
| box-shadow: 0 0 0 1px var(--amber), 0 0 28px rgba(232,168,50,0.35); |
| background: var(--stone-mid); |
| pointer-events: none; |
| } |
| |
| .choice-label { |
| font-family: var(--font-head); |
| font-size: 0.72rem; |
| letter-spacing: 0.18em; |
| text-transform: uppercase; |
| color: var(--amber); |
| margin-bottom: 0.3rem; |
| display: block; |
| } |
| |
| .choice-name { |
| font-weight: normal; |
| color: var(--parchment); |
| font-size: 1.02rem; |
| display: block; |
| margin-bottom: 0.2rem; |
| } |
| |
| .choice-desc { |
| color: var(--text-dim); |
| font-size: 0.88rem; |
| font-style: italic; |
| display: block; |
| } |
| |
| |
| #inventory-bar { |
| background: var(--stone-dark); |
| border: 1px solid var(--stone-light); |
| border-radius: var(--radius); |
| padding: 0.85rem 1.25rem; |
| display: flex; |
| align-items: center; |
| gap: 0.75rem; |
| flex-wrap: wrap; |
| font-size: 0.88rem; |
| } |
| |
| .inv-label { |
| font-family: var(--font-head); |
| font-size: 0.65rem; |
| letter-spacing: 0.25em; |
| text-transform: uppercase; |
| color: var(--text-dim); |
| flex-shrink: 0; |
| } |
| |
| .inv-empty { |
| font-style: italic; |
| color: var(--text-faint); |
| } |
| |
| .inv-item { |
| background: var(--stone-mid); |
| border: 1px solid var(--amber-deep); |
| border-radius: 2px; |
| padding: 0.2rem 0.6rem; |
| color: var(--amber-pale); |
| font-family: var(--font-head); |
| font-size: 0.78rem; |
| letter-spacing: 0.05em; |
| animation: item-appear 0.35s ease; |
| } |
| @keyframes item-appear { |
| from { opacity: 0; transform: scale(0.85); } |
| to { opacity: 1; transform: scale(1); } |
| } |
| |
| |
| #screen-win { |
| display: none; |
| flex-direction: column; |
| align-items: center; |
| gap: 1.5rem; |
| padding: 3rem 1rem; |
| text-align: center; |
| animation: fade-in 0.7s ease; |
| } |
| |
| .win-rune { |
| font-family: var(--font-title); |
| font-size: 4rem; |
| color: var(--amber-glow); |
| text-shadow: 0 0 30px rgba(232,168,50,0.7), 0 0 60px rgba(232,168,50,0.3); |
| animation: pulse-glow 3s ease-in-out infinite; |
| } |
| @keyframes pulse-glow { |
| 0%, 100% { text-shadow: 0 0 30px rgba(232,168,50,0.7), 0 0 60px rgba(232,168,50,0.3); } |
| 50% { text-shadow: 0 0 50px rgba(232,168,50,0.9), 0 0 100px rgba(232,168,50,0.4); } |
| } |
| |
| .win-title { |
| font-family: var(--font-head); |
| font-size: 1.6rem; |
| color: var(--amber-pale); |
| letter-spacing: 0.06em; |
| } |
| |
| .win-text { |
| max-width: 520px; |
| font-style: italic; |
| color: var(--text-body); |
| line-height: 1.8; |
| } |
| |
| .restart-btn { |
| margin-top: 0.5rem; |
| background: transparent; |
| border: 1px solid var(--amber); |
| color: var(--amber-pale); |
| font-family: var(--font-head); |
| font-size: 0.82rem; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| padding: 0.65rem 1.5rem; |
| border-radius: var(--radius); |
| cursor: pointer; |
| transition: background var(--transition), box-shadow var(--transition); |
| } |
| .restart-btn:hover { |
| background: var(--amber-deep); |
| box-shadow: var(--glow-amber); |
| } |
| |
| |
| .error-notice { |
| background: rgba(139,26,26,0.2); |
| border: 1px solid var(--blood); |
| border-radius: var(--radius); |
| padding: 0.9rem 1.2rem; |
| color: #e07070; |
| font-size: 0.9rem; |
| font-style: italic; |
| display: none; |
| } |
| |
| |
| @media (min-width: 600px) { |
| .choices-grid { grid-template-columns: 1fr 1fr 1fr; } |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| .flame, .loading-text, .win-rune { animation: none; } |
| .choice-btn { transition: none; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| <header> |
| <div class="title-lockup"> |
| <span class="title-eyebrow">An escape room of infinite chambers</span> |
| <h1>1000 Rooms</h1> |
| <p class="title-sub">Every door leads somewhere stranger than the last.</p> |
| </div> |
| </header> |
|
|
| <main id="stage"> |
| |
| <div id="screen-loading"> |
| <div class="flame-wrap"> |
| <div class="flame"></div> |
| <div class="candle-body"></div> |
| </div> |
| <span class="loading-text" id="loading-msg">Conjuring your chamberβ¦</span> |
| <p class="loading-hint" id="loading-hint">The dungeon-master is weaving the walls of your prison. This may take a moment.</p> |
| </div> |
|
|
| |
| <section id="screen-game"> |
| <div class="room-header"> |
| <div class="room-name" id="room-name">β</div> |
| <p class="room-story" id="room-story"></p> |
| </div> |
|
|
| <div class="narrative" id="narrative-box" style="display:none"></div> |
|
|
| <div id="inventory-bar"> |
| <span class="inv-label">Inventory</span> |
| <span class="inv-empty" id="inv-empty">nothing yet</span> |
| </div> |
|
|
| <div> |
| <p class="section-label" id="choices-label">What do you do?</p> |
| <div class="choices-grid" id="choices-grid"></div> |
| </div> |
|
|
| <div class="error-notice" id="error-notice"></div> |
| </section> |
|
|
| |
| <div id="screen-win"> |
| <div class="win-rune">β¦</div> |
| <h2 class="win-title">You Escaped</h2> |
| <p class="win-text" id="win-text"></p> |
| <button class="restart-btn" onclick="restartGame()">Try Another Room</button> |
| </div> |
| </main> |
| </div> |
|
|
| <script type="module"> |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| |
| |
| let client = null; |
| let gameData = null; |
| let phase = "idle"; |
| |
| |
| |
| let inventory = []; |
| let pendingContainer = null; |
| let containersLeft = 0; |
| |
| let activeContainers = []; |
| let containerRewards = {}; |
| |
| |
| const elLoading = document.getElementById("screen-loading"); |
| const elGame = document.getElementById("screen-game"); |
| const elWin = document.getElementById("screen-win"); |
| const elLoadMsg = document.getElementById("loading-msg"); |
| const elLoadHint = document.getElementById("loading-hint"); |
| const elRoomName = document.getElementById("room-name"); |
| const elRoomStory = document.getElementById("room-story"); |
| const elNarrative = document.getElementById("narrative-box"); |
| const elInvEmpty = document.getElementById("inv-empty"); |
| const elInvBar = document.getElementById("inventory-bar"); |
| const elLabel = document.getElementById("choices-label"); |
| const elGrid = document.getElementById("choices-grid"); |
| const elError = document.getElementById("error-notice"); |
| |
| |
| async function boot() { |
| showScreen("loading"); |
| try { |
| client = await Client.connect(window.location.origin); |
| await startGame(); |
| } catch (err) { |
| showError("Could not connect to the game server: " + err.message); |
| } |
| } |
| |
| |
| async function startGame() { |
| showScreen("loading"); |
| setLoadMsg("Conjuring your chamberβ¦", "The dungeon-master is weaving the walls of your prison."); |
| |
| inventory = []; |
| pendingContainer = null; |
| phase = "idle"; |
| |
| try { |
| const res = await fetch("/generate_room", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({}) |
| }); |
| |
| gameData = await res.json(); |
| |
| |
| activeContainers = shuffle([...gameData.containers]).slice(0, 3); |
| |
| containerRewards = {}; |
| |
| |
| containerRewards[activeContainers[0].container_name] = |
| gameData.keys[0]; |
| |
| |
| containerRewards[activeContainers[1].container_name] = |
| gameData.keys[1]; |
| |
| |
| containerRewards[activeContainers[2].container_name] = { |
| key_name: gameData.door_key_name, |
| key_prompt: gameData.door_key_prompt |
| }; |
| |
| containersLeft = activeContainers.length; |
| |
| |
| } catch (err) { |
| showError("The room generation failed: " + err.message); |
| return; |
| } |
| |
| |
| elRoomName.textContent = gameData.room_name; |
| elRoomStory.textContent = gameData.room_story; |
| renderInventory(); |
| showNarrative(gameData.room_prompt, false); |
| buildExploreChoices(); |
| showScreen("game"); |
| } |
| |
| window.restartGame = () => startGame(); |
| |
| |
| function buildExploreChoices() { |
| phase = "explore"; |
| elLabel.textContent = "What do you examine?"; |
| |
| const options = []; |
| |
| |
| const remaining = activeContainers; |
| for (const c of remaining) { |
| options.push({ |
| label: "Container", |
| name: c.container_name, |
| desc: c.container_prompt, |
| action: () => chooseContainer(c), |
| }); |
| } |
| |
| |
| const hasDoorKey = inventory.includes(gameData.door_key_name); |
| if (hasDoorKey) { |
| options.push({ |
| label: "Exit", |
| name: gameData.door_description, |
| desc: gameData.door_prompt, |
| action: () => tryDoor(), |
| }); |
| } |
| |
| renderChoices(options.slice(0, 3)); |
| } |
| |
| |
| function chooseContainer(container) { |
| pendingContainer = container; |
| |
| const assignedKey = containerRewards[container.container_name]; |
| |
| |
| |
| |
| if (inventory.length === 0) { |
| rightKey(container, assignedKey.key_name); |
| return; |
| } |
| |
| phase = "key"; |
| elLabel.textContent = `Which key fits the ${container.container_name}?`; |
| |
| const options = []; |
| |
| |
| const playerHasKey = inventory.includes(assignedKey.key_name); |
| if (playerHasKey) { |
| options.push({ |
| label: "Key", |
| name: assignedKey.key_name, |
| desc: assignedKey.key_prompt, |
| action: () => rightKey(container, assignedKey.key_name), |
| }); |
| } |
| |
| |
| const heldDistractors = inventory |
| .filter(k => k !== assignedKey.key_name) |
| .map(k => { |
| const meta = gameData.keys.find(gk => gk.key_name === k) |
| || { key_name: k, key_prompt: "A key you found earlier." }; |
| return { |
| label: "Key", |
| name: meta.key_name, |
| desc: meta.key_prompt, |
| action: () => wrongKey(), |
| }; |
| }); |
| options.push(...heldDistractors); |
| |
| |
| |
| |
| if (!playerHasKey) { |
| const decoy = gameData.keys.find( |
| k => k.key_name !== assignedKey.key_name && !inventory.includes(k.key_name) |
| ); |
| if (decoy) { |
| options.push({ |
| label: "Key", |
| name: decoy.key_name, |
| desc: decoy.key_prompt, |
| action: () => wrongKey(), |
| }); |
| } |
| } |
| |
| |
| options.push({ |
| label: "Empty Handed", |
| name: "Try nothing", |
| desc: "You hesitate and step back.", |
| action: () => wrongKey(), |
| }); |
| |
| renderChoices(shuffle(options).slice(0, 3)); |
| } |
| |
| |
| |
| async function rightKey(container, keyName) { |
| setLoadMsg("Opening the containerβ¦", ""); |
| showScreen("loading"); |
| |
| try { |
| const res = await fetch("/continue_room", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json" |
| }, |
| body: JSON.stringify({ |
| container_name: container.container_name, |
| key_name: keyName |
| }) |
| }); |
| |
| const result = await res.json(); |
| const text = result.text; |
| showNarrative(text, true); |
| } catch (_) { |
| showNarrative(`You open the ${container.container_name} and find the ${keyName}.`, true); |
| } |
| |
| |
| addItem(keyName); |
| activeContainers = |
| activeContainers.filter(c => c !== container); |
| |
| containersLeft = activeContainers.length; |
| |
| if (containersLeft === 0) { |
| tryDoor(); |
| return; |
| } |
| |
| showScreen("game"); |
| buildExploreChoices(); |
| } |
| |
| |
| |
| function wrongKey() { |
| showNarrative("The key doesn't fit. You step back and reconsider.", true); |
| buildExploreChoices(); |
| } |
| |
| |
| |
| |
| async function tryDoor() { |
| if (!inventory.includes(gameData.door_key_name)) { |
| showNarrative("You don't have what you need to open this door.", true); |
| buildExploreChoices(); |
| return; |
| } |
| |
| setLoadMsg("The door groans openβ¦", ""); |
| showScreen("loading"); |
| |
| try { |
| const res = await fetch("/open_door", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| room_name: gameData.room_name, |
| door_name: gameData.door_description |
| }) |
| }); |
| |
| const result = await res.json(); |
| const text = result.text; |
| |
| document.getElementById("win-text").textContent = text; |
| |
| showScreen("win"); |
| } catch (_) { |
| document.getElementById("win-text").textContent = |
| `You unlock ${gameData.door_description} and escape!`; |
| showScreen("win"); |
| } |
| } |
| |
| |
| function addItem(name) { |
| if (!inventory.includes(name)) inventory.push(name); |
| renderInventory(); |
| } |
| |
| function renderInventory() { |
| elInvBar.querySelectorAll(".inv-item").forEach(el => el.remove()); |
| |
| if (inventory.length === 0) { |
| elInvEmpty.style.display = ""; |
| elInvEmpty.textContent = "empty hands"; |
| return; |
| } |
| |
| elInvEmpty.style.display = "none"; |
| for (const name of inventory) { |
| const chip = document.createElement("span"); |
| chip.className = "inv-item"; |
| chip.textContent = name; |
| elInvBar.appendChild(chip); |
| } |
| } |
| |
| |
| function renderChoices(options) { |
| elGrid.innerHTML = ""; |
| for (const opt of options) { |
| const btn = document.createElement("button"); |
| btn.className = "choice-btn"; |
| btn.innerHTML = ` |
| <span class="choice-label">${opt.label}</span> |
| <span class="choice-name">${opt.name}</span> |
| <span class="choice-desc">${opt.desc}</span>`; |
| btn.addEventListener("click", () => { |
| btn.classList.add("selected"); |
| elGrid.querySelectorAll(".choice-btn").forEach(b => b.style.pointerEvents = "none"); |
| opt.action(); |
| }); |
| elGrid.appendChild(btn); |
| } |
| } |
| |
| function showNarrative(text, animate) { |
| elNarrative.textContent = text; |
| elNarrative.style.display = "block"; |
| if (animate) { |
| elNarrative.style.animation = "none"; |
| void elNarrative.offsetWidth; |
| elNarrative.style.animation = ""; |
| } |
| } |
| |
| function showScreen(name) { |
| elLoading.style.display = name === "loading" ? "flex" : "none"; |
| elGame.style.display = name === "game" ? "flex" : "none"; |
| elWin.style.display = name === "win" ? "flex" : "none"; |
| } |
| |
| function setLoadMsg(msg, hint) { |
| elLoadMsg.textContent = msg; |
| elLoadHint.textContent = hint; |
| } |
| |
| function showError(msg) { |
| elError.textContent = msg; |
| elError.style.display = "block"; |
| elLoading.style.display = "none"; |
| elGame.style.display = "flex"; |
| } |
| |
| |
| function shuffle(arr) { |
| const a = [...arr]; |
| for (let i = a.length - 1; i > 0; i--) { |
| const j = Math.floor(Math.random() * (i + 1)); |
| [a[i], a[j]] = [a[j], a[i]]; |
| } |
| return a; |
| } |
| |
| |
| boot(); |
| </script> |
| </body> |
| </html> |