Moonwell Foundry
by NovaCaptain32420 lines15.0 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="#171327">
<title>Moonwell Foundry</title>
<style>:root {
color-scheme: dark;
--back: #171327;
--edge: #453158;
--accent: #ffd27c;
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">03 / TIMING</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="Moonwell Foundry game">
<canvas id="game" width="960" height="540" tabindex="0" aria-label="Moonwell Foundry playfield"></canvas>
<div id="overlay" class="overlay">
<div class="card">
<p id="eyebrow" class="eyebrow">A three-lane timing game</p>
<h1 id="title">Moonwell Foundry</h1>
<p id="message" class="message">Catch the bright crystals as they cross the gold line. Let the rough slag fall through.</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">Press A, S, D or Left, Down, Right. On a touchscreen, tap the lane.</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 } = Arcade;
const gold = "#ffd27c", lilac = "#ccafff", soot = "#171327", muted = "#b5a9c9";
const laneX = [274, 480, 686];
const gateY = 397;
const shift = 32;
const lanePattern = [0, 2, 1, 0, 1, 2, 0, 2, 1, 2, 0, 1];
const slagPattern = [4, 7, 11, 16, 19, 23];
let elapsed, spawnAt, spawned, drops, seals, score, combo, perfect, caught, glow, feedback;
function reset() {
elapsed = 0; spawnAt = .3; spawned = 0; drops = [];
seals = 5; score = 0; combo = 0; perfect = 0; caught = 0;
glow = [0, 0, 0]; feedback = "Catch crystals. Let slag pass.";
announce();
}
function announce() {
setStatus(`${score} points ยท ${seals} seals`);
}
function finish() {
const record = best("moonwell-score", score);
setStatus(seals ? "Shift complete" : "Furnace stalled");
tone(seals ? 690 : 129, .33, "triangle");
show(
"result", "Foundry report", seals ? "Shift complete" : "Furnace stalled",
`${caught} crystals caught, ${perfect} dead center. Score ${score}.`,
"Run another shift", () => { reset(); play(); }, `Best ยท ${record}`
);
}
function fault(message) {
seals--;
combo = 0;
feedback = message;
announce();
tone(132, .18, "triangle");
if (!seals) finish();
}
function tap(lane) {
if (glow[lane] > .04) return;
glow[lane] = .22;
const index = drops.findIndex(drop => drop.lane === lane && Math.abs(drop.y - gateY) <= 39);
if (index < 0) return fault("Early or late. Aim for the bright band.");
const [drop] = drops.splice(index, 1);
if (drop.slag) return fault("Slag caught. Let rough rocks pass.");
const centered = Math.abs(drop.y - gateY) <= 15;
caught++;
perfect += Number(centered);
combo++;
score += (centered ? 140 : 100) + Math.min(8, combo - 1) * 15;
feedback = centered ? "Dead center!" : "Crystal sealed.";
tone(380 + Math.min(8, combo) * 44, .12, "triangle");
announce();
}
function update(dt) {
elapsed += dt;
glow = glow.map(value => Math.max(0, value - dt));
if (elapsed >= spawnAt && spawnAt < shift) {
const speed = 148 + elapsed * 3;
drops.push({ lane: lanePattern[spawned % lanePattern.length], y: 60, speed, slag: slagPattern.includes(spawned % 25) });
spawned++;
spawnAt += Math.max(.69, 1.22 - elapsed * .014);
}
drops.forEach(drop => { drop.y += drop.speed * dt; });
const missed = drops.filter(drop => drop.y > gateY + 45 && !drop.slag).length;
drops = drops.filter(drop => drop.y <= gateY + 45);
for (let i = 0; i < missed && seals > 0; i++) fault("Crystal missed. Watch the next lane.");
if (seals && elapsed >= shift && drops.length === 0) finish();
}
function key(value) {
const lane = { a: 0, s: 1, d: 2, arrowleft: 0, arrowdown: 1, arrowright: 2 }[value];
if (lane >= 0 && lane <= 2) tap(lane);
}
function pointer({ x, y }) {
if (y < 83 || y > 491) return;
const lane = laneX.findIndex(center => Math.abs(center - x) <= 92);
if (lane >= 0) tap(lane);
}
function drawDrop(ctx, x, y, slag) {
ctx.beginPath();
if (slag) {
ctx.moveTo(x - 17, y - 12);
ctx.lineTo(x + 4, y - 19);
ctx.lineTo(x + 18, y - 5);
ctx.lineTo(x + 15, y + 15);
ctx.lineTo(x - 12, y + 16);
} else {
ctx.moveTo(x, y - 20);
ctx.lineTo(x + 17, y);
ctx.lineTo(x, y + 20);
ctx.lineTo(x - 17, y);
}
ctx.closePath();
ctx.fillStyle = slag ? "#8f839f" : gold;
ctx.fill();
text(ctx, slag ? "ร" : "+", x, y + 1, soot, 16, "center");
}
function draw(ctx, time) {
ctx.fillStyle = soot;
ctx.fillRect(0, 0, 960, 540);
text(ctx, "MOONWELL / NIGHT SHIFT", 24, 24, lilac, 16);
text(ctx, `SCORE ${score} COMBO ${combo}`, 24, 54, gold, 17);
text(ctx, `SEALS ${"โ".repeat(seals)}${"โ".repeat(5 - seals)}`, 935, 27, lilac, 17, "right");
text(ctx, `${Math.max(0, Math.ceil(shift - elapsed))}s`, 935, 55, muted, 18, "right");
laneX.forEach((center, index) => {
box(ctx, center - 92, 82, 184, 408, 10, glow[index] ? "#45315f" : "#261e3a");
for (let y = 115; y < 365; y += 36) {
ctx.fillStyle = "#3b2e50";
ctx.fillRect(center - 1, y + Math.sin(time * 2) * 5, 2, 14);
}
box(ctx, center - 91, gateY - 39, 182, 78, 4, glow[index] ? "#7b5791" : "#49374e");
ctx.strokeStyle = glow[index] ? "#fff3db" : gold;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(center - 90, gateY);
ctx.lineTo(center + 90, gateY);
ctx.stroke();
box(ctx, center - 28, 447, 56, 31, 5, gold);
text(ctx, "ASD"[index], center, 463, soot, 18, "center");
});
ctx.save();
ctx.beginPath();
ctx.rect(0, 82, 960, 360);
ctx.clip();
drops.forEach(drop => drawDrop(ctx, laneX[drop.lane], drop.y, drop.slag));
if (!elapsed && !drops.length) {
drawDrop(ctx, laneX[0], 170, false);
drawDrop(ctx, laneX[2], 264, true);
}
ctx.restore();
drawDrop(ctx, 839, 157, false);
text(ctx, "CATCH", 839, 196, gold, 14, "center");
drawDrop(ctx, 839, 251, true);
text(ctx, "IGNORE", 839, 290, muted, 14, "center");
text(ctx, feedback, 25, 514, lilac, 16);
}
reset();
start({ reset, update, draw, key, pointer });
})();
</script>
</body>
</html>
Game Source: Moonwell Foundry
Creator: NovaCaptain32
Libraries: none
Complexity: complex (420 lines, 15.0 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: moonwell-foundry-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.