Tideglass Courier
by NovaCaptain32437 lines15.6 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0c2033">
<title>Tideglass Courier</title>
<style>:root {
color-scheme: dark;
--back: #0c2033;
--edge: #244761;
--accent: #69e5eb;
font-family: system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: clamp(8px, 2vw, 20px);
background: radial-gradient(ellipse at 50% 15%, var(--edge), var(--back) 70%);
color: #f4f6fa;
}
.cabinet { width: min(100%, 1040px, calc((100dvh - 114px) * 1.7778)); }
.masthead, .console, .brand, .actions { display: flex; align-items: center; }
.masthead, .console { justify-content: space-between; gap: 12px; padding: 8px 2px; }
.brand, .actions { gap: 9px; }
.brand, .eyebrow, .details, .console {
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
}
.brand { font-weight: 800; letter-spacing: .08em; }
.mark { width: 10px; height: 10px; background: var(--accent); transform: rotate(45deg); }
.edition { color: #bbc4cf; font-size: 10px; white-space: nowrap; }
button {
color: inherit;
background: var(--back);
border: 1px solid #ffffff42;
border-radius: 6px;
cursor: pointer;
padding: 7px 10px;
font: inherit;
font-size: 12px;
white-space: nowrap;
}
button:focus-visible, canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .45; cursor: default; }
.stage { position: relative; overflow: hidden; border: 1px solid #ffffff42; border-radius: 12px; background: var(--back); }
canvas { display: block; width: 100%; height: auto; touch-action: none; }
.overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; background: #070b14a0; }
.overlay[hidden] { display: none; }
.card {
width: min(94%, 510px);
padding: clamp(10px, 3vw, 30px);
text-align: center;
background: color-mix(in srgb, var(--back), transparent 6%);
border: 1px solid var(--accent);
border-radius: 12px;
box-shadow: 0 12px 65px #0008;
}
.eyebrow { color: var(--accent); font-weight: 800; letter-spacing: .12em; text-transform: uppercase; margin: 0 0 7px; }
h1 { margin: 0; font-size: clamp(25px, 5.5vw, 54px); line-height: .98; letter-spacing: -.055em; }
.message { max-width: 37ch; margin: 12px auto; line-height: 1.45; font-size: clamp(12px, 2.2vw, 16px); color: #e0e4ed; }
.primary { background: var(--accent); color: #10141a; padding: 9px 22px; font-size: 15px; font-weight: 850; }
.primary:focus-visible { outline-color: white; }
.details { color: #ccd1d9; margin: 8px 0 0; }
.details:empty { display: none; }
.console { align-items: flex-start; color: #d4dae4; line-height: 1.45; }
.console p { margin: 0; }
output { color: var(--accent); text-align: right; min-width: 8ch; }
@media (max-width: 550px) {
.cabinet { width: 100%; }
.edition { display: none; }
.masthead, .console { gap: 6px; }
.message { margin: 6px auto; }
.card { padding: 9px; }
.eyebrow, .details, .console { font-size: 10px; }
.primary { padding: 5px 17px; }
}
</style>
</head>
<body>
<main class="cabinet">
<header class="masthead">
<div class="brand"><span class="mark"></span><span>D3MONGAMES</span><span class="edition">02 / ARCADE</span></div>
<div class="actions">
<button id="sound" type="button" aria-pressed="false">Sound off</button>
<button id="pause" type="button" disabled>Pause</button>
</div>
</header>
<section class="stage" aria-label="Tideglass Courier game">
<canvas id="game" width="960" height="540" tabindex="0" aria-label="Tideglass Courier playfield"></canvas>
<div id="overlay" class="overlay">
<div class="card">
<p id="eyebrow" class="eyebrow">An arcade delivery run</p>
<h1 id="title">Tideglass Courier</h1>
<p id="message" class="message">Pick up four parcels and deliver them across a harbor full of moving currents. You have 75 seconds.</p>
<button id="primary" class="primary" type="button">Play</button>
<p id="details" class="details"></p>
</div>
</div>
</section>
<footer class="console">
<p id="help">Move with WASD or arrows. On a touchscreen, hold or drag where you want to sail.</p>
<output id="status" role="status"></output>
</footer>
<noscript>This game needs JavaScript to run.</noscript>
</main>
<script>"use strict";
const Arcade = (() => {
const canvas = document.querySelector("#game");
const ctx = canvas.getContext("2d");
const overlay = document.querySelector("#overlay");
const primary = document.querySelector("#primary");
const pause = document.querySelector("#pause");
const sound = document.querySelector("#sound");
const status = document.querySelector("#status");
const keys = new Set();
let audio, soundOn = false, active = false, screen = "menu";
let game, nextAction, lastTime = 0, announcement = "";
function setStatus(message) {
if (announcement !== message) {
status.value = message;
announcement = message;
}
}
function show(kind, eyebrow, title, message, button, action, detail = "") {
active = false;
screen = kind;
keys.clear();
overlay.hidden = false;
pause.disabled = kind !== "paused";
pause.textContent = kind === "paused" ? "Resume" : "Pause";
document.querySelector("#eyebrow").textContent = eyebrow;
document.querySelector("#title").textContent = title;
document.querySelector("#message").textContent = message;
document.querySelector("#details").textContent = detail;
primary.textContent = button;
nextAction = action;
primary.focus({ preventScroll: true });
}
function play() {
active = true;
screen = "playing";
overlay.hidden = true;
pause.disabled = false;
pause.textContent = "Pause";
canvas.focus({ preventScroll: true });
}
function togglePause() {
if (screen === "playing") {
game.release?.();
show("paused", "Intermission", "Paused", "Your run is ready when you are.", "Resume", play);
} else if (screen === "paused") {
play();
}
}
function tone(frequency, duration = .11, type = "sine") {
if (!soundOn || !audio || audio.state !== "running") return;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
oscillator.connect(gain);
gain.connect(audio.destination);
gain.gain.setValueAtTime(.07, audio.currentTime);
gain.gain.exponentialRampToValueAtTime(.001, audio.currentTime + duration);
oscillator.start();
oscillator.stop(audio.currentTime + duration);
}
function best(key, score = 0) {
try {
const prior = Number(localStorage.getItem("d3mg." + key)) || 0;
if (score > prior) localStorage.setItem("d3mg." + key, String(score));
return Math.max(score, prior);
} catch {
return score;
}
}
function pointer(event) {
const box = canvas.getBoundingClientRect();
return {
x: (event.clientX - box.left) * canvas.width / box.width,
y: (event.clientY - box.top) * canvas.height / box.height
};
}
function start(controller) {
game = controller;
nextAction = () => { game.reset(); play(); };
primary.addEventListener("click", () => nextAction());
pause.addEventListener("click", togglePause);
sound.addEventListener("click", () => {
soundOn = !soundOn;
if (soundOn && window.AudioContext) {
audio ??= new AudioContext();
audio.resume().then(() => tone(480)).catch(() => {});
}
sound.textContent = soundOn ? "Sound on" : "Sound off";
sound.setAttribute("aria-pressed", String(soundOn));
});
document.addEventListener("keydown", event => {
const key = event.key.toLowerCase();
if (key === "escape" || key === "p") {
if (!event.repeat) togglePause();
return;
}
if (!active || (event.target instanceof HTMLButtonElement && key === " ")) return;
if ([" ", "arrowup", "arrowdown", "arrowleft", "arrowright"].includes(key)) event.preventDefault();
keys.add(key);
if (!event.repeat) game.key?.(key);
});
document.addEventListener("keyup", event => keys.delete(event.key.toLowerCase()));
canvas.addEventListener("pointerdown", event => {
if (!active) return;
canvas.setPointerCapture(event.pointerId);
game.pointer?.(pointer(event));
});
canvas.addEventListener("pointermove", event => {
if (active && canvas.hasPointerCapture(event.pointerId)) game.drag?.(pointer(event));
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) {
canvas.addEventListener(type, () => game.release?.());
}
window.addEventListener("blur", () => {
keys.clear();
game.release?.();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden && active) togglePause();
});
function frame(time) {
const dt = Math.min(.04, (time - (lastTime || time)) / 1000);
lastTime = time;
if (active) game.update?.(dt);
game.draw(ctx, time / 1000);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function text(context, value, x, y, color = "#f4f6fa", size = 18, align = "left") {
context.font = `700 ${size}px ui-monospace, Consolas, monospace`;
context.textAlign = align;
context.textBaseline = "middle";
context.fillStyle = color;
context.fillText(value, x, y);
}
function box(context, x, y, width, height, radius, color) {
context.beginPath();
context.roundRect(x, y, width, height, radius);
context.fillStyle = color;
context.fill();
}
return { start, show, play, setStatus, tone, best, text, box, keys };
})();
(() => {
const { start, show, play, setStatus, tone, best, text, box, keys } = Arcade;
const cyan = "#69e5eb", amber = "#ffca7a", coral = "#ff8673", slate = "#9ebbc9";
const docks = [
{ x: 140, y: 170, name: "NORTH QUAY" },
{ x: 818, y: 408, name: "SOUTH QUAY" },
{ x: 804, y: 160, name: "EAST LIGHT" },
{ x: 156, y: 406, name: "WEST LIGHT" }
];
const jobs = [[0, 1], [2, 3], [1, 0], [3, 2]];
const limit = 75;
let x, y, heading, elapsed, life, job, cargo, invulnerable, target, pulse;
function reset() {
x = 105; y = 280; heading = 0;
elapsed = 0; life = 3; job = 0; cargo = false;
invulnerable = 0; target = null; pulse = 0;
announce();
}
function announce() {
setStatus(`${job}/${jobs.length} delivered ยท ${life} hull`);
}
function finish(won) {
const remaining = Math.max(0, Math.ceil(limit - elapsed));
const score = job * 100 + (won ? remaining + life * 20 : 0);
const record = best("tideglass-score", score);
setStatus(won ? "Harbor supplied" : "Route ended");
tone(won ? 740 : 155, .35, "triangle");
show(
"result", won ? "Delivery route complete" : "Coast report",
won ? "Harbor supplied" : life === 0 ? "Hull breached" : "Tide is out",
`${job} of ${jobs.length} parcels delivered. Score ${score}.`,
"Sail again", () => { reset(); play(); }, `Best ยท ${record}`
);
}
function currents() {
return [
{ x: 314, y: 280 + Math.sin(elapsed * 1.21) * 127, r: 31 },
{ x: 485, y: 278 + Math.sin(elapsed * 1.52 + 2.1) * 144, r: 36 },
{ x: 650, y: 283 + Math.sin(elapsed * 1.38 + 3.8) * 123, r: 30 },
{ x: 493 + Math.cos(elapsed * 1.23 + 1.5) * 146, y: 284, r: 21 }
];
}
function update(dt) {
elapsed += dt;
if (elapsed >= limit) return finish(false);
invulnerable = Math.max(0, invulnerable - dt);
pulse = Math.max(0, pulse - dt);
const pressed = (...options) => Number(options.some(key => keys.has(key)));
let dx = pressed("arrowright", "d") - pressed("arrowleft", "a");
let dy = pressed("arrowdown", "s") - pressed("arrowup", "w");
if (target && !dx && !dy) {
dx = target.x - x;
dy = target.y - y;
if (Math.hypot(dx, dy) < 9) dx = dy = 0;
}
const length = Math.hypot(dx, dy);
if (length) {
heading = Math.atan2(dy, dx);
const speed = cargo ? 212 : 252;
x = Math.max(49, Math.min(911, x + dx / length * speed * dt));
y = Math.max(107, Math.min(461, y + dy / length * speed * dt));
}
if (!invulnerable && currents().some(c => Math.hypot(c.x - x, c.y - y) < c.r + 12)) {
life--;
invulnerable = 1.3;
announce();
tone(146, .24, "sawtooth");
if (life === 0) return finish(false);
}
const dock = docks[jobs[job][cargo ? 1 : 0]];
if (Math.hypot(dock.x - x, dock.y - y) > 34) return;
if (!cargo) {
cargo = true;
tone(440, .12, "triangle");
return;
}
job++;
cargo = false;
pulse = .7;
announce();
tone(690, .17, "triangle");
if (job === jobs.length) finish(true);
}
function draw(ctx, time) {
ctx.fillStyle = "#0c2033";
ctx.fillRect(0, 0, 960, 540);
box(ctx, 22, 82, 916, 405, 14, "#112e44");
ctx.save();
ctx.beginPath();
ctx.roundRect(22, 82, 916, 405, 14);
ctx.clip();
ctx.strokeStyle = "#31536a";
ctx.lineWidth = 1;
for (let row = 102; row < 500; row += 36) {
ctx.beginPath();
for (let col = 22; col <= 938; col += 8) {
const wave = row + Math.sin(col / 45 + row / 37 - time) * 3;
if (col === 22) ctx.moveTo(col, wave);
else ctx.lineTo(col, wave);
}
ctx.stroke();
}
ctx.restore();
text(ctx, "TIDEGLASS / COASTAL DISPATCH", 26, 29, cyan, 16);
text(ctx, `DELIVERED ${job}/${jobs.length}`, 26, 57, slate, 17);
text(ctx, `HULL ${"โ".repeat(life)}${"โ".repeat(3 - life)}`, 469, 51, coral, 19, "center");
text(ctx, `${Math.max(0, Math.ceil(limit - elapsed))}s`, 934, 48, cyan, 34, "right");
const activeDock = jobs[job]?.[cargo ? 1 : 0];
docks.forEach((dock, i) => {
const active = i === activeDock;
const color = cargo ? amber : cyan;
ctx.strokeStyle = active ? color : "#547181";
ctx.lineWidth = active ? 3 : 1;
ctx.beginPath();
ctx.arc(dock.x, dock.y, 34 + (active ? Math.sin(time * 4) * 2 : 0), 0, Math.PI * 2);
ctx.stroke();
box(ctx, dock.x - 12, dock.y - 12, 24, 24, 4, active ? color : "#547181");
text(ctx, String(i + 1), dock.x, dock.y, "#0c2033", 15, "center");
text(ctx, dock.name, dock.x, dock.y + 52, active ? color : slate, 13, "center");
});
for (const current of currents()) {
const { x: cx, y: cy, r } = current;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = "#ed6f6024";
ctx.fill();
ctx.strokeStyle = coral;
ctx.lineWidth = 2;
ctx.stroke();
for (let i = 0; i < 3; i++) {
const angle = elapsed * 2 + i * Math.PI * 2 / 3;
ctx.beginPath();
ctx.arc(cx, cy, r * .56, angle, angle + 1);
ctx.stroke();
}
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(heading);
ctx.globalAlpha = invulnerable && Math.floor(elapsed * 12) % 2 ? .45 : 1;
ctx.fillStyle = cyan;
ctx.beginPath();
ctx.moveTo(22, 0);
ctx.lineTo(-14, -12);
ctx.lineTo(-9, 0);
ctx.lineTo(-14, 12);
ctx.closePath();
ctx.fill();
if (cargo) box(ctx, -5, -5, 12, 10, 2, amber);
ctx.restore();
const next = docks[activeDock];
const objective = next ? (cargo ? "DELIVER TO " : "PICK UP AT ") + next.name : "ROUTE COMPLETE";
text(ctx, objective, 26, 513, cargo ? amber : cyan, 17);
text(ctx, pulse > 0 ? "PARCEL DELIVERED" : "WATCH THE CORAL CURRENTS", 934, 513, slate, 14, "right");
}
reset();
start({ reset, update, draw, pointer: point => { target = point; }, drag: point => { target = point; }, release: () => { target = null; } });
})();
</script>
</body>
</html>
Game Source: Tideglass Courier
Creator: NovaCaptain32
Libraries: none
Complexity: complex (437 lines, 15.6 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: tideglass-courier-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.