Game — Dialogue Box (typewriter · choices)
A visual-novel and RPG style game dialogue box with a CSS-drawn speaker portrait, angled neon nameplate, and a typewriter-animated line you can fast-forward by click or Space. A scripted conversation tree drives branching choice buttons that route to different nodes, swap the portrait and name per speaker, and resolve into two distinct endings. Includes a blinking continue indicator, Slow-Normal-Fast text-speed control, auto-advance toggle, scene restart, toast feedback, and a rain-soaked sci-fi backdrop — all vanilla HTML, CSS, and JS.
MCP
Code
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.10);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(900px 500px at 80% -10%, rgba(124, 77, 255, 0.18), transparent 60%),
radial-gradient(800px 480px at 10% 110%, rgba(0, 229, 255, 0.14), transparent 60%),
var(--bg);
color: var(--text);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
align-items: center;
justify-content: center;
padding: 28px 18px;
}
/* ---------- Stage ---------- */
.stage {
width: min(880px, 100%);
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
width: 38px;
height: 38px;
display: inline-block;
background:
conic-gradient(from 210deg, var(--accent), var(--accent-2), var(--accent-3), var(--accent));
clip-path: polygon(50% 0, 100% 28%, 100% 72%, 50% 100%, 0 72%, 0 28%);
box-shadow: var(--glow);
}
.brand-meta {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.game-title {
font-family: "Orbitron", sans-serif;
font-weight: 900;
letter-spacing: 0.14em;
font-size: 15px;
}
.studio {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hud-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
.hud-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 10px var(--success);
animation: pulse 1.8s ease-in-out infinite;
}
.hud-label {
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 10.5px;
letter-spacing: 0.12em;
color: var(--accent);
}
/* ---------- Scene ---------- */
.scene {
position: relative;
border-radius: var(--r-lg);
overflow: hidden;
border: 1px solid var(--line-2);
background: var(--bg-2);
min-height: 360px;
display: flex;
align-items: flex-end;
padding: 22px;
}
.scene-glow {
position: absolute;
inset: 0;
background:
radial-gradient(600px 280px at 70% 0%, rgba(124, 77, 255, 0.22), transparent 65%),
radial-gradient(500px 240px at 20% 30%, rgba(0, 229, 255, 0.12), transparent 60%);
pointer-events: none;
}
.scene-bg {
position: absolute;
inset: 0;
overflow: hidden;
}
.moon {
position: absolute;
top: 34px;
right: 56px;
width: 78px;
height: 78px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff, #c8d0ff 55%, #6f78c4);
box-shadow: 0 0 60px rgba(124, 77, 255, 0.55);
}
.skyline {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 58%;
background:
linear-gradient(180deg, transparent, rgba(10, 11, 16, 0.85)),
repeating-linear-gradient(90deg,
#0e1020 0 26px, #141733 26px 30px, #0b0d1a 30px 64px, #16193a 64px 70px,
#0e1124 70px 104px, #181c40 104px 110px);
clip-path: polygon(
0 38%, 6% 38%, 6% 20%, 12% 20%, 12% 44%, 20% 44%, 20% 14%, 28% 14%,
28% 40%, 38% 40%, 38% 26%, 46% 26%, 46% 50%, 56% 50%, 56% 18%, 64% 18%,
64% 42%, 74% 42%, 74% 30%, 82% 30%, 82% 48%, 92% 48%, 92% 22%, 100% 22%,
100% 100%, 0 100%);
opacity: 0.9;
}
.rain {
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
74deg,
transparent 0 9px,
rgba(0, 229, 255, 0.10) 9px 10px
);
mask-image: linear-gradient(180deg, transparent, #000 60%);
animation: rain 0.55s linear infinite;
opacity: 0.5;
}
/* ---------- Dialogue box ---------- */
.dialogue {
position: relative;
z-index: 2;
width: 100%;
display: flex;
align-items: flex-end;
gap: 16px;
}
.portrait-wrap {
position: relative;
flex-shrink: 0;
}
.portrait {
position: relative;
width: 120px;
height: 132px;
border-radius: var(--r-md);
overflow: hidden;
border: 2px solid var(--accent);
background: linear-gradient(180deg, #20243c, #12131c);
box-shadow: var(--glow);
transition: border-color 0.35s ease, box-shadow 0.35s ease, transform 0.3s ease;
}
.portrait[data-speaker="kael"] {
border-color: var(--accent-3);
box-shadow: 0 0 18px rgba(255, 61, 113, 0.45);
}
.portrait[data-speaker="echo"] {
border-color: var(--success);
box-shadow: 0 0 18px rgba(54, 226, 122, 0.45);
}
.p-face {
position: absolute;
top: 30%;
left: 50%;
transform: translateX(-50%);
width: 58%;
height: 46%;
border-radius: 46% 46% 50% 50%;
background: linear-gradient(180deg, #d9b48f, #b98a63);
}
.portrait[data-speaker="kael"] .p-face {
background: linear-gradient(180deg, #c9a0d8, #8e6aa8);
}
.portrait[data-speaker="echo"] .p-face {
background: linear-gradient(180deg, #9fe6d4, #5cc7b2);
}
.p-hair {
position: absolute;
top: 16%;
left: 50%;
transform: translateX(-50%);
width: 66%;
height: 34%;
border-radius: 50% 50% 30% 30%;
background: linear-gradient(180deg, #2b2f4a, #1a1d30);
}
.portrait[data-speaker="kael"] .p-hair {
background: linear-gradient(180deg, var(--accent-3), #7a1c3a);
}
.portrait[data-speaker="echo"] .p-hair {
background: linear-gradient(180deg, #173d33, #0d251f);
}
.p-visor {
position: absolute;
top: 40%;
left: 50%;
transform: translateX(-50%);
width: 50%;
height: 9%;
border-radius: 99px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 10px var(--accent);
opacity: 0.92;
}
.portrait[data-speaker="kael"] .p-visor {
background: linear-gradient(90deg, transparent, var(--accent-3), transparent);
box-shadow: 0 0 10px var(--accent-3);
}
.portrait[data-speaker="echo"] .p-visor {
background: linear-gradient(90deg, transparent, var(--success), transparent);
box-shadow: 0 0 10px var(--success);
}
.p-mouth {
position: absolute;
top: 64%;
left: 50%;
transform: translateX(-50%);
width: 16%;
height: 4%;
border-radius: 99px;
background: rgba(20, 12, 16, 0.6);
}
.p-collar {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 88%;
height: 22%;
border-radius: 40% 40% 0 0;
background: linear-gradient(180deg, #2a2e4a, #15182a);
border-top: 2px solid var(--accent);
}
.portrait[data-speaker="kael"] .p-collar {
border-top-color: var(--accent-3);
}
.portrait[data-speaker="echo"] .p-collar {
border-top-color: var(--success);
}
.emote {
position: absolute;
top: -14px;
right: -10px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: 16px;
color: var(--bg);
background: var(--warn);
border-radius: 50%;
box-shadow: 0 0 14px rgba(255, 200, 87, 0.6);
opacity: 0;
transform: scale(0.4);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.emote.show {
opacity: 1;
transform: scale(1);
}
/* ---------- Dialogue text box ---------- */
.dbox {
position: relative;
flex: 1;
min-width: 0;
padding: 16px 18px 18px;
background:
linear-gradient(180deg, rgba(31, 34, 51, 0.96), rgba(23, 25, 38, 0.96));
border: 1px solid var(--line-2);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 14px 40px rgba(0, 0, 0, 0.45);
}
.dbox::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
border: 1px solid transparent;
background: linear-gradient(120deg, rgba(0, 229, 255, 0.5), transparent 40%, transparent 60%, rgba(124, 77, 255, 0.5)) border-box;
-webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.6;
}
.nameplate {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin: -28px 0 12px;
padding: 6px 16px;
background: linear-gradient(180deg, var(--accent-2), #5a35c4);
clip-path: polygon(0 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
box-shadow: 0 6px 16px rgba(124, 77, 255, 0.4);
}
.dialogue[data-speaker="kael"] .nameplate {
background: linear-gradient(180deg, var(--accent-3), #c01b4c);
box-shadow: 0 6px 16px rgba(255, 61, 113, 0.4);
}
.dialogue[data-speaker="echo"] .nameplate {
background: linear-gradient(180deg, var(--success), #1f9d52);
box-shadow: 0 6px 16px rgba(54, 226, 122, 0.4);
}
.name {
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: 14px;
letter-spacing: 0.1em;
color: #fff;
}
.role {
font-size: 10.5px;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.82);
text-transform: uppercase;
}
.line {
margin: 0;
min-height: 3.6em;
font-size: 16px;
color: var(--text);
letter-spacing: 0.005em;
}
.line .caret {
display: inline-block;
width: 9px;
height: 1.05em;
margin-left: 2px;
transform: translateY(2px);
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
animation: caret 0.7s step-end infinite;
}
.continue {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
height: 18px;
opacity: 0;
transition: opacity 0.25s ease;
}
.continue.show {
opacity: 1;
}
.continue .hint {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.continue .arrow {
font-size: 13px;
color: var(--accent);
animation: bob 0.9s ease-in-out infinite;
text-shadow: 0 0 8px var(--accent);
}
/* ---------- Choices ---------- */
.choices {
display: grid;
gap: 8px;
margin-top: 14px;
}
.choice {
position: relative;
text-align: left;
font-family: "Inter", sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text);
padding: 11px 14px 11px 38px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
clip-path: polygon(12px 0, 100% 0, 100% 100%, 0 100%, 0 12px);
cursor: pointer;
transition: transform 0.16s ease, border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
animation: choiceIn 0.32s ease backwards;
}
.choice::before {
content: "▷";
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--accent);
font-size: 12px;
transition: transform 0.16s ease;
}
.choice:hover,
.choice:focus-visible {
transform: translateX(4px);
border-color: var(--accent);
background: linear-gradient(180deg, #242843, #1a1d2e);
box-shadow: var(--glow);
outline: none;
}
.choice:hover::before,
.choice:focus-visible::before {
transform: translateY(-50%) translateX(3px);
}
.choice:active {
transform: translateX(2px) scale(0.99);
}
.choice .tag {
display: inline-block;
margin-left: 8px;
font-family: "Orbitron", sans-serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: var(--r-sm);
vertical-align: middle;
}
.choice .tag.bold {
color: var(--accent-3);
background: rgba(255, 61, 113, 0.14);
border: 1px solid rgba(255, 61, 113, 0.4);
}
.choice .tag.calm {
color: var(--success);
background: rgba(54, 226, 122, 0.12);
border: 1px solid rgba(54, 226, 122, 0.4);
}
.choice .tag.lore {
color: var(--accent);
background: rgba(0, 229, 255, 0.12);
border: 1px solid rgba(0, 229, 255, 0.4);
}
.choice.is-end {
text-align: center;
padding-left: 14px;
font-family: "Orbitron", sans-serif;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--accent);
}
.choice.is-end::before {
content: none;
}
/* ---------- Controls ---------- */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 12px 14px;
background: linear-gradient(180deg, var(--panel), var(--bg-2));
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.ctl-group {
display: flex;
align-items: center;
gap: 12px;
}
.ctl-label {
font-family: "Orbitron", sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--muted);
}
.speed {
display: inline-flex;
padding: 3px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.speed-btn {
font-family: "Inter", sans-serif;
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: transparent;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.speed-btn:hover {
color: var(--text);
}
.speed-btn.is-active {
color: var(--bg);
background: var(--accent);
box-shadow: var(--glow);
}
.ctl-actions {
display: flex;
gap: 10px;
}
.btn {
font-family: "Orbitron", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
cursor: pointer;
padding: 9px 16px;
border-radius: var(--r-sm);
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
.btn.ghost {
color: var(--muted);
background: transparent;
border: 1px solid var(--line-2);
}
.btn.ghost:hover {
color: var(--text);
border-color: var(--accent);
box-shadow: var(--glow);
}
.btn.ghost[aria-pressed="true"] {
color: var(--accent);
border-color: var(--accent);
box-shadow: var(--glow);
}
.btn:active {
transform: scale(0.97);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Toast ---------- */
.toast-host {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
font-family: "Orbitron", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text);
padding: 10px 16px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--accent);
border-radius: var(--r-sm);
box-shadow: var(--glow);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
/* ---------- Animations ---------- */
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.8); }
}
@keyframes caret {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes choiceIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes rain {
from { background-position: 0 0; }
to { background-position: 6px 22px; }
}
@media (prefers-reduced-motion: reduce) {
.rain, .hud-dot, .continue .arrow, .line .caret { animation: none !important; }
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
body { padding: 16px 12px; }
.scene { min-height: 320px; padding: 14px; }
.dialogue {
flex-direction: column;
align-items: stretch;
gap: 0;
}
.portrait-wrap {
align-self: flex-start;
margin-bottom: -10px;
z-index: 3;
}
.portrait {
width: 84px;
height: 92px;
}
.dbox {
padding-top: 18px;
}
.nameplate { margin-top: 4px; }
.line { font-size: 15px; }
.moon { width: 56px; height: 56px; right: 28px; top: 22px; }
.controls { flex-direction: column; align-items: stretch; }
.ctl-group { justify-content: space-between; }
.ctl-actions { justify-content: stretch; }
.btn.ghost { flex: 1; }
}(function () {
"use strict";
// ---- Speakers ----
var SPEAKERS = {
vex: { id: "vex", name: "CDR. VEX", role: "Vanguard Lead", emote: null },
kael: { id: "kael", name: "KAEL-9", role: "Rogue Synth", emote: "?" },
echo: { id: "echo", name: "ECHO", role: "AI Scout", emote: null },
};
// ---- Conversation tree ----
// Each node: speaker, text, then either `next` (continue) or `choices`.
var TREE = {
start: {
speaker: "vex",
text: "The reactor's gone dark, soldier. Whatever the Hollow Reign did down there, it's spreading. We move on my mark — but I need to know you're with me.",
next: "vex2",
},
vex2: {
speaker: "vex",
text: "Last team that went in didn't come back. So tell me straight: how do you want to play this?",
choices: [
{ label: "We go in loud. Burn it to the ground.", tag: "bold", to: "loud" },
{ label: "Quiet approach. We can't lose anyone else.", tag: "calm", to: "quiet" },
{ label: "What exactly is the Hollow Reign?", tag: "lore", to: "lore" },
],
},
loud: {
speaker: "vex",
text: "Ha — that's the Vanguard talking. I like it. But subtlety has its uses too. Patch in the scout, let's see what it found.",
next: "echoIntro",
},
quiet: {
speaker: "vex",
text: "Smart. Reckless heroes fill the memorial wall. Stay sharp, stay low. I'll loop in the scout to map our route.",
next: "echoIntro",
},
lore: {
speaker: "vex",
text: "A swarm-mind. It hollows you out and wears you like a coat. Half the men I trained are wearing the Reign's colors now. That's why this matters.",
next: "vex2",
},
echoIntro: {
speaker: "echo",
text: "Scout online. I've mapped three corridors past the blast doors. One's collapsed. One's crawling with hostiles. The third... reads as empty. Too empty.",
choices: [
{ label: "Take the empty corridor.", tag: "bold", to: "empty" },
{ label: "Echo, run a deeper scan first.", tag: "calm", to: "scan" },
],
},
empty: {
speaker: "echo",
text: "Acknowledged. Routing now. Commander — be advised, something just moved in that 'empty' corridor. Something that registers as friendly.",
next: "kaelReveal",
},
scan: {
speaker: "echo",
text: "Deeper scan complete. There's a lifesign in the empty corridor — its transponder is one of ours. It's broadcasting an old Vanguard recognition code.",
next: "kaelReveal",
},
kaelReveal: {
speaker: "kael",
text: "...Don't shoot. Please. It's me — what's left of me. The Reign got into my wetware, but I fought it back. I can get you to the reactor core. Trust me one more time, Commander.",
choices: [
{ label: "Lower your weapon. We trust Kael.", tag: "calm", to: "trust" },
{ label: "Stand down. Synths don't get second chances.", tag: "bold", to: "distrust" },
],
},
trust: {
speaker: "vex",
text: "Stand down, all of you. Kael's bled for this unit before. We finish this together — or not at all. Move out.",
next: "endTrust",
},
distrust: {
speaker: "vex",
text: "I can't risk the squad on a maybe. I'm sorry, Kael. Echo, override the door — we take the long way around.",
next: "endCold",
},
endTrust: {
speaker: "echo",
text: "Squad regrouped. Path to the core is clear. Logging this as the moment the Vanguard refused to leave one of its own behind.",
end: "SCENE COMPLETE — ‘FAITH HELD’",
},
endCold: {
speaker: "echo",
text: "Alternate route locked. The corridor seals behind us. Whatever Kael was... it's on the other side of that door now.",
end: "SCENE COMPLETE — ‘THE LONG WAY’",
},
};
// ---- DOM ----
var dialogueEl = document.getElementById("dialogue");
var portraitEl = document.getElementById("portrait");
var emoteEl = document.getElementById("emote");
var nameEl = document.getElementById("speakerName");
var roleEl = document.getElementById("speakerRole");
var lineEl = document.getElementById("line");
var continueEl = document.getElementById("continue");
var hintEl = document.getElementById("hint");
var choicesEl = document.getElementById("choices");
var speedEl = document.getElementById("speed");
var restartBtn = document.getElementById("restart");
var autoBtn = document.getElementById("autoBtn");
var toastHost = document.getElementById("toastHost");
// ---- State ----
var typeSpeed = 28; // ms per char
var current = "start";
var fullText = "";
var typing = false;
var typeTimer = null;
var autoTimer = null;
var autoOn = false;
// ---- Toast ----
function toast(msg) {
var t = document.createElement("div");
t.className = "toast";
t.textContent = msg;
toastHost.appendChild(t);
requestAnimationFrame(function () {
t.classList.add("show");
});
setTimeout(function () {
t.classList.remove("show");
setTimeout(function () {
t.remove();
}, 280);
}, 1900);
}
// ---- Speaker swap ----
function setSpeaker(id) {
var s = SPEAKERS[id] || SPEAKERS.vex;
portraitEl.setAttribute("data-speaker", id);
dialogueEl.setAttribute("data-speaker", id);
nameEl.textContent = s.name;
roleEl.textContent = s.role;
if (s.emote) {
emoteEl.textContent = s.emote;
emoteEl.classList.add("show");
setTimeout(function () {
emoteEl.classList.remove("show");
}, 900);
} else {
emoteEl.classList.remove("show");
}
}
// ---- Typewriter ----
function typeLine(text, onDone) {
clearTimeout(typeTimer);
fullText = text;
typing = true;
var i = 0;
lineEl.textContent = "";
var caret = document.createElement("span");
caret.className = "caret";
function tick() {
if (i < text.length) {
lineEl.textContent = text.slice(0, i + 1);
lineEl.appendChild(caret);
i++;
typeTimer = setTimeout(tick, typeSpeed);
} else {
finishTyping(onDone);
}
}
tick();
}
function finishTyping(onDone) {
clearTimeout(typeTimer);
typing = false;
lineEl.textContent = fullText;
if (typeof onDone === "function") onDone();
}
function skipTyping() {
if (typing) {
finishTyping(afterLine);
}
}
// ---- Node rendering ----
function renderNode(key) {
var node = TREE[key];
if (!node) return;
current = key;
setSpeaker(node.speaker);
continueEl.classList.remove("show");
continueEl.setAttribute("aria-hidden", "true");
choicesEl.innerHTML = "";
typeLine(node.text, afterLine);
}
// Called once a line finishes typing
function afterLine() {
var node = TREE[current];
if (!node) return;
if (node.choices) {
renderChoices(node.choices);
} else if (node.end) {
renderEnd(node.end);
} else if (node.next) {
// show continue indicator
continueEl.classList.add("show");
continueEl.setAttribute("aria-hidden", "false");
hintEl.textContent = autoOn ? "auto" : "click / space";
if (autoOn) {
clearTimeout(autoTimer);
autoTimer = setTimeout(function () {
advance();
}, 1100);
}
}
}
function renderChoices(choices) {
choicesEl.innerHTML = "";
choices.forEach(function (c, idx) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "choice";
btn.style.animationDelay = idx * 0.06 + "s";
var label = document.createElement("span");
label.textContent = c.label;
btn.appendChild(label);
if (c.tag) {
var tag = document.createElement("span");
tag.className = "tag " + c.tag;
tag.textContent = c.tag.toUpperCase();
btn.appendChild(tag);
}
btn.addEventListener("click", function () {
renderNode(c.to);
});
choicesEl.appendChild(btn);
});
// focus first choice for keyboard users
var first = choicesEl.querySelector(".choice");
if (first) first.focus();
}
function renderEnd(label) {
choicesEl.innerHTML = "";
var btn = document.createElement("button");
btn.type = "button";
btn.className = "choice is-end";
btn.textContent = label + " · ↺ Replay";
btn.addEventListener("click", function () {
restart();
});
choicesEl.appendChild(btn);
btn.focus();
toast("Scene complete");
}
// ---- Advance (continue indicator / space / click) ----
function advance() {
var node = TREE[current];
if (!node) return;
if (typing) {
skipTyping();
return;
}
if (node.next) {
clearTimeout(autoTimer);
renderNode(node.next);
}
}
// ---- Controls ----
function setSpeed(ms, btn) {
typeSpeed = ms;
Array.prototype.forEach.call(speedEl.querySelectorAll(".speed-btn"), function (b) {
var active = b === btn;
b.classList.toggle("is-active", active);
b.setAttribute("aria-pressed", active ? "true" : "false");
});
if (typing) {
// re-type remaining at new speed feel: just keep going, timer already uses typeSpeed
}
}
function restart() {
clearTimeout(autoTimer);
clearTimeout(typeTimer);
renderNode("start");
toast("Scene restarted");
}
function toggleAuto() {
autoOn = !autoOn;
autoBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
autoBtn.textContent = autoOn ? "⏸ Auto" : "▶ Auto";
toast(autoOn ? "Auto-advance on" : "Auto-advance off");
if (autoOn && !typing) {
var node = TREE[current];
if (node && node.next) {
clearTimeout(autoTimer);
autoTimer = setTimeout(advance, 800);
}
}
}
// ---- Events ----
// Click anywhere on the dialogue box to fast-forward / continue
dialogueEl.addEventListener("click", function (e) {
if (e.target.closest(".choice")) return; // choices handle themselves
advance();
});
document.addEventListener("keydown", function (e) {
if (e.code === "Space" || e.code === "Enter") {
var tag = (e.target.tagName || "").toLowerCase();
// let Enter/Space work normally on choice buttons
if (e.target.classList && e.target.classList.contains("choice")) return;
if (tag === "button") return;
e.preventDefault();
advance();
}
});
speedEl.addEventListener("click", function (e) {
var btn = e.target.closest(".speed-btn");
if (!btn) return;
setSpeed(parseInt(btn.getAttribute("data-speed"), 10), btn);
});
restartBtn.addEventListener("click", restart);
autoBtn.addEventListener("click", toggleAuto);
// ---- Boot ----
renderNode("start");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Game — Dialogue Box</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=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage" aria-label="Game dialogue box demo">
<header class="topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-meta">
<span class="game-title">ASHEN VANGUARD</span>
<span class="studio">Nullforge Interactive</span>
</div>
</div>
<div class="hud-chip" role="status" aria-label="Quest tracker">
<span class="hud-dot" aria-hidden="true"></span>
<span class="hud-label">CH. 3 — THE HOLLOW REIGN</span>
</div>
</header>
<section class="scene" aria-label="Conversation scene">
<div class="scene-glow" aria-hidden="true"></div>
<div class="scene-bg" aria-hidden="true">
<span class="moon"></span>
<span class="skyline"></span>
<span class="rain"></span>
</div>
<!-- Dialogue box -->
<div class="dialogue" id="dialogue">
<div class="portrait-wrap">
<div class="portrait" id="portrait" data-speaker="vex" aria-hidden="true">
<span class="p-hair"></span>
<span class="p-face"></span>
<span class="p-visor"></span>
<span class="p-mouth"></span>
<span class="p-collar"></span>
</div>
<div class="emote" id="emote" aria-hidden="true">!</div>
</div>
<div class="dbox" role="group" aria-label="Dialogue">
<div class="nameplate">
<span class="name" id="speakerName">CDR. VEX</span>
<span class="role" id="speakerRole">Vanguard Lead</span>
</div>
<p class="line" id="line" aria-live="polite" aria-atomic="true"></p>
<div class="continue" id="continue" aria-hidden="true">
<span class="hint" id="hint">click / space</span>
<span class="arrow">▶</span>
</div>
<div class="choices" id="choices" role="group" aria-label="Dialogue choices"></div>
</div>
</div>
</section>
<footer class="controls">
<div class="ctl-group" role="group" aria-label="Text speed">
<span class="ctl-label">TEXT SPEED</span>
<div class="speed" id="speed">
<button type="button" class="speed-btn" data-speed="55" aria-pressed="false">Slow</button>
<button type="button" class="speed-btn is-active" data-speed="28" aria-pressed="true">Normal</button>
<button type="button" class="speed-btn" data-speed="10" aria-pressed="false">Fast</button>
</div>
</div>
<div class="ctl-actions">
<button type="button" class="btn ghost" id="restart">↺ Restart Scene</button>
<button type="button" class="btn ghost" id="autoBtn" aria-pressed="false">▶ Auto</button>
</div>
</footer>
</main>
<div class="toast-host" id="toastHost" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Dialogue Box (typewriter · choices)
A visual-novel style dialogue box staged over a CSS-drawn neon cityscape (moon, clipped skyline, animated rain). Each line types out character by character with a glowing caret; clicking the box or pressing Space fast-forwards to the full line, then advances to the next node. A blinking ▶ continue indicator signals when a line is done.
The conversation is a small scripted tree: branching choice buttons (tagged BOLD / CALM / LORE) route to different nodes, the CSS portrait and angled nameplate swap per speaker — Commander Vex, the rogue synth Kael-9, and the AI scout Echo — and two distinct endings close the scene with a replay button. Choices animate in staggered and the first one receives keyboard focus.
A footer control bar adds a Slow / Normal / Fast text-speed segmented toggle, an auto-advance mode, and a scene restart, with toast feedback for each action. Everything is vanilla HTML/CSS/JS with focus-visible rings and reduced-motion fallbacks.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.