924 lines
27 KiB
HTML
924 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>PicoSwitch Pulse Cut</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #070b10;
|
|
--panel: rgba(13, 19, 28, 0.9);
|
|
--line: rgba(255, 255, 255, 0.16);
|
|
--text: #f5f7fb;
|
|
--muted: #a8b3c4;
|
|
--xray: #6ce7ff;
|
|
--acoustic: #f2d16b;
|
|
--good: #8be27e;
|
|
--bad: #f06f63;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
body {
|
|
min-height: 100%;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
background:
|
|
radial-gradient(circle at 72% 12%, rgba(108, 231, 255, 0.19), transparent 28%),
|
|
radial-gradient(circle at 18% 84%, rgba(242, 209, 107, 0.12), transparent 30%),
|
|
linear-gradient(135deg, #071019 0%, #101722 48%, #080b0f 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(--xray);
|
|
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 38px rgba(0, 0, 0, 0.3);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.stat {
|
|
min-width: 96px;
|
|
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: #08101a;
|
|
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: manipulation;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
pointer-events: none;
|
|
z-index: 4;
|
|
transition: opacity 170ms ease, transform 170ms ease;
|
|
}
|
|
|
|
.overlay.hidden {
|
|
opacity: 0;
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.modal {
|
|
width: min(820px, calc(100vw - 20px));
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 22px;
|
|
background: rgba(6, 10, 15, 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 18px;
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.score-report {
|
|
display: grid;
|
|
place-items: center;
|
|
margin: 6px 0 18px;
|
|
border: 1px solid rgba(108, 231, 255, 0.32);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
background: rgba(108, 231, 255, 0.08);
|
|
}
|
|
|
|
.score-report[hidden],
|
|
.result-report[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.score-report strong {
|
|
display: block;
|
|
color: var(--text);
|
|
font-size: clamp(3.6rem, 9vw, 6.6rem);
|
|
line-height: 0.9;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.score-report span {
|
|
margin-top: 8px;
|
|
color: var(--xray);
|
|
font-size: 0.9rem;
|
|
font-weight: 900;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.result-report {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin: 0 0 18px;
|
|
}
|
|
|
|
.report-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
text-align: center;
|
|
}
|
|
|
|
.report-item strong {
|
|
display: block;
|
|
margin-bottom: 7px;
|
|
color: var(--text);
|
|
font-size: clamp(2.2rem, 5vw, 4.4rem);
|
|
line-height: 0.95;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.report-item span {
|
|
display: block;
|
|
color: var(--xray);
|
|
font-weight: 900;
|
|
text-transform: uppercase;
|
|
font-size: 0.76rem;
|
|
}
|
|
|
|
.report-item p {
|
|
margin: 10px 0 0;
|
|
color: var(--muted);
|
|
font-size: clamp(0.92rem, 1.6vw, 1.05rem);
|
|
line-height: 1.35;
|
|
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(108, 231, 255, 0.55);
|
|
background: linear-gradient(180deg, #8ff0ff, #31a8cb);
|
|
color: #031018;
|
|
}
|
|
|
|
button:focus-visible {
|
|
outline: 3px solid rgba(242, 209, 107, 0.75);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.tip {
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--muted);
|
|
font-size: 0.86rem;
|
|
}
|
|
|
|
.controls {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.trigger-button {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 14px;
|
|
z-index: 3;
|
|
width: min(430px, calc(100% - 28px));
|
|
min-height: 62px;
|
|
border-color: rgba(108, 231, 255, 0.58);
|
|
background: linear-gradient(180deg, rgba(108, 231, 255, 0.24), rgba(108, 231, 255, 0.08));
|
|
color: var(--text);
|
|
touch-action: none;
|
|
}
|
|
|
|
.trigger-button:active {
|
|
border-color: rgba(242, 209, 107, 0.78);
|
|
background: linear-gradient(180deg, #8ff0ff, #31a8cb);
|
|
color: #031018;
|
|
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 96px;
|
|
}
|
|
|
|
.result-report {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header>
|
|
<div class="brand">
|
|
<small>txproducts.de presents</small>
|
|
<h1>PicoSwitch Pulse Cut</h1>
|
|
</div>
|
|
<div class="stats" aria-live="polite">
|
|
<div class="stat"><span>Score</span><strong id="score">0</strong></div>
|
|
<div class="stat"><span>Pulse</span><strong id="pulse">-- ps</strong></div>
|
|
<div class="stat"><span>Coherence</span><strong id="coherence">100%</strong></div>
|
|
<div class="stat"><span>Round</span><strong id="round">1 / 8</strong></div>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<canvas id="game" aria-label="PicoSwitch photo-acoustic Bragg pulse timing game"></canvas>
|
|
<div class="watermark" aria-hidden="true"><img src="assets/txproducts-logo-watermark.png" alt=""></div>
|
|
<button class="trigger-button" id="triggerButton" type="button" hidden>Trigger PicoSwitch</button>
|
|
<div class="overlay" id="overlay">
|
|
<section class="modal">
|
|
<h2 id="modalTitle">Are you ready?</h2>
|
|
<p id="modalText">You are about to trigger PicoSwitch. Fire when the pulse reaches the Bragg gate and the acoustic bands are aligned.</p>
|
|
<div class="score-report" id="scoreReport" hidden>
|
|
<strong id="finalScore">0</strong>
|
|
<span>Final score</span>
|
|
</div>
|
|
<div class="result-report" id="resultReport" hidden>
|
|
<div class="report-item">
|
|
<strong id="fivePsCount">0</strong>
|
|
<span>5 ps Cut</span>
|
|
<p>Best timing: pulse and acoustic wave aligned for the shortest cut.</p>
|
|
</div>
|
|
<div class="report-item">
|
|
<strong id="picoCutCount">0</strong>
|
|
<span>Pico Cut</span>
|
|
<p>Successful pulse shortening into the 5-10 ps target range.</p>
|
|
</div>
|
|
<div class="report-item">
|
|
<strong id="weakCount">0</strong>
|
|
<span>Weak Diffraction</span>
|
|
<p>The pulse hit the gate, but alignment was not clean enough.</p>
|
|
</div>
|
|
<div class="report-item">
|
|
<strong id="missCount">0</strong>
|
|
<span>Missed</span>
|
|
<p>The trigger fired outside the useful Bragg timing moment.</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">Tap at the picosecond moment: pulse, acoustic wave, Bragg gate.</div>
|
|
<div class="controls" aria-label="Controls">
|
|
<span><kbd>Space</kbd> trigger</span>
|
|
<span><kbd>Tap</kbd> trigger</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 pulseEl = document.querySelector("#pulse");
|
|
const coherenceEl = document.querySelector("#coherence");
|
|
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 scoreReport = document.querySelector("#scoreReport");
|
|
const finalScore = document.querySelector("#finalScore");
|
|
const resultReport = document.querySelector("#resultReport");
|
|
const fivePsCount = document.querySelector("#fivePsCount");
|
|
const picoCutCount = document.querySelector("#picoCutCount");
|
|
const weakCount = document.querySelector("#weakCount");
|
|
const missCount = document.querySelector("#missCount");
|
|
const startButton = document.querySelector("#startButton");
|
|
const triggerButton = document.querySelector("#triggerButton");
|
|
const stopButton = document.querySelector("#stopButton");
|
|
|
|
const state = {
|
|
mode: "menu",
|
|
score: 0,
|
|
pulsePs: null,
|
|
coherence: 100,
|
|
round: 1,
|
|
totalRounds: 8,
|
|
practice: false,
|
|
lastTime: 0,
|
|
gateX: 0,
|
|
beamY: 0,
|
|
pulseX: 0,
|
|
pulseSpeed: 0,
|
|
pulseLength: 170,
|
|
acousticPhase: 0,
|
|
acousticSpeed: 2.2,
|
|
triggerFlash: 0,
|
|
report: { five: 0, pico: 0, weak: 0, miss: 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);
|
|
state.gateX = canvas.clientWidth * 0.56;
|
|
state.beamY = canvas.clientHeight * 0.48;
|
|
}
|
|
|
|
function rand(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
|
|
function startGame(practice = false) {
|
|
state.mode = "armed";
|
|
state.practice = practice;
|
|
state.score = 0;
|
|
state.pulsePs = null;
|
|
state.coherence = 100;
|
|
state.round = 1;
|
|
state.report = { five: 0, pico: 0, weak: 0, miss: 0 };
|
|
overlay.classList.add("hidden");
|
|
scoreReport.hidden = true;
|
|
resultReport.hidden = true;
|
|
triggerButton.hidden = false;
|
|
nextRound();
|
|
}
|
|
|
|
function nextRound() {
|
|
const w = canvas.clientWidth;
|
|
state.mode = "armed";
|
|
state.gateX = w * 0.56;
|
|
state.beamY = canvas.clientHeight * 0.48;
|
|
state.pulseX = -state.pulseLength - rand(0, w * 0.22);
|
|
state.pulseSpeed = 260 + state.round * 46 + rand(-15, 45);
|
|
state.pulseLength = Math.max(92, 178 - state.round * 8);
|
|
state.acousticSpeed = 2.0 + state.round * 0.22;
|
|
state.triggerFlash = 0;
|
|
state.result = null;
|
|
triggerButton.hidden = false;
|
|
messageEl.textContent = "Trigger when the x-ray pulse is inside the Bragg gate and the acoustic bands align.";
|
|
updateHud();
|
|
}
|
|
|
|
function updateHud() {
|
|
scoreEl.textContent = state.score;
|
|
pulseEl.textContent = state.pulsePs ? `${state.pulsePs.toFixed(1)} ps` : "-- ps";
|
|
coherenceEl.textContent = `${Math.max(0, Math.round(state.coherence))}%`;
|
|
roundEl.textContent = state.practice ? `${state.round}` : `${state.round} / ${state.totalRounds}`;
|
|
}
|
|
|
|
function trigger() {
|
|
if (state.mode !== "armed") return;
|
|
|
|
const distance = Math.abs(state.pulseX - state.gateX);
|
|
const pulseWindow = Math.max(34, state.pulseLength * 0.34);
|
|
const pulseHit = Math.max(0, 1 - distance / pulseWindow);
|
|
const phaseHit = acousticAlignment();
|
|
const precision = Math.pow(pulseHit, 0.72) * (0.55 + Math.pow(phaseHit, 0.9) * 0.45);
|
|
const pulsePs = 32 - precision * 27;
|
|
const clampedPulse = Math.max(4.8, pulsePs);
|
|
const coherence = Math.max(8, 55 + precision * 45 - Math.max(0, distance - 8) * 0.18);
|
|
let points = Math.round(precision * 700 + Math.max(0, 12 - clampedPulse) * 80);
|
|
let label = "MISSED";
|
|
let color = getCss("--bad");
|
|
|
|
if (clampedPulse <= 6.2) {
|
|
label = "5 ps CUT";
|
|
state.report.five += 1;
|
|
color = getCss("--good");
|
|
messageEl.textContent = `Clean Bragg cut: ${clampedPulse.toFixed(1)} ps pulse. +${points}`;
|
|
} else if (clampedPulse <= 10) {
|
|
label = "PICO CUT";
|
|
state.report.pico += 1;
|
|
color = getCss("--xray");
|
|
messageEl.textContent = `Pulse shortened to ${clampedPulse.toFixed(1)} ps. +${points}`;
|
|
} else if (pulseHit > 0.38 || precision > 0.22) {
|
|
label = "WEAK DIFFRACTION";
|
|
state.report.weak += 1;
|
|
color = getCss("--acoustic");
|
|
points = Math.max(points, Math.round(pulseHit * 180));
|
|
messageEl.textContent = phaseHit < 0.35
|
|
? `Pulse was in the gate, but the acoustic wave was out of phase. +${points}`
|
|
: `Partial cut: ${clampedPulse.toFixed(1)} ps. +${points}`;
|
|
} else {
|
|
state.report.miss += 1;
|
|
points = 0;
|
|
state.coherence = Math.max(0, state.coherence - 18);
|
|
messageEl.textContent = "Timing missed: trigger when the pulse and acoustic bands align.";
|
|
}
|
|
|
|
state.score += points;
|
|
state.pulsePs = clampedPulse;
|
|
state.coherence = label === "MISSED" ? state.coherence : coherence;
|
|
state.triggerFlash = 1;
|
|
state.result = { label, color, precision, age: 0 };
|
|
state.mode = "result";
|
|
triggerButton.hidden = true;
|
|
updateHud();
|
|
|
|
window.setTimeout(() => {
|
|
if (state.mode !== "result") return;
|
|
if (!state.practice && state.round >= state.totalRounds) {
|
|
endGame();
|
|
return;
|
|
}
|
|
state.round += 1;
|
|
nextRound();
|
|
}, 820);
|
|
}
|
|
|
|
function acousticAlignment() {
|
|
const phase = Math.sin(state.acousticPhase);
|
|
return 1 - Math.min(1, Math.abs(phase) / 0.92);
|
|
}
|
|
|
|
function triggerReadiness() {
|
|
const distance = Math.abs(state.pulseX - state.gateX);
|
|
const pulseWindow = Math.max(34, state.pulseLength * 0.34);
|
|
const pulseHit = Math.max(0, 1 - distance / pulseWindow);
|
|
return {
|
|
pulseHit,
|
|
phaseHit: acousticAlignment(),
|
|
ready: pulseHit > 0.42 && acousticAlignment() > 0.32
|
|
};
|
|
}
|
|
|
|
function endGame() {
|
|
state.mode = "over";
|
|
triggerButton.hidden = true;
|
|
modalTitle.textContent = "Pulse run complete";
|
|
modalText.textContent = `Coherence: ${Math.round(state.coherence)}%. Best runs hold coherence while cutting x-ray pulses into the 5-10 ps range.`;
|
|
showReport();
|
|
startButton.textContent = "Run again";
|
|
overlay.classList.remove("hidden");
|
|
updateHud();
|
|
}
|
|
|
|
function restart() {
|
|
startGame(state.practice);
|
|
}
|
|
|
|
function stopGame() {
|
|
state.mode = "over";
|
|
triggerButton.hidden = true;
|
|
modalTitle.textContent = "Demo paused";
|
|
modalText.textContent = "Start again when the next visitor is ready. The goal is to trigger PicoSwitch when the pulse reaches the Bragg gate and the acoustic bands align.";
|
|
scoreReport.hidden = true;
|
|
resultReport.hidden = true;
|
|
startButton.textContent = "I'm ready";
|
|
overlay.classList.remove("hidden");
|
|
messageEl.textContent = "Stopped. Ready for the next booth visitor.";
|
|
}
|
|
|
|
function showReport() {
|
|
finalScore.textContent = state.score;
|
|
fivePsCount.textContent = state.report.five;
|
|
picoCutCount.textContent = state.report.pico;
|
|
weakCount.textContent = state.report.weak;
|
|
missCount.textContent = state.report.miss;
|
|
scoreReport.hidden = false;
|
|
resultReport.hidden = false;
|
|
}
|
|
|
|
function update(dt) {
|
|
state.acousticPhase += state.acousticSpeed * dt;
|
|
|
|
if (state.mode === "armed") {
|
|
state.pulseX += state.pulseSpeed * dt;
|
|
if (state.pulseX - state.pulseLength > canvas.clientWidth + 80) {
|
|
state.coherence = Math.max(0, state.coherence - 10);
|
|
state.pulsePs = null;
|
|
state.result = { label: "NO TRIGGER", color: getCss("--bad"), precision: 0, age: 0 };
|
|
state.mode = "result";
|
|
triggerButton.hidden = true;
|
|
messageEl.textContent = "No trigger: repetition rate outran the switch.";
|
|
updateHud();
|
|
window.setTimeout(() => {
|
|
if (state.mode !== "result") return;
|
|
if (!state.practice && state.round >= state.totalRounds) endGame();
|
|
else {
|
|
state.round += 1;
|
|
nextRound();
|
|
}
|
|
}, 700);
|
|
}
|
|
}
|
|
|
|
if (state.triggerFlash > 0) state.triggerFlash = Math.max(0, state.triggerFlash - dt * 2.7);
|
|
if (state.result) state.result.age += dt;
|
|
}
|
|
|
|
function draw() {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
drawInstrumentBackground(w, h);
|
|
drawBeamline(w);
|
|
drawCrystalGate();
|
|
drawAcousticWave();
|
|
drawPulse();
|
|
drawDetector(w);
|
|
drawTimingGuide(w);
|
|
drawResult();
|
|
}
|
|
|
|
function drawInstrumentBackground(w, h) {
|
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
grad.addColorStop(0, "#0a1420");
|
|
grad.addColorStop(0.56, "#111a22");
|
|
grad.addColorStop(1, "#070a0d");
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.strokeStyle = "#d7efff";
|
|
ctx.lineWidth = 1;
|
|
for (let x = -40; x < w + 60; x += 44) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x + 76, h);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 70; y < h; y += 72) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(w, y);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.36)";
|
|
ctx.fillRect(0, h - 72, w, 72);
|
|
}
|
|
|
|
function drawBeamline(w) {
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(108, 231, 255, 0.3)";
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, state.beamY);
|
|
ctx.lineTo(w, state.beamY);
|
|
ctx.stroke();
|
|
ctx.strokeStyle = "rgba(108, 231, 255, 0.08)";
|
|
ctx.lineWidth = 22;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, state.beamY);
|
|
ctx.lineTo(w, state.beamY);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawCrystalGate() {
|
|
const gateW = 88;
|
|
const gateH = 220;
|
|
const x = state.gateX - gateW / 2;
|
|
const y = state.beamY - gateH / 2;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = "rgba(108, 231, 255, 0.08)";
|
|
ctx.strokeStyle = "rgba(108, 231, 255, 0.65)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, gateW, gateH, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.globalAlpha = 0.55;
|
|
ctx.strokeStyle = getCss("--acoustic");
|
|
ctx.setLineDash([8, 8]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(state.gateX, y - 20);
|
|
ctx.lineTo(state.gateX, y + gateH + 20);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawAcousticWave() {
|
|
const gateW = 88;
|
|
const gateH = 220;
|
|
const left = state.gateX - gateW / 2;
|
|
const top = state.beamY - gateH / 2;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(left, top, gateW, gateH);
|
|
ctx.clip();
|
|
|
|
for (let i = -5; i < 12; i += 1) {
|
|
const y = top + i * 26 + ((state.acousticPhase * 34) % 26);
|
|
const alignmentBoost = acousticAlignment();
|
|
ctx.strokeStyle = `rgba(242, 209, 107, ${0.16 + alignmentBoost * 0.28})`;
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(left - 12, y);
|
|
ctx.lineTo(left + gateW + 12, y - 30);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPulse() {
|
|
const y = state.beamY;
|
|
const x = state.pulseX;
|
|
const length = state.mode === "result" && state.pulsePs && state.pulsePs <= 10 ? 34 : state.pulseLength;
|
|
const glow = ctx.createLinearGradient(x - length, 0, x + length, 0);
|
|
glow.addColorStop(0, "rgba(108, 231, 255, 0)");
|
|
glow.addColorStop(0.48, "rgba(108, 231, 255, 0.85)");
|
|
glow.addColorStop(1, "rgba(108, 231, 255, 0)");
|
|
|
|
ctx.save();
|
|
ctx.shadowColor = getCss("--xray");
|
|
ctx.shadowBlur = 18 + state.triggerFlash * 30;
|
|
ctx.strokeStyle = glow;
|
|
ctx.lineWidth = 18;
|
|
ctx.lineCap = "round";
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - length, y);
|
|
ctx.lineTo(x + length * 0.35, y);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = "#f5fbff";
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 7 + state.triggerFlash * 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
if (state.triggerFlash > 0) {
|
|
ctx.strokeStyle = `rgba(242, 209, 107, ${state.triggerFlash})`;
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.arc(state.gateX, y, 42 + (1 - state.triggerFlash) * 70, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawDetector(w) {
|
|
const x = w - 92;
|
|
const y = state.beamY;
|
|
ctx.save();
|
|
ctx.fillStyle = "rgba(255, 255, 255, 0.08)";
|
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.28)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y - 58, 58, 116, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
const bars = Math.max(1, Math.round((state.coherence / 100) * 5));
|
|
for (let i = 0; i < 5; i += 1) {
|
|
ctx.fillStyle = i < bars ? getCss("--good") : "rgba(255, 255, 255, 0.12)";
|
|
ctx.fillRect(x + 12, y + 35 - i * 18, 34, 9);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawTimingGuide(w) {
|
|
if (state.mode !== "armed") return;
|
|
const y = state.beamY + 92;
|
|
const readiness = triggerReadiness();
|
|
const heat = Math.max(readiness.pulseHit, readiness.phaseHit * 0.7);
|
|
ctx.save();
|
|
ctx.fillStyle = `rgba(108, 231, 255, ${0.14 + heat * 0.22})`;
|
|
ctx.fillRect(state.gateX - 72, y, 144, 6);
|
|
ctx.fillStyle = getCss("--acoustic");
|
|
ctx.fillRect(state.gateX - 72, y, 144 * acousticAlignment(), 6);
|
|
ctx.font = "900 13px Inter, system-ui, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = readiness.ready ? getCss("--good") : "rgba(245, 247, 251, 0.78)";
|
|
ctx.fillText(readiness.ready ? "BEST ALIGNMENT" : "ALIGN PULSE + ACOUSTIC WAVE", state.gateX, y + 24);
|
|
ctx.font = "800 11px Inter, system-ui, sans-serif";
|
|
ctx.fillStyle = "rgba(245, 247, 251, 0.62)";
|
|
ctx.fillText("pulse in gate + acoustic wave aligned", state.gateX, y + 42);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawResult() {
|
|
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.label, state.gateX, Math.max(48, state.beamY - 150 - age * 28));
|
|
if (state.pulsePs) {
|
|
ctx.font = "900 18px Inter, system-ui, sans-serif";
|
|
ctx.fillText(`${state.pulsePs.toFixed(1)} ps`, state.gateX, Math.max(80, state.beamY - 116 - age * 20));
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function getCss(name) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
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();
|
|
trigger();
|
|
}
|
|
if (event.key.toLowerCase() === "r") restart();
|
|
if (event.key === "Escape") stopGame();
|
|
});
|
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
triggerButton.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
triggerButton.addEventListener("pointerdown", (event) => {
|
|
event.preventDefault();
|
|
trigger();
|
|
});
|
|
onTap(startButton, () => startGame(false));
|
|
onTap(stopButton, stopGame);
|
|
|
|
fitCanvas();
|
|
requestAnimationFrame(loop);
|
|
</script>
|
|
</body>
|
|
</html>
|