915 lines
27 KiB
HTML
915 lines
27 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
<title>WaveGate Pulse Tailor</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--panel: rgba(12, 18, 27, 0.9);
|
|
--line: rgba(255, 255, 255, 0.16);
|
|
--text: #f7f3ea;
|
|
--muted: #adb7c6;
|
|
--cyan: #65d8e8;
|
|
--gold: #f2c96b;
|
|
--bad: #ef735f;
|
|
--good: #88d76f;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html, body {
|
|
height: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
body {
|
|
overflow: hidden;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
background:
|
|
radial-gradient(circle at 16% 12%, rgba(101, 216, 232, 0.24), transparent 28%),
|
|
radial-gradient(circle at 84% 72%, rgba(242, 201, 107, 0.11), transparent 30%),
|
|
linear-gradient(140deg, #101926 0%, #1b242d 48%, #090c10 100%);
|
|
color: var(--text);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
|
|
.shell {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr auto;
|
|
height: 100%;
|
|
gap: 14px;
|
|
padding: 18px;
|
|
}
|
|
|
|
header, footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brand { display: grid; gap: 2px; }
|
|
|
|
.brand small {
|
|
color: var(--cyan);
|
|
font-size: 0.78rem;
|
|
font-weight: 900;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(1.35rem, 2.4vw, 2.15rem);
|
|
font-weight: 900;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat, .tip, button {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.3);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.stat {
|
|
min-width: 98px;
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.stat span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 0.7rem;
|
|
font-weight: 900;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.stat strong {
|
|
display: block;
|
|
margin-top: 2px;
|
|
font-size: 1rem;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
main {
|
|
position: relative;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
border-radius: 8px;
|
|
background: #101821;
|
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
.watermark {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
display: grid;
|
|
place-items: center;
|
|
pointer-events: none;
|
|
opacity: 0.34;
|
|
transform: rotate(-18deg);
|
|
}
|
|
|
|
.watermark img {
|
|
width: min(62vmin, 520px);
|
|
height: auto;
|
|
}
|
|
|
|
canvas {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
touch-action: none;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 10px;
|
|
pointer-events: none;
|
|
z-index: 4;
|
|
transition: opacity 170ms ease, transform 170ms ease;
|
|
}
|
|
|
|
.overlay.hidden {
|
|
opacity: 0;
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.modal {
|
|
width: min(840px, calc(100vw - 20px));
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 22px;
|
|
background: rgba(8, 12, 18, 0.9);
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.modal h2 {
|
|
margin: 0 0 8px;
|
|
font-size: 1.55rem;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.modal p {
|
|
margin: 0 0 12px;
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.how-it-works {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin: 0 0 18px;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.how-it-works[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.how-it-works li {
|
|
display: grid;
|
|
grid-template-columns: 24px 1fr;
|
|
gap: 9px;
|
|
align-items: start;
|
|
color: var(--muted);
|
|
line-height: 1.38;
|
|
}
|
|
|
|
.how-it-works b {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 1px solid rgba(101, 216, 232, 0.45);
|
|
border-radius: 50%;
|
|
color: var(--cyan);
|
|
font-size: 0.74rem;
|
|
}
|
|
|
|
.result-report {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 14px;
|
|
margin: 8px 0 22px;
|
|
}
|
|
|
|
.result-report[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.report-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 20px 16px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
text-align: center;
|
|
}
|
|
|
|
.report-item strong {
|
|
display: block;
|
|
margin-bottom: 9px;
|
|
color: var(--text);
|
|
font-size: clamp(3.3rem, 8vw, 6rem);
|
|
line-height: 0.95;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.report-item span {
|
|
display: block;
|
|
color: var(--gold);
|
|
font-weight: 900;
|
|
text-transform: uppercase;
|
|
font-size: 0.86rem;
|
|
}
|
|
|
|
.report-item p {
|
|
display: block;
|
|
margin: 12px 0 0;
|
|
color: var(--muted);
|
|
font-size: clamp(1rem, 2.2vw, 1.18rem);
|
|
line-height: 1.38;
|
|
text-align: left;
|
|
}
|
|
|
|
.actions, .controls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
button {
|
|
min-height: 42px;
|
|
border-radius: 8px;
|
|
padding: 0 15px;
|
|
color: var(--text);
|
|
font: inherit;
|
|
font-weight: 900;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button.primary {
|
|
border-color: rgba(242, 201, 107, 0.6);
|
|
background: linear-gradient(180deg, #f3d16e, #d89a37);
|
|
color: #211609;
|
|
}
|
|
|
|
.tip {
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--muted);
|
|
font-size: 0.86rem;
|
|
}
|
|
|
|
.controls { justify-content: flex-end; }
|
|
|
|
.touch-gate {
|
|
min-height: 58px;
|
|
border-color: rgba(101, 216, 232, 0.52);
|
|
background: linear-gradient(180deg, rgba(101, 216, 232, 0.2), rgba(101, 216, 232, 0.08));
|
|
color: var(--text);
|
|
touch-action: none;
|
|
min-width: 230px;
|
|
}
|
|
|
|
.touch-gate.is-open,
|
|
.touch-gate:active {
|
|
border-color: rgba(242, 201, 107, 0.78);
|
|
background: linear-gradient(180deg, #f3d16e, #d89a37);
|
|
color: #211609;
|
|
}
|
|
|
|
.floating-gate {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 14px;
|
|
z-index: 3;
|
|
width: min(430px, calc(100% - 28px));
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.floating-gate.is-open,
|
|
.floating-gate:active {
|
|
transform: translateX(-50%) translateY(1px);
|
|
}
|
|
|
|
kbd {
|
|
min-width: 24px;
|
|
border: 1px solid var(--line);
|
|
border-bottom-color: rgba(255, 255, 255, 0.34);
|
|
border-radius: 6px;
|
|
padding: 2px 6px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 0.78rem;
|
|
font-weight: 900;
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
body { overflow: auto; }
|
|
.shell {
|
|
min-height: 100%;
|
|
height: auto;
|
|
grid-template-rows: auto minmax(560px, 1fr) auto;
|
|
padding: 10px;
|
|
}
|
|
header, footer {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
.stats, .controls { justify-content: flex-start; }
|
|
.stat { flex: 1 1 98px; }
|
|
.result-report { grid-template-columns: 1fr; }
|
|
.touch-gate {
|
|
width: 100%;
|
|
min-height: 72px;
|
|
font-size: 1.08rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header>
|
|
<div class="brand">
|
|
<small>txproducts.de presents</small>
|
|
<h1>WaveGate Pulse Tailor</h1>
|
|
</div>
|
|
<div class="stats">
|
|
<div class="stat"><span>Score</span><strong id="score">0</strong></div>
|
|
<div class="stat"><span>Timescale</span><strong id="scale">ns</strong></div>
|
|
<div class="stat"><span>Contrast</span><strong id="contrast">100%</strong></div>
|
|
<div class="stat"><span>Round</span><strong id="round">1 / 8</strong></div>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<canvas id="game" aria-label="WaveGate temporal synchrotron pulse tailoring game"></canvas>
|
|
<div class="watermark" aria-hidden="true"><img src="assets/txproducts-logo-watermark.png" alt=""></div>
|
|
<button class="touch-gate floating-gate" id="gateButton" type="button">Press + hold to open WaveGate</button>
|
|
<div class="overlay" id="overlay">
|
|
<section class="modal">
|
|
<h2 id="modalTitle">Are you ready?</h2>
|
|
<p id="modalText">You are about to control WaveGate: decide exactly when synchrotron pulses are allowed through.</p>
|
|
<ul class="how-it-works" id="howItWorks">
|
|
<li><b>1</b><span>Watch pulses move along the timeline.</span></li>
|
|
<li><b>2</b><span>The gold window is the structure to create.</span></li>
|
|
<li><b>3</b><span>Hold Space or press the screen to open WaveGate.</span></li>
|
|
<li><b>4</b><span>Let gold pulses through. Block the rest.</span></li>
|
|
</ul>
|
|
<div class="result-report" id="resultReport" hidden>
|
|
<div class="report-item">
|
|
<strong id="tailoredCount">0</strong>
|
|
<span>Tailored</span>
|
|
<p>Excellent capture of the target window with almost no leakage.</p>
|
|
</div>
|
|
<div class="report-item">
|
|
<strong id="shapedCount">0</strong>
|
|
<span>Shaped</span>
|
|
<p>Useful pulse structure, but some target pulses or blockers were missed.</p>
|
|
</div>
|
|
<div class="report-item">
|
|
<strong id="leakageCount">0</strong>
|
|
<span>Leakage</span>
|
|
<p>Too many unwanted pulses passed through the gate.</p>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="primary" id="startButton" type="button" onclick="startGame(false)" ontouchstart="event.preventDefault(); startGame(false);">I'm ready</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<footer>
|
|
<div class="tip" id="message">Transmit the highlighted pulse window. Block everything else.</div>
|
|
<div class="controls">
|
|
<span><kbd>Hold Space</kbd> open gate</span>
|
|
<span><kbd>Press</kbd> open gate</span>
|
|
<span><kbd>R</kbd> restart</span>
|
|
<span><kbd>Esc</kbd> stop</span>
|
|
<button id="stopButton" type="button">Stop demo</button>
|
|
<button type="button" onclick="window.location.href='index.html'">Back to menu</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.querySelector("#game");
|
|
const ctx = canvas.getContext("2d");
|
|
const scoreEl = document.querySelector("#score");
|
|
const scaleEl = document.querySelector("#scale");
|
|
const contrastEl = document.querySelector("#contrast");
|
|
const roundEl = document.querySelector("#round");
|
|
const messageEl = document.querySelector("#message");
|
|
const overlay = document.querySelector("#overlay");
|
|
const modalTitle = document.querySelector("#modalTitle");
|
|
const modalText = document.querySelector("#modalText");
|
|
const howItWorks = document.querySelector("#howItWorks");
|
|
const resultReport = document.querySelector("#resultReport");
|
|
const tailoredCount = document.querySelector("#tailoredCount");
|
|
const shapedCount = document.querySelector("#shapedCount");
|
|
const leakageCount = document.querySelector("#leakageCount");
|
|
const startButton = document.querySelector("#startButton");
|
|
const stopButton = document.querySelector("#stopButton");
|
|
const gateButton = document.querySelector("#gateButton");
|
|
|
|
const scales = ["300 ns", "800 ns", "2 us", "20 us", "1 ms", "100 ms", "1 s", "seconds"];
|
|
const state = {
|
|
mode: "menu",
|
|
score: 0,
|
|
contrast: 100,
|
|
round: 1,
|
|
totalRounds: 8,
|
|
practice: false,
|
|
gateOpen: false,
|
|
time: 0,
|
|
duration: 5.2,
|
|
lastTime: 0,
|
|
pulses: [],
|
|
transmitted: [],
|
|
report: { tailored: 0, shaped: 0, leakage: 0 },
|
|
result: null
|
|
};
|
|
|
|
function fitCanvas() {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scale = window.devicePixelRatio || 1;
|
|
canvas.width = Math.max(1, Math.floor(rect.width * scale));
|
|
canvas.height = Math.max(1, Math.floor(rect.height * scale));
|
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
}
|
|
|
|
function css(name) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
function startGame(practice = false) {
|
|
state.mode = "running";
|
|
state.practice = practice;
|
|
state.score = 0;
|
|
state.contrast = 100;
|
|
state.round = 1;
|
|
state.report = { tailored: 0, shaped: 0, leakage: 0 };
|
|
overlay.classList.add("hidden");
|
|
resultReport.hidden = true;
|
|
gateButton.hidden = false;
|
|
nextRound();
|
|
}
|
|
|
|
function showIntro() {
|
|
state.mode = "menu";
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = true;
|
|
modalTitle.textContent = "Are you ready?";
|
|
modalText.textContent = "You are about to control WaveGate: decide exactly when synchrotron pulses are allowed through.";
|
|
howItWorks.hidden = false;
|
|
resultReport.hidden = true;
|
|
startButton.textContent = "I'm ready";
|
|
overlay.classList.remove("hidden");
|
|
messageEl.textContent = "Transmit the highlighted pulse window. Block everything else.";
|
|
updateHud();
|
|
}
|
|
|
|
function nextRound() {
|
|
state.mode = "running";
|
|
state.time = 0;
|
|
state.duration = Math.max(3.4, 5.3 - state.round * 0.18);
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = false;
|
|
state.transmitted = [];
|
|
state.result = null;
|
|
buildPulses();
|
|
messageEl.textContent = state.round < 5
|
|
? "Fast structure: catch only the highlighted burst window."
|
|
: "Long structure: hold the gate through the requested macro window.";
|
|
updateHud();
|
|
}
|
|
|
|
function buildPulses() {
|
|
const count = 18 + state.round * 2;
|
|
const start = 0.45;
|
|
const end = state.duration - 0.35;
|
|
const step = (end - start) / count;
|
|
const windowWidth = state.round < 5 ? step * (2.3 + state.round * 0.12) : step * (4.2 + state.round * 0.45);
|
|
const windowStart = start + step * (4 + Math.floor(Math.random() * Math.max(1, count - 9)));
|
|
const windowEnd = Math.min(end - step, windowStart + windowWidth);
|
|
state.targetStart = windowStart;
|
|
state.targetEnd = windowEnd;
|
|
state.pulses = [];
|
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
const t = start + i * step;
|
|
state.pulses.push({
|
|
t,
|
|
strength: 0.72 + Math.random() * 0.28,
|
|
desired: t >= windowStart && t <= windowEnd,
|
|
judged: false,
|
|
passed: false
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateHud() {
|
|
scoreEl.textContent = state.score;
|
|
scaleEl.textContent = scales[Math.min(scales.length - 1, state.round - 1)];
|
|
contrastEl.textContent = `${Math.max(0, Math.round(state.contrast))}%`;
|
|
roundEl.textContent = state.practice ? `${state.round}` : `${state.round} / ${state.totalRounds}`;
|
|
}
|
|
|
|
function setGate(open) {
|
|
if (state.mode !== "running") {
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
return;
|
|
}
|
|
state.gateOpen = open;
|
|
gateButton.classList.toggle("is-open", open);
|
|
}
|
|
|
|
function scorePulse(pulse) {
|
|
pulse.judged = true;
|
|
pulse.passed = state.gateOpen;
|
|
const correct = pulse.desired === pulse.passed;
|
|
if (correct) {
|
|
state.score += pulse.desired ? 95 : 22;
|
|
} else {
|
|
state.contrast -= pulse.desired ? 12 : 8;
|
|
}
|
|
if (pulse.passed) {
|
|
state.transmitted.push({ t: pulse.t, desired: pulse.desired, age: 0 });
|
|
}
|
|
updateHud();
|
|
}
|
|
|
|
function finishRound() {
|
|
if (state.mode !== "running") return;
|
|
const desired = state.pulses.filter((p) => p.desired);
|
|
const passedDesired = desired.filter((p) => p.passed).length;
|
|
const leaks = state.pulses.filter((p) => !p.desired && p.passed).length;
|
|
const capture = passedDesired / Math.max(1, desired.length);
|
|
const purity = 1 - leaks / Math.max(1, state.pulses.length - desired.length);
|
|
const quality = Math.max(0, capture * 0.62 + purity * 0.38);
|
|
|
|
if (quality > 0.92) {
|
|
state.report.tailored += 1;
|
|
state.result = { text: "TAILORED", color: css("--gold"), age: 0 };
|
|
state.score += 350;
|
|
messageEl.textContent = `Temporal structure tailored on the ${scaleEl.textContent} scale.`;
|
|
} else if (quality > 0.68) {
|
|
state.report.shaped += 1;
|
|
state.result = { text: "SHAPED", color: css("--good"), age: 0 };
|
|
state.score += 150;
|
|
messageEl.textContent = "Useful pulse structure, with some leakage.";
|
|
} else {
|
|
state.report.leakage += 1;
|
|
state.result = { text: "LEAKAGE", color: css("--bad"), age: 0 };
|
|
messageEl.textContent = "Too much unwanted pulse transmission.";
|
|
}
|
|
|
|
state.contrast = Math.max(0, state.contrast * (0.76 + quality * 0.24));
|
|
if (state.report.leakage >= 3) {
|
|
failRun("Leakage threshold reached", "Three leakage rounds means too many unwanted pulses passed through the gate.");
|
|
return;
|
|
}
|
|
state.mode = "result";
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = true;
|
|
updateHud();
|
|
|
|
window.setTimeout(() => {
|
|
if (state.mode !== "result") return;
|
|
if (!state.practice && state.round >= state.totalRounds) {
|
|
endGame();
|
|
return;
|
|
}
|
|
state.round += 1;
|
|
nextRound();
|
|
}, 900);
|
|
}
|
|
|
|
function endGame() {
|
|
state.mode = "over";
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = true;
|
|
modalTitle.textContent = "Pulse program complete";
|
|
modalText.textContent = `Final score: ${state.score}. Contrast: ${Math.round(state.contrast)}%.`;
|
|
howItWorks.hidden = true;
|
|
showReport();
|
|
startButton.textContent = "Run again";
|
|
overlay.classList.remove("hidden");
|
|
}
|
|
|
|
function failRun(title, text) {
|
|
state.mode = "over";
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = true;
|
|
state.result = { text: "CONTRAST LOST", color: css("--bad"), age: 0 };
|
|
modalTitle.textContent = title;
|
|
modalText.textContent = `${text} Final score: ${state.score}.`;
|
|
howItWorks.hidden = true;
|
|
showReport();
|
|
startButton.textContent = "Try again";
|
|
overlay.classList.remove("hidden");
|
|
messageEl.textContent = "Run stopped: contrast dropped below the threshold.";
|
|
updateHud();
|
|
}
|
|
|
|
function stopGame() {
|
|
state.mode = "over";
|
|
state.gateOpen = false;
|
|
gateButton.classList.remove("is-open");
|
|
gateButton.hidden = true;
|
|
modalTitle.textContent = "Demo paused";
|
|
modalText.textContent = "Take a breath. When you are ready, open WaveGate only during the gold time window and block everything else.";
|
|
howItWorks.hidden = false;
|
|
resultReport.hidden = true;
|
|
startButton.textContent = "I'm ready";
|
|
overlay.classList.remove("hidden");
|
|
messageEl.textContent = "Stopped. Ready for the next booth visitor.";
|
|
}
|
|
|
|
function showReport() {
|
|
tailoredCount.textContent = state.report.tailored;
|
|
shapedCount.textContent = state.report.shaped;
|
|
leakageCount.textContent = state.report.leakage;
|
|
resultReport.hidden = false;
|
|
}
|
|
|
|
function restart() {
|
|
startGame(state.practice);
|
|
}
|
|
|
|
function update(dt) {
|
|
if (state.mode === "running") {
|
|
state.time += dt;
|
|
for (const pulse of state.pulses) {
|
|
if (!pulse.judged && state.time >= pulse.t) scorePulse(pulse);
|
|
if (state.mode !== "running") break;
|
|
}
|
|
if (state.time >= state.duration) finishRound();
|
|
}
|
|
for (const pulse of state.transmitted) pulse.age += dt;
|
|
if (state.result) state.result.age += dt;
|
|
}
|
|
|
|
function draw() {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
drawBackground(w, h);
|
|
drawTimeline(w, h);
|
|
drawTargetWindow(w, h);
|
|
drawGate(w, h);
|
|
drawPulses(w, h);
|
|
drawOutput(w, h);
|
|
drawResult(w, h);
|
|
}
|
|
|
|
function drawBackground(w, h) {
|
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
grad.addColorStop(0, "#202a35");
|
|
grad.addColorStop(0.62, "#141d27");
|
|
grad.addColorStop(1, "#080b0d");
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.strokeStyle = "#e9f7ff";
|
|
ctx.lineWidth = 1;
|
|
for (let x = -20; x < w + 80; x += 42) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x + 70, h);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 70; y < h; y += 74) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(w, y);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function timeToX(t, w) {
|
|
return 52 + (t / state.duration) * (w - 104);
|
|
}
|
|
|
|
function drawTimeline(w, h) {
|
|
const y = h * 0.48;
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(101, 216, 232, 0.32)";
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(36, y);
|
|
ctx.lineTo(w - 36, y);
|
|
ctx.stroke();
|
|
ctx.strokeStyle = "rgba(101, 216, 232, 0.08)";
|
|
ctx.lineWidth = 24;
|
|
ctx.beginPath();
|
|
ctx.moveTo(36, y);
|
|
ctx.lineTo(w - 36, y);
|
|
ctx.stroke();
|
|
|
|
const nowX = timeToX(state.time, w);
|
|
ctx.strokeStyle = css("--gold");
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([7, 8]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(nowX, y - 155);
|
|
ctx.lineTo(nowX, y + 155);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawTargetWindow(w, h) {
|
|
const y = h * 0.48;
|
|
const x1 = timeToX(state.targetStart || 0, w);
|
|
const x2 = timeToX(state.targetEnd || 0, w);
|
|
ctx.save();
|
|
ctx.fillStyle = "rgba(242, 201, 107, 0.16)";
|
|
ctx.strokeStyle = "rgba(242, 201, 107, 0.76)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x1, y - 92, x2 - x1, 184, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = css("--gold");
|
|
ctx.font = "900 12px Inter, system-ui, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("TRANSMIT WINDOW", (x1 + x2) / 2, y - 108);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawGate(w, h) {
|
|
const y = h * 0.48;
|
|
const x = timeToX(state.time, w);
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.fillStyle = state.gateOpen ? "rgba(101, 216, 232, 0.24)" : "rgba(0, 0, 0, 0.55)";
|
|
ctx.strokeStyle = state.gateOpen ? css("--cyan") : "rgba(255, 255, 255, 0.32)";
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.roundRect(-34, -74, 68, 148, 10);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = state.gateOpen ? css("--cyan") : "rgba(255,255,255,0.25)";
|
|
ctx.fillRect(-7, -82, 14, 164);
|
|
ctx.font = "900 12px Inter, system-ui, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(state.gateOpen ? "OPEN" : "CLOSED", 0, 100);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPulses(w, h) {
|
|
const y = h * 0.48;
|
|
ctx.save();
|
|
for (const pulse of state.pulses) {
|
|
const x = timeToX(pulse.t, w);
|
|
if (x < 24 || x > w - 24) continue;
|
|
ctx.globalAlpha = pulse.judged ? 0.32 : 0.95;
|
|
ctx.fillStyle = pulse.desired ? css("--gold") : css("--cyan");
|
|
ctx.shadowColor = ctx.fillStyle;
|
|
ctx.shadowBlur = pulse.desired ? 13 : 8;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, pulse.desired ? 8 : 5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawOutput(w, h) {
|
|
const outY = h * 0.73;
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(36, outY);
|
|
ctx.lineTo(w - 36, outY);
|
|
ctx.stroke();
|
|
ctx.fillStyle = "rgba(255,255,255,0.65)";
|
|
ctx.font = "900 12px Inter, system-ui, sans-serif";
|
|
ctx.fillText("OUTPUT STRUCTURE", 42, outY - 16);
|
|
|
|
for (const pulse of state.transmitted) {
|
|
const x = timeToX(pulse.t, w);
|
|
ctx.globalAlpha = pulse.desired ? 0.95 : 0.55;
|
|
ctx.fillStyle = pulse.desired ? css("--good") : css("--bad");
|
|
ctx.beginPath();
|
|
ctx.roundRect(x - 5, outY - 26, 10, 52, 4);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawResult(w, h) {
|
|
if (!state.result) return;
|
|
const age = state.result.age;
|
|
ctx.save();
|
|
ctx.globalAlpha = Math.max(0, 1 - age * 0.55);
|
|
ctx.fillStyle = state.result.color;
|
|
ctx.font = "900 30px Inter, system-ui, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(state.result.text, w / 2, Math.max(52, h * 0.26 - age * 24));
|
|
ctx.restore();
|
|
}
|
|
|
|
function loop(time) {
|
|
const dt = Math.min(0.033, (time - state.lastTime) / 1000 || 0);
|
|
state.lastTime = time;
|
|
update(dt);
|
|
draw();
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
function onTap(element, handler) {
|
|
let handledAt = 0;
|
|
const run = (event) => {
|
|
const now = Date.now();
|
|
if (now - handledAt < 450) return;
|
|
handledAt = now;
|
|
event.preventDefault();
|
|
handler();
|
|
};
|
|
element.addEventListener("click", run);
|
|
element.addEventListener("touchend", run, { passive: false });
|
|
}
|
|
|
|
window.addEventListener("resize", fitCanvas);
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.code === "Space") {
|
|
event.preventDefault();
|
|
setGate(true);
|
|
}
|
|
if (event.key.toLowerCase() === "r") restart();
|
|
if (event.key === "Escape") stopGame();
|
|
});
|
|
window.addEventListener("keyup", (event) => {
|
|
if (event.code === "Space") setGate(false);
|
|
});
|
|
window.addEventListener("blur", () => setGate(false));
|
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
event.preventDefault();
|
|
setGate(true);
|
|
});
|
|
canvas.addEventListener("pointerup", () => setGate(false));
|
|
canvas.addEventListener("pointerleave", () => setGate(false));
|
|
canvas.addEventListener("pointercancel", () => setGate(false));
|
|
gateButton.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
gateButton.addEventListener("pointerdown", (event) => {
|
|
event.preventDefault();
|
|
gateButton.setPointerCapture(event.pointerId);
|
|
setGate(true);
|
|
});
|
|
gateButton.addEventListener("pointerup", (event) => {
|
|
if (gateButton.hasPointerCapture(event.pointerId)) {
|
|
gateButton.releasePointerCapture(event.pointerId);
|
|
}
|
|
setGate(false);
|
|
});
|
|
gateButton.addEventListener("pointerleave", () => setGate(false));
|
|
gateButton.addEventListener("pointercancel", () => setGate(false));
|
|
gateButton.addEventListener("lostpointercapture", () => setGate(false));
|
|
onTap(startButton, () => startGame(false));
|
|
onTap(stopButton, stopGame);
|
|
|
|
fitCanvas();
|
|
showIntro();
|
|
requestAnimationFrame(loop);
|
|
</script>
|
|
</body>
|
|
</html>
|