Windup Workshop Preview
by NovaCaptain32490 lines19.4 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Jump the gaps in a clockwork workshop. Collect brass gears and reach the red door.">
<title>Windup Workshop · Preview</title>
<style>:root {
color-scheme: dark;
--accent: #ffe39a;
--ink: #263044;
--paper: #f3f6ff;
--dim: #bcc9dc;
font: 16px/1.5 system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(ellipse at 78% 0%, color-mix(in srgb, var(--ink), var(--accent) 15%), var(--ink) 60%);
color: var(--paper);
}
main { max-width: 920px; margin: auto; padding: clamp(10px, 3vw, 30px); }
header { display: flex; justify-content: space-between; align-items: end; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; }
h1, h2, p { margin: 0; }
h1 { font-size: clamp(1.5rem, 5vw, 2.7rem); line-height: 1.05; letter-spacing: -.045em; }
h2 { font-size: clamp(1.1rem, 5vw, 2.4rem); line-height: 1.05; letter-spacing: -.04em; }
.kicker { color: var(--accent); font: 750 .72rem/1.2 ui-monospace, monospace; letter-spacing: .12em; margin-bottom: .38rem; }
.tools { display: flex; gap: .4rem; flex-wrap: wrap; }
button {
border: 1px solid color-mix(in srgb, var(--accent), transparent 66%);
background: var(--ink);
color: var(--paper);
border-radius: 8px;
font: 700 .82rem/1.15 system-ui, sans-serif;
padding: .48rem .67rem;
min-height: 40px;
cursor: pointer;
}
button.primary { color: var(--ink); background: var(--accent); }
button:hover:enabled { border-color: var(--accent); filter: brightness(1.13); }
button:focus-visible, canvas:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .55; cursor: default; }
.cabinet { position: relative; overflow: clip; border: 1px solid color-mix(in srgb, var(--accent), transparent 62%); border-radius: 14px; background: var(--ink); box-shadow: 0 14px 60px #0006; }
canvas { display: block; width: 100%; height: auto; touch-action: none; }
#overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; background: #07101abd; overflow: auto; }
#overlay[hidden] { display: none; }
.card {
width: min(100%, 470px);
padding: clamp(9px, 2.4vw, 24px);
background: color-mix(in srgb, var(--ink), #202e42 22%);
border: 1px solid color-mix(in srgb, var(--accent), transparent 54%);
border-radius: 12px;
}
#message { font-size: clamp(.78rem, 2.7vw, 1rem); margin: .45rem 0 .55rem; }
#record { margin-top: .45rem; color: var(--dim); font-size: .74rem; }
.status { display: flex; gap: .3rem 1rem; flex-wrap: wrap; justify-content: space-between; padding: .62rem .12rem; font-size: .85rem; font-weight: 700; }
#goal { color: var(--accent); }
#touch button { min-width: 48px; min-height: 44px; touch-action: none; }
#help, details { color: var(--dim); font-size: .82rem; margin: .5rem .12rem; }
@media (max-width: 420px) {
#overlay { align-items: start; }
#overlay button { min-height: 34px; padding: .35rem .48rem; font-size: .74rem; }
.card { padding: 8px; }
#message { margin: .27rem 0 .36rem; }
}
</style>
</head>
<body>
<main>
<header>
<div><p class="kicker">D3MONGAMES / PRECISION PLATFORMER / PREVIEW</p><h1>Windup Workshop · Preview</h1></div>
<div class="tools"><button id="audio" aria-pressed="false">Sound off</button><button id="pause" disabled>Pause</button></div>
</header>
<section class="cabinet" aria-label="Game">
<canvas id="board" width="720" height="432" tabindex="0" aria-label="Windup Workshop · Preview playfield"></canvas>
<div id="overlay">
<section class="card" aria-labelledby="card-title">
<p class="kicker" id="eyebrow">PLAYABLE PREVIEW</p>
<h2 id="card-title">Windup Workshop · Preview</h2>
<p id="message">Jump the gaps in a clockwork workshop. Collect brass gears and reach the red door.</p>
<div class="tools" id="choices"></div>
<p id="record"></p>
</section>
</div>
</section>
<div class="status"><p id="status">Choose a game mode.</p><p id="goal"></p></div>
<div id="touch" class="tools" aria-label="Game controls"></div>
<p id="help">Hold Left or Right to run. Press Space or Up to jump. On touch, hold the on-screen buttons.</p>
<details><summary>How progress works</summary><p>This preview includes one stage. Replay to improve your score and medals. The full edition includes 12 campaign stages and a daily challenge.</p></details>
<noscript>JavaScript is required to play.</noscript>
</main>
<script>const preview = true;
const game = {
slug: "windup-workshop",
title: "Windup Workshop",
genre: "PRECISION PLATFORMER",
pitch: "Jump the gaps in a clockwork workshop. Collect brass gears and reach the red door.",
controls: "Hold Left or Right to run. Press Space or Up to jump. On touch, hold the on-screen buttons.",
accent: "#ffe39a",
ink: "#263044",
levels: 12,
buttons: [["left", "← Run"], ["act", "Jump"], ["right", "Run →"]],
create() { return {"pads":[{"x":12,"end":93,"y":342,"gear":false},{"x":118,"end":173,"y":328.5297423383454,"gear":true},{"x":196,"end":257,"y":312.7174672173569,"gear":true},{"x":285,"end":342,"y":334.0717977763852,"gear":true},{"x":374,"end":431,"y":355,"gear":true},{"x":459,"end":512,"y":355,"gear":true},{"x":548,"end":608,"y":355,"gear":true},{"x":639,"end":707,"y":337.0264959441265,"gear":false}],"x":44,"y":342,"vx":0,"vy":0,"landed":true,"coyote":0.08,"jump":0,"gears":0,"time":0,"falls":0,"checkpoint":0,"cleared":false}; },
input(s, action) { if (action === "act" || action === "up") s.jump = .14; },
update(s, dt, down, chime) {
s.time += dt;
s.jump = Math.max(0, s.jump - dt);
s.coyote = s.landed ? .09 : Math.max(0, s.coyote - dt);
const motion = Number(down.has("right")) - Number(down.has("left"));
s.vx += (motion * 215 - s.vx) * Math.min(1, dt * (motion ? 16 : 20));
if (s.jump && s.coyote) {
s.vy = -395;
s.coyote = 0;
s.jump = 0;
s.landed = false;
chime(415);
}
const beforeY = s.y;
s.x = Math.min(707, Math.max(13, s.x + s.vx * dt));
s.vy += 850 * dt;
s.y += s.vy * dt;
s.landed = false;
for (let i = 0; i < s.pads.length; i++) {
const pad = s.pads[i];
if (s.x + 7 > pad.x && s.x - 7 < pad.end && s.vy >= 0 && beforeY <= pad.y && s.y >= pad.y) {
s.y = pad.y;
s.vy = 0;
s.landed = true;
if (i > s.checkpoint && i % 3 === 0) s.checkpoint = i;
}
if (pad.gear && Math.hypot(s.x - (pad.x + pad.end) / 2, s.y - pad.y + 4) < 28) {
pad.gear = false;
s.gears++;
chime(650);
}
}
if (s.y > 461) {
s.falls++;
const pad = s.pads[s.checkpoint];
s.x = (pad.x + pad.end) / 2;
s.y = pad.y;
s.vx = 0;
s.vy = 0;
s.landed = true;
chime(175);
}
s.cleared = s.landed && s.x > 679 && Math.abs(s.y - s.pads.at(-1).y) < 1;
if (s.cleared || s.falls >= 5) {
return { clear: s.cleared, medals: !s.falls && s.gears === 6 ? 3 : s.falls <= 2 && s.gears >= 4 ? 2 : 1, score: Math.max(0, 1000 + s.gears * 160 - s.falls * 160 - Math.floor(s.time * 9)),
message: s.cleared ? "Door reached with " + s.gears + "/6 gears. The brass flags saved your checkpoints." : "Five falls used. Take off near the edge and ease off the run button as you land." };
}
},
status(s) { return s.gears + "/6 gears · " + s.falls + "/5 falls · " + s.time.toFixed(1) + " s"; },
draw(s, { ctx, box, circle, line, label }) {
const sky = ctx.createLinearGradient(0, 0, 0, 432);
sky.addColorStop(0, "#3c5674");
sky.addColorStop(1, "#2c344b");
box(0, 0, 720, 432, sky);
for (let i = 0; i < 16; i++) {
const x = (i * 83 + 44) % 720, y = (i * 51 + 19) % 360;
circle(x, y, 19 + i % 4 * 7, "#55708a55");
circle(x, y, 8, "#364c675e");
}
box(0, 392, 720, 40, "#302c38");
line(0, 400, 720, 400, 3, "#fa9468");
for (let i = 0; i < s.pads.length; i++) {
const { x, end, y, gear } = s.pads[i];
box(x, y, end - x, 16, "#596070", 4);
box(x + 2, y, end - x - 4, 4, "#ffd481", 2);
if (gear) {
circle((x + end) / 2, y - 27, 10, "#ffdb84");
circle((x + end) / 2, y - 27, 4, "#715668");
}
if (i === 3 || i === 6) {
line(x + 10, y - 36, x + 10, y, 2, "#ffd481");
box(x + 11, y - 36, 17, 11, i <= s.checkpoint ? "#9bf1c9" : "#f3aa7c", 2);
}
}
const door = s.pads.at(-1);
box(682, door.y - 46, 24, 46, "#ed8c85", 6);
circle(699, door.y - 19, 3, "#fff5c9");
box(s.x - 10, s.y - 27, 20, 24, "#b7e1ff", 5);
box(s.x - 6, s.y - 22, 12, 7, "#364764", 2);
circle(s.x - 3, s.y - 19, 1.5, "#fff0c9");
circle(s.x + 3, s.y - 19, 1.5, "#fff0c9");
label("WORKSHOP", 17, 23, 14, "#ffe39a");
}
};
const board = document.querySelector("#board");
const ctx = board.getContext("2d");
const ui = Object.fromEntries(["overlay", "eyebrow", "card-title", "message", "choices", "record", "status", "goal", "touch", "audio", "pause"].map(id => [id, document.getElementById(id)]));
const campaignCount = preview ? 1 : game.levels;
const saveKey = "d3mg-collection-v1:" + game.slug + (preview ? ":preview" : "");
const dateKey = date => date.toISOString().slice(0, 10);
const today = () => dateKey(new Date());
function hash(text) {
let value = 2166136261;
for (const character of text) value = Math.imul(value ^ character.charCodeAt(0), 16777619);
return value >>> 0;
}
function random(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let value = Math.imul(seed ^ seed >>> 15, seed | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
return ((value ^ value >>> 14) >>> 0) / 4294967296;
};
}
function readProgress() {
const empty = { levels: {}, days: {}, streak: 0, last: "" };
try {
const record = JSON.parse(localStorage.getItem(saveKey));
if (record?.levels && record.days) return { ...empty, ...record };
} catch {}
return empty;
}
function saveProgress() {
try { localStorage.setItem(saveKey, JSON.stringify(progress)); } catch {}
}
function box(x, y, width, height, color, radius = 0) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
}
function circle(x, y, radius, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
function line(x, y, endX, endY, width, color) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(endX, endY);
ctx.stroke();
}
function label(text, x, y, size, color = "#f3f6ff", align = "left") {
ctx.fillStyle = color;
ctx.textAlign = align;
ctx.textBaseline = "middle";
ctx.font = "700 " + size + "px system-ui, sans-serif";
ctx.fillText(text, x, y);
}
const art = { ctx, box, circle, line, label, accent: game.accent, ink: game.ink };
const progress = readProgress();
const down = new Set();
const actions = new Map([
["ArrowLeft", "left"], ["a", "left"], ["ArrowRight", "right"], ["d", "right"],
["ArrowUp", "up"], ["w", "up"], ["ArrowDown", "down"], ["s", "down"],
[" ", "act"], ["Enter", "act"], ["z", "undo"], ["x", "mark"]
]);
for (let digit = 1; digit <= 9; digit++) actions.set(String(digit), "digit" + digit);
actions.set("Backspace", "erase");
actions.set("Delete", "erase");
let audioContext, soundOn = false;
function chime(frequency = 560) {
if (!soundOn || !audioContext || audioContext.state !== "running") return;
const note = audioContext.createOscillator();
const volume = audioContext.createGain();
const time = audioContext.currentTime;
note.type = "sine";
note.frequency.value = frequency;
volume.gain.setValueAtTime(.065, time);
volume.gain.exponentialRampToValueAtTime(.001, time + .14);
note.connect(volume).connect(audioContext.destination);
note.start(time);
note.stop(time + .15);
}
let phase = "menu";
let level = 1;
let daily = false;
let date = today();
let state;
function highestStage() {
let stage = 1;
while (stage < campaignCount && progress.levels[stage]) stage++;
return stage;
}
function prepare(stage, isDaily, day) {
level = stage;
daily = isDaily;
date = day;
const seed = hash(game.slug + "/" + (isDaily ? "daily/" + day : "stage/" + stage));
state = game.create({ stage, daily: isDaily, seed, rng: random(seed) });
down.clear();
}
function showCard(heading, title, message, buttons, record = "") {
ui.eyebrow.textContent = heading;
ui["card-title"].textContent = title;
ui.message.textContent = message;
ui.record.textContent = record;
ui.choices.replaceChildren();
for (const [name, callback, disabled] of buttons) {
const button = document.createElement("button");
button.type = "button";
button.textContent = name;
button.disabled = !!disabled;
if (!ui.choices.children.length) button.className = "primary";
button.addEventListener("click", callback);
ui.choices.append(button);
}
ui.overlay.hidden = false;
}
function menu(focus = true) {
phase = "menu";
prepare(highestStage(), false, today());
ui.pause.disabled = true;
if (preview) {
showCard("PLAYABLE PREVIEW", game.title, game.pitch, [["Play preview", () => begin(1, false)]], "One stage · Keyboard and touch");
if (focus) ui.choices.querySelector("button").focus();
return;
}
const earned = Object.values(progress.levels).reduce((sum, entry) => sum + entry.medals, 0);
const count = Object.keys(progress.levels).length;
const dailyRecord = progress.days[today()];
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const activeStreak = progress.last === today() || progress.last === dateKey(yesterday) ? progress.streak : 0;
showCard("CAMPAIGN AND DAILY CHALLENGE", game.title, game.pitch, [
[count ? "Continue stage " + highestStage() : "Play stage 1", () => begin(highestStage(), false)],
["Daily challenge", () => begin(3, true)],
["Stage select", chooseStage, count === 0]
], count + "/" + campaignCount + " stages cleared · " + earned + "/" + (campaignCount * 3) + " medals" +
(dailyRecord ? " · Daily best " + dailyRecord.score : "") +
(activeStreak ? " · " + activeStreak + "-day streak" : ""));
if (focus) ui.choices.querySelector("button").focus();
}
function chooseStage() {
const buttons = [];
for (let stage = 1; stage <= campaignCount; stage++) {
const result = progress.levels[stage];
buttons.push(["Stage " + stage + (result ? " · " + "★".repeat(result.medals) : ""), () => begin(stage, false), stage > highestStage()]);
}
buttons.push(["Menu", menu]);
showCard("CAMPAIGN", "Choose a stage", "Clear each stage to open the next. Replay earlier stages to improve your medals.", buttons);
ui.choices.querySelector("button").focus();
}
function begin(stage, isDaily, day = today()) {
prepare(stage, isDaily, day);
phase = "play";
ui.pause.disabled = false;
ui.pause.textContent = "Pause";
ui.overlay.hidden = true;
board.focus({ preventScroll: true });
chime(420);
}
function pause() {
if (phase === "paused") {
phase = "play";
ui.overlay.hidden = true;
ui.pause.textContent = "Pause";
board.focus({ preventScroll: true });
return;
}
if (phase !== "play") return;
phase = "paused";
down.clear();
ui.pause.textContent = "Resume";
showCard("TAKE A BREAK", "Paused", "Your run is waiting.", [
["Resume", pause], ["Restart", () => begin(level, daily, date)], ["Menu", menu]
]);
ui.choices.querySelector("button").focus();
}
function finish(result) {
phase = "result";
down.clear();
ui.pause.disabled = true;
if (result.clear || daily) {
const shelf = daily ? progress.days : progress.levels;
const id = daily ? date : level;
const old = shelf[id];
shelf[id] = { score: Math.max(old?.score ?? 0, result.score), medals: Math.max(old?.medals ?? 0, result.clear ? result.medals : 0) };
if (daily && result.clear && (!progress.last || date > progress.last)) {
const previous = new Date(date + "T00:00:00Z");
previous.setUTCDate(previous.getUTCDate() - 1);
progress.streak = dateKey(previous) === progress.last ? progress.streak + 1 : 1;
progress.last = date;
}
saveProgress();
}
chime(result.clear ? 810 : 210);
const buttons = [["Replay", () => begin(level, daily, date)], ["Menu", menu]];
if (result.clear && !daily && level < campaignCount) buttons.unshift(["Next stage", () => begin(level + 1, false)]);
showCard(preview ? "PLAYABLE PREVIEW" : daily ? "DAILY CHALLENGE" : "STAGE " + level, result.clear ? "Challenge cleared" : "Run complete",
result.message, buttons, "Score " + result.score + (result.clear ? " · " + "★".repeat(result.medals) + "☆".repeat(3 - result.medals) : ""));
ui.choices.querySelector("button").focus();
}
function trigger(action) {
if (phase !== "play") return;
if (!down.has(action)) game.input?.(state, action, chime);
down.add(action);
}
document.addEventListener("keydown", event => {
if (event.key === "Escape" || event.key.toLowerCase() === "p") {
if (!event.repeat && (phase === "play" || phase === "paused")) {
event.preventDefault();
pause();
}
return;
}
if (phase !== "play" || event.target.closest("button, summary")) return;
const action = actions.get(event.key.length === 1 ? event.key.toLowerCase() : event.key);
if (action) {
event.preventDefault();
trigger(action);
}
});
document.addEventListener("keyup", event => down.delete(actions.get(event.key.length === 1 ? event.key.toLowerCase() : event.key)));
document.addEventListener("visibilitychange", () => { if (document.hidden && phase === "play") pause(); });
window.addEventListener("blur", () => down.clear());
board.addEventListener("pointerdown", event => {
if (phase !== "play") return;
const bounds = board.getBoundingClientRect();
game.tap?.(state, (event.clientX - bounds.x) * 720 / bounds.width, (event.clientY - bounds.y) * 432 / bounds.height, chime);
});
for (const [action, text] of game.buttons) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.addEventListener("pointerdown", event => {
event.preventDefault();
button.setPointerCapture(event.pointerId);
trigger(action);
});
for (const eventName of ["pointerup", "pointercancel", "lostpointercapture"]) {
button.addEventListener(eventName, () => down.delete(action));
}
button.addEventListener("click", event => {
if (event.detail === 0) { trigger(action); down.delete(action); }
});
ui.touch.append(button);
}
ui.pause.addEventListener("click", pause);
ui.audio.addEventListener("click", async () => {
soundOn = !soundOn;
if (soundOn && window.AudioContext) {
audioContext ??= new AudioContext();
await audioContext.resume().catch(() => {});
chime();
}
ui.audio.textContent = soundOn ? "Sound on" : "Sound off";
ui.audio.setAttribute("aria-pressed", soundOn);
});
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, .05);
last = now;
if (phase === "play") {
const result = game.update(state, dt, down, chime);
if (result) finish(result);
}
game.draw(state, art);
const status = game.status(state);
if (ui.status.textContent !== status) ui.status.textContent = status;
ui.goal.textContent = preview ? "Preview · Stage 1" : daily ? "Daily · " + date : "Campaign · " + level + "/" + campaignCount;
requestAnimationFrame(frame);
}
menu(false);
requestAnimationFrame(frame);
</script>
</body>
</html>
Game Source: Windup Workshop Preview
Creator: NovaCaptain32
Libraries: none
Complexity: complex (490 lines, 19.4 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: windup-workshop-preview-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.