| |
| |
|
|
| (async () => { |
| |
| |
| { |
| let vp = document.querySelector('meta[name="viewport"]'); |
| if (!vp) { |
| vp = document.createElement("meta"); |
| vp.name = "viewport"; |
| document.head.appendChild(vp); |
| } |
| vp.content = |
| "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (window.self !== window.top) { |
| const rootEl = element.querySelector(".sq-root"); |
| document.documentElement.style.height = document.body.style.height = "auto"; |
| const fit = (h) => h > 0 && (rootEl.style.height = Math.round(h) + "px"); |
| |
| |
| fit(Math.min(window.innerHeight, window.screen.height || Infinity)); |
| |
| |
| |
| |
| let chrome = 0, lastKey = ""; |
| const apply = (info) => { |
| |
| |
| |
| |
| |
| |
| |
| |
| const avail = Math.max(0, info.clientHeight - info.offsetTop); |
| const key = avail + "x" + info.clientWidth; |
| if (key === lastKey) return; |
| lastKey = key; |
| fit(avail - chrome); |
| requestAnimationFrame(() => { |
| const excess = document.body.offsetHeight - rootEl.offsetHeight; |
| if (excess >= 0 && excess !== chrome) { chrome = excess; fit(avail - chrome); } |
| }); |
| }; |
| |
| |
| |
| const poll = setInterval(() => { |
| if (!window.parentIFrame) return; |
| clearInterval(poll); |
| window.parentIFrame.getPageInfo(apply); |
| }, 50); |
| setTimeout(() => clearInterval(poll), 5000); |
| } |
|
|
| const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js"); |
|
|
| |
| const data = props.value; |
| const LEVELS = Object.fromEntries(data.levels.map((l) => [l.id, l])); |
| const ORDER = data.order; |
| let level = LEVELS[data.home]; |
| let ROWS = level.grid.length; |
| let COLS = level.grid[0].length; |
| |
| const checkedByLevel = Object.fromEntries(data.levels.map((l) => [l.id, new Set()])); |
| const root = element.querySelector(".sq-root"); |
| const stage = element.querySelector(".sq-stage"); |
|
|
| const INK = "#1c1b18"; |
| const INK_SOFT = "#5a564c"; |
| const ACCENT = "#b3402e"; |
|
|
| |
| |
| const isSubmit = (w) => w === "⏎"; |
| |
| const isShrink = (w) => w === "shrink"; |
| |
| |
| const isFile = (w) => /\.[a-z0-9]{2,4}$/i.test(w); |
| |
| const isImageFile = (w) => /\.(png|jpe?g|gif|webp|svg|bmp|avif)$/i.test(w); |
|
|
| |
| |
| const appendsWord = (w) => |
| w && w !== "start" && !isSubmit(w) && !isShrink(w) && !isFile(w) && |
| w !== "wings" && w !== "portal" && w !== "shift"; |
|
|
| |
| |
| let shrunk = false; |
| let hasHug = false; |
| const SHRINK_SCALE = 0.56; |
| |
| let returnTo = null; |
|
|
| |
| |
| |
| |
| |
| const stats = { |
| hops: 0, |
| startMs: 0, |
| hints: 0, |
| crashes: 0, |
| wins: 0, |
| losses: 0, |
| deaths: 0, |
| }; |
|
|
| |
| |
| |
| const targetUnlocked = (t) => |
| t === "build" || (t === "small" && shrunk) || (t === "hackathon" && hasHug); |
| const submitPressable = (t) => targetUnlocked(t) && !checkedOnThisLevel().has(t); |
| |
| |
| const submitTileState = (t) => |
| checkedOnThisLevel().has(t) ? "broken" : targetUnlocked(t) ? "ready" : "locked"; |
|
|
| |
| |
| |
| const targetListEl = element.querySelector(".sq-target-list"); |
| let targetItems = {}; |
| const checkedOnThisLevel = () => checkedByLevel[level.id]; |
| const remainingTargets = () => level.targets.filter((t) => !checkedOnThisLevel().has(t)); |
|
|
| |
| function buildTargets() { |
| targetListEl.innerHTML = ""; |
| targetItems = {}; |
| const checked = checkedOnThisLevel(); |
| for (const label of level.targets) { |
| const item = document.createElement("span"); |
| item.className = "sq-target-item"; |
| item.style.setProperty("--tilt", `${(Math.random() * 4 - 2.5).toFixed(1)}deg`); |
| item.appendChild(document.createTextNode(label)); |
| if (checked.has(label)) item.classList.add("sq-checked"); |
| targetListEl.appendChild(item); |
| targetItems[label] = item; |
| } |
| element.querySelector(".sq-ctx-cap").textContent = level.budget; |
| } |
|
|
| function checkOff(label) { |
| checkedOnThisLevel().add(label); |
| const item = targetItems[label]; |
| item.classList.add("sq-checked"); |
| gsap.fromTo(item, { scale: 1.3 }, { scale: 1, duration: 0.3, ease: "back.out(2)" }); |
| |
| if (remainingTargets().length === 0) { |
| revealForwardSwap(); |
| if (level.id === "animal") grantHug(); |
| if (level.id === "bonus") gsap.delayedCall(0.9, showVictory); |
| } |
| } |
|
|
| |
| function grantHug() { |
| if (hasHug) return; |
| hasHug = true; |
| setPose(charPose); |
| sfx.pop(); |
| gsap.fromTo(charMesh.scale, { x: 1.35, y: 1.35 }, { x: 1, y: 1, duration: 0.5, ease: "elastic.out(1,0.5)" }); |
| } |
|
|
| |
| |
| const countEl = element.querySelector(".sq-context-count"); |
| const usedEl = element.querySelector(".sq-ctx-used"); |
| const hud = { |
| update(used) { |
| usedEl.textContent = used; |
| }, |
| warnFull() { |
| sfx.warn(); |
| countEl.classList.add("sq-full"); |
| gsap.to(countEl, { x: "+=3", yoyo: true, repeat: 5, duration: 0.05 }); |
| }, |
| overflow() { |
| usedEl.textContent = level.budget + 1; |
| countEl.classList.add("sq-full"); |
| }, |
| reset() { |
| usedEl.textContent = 0; |
| countEl.classList.remove("sq-full"); |
| }, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| await Promise.all([ |
| document.fonts.load('400 80px "Patrick Hand"'), |
| document.fonts.load('700 42px "Caveat"'), |
| document.fonts.load('600 34px "Caveat"'), |
| document.fonts.ready, |
| ]); |
|
|
| |
|
|
| const rand = (a, b) => a + Math.random() * (b - a); |
|
|
| |
| |
| |
| |
| const GLITCH_GLYPHS = "▚▞▓▒░█◣◢◤◥⌗⟁¥§Ø∆#%&@?!"; |
| const glitchGlyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0]; |
| function glitchLabel(text) { |
| if (Math.random() < 0.62) return text; |
| let out = ""; |
| for (const ch of text) out += Math.random() < 0.5 ? glitchGlyph() : ch; |
| return out; |
| } |
| |
| |
| |
| |
| function glitchWord(text) { |
| if (!text || Math.random() < 0.7) return text; |
| const chars = [...text]; |
| const n = chars.length > 1 && Math.random() < 0.5 ? 2 : 1; |
| for (let k = 0; k < n; k++) chars[(Math.random() * chars.length) | 0] = glitchGlyph(); |
| return chars.join(""); |
| } |
|
|
| |
| function wobblyRoundRect(ctx, x, y, w, h, r, jitter) { |
| const pts = []; |
| const seg = 14; |
| const corner = (cx, cy, a0, a1) => { |
| for (let i = 0; i <= 6; i++) { |
| const a = a0 + (a1 - a0) * (i / 6); |
| pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); |
| } |
| }; |
| const edge = (x0, y0, x1, y1) => { |
| for (let i = 1; i < seg; i++) { |
| const t = i / seg; |
| pts.push([x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]); |
| } |
| }; |
| corner(x + r, y + r, Math.PI, Math.PI * 1.5); |
| edge(x + r, y, x + w - r, y); |
| corner(x + w - r, y + r, Math.PI * 1.5, Math.PI * 2); |
| edge(x + w, y + r, x + w, y + h - r); |
| corner(x + w - r, y + h - r, 0, Math.PI * 0.5); |
| edge(x + w - r, y + h, x + r, y + h); |
| corner(x + r, y + h - r, Math.PI * 0.5, Math.PI); |
| edge(x, y + h - r, x, y + r); |
|
|
| ctx.beginPath(); |
| pts.forEach(([px, py], i) => { |
| const jx = px + rand(-jitter, jitter); |
| const jy = py + rand(-jitter, jitter); |
| i === 0 ? ctx.moveTo(jx, jy) : ctx.lineTo(jx, jy); |
| }); |
| ctx.closePath(); |
| } |
|
|
| const TILE_PX = 384; |
| |
| |
| const TEX_SCALE = Math.min(window.devicePixelRatio || 1, 2); |
|
|
| |
| |
| |
| |
| function drawKeycapBase(ctx, special, dashed) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| |
| |
| ctx.fillStyle = "#faf8f2"; |
| ctx.fillRect(0, 0, TILE_PX, TILE_PX); |
| ctx.lineJoin = "round"; |
| ctx.lineCap = "round"; |
|
|
| |
| |
| const inset = 5; |
| const span = TILE_PX - inset * 2; |
| const cr = Math.round(span * 0.17); |
| wobblyRoundRect(ctx, inset, inset, span, span, cr, 3); |
| ctx.fillStyle = "rgba(255, 253, 247, 0.92)"; |
| ctx.fill(); |
|
|
| |
| ctx.strokeStyle = special ? INK_SOFT : INK; |
| ctx.setLineDash(dashed ? [16, 13] : []); |
| ctx.lineWidth = 7; |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| wobblyRoundRect(ctx, inset, inset, span, span, cr, 4.5); |
| ctx.strokeStyle = special ? "rgba(90,86,76,0.35)" : "rgba(28,27,24,0.35)"; |
| ctx.lineWidth = 4; |
| ctx.stroke(); |
| } |
|
|
| |
| |
| function drawWingsKeyIcon(ctx) { |
| const cx = TILE_PX / 2, cy = 200; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| for (const sign of [-1, 1]) { |
| const ax = cx + sign * 14, ay = cy + 6; |
| const tipx = ax + sign * 128, tipy = cy - 70; |
| const elbx = cx + sign * 84, elby = cy - 40; |
| ctx.beginPath(); |
| ctx.moveTo(ax, ay); |
| ctx.quadraticCurveTo(elbx, elby - 40, tipx, tipy); |
| |
| ctx.quadraticCurveTo(cx + sign * 96, cy + 6, cx + sign * 70, cy + 24); |
| ctx.quadraticCurveTo(cx + sign * 58, cy + 4, cx + sign * 44, cy + 32); |
| ctx.quadraticCurveTo(cx + sign * 34, cy + 10, ax, ay); |
| ctx.closePath(); |
| ctx.fillStyle = "rgba(255,253,247,0.92)"; |
| ctx.fill(); |
| ctx.strokeStyle = ACCENT; |
| ctx.lineWidth = 9; |
| ctx.stroke(); |
| |
| ctx.lineWidth = 4.5; |
| wobblyLine(ctx, ax + sign * 10, ay - 8, elbx, elby + 4, 2); ctx.stroke(); |
| wobblyLine(ctx, ax + sign * 26, ay + 2, elbx + sign * 8, elby + 22, 2); ctx.stroke(); |
| } |
| } |
|
|
| |
| |
| function drawShiftKeyIcon(ctx) { |
| ctx.strokeStyle = ACCENT; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const cx = TILE_PX / 2; |
| const half = 70; |
| const barb = 42; |
| for (const ay of [150, 234]) { |
| const tip = cx + half; |
| ctx.lineWidth = 16; |
| wobblyLine(ctx, cx - half, ay, tip, ay, 3); ctx.stroke(); |
| wobblyLine(ctx, tip, ay, tip - barb, ay - barb * 0.82, 3); ctx.stroke(); |
| wobblyLine(ctx, tip, ay, tip - barb, ay + barb * 0.82, 3); ctx.stroke(); |
| } |
| } |
|
|
| |
| |
| |
| function drawFileKeyIcon(ctx, name) { |
| const cx = TILE_PX / 2; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const pw = 116, ph = 132, px = cx - pw / 2, py = 58, fold = 34; |
| ctx.beginPath(); |
| ctx.moveTo(px, py); |
| ctx.lineTo(px + pw - fold, py); |
| ctx.lineTo(px + pw, py + fold); |
| ctx.lineTo(px + pw, py + ph); |
| ctx.lineTo(px, py + ph); |
| ctx.closePath(); |
| ctx.fillStyle = "rgba(255,253,247,0.92)"; |
| ctx.fill(); |
| ctx.strokeStyle = ACCENT; |
| ctx.lineWidth = 7; |
| ctx.stroke(); |
| ctx.beginPath(); |
| ctx.moveTo(px + pw - fold, py); |
| ctx.lineTo(px + pw - fold, py + fold); |
| ctx.lineTo(px + pw, py + fold); |
| ctx.stroke(); |
| if (isImageFile(name)) { |
| |
| |
| ctx.strokeStyle = INK_SOFT; |
| const fx = px + 14, fy = py + 16, fw = pw - 28, fh = ph - 30, base = fy + fh; |
| ctx.lineWidth = 5; |
| ctx.strokeRect(fx, fy, fw, fh); |
| ctx.beginPath(); |
| ctx.arc(fx + 24, fy + 24, 12, 0, Math.PI * 2); |
| ctx.stroke(); |
| ctx.lineWidth = 6; |
| wobblyLine(ctx, fx + 2, base, fx + 34, base - 60, 2); ctx.stroke(); |
| wobblyLine(ctx, fx + 34, base - 60, fx + 58, base, 2); ctx.stroke(); |
| wobblyLine(ctx, fx + 40, base, fx + 64, base - 42, 2); ctx.stroke(); |
| wobblyLine(ctx, fx + 64, base - 42, fx + fw - 2, base, 2); ctx.stroke(); |
| } else { |
| ctx.strokeStyle = INK_SOFT; |
| ctx.lineWidth = 4; |
| for (let i = 0; i < 4; i++) { |
| const yy = py + 52 + i * 18; |
| wobblyLine(ctx, px + 16, yy, px + pw - 16, yy, 1.4); |
| ctx.stroke(); |
| } |
| } |
| ctx.fillStyle = INK; |
| const size = name.length > 10 ? 50 : 58; |
| ctx.font = `400 ${size}px "Patrick Hand"`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText(name, cx + rand(-2, 2), 256 + rand(-1, 2)); |
| } |
|
|
| |
| |
| function drawShrinkKeyIcon(ctx) { |
| ctx.strokeStyle = ACCENT; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const c = TILE_PX / 2, out = 96, inn = 30, barb = 30; |
| |
| |
| for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { |
| const ox = c + sx * out, oy = c + sy * out; |
| const ix = c + sx * inn, iy = c + sy * inn; |
| ctx.lineWidth = 15; |
| wobblyLine(ctx, ox, oy, ix, iy, 3); ctx.stroke(); |
| ctx.lineWidth = 13; |
| wobblyLine(ctx, ix, iy, ix + sx * barb, iy, 2); ctx.stroke(); |
| wobblyLine(ctx, ix, iy, ix, iy + sy * barb, 2); ctx.stroke(); |
| } |
| } |
|
|
| function drawTileCanvas(ctx, word) { |
| const special = |
| word === "start" || isSubmit(word) || isShrink(word) || |
| word === "wings" || word === "shift" || word === "portal"; |
| |
| |
| drawKeycapBase(ctx, special, isSubmit(word) || word === "shift" || isShrink(word)); |
|
|
| |
| if (word === "wings") return drawWingsKeyIcon(ctx); |
| if (word === "shift") return drawShiftKeyIcon(ctx); |
| if (isShrink(word)) return drawShrinkKeyIcon(ctx); |
| if (isFile(word)) return drawFileKeyIcon(ctx, word); |
|
|
| |
| |
| const blank = !word || word === "start" || word === "portal"; |
| if (!blank) { |
| |
| |
| |
| |
| |
| if (dataArmed && level.glitch && word === "data") { |
| ctx.save(); |
| ctx.filter = "hue-rotate(180deg) invert(1)"; |
| ctx.strokeStyle = ctx.fillStyle = "#d23a2a"; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const ins = 5, span = TILE_PX - ins * 2, cr = Math.round(span * 0.17); |
| ctx.lineWidth = 9; |
| wobblyRoundRect(ctx, ins, ins, span, span, cr, 3.5); |
| ctx.stroke(); |
| ctx.font = `400 88px "Patrick Hand"`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText(glitchWord(word), TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3)); |
| ctx.restore(); |
| return; |
| } |
| ctx.fillStyle = special ? INK_SOFT : INK; |
| |
| |
| const shown = level.glitch ? glitchWord(word) : word; |
| |
| |
| let size = word.length > 8 ? 72 : word.length > 5 ? 80 : 88; |
| ctx.font = `400 ${size}px "Patrick Hand"`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText(shown, TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3)); |
| } |
| } |
|
|
| |
| |
| |
| function drawSwapTileCanvas(ctx, label) { |
| drawKeycapBase(ctx, true, true); |
| const cx = TILE_PX / 2; |
| const top = 86, tip = 212; |
| const barb = 52; |
| ctx.strokeStyle = ACCENT; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| ctx.lineWidth = 20; |
| wobblyLine(ctx, cx, top, cx, tip, 4); ctx.stroke(); |
| wobblyLine(ctx, cx, tip, cx - barb, tip - 50, 3); ctx.stroke(); |
| wobblyLine(ctx, cx, tip, cx + barb, tip - 50, 3); ctx.stroke(); |
|
|
| if (label) { |
| ctx.fillStyle = INK; |
| const size = label.length > 7 ? 56 : 64; |
| ctx.font = `400 ${size}px "Patrick Hand"`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText(label, cx + rand(-2, 2), 288 + rand(-1, 2)); |
| } |
| } |
|
|
| |
| function drawCracks(ctx) { |
| ctx.strokeStyle = INK; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const cx = TILE_PX / 2; |
| const jag = (pts, w) => { |
| ctx.lineWidth = w; |
| ctx.beginPath(); |
| pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); |
| ctx.stroke(); |
| }; |
| jag([[cx - 46, 46], [cx + 24, 150], [cx - 28, 214], [cx + 44, 300], [cx - 8, 340]], 7); |
| jag([[cx + 24, 150], [TILE_PX - 56, 126]], 5); |
| jag([[cx - 28, 214], [56, 248]], 5); |
| jag([[cx + 44, 300], [TILE_PX - 72, 334]], 3); |
| } |
|
|
| |
| |
| |
| const rainbowPhase = () => (performance.now() / 9) % 360; |
| function rainbowGradient(ctx, x0, x1, phase) { |
| const g = ctx.createLinearGradient(x0, 0, x1, 0); |
| for (let i = 0; i <= 6; i++) |
| g.addColorStop(i / 6, `hsl(${(phase + i * 64) % 360}, 92%, 56%)`); |
| return g; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function drawSubmitTileCanvas(ctx, label, state) { |
| const ready = state === "ready"; |
| drawKeycapBase(ctx, !ready, ready); |
| const size = label.length > 6 ? 64 : 88; |
| ctx.font = `400 ${size}px "Patrick Hand"`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| const tx = TILE_PX / 2 + rand(-2, 2), ty = TILE_PX / 2 + rand(-1, 3); |
| if (ready && label === "hackathon") { |
| |
| |
| |
| ctx.save(); |
| if (level.glitch) ctx.filter = "hue-rotate(180deg) invert(1)"; |
| ctx.fillStyle = rainbowGradient(ctx, size * 0.6, TILE_PX - size * 0.6, rainbowPhase()); |
| ctx.fillText(label, tx, ty); |
| ctx.restore(); |
| } else { |
| ctx.fillStyle = ready ? INK : INK_SOFT; |
| ctx.fillText(label, tx, ty); |
| } |
| if (state === "broken") drawCracks(ctx); |
| } |
|
|
| |
| |
| |
| const SIDE_PX_W = 384; |
| const SIDE_PX_H = 128; |
|
|
| function drawTileSideCanvas(ctx) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.fillStyle = "#faf8f2"; |
| ctx.fillRect(0, 0, SIDE_PX_W, SIDE_PX_H); |
| ctx.lineJoin = "round"; |
| ctx.lineCap = "round"; |
|
|
| ctx.strokeStyle = "rgba(28,27,24,0.13)"; |
| ctx.lineWidth = 5; |
| for (let x = rand(8, 44); x < SIDE_PX_W; x += rand(52, 84)) { |
| wobblyLine(ctx, x, SIDE_PX_H - 12, x + 67, 14, 2); |
| ctx.stroke(); |
| } |
|
|
| |
| ctx.strokeStyle = INK; |
| ctx.lineWidth = 7; |
| wobblyLine(ctx, -6, SIDE_PX_H - 7, SIDE_PX_W + 6, SIDE_PX_H - 7, 2.5); |
| ctx.stroke(); |
| } |
|
|
| |
| |
| |
| |
| |
| const SPIRAL_PX = 256; |
| function drawSpiralCanvas(ctx) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, SPIRAL_PX, SPIRAL_PX); |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const cx = SPIRAL_PX / 2, cy = SPIRAL_PX / 2; |
| const path = (jit) => { |
| const turns = 3.0, maxR = SPIRAL_PX * 0.4, steps = 200; |
| ctx.beginPath(); |
| for (let i = 0; i <= steps; i++) { |
| const t = i / steps; |
| const a = t * Math.PI * 2 * turns; |
| const r = 7 + t * maxR; |
| const x = cx + r * Math.cos(a) + rand(-jit, jit); |
| const y = cy + r * Math.sin(a) + rand(-jit, jit); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| } |
| }; |
| ctx.globalAlpha = 0.5; |
| ctx.strokeStyle = ACCENT; ctx.lineWidth = 11; path(2); ctx.stroke(); |
| ctx.globalAlpha = 1; |
| ctx.strokeStyle = INK; ctx.lineWidth = 6; path(2.4); ctx.stroke(); |
| ctx.strokeStyle = "rgba(28,27,24,0.4)"; ctx.lineWidth = 3; path(1.4); ctx.stroke(); |
| ctx.beginPath(); ctx.arc(cx, cy, 7, 0, Math.PI * 2); |
| ctx.fillStyle = INK; ctx.fill(); |
| } |
|
|
| |
| const scene = new THREE.Scene(); |
|
|
| const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100); |
| camera.position.set(0, -0.9, 11.5); |
| camera.lookAt(0, 0.25, 0); |
|
|
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
| |
| |
| |
| |
| |
| |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 3)); |
| stage.appendChild(renderer.domElement); |
|
|
| |
| |
| const boardTilt = new THREE.Group(); |
| boardTilt.rotation.x = THREE.MathUtils.degToRad(68); |
| scene.add(boardTilt); |
|
|
| |
| const board = new THREE.Group(); |
| board.rotation.z = THREE.MathUtils.degToRad(1.5); |
| boardTilt.add(board); |
|
|
| |
| const SPACING = 1.5; |
| const TILE_SIZE = 1.32; |
| const tileAt = (r, c) => ({ |
| x: (c - (COLS - 1) / 2) * SPACING, |
| z: (r - (ROWS - 1) / 2) * SPACING, |
| }); |
|
|
| |
| |
| |
| |
| const TILE_DEPTH = 0.42; |
| const TILE_R = TILE_SIZE * 0.17; |
| const tileShape = new THREE.Shape(); |
| { |
| const h = TILE_SIZE / 2; |
| tileShape.moveTo(-h + TILE_R, -h); |
| tileShape.lineTo(h - TILE_R, -h); |
| tileShape.quadraticCurveTo(h, -h, h, -h + TILE_R); |
| tileShape.lineTo(h, h - TILE_R); |
| tileShape.quadraticCurveTo(h, h, h - TILE_R, h); |
| tileShape.lineTo(-h + TILE_R, h); |
| tileShape.quadraticCurveTo(-h, h, -h, h - TILE_R); |
| tileShape.lineTo(-h, -h + TILE_R); |
| tileShape.quadraticCurveTo(-h, -h, -h + TILE_R, -h); |
| } |
| const tileGeometry = new THREE.ExtrudeGeometry(tileShape, { |
| depth: TILE_DEPTH, |
| bevelEnabled: false, |
| curveSegments: 6, |
| }); |
| |
| |
| { |
| const pos = tileGeometry.attributes.position; |
| const uv = tileGeometry.attributes.uv; |
| const [caps, walls] = tileGeometry.groups; |
| for (let i = caps.start; i < caps.start + caps.count; i++) { |
| uv.setXY(i, pos.getX(i) / TILE_SIZE + 0.5, pos.getY(i) / TILE_SIZE + 0.5); |
| } |
| for (let i = walls.start; i < walls.start + walls.count; i++) { |
| uv.setXY(i, uv.getX(i) / TILE_SIZE + 0.5, pos.getZ(i) / TILE_DEPTH); |
| } |
| } |
| |
| |
| tileGeometry.rotateX(-Math.PI / 2); |
| tileGeometry.translate(0, -TILE_DEPTH / 2, 0); |
|
|
| |
| const sideCanvas = document.createElement("canvas"); |
| sideCanvas.width = SIDE_PX_W * TEX_SCALE; |
| sideCanvas.height = SIDE_PX_H * TEX_SCALE; |
| const sideCtx = sideCanvas.getContext("2d"); |
| drawTileSideCanvas(sideCtx); |
| const sideTexture = new THREE.CanvasTexture(sideCanvas); |
| sideTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); |
| sideTexture.colorSpace = THREE.SRGBColorSpace; |
| const sideMaterial = new THREE.MeshBasicMaterial({ map: sideTexture }); |
|
|
| |
| |
| |
| |
| const SPIRAL_LIFT = 0.02; |
| const spiralCanvas = document.createElement("canvas"); |
| spiralCanvas.width = spiralCanvas.height = SPIRAL_PX * TEX_SCALE; |
| drawSpiralCanvas(spiralCanvas.getContext("2d")); |
| const spiralTexture = new THREE.CanvasTexture(spiralCanvas); |
| spiralTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); |
| spiralTexture.colorSpace = THREE.SRGBColorSpace; |
| const spiralMaterial = new THREE.MeshBasicMaterial({ |
| map: spiralTexture, transparent: true, depthWrite: false, side: THREE.DoubleSide, |
| }); |
| const spiralGeometry = new THREE.PlaneGeometry(TILE_SIZE * 0.9, TILE_SIZE * 0.9); |
| spiralGeometry.rotateX(-Math.PI / 2); |
|
|
| let tiles = []; |
| let portalSpinners = []; |
| let portalDest = {}; |
| const maxAniso = renderer.capabilities.getMaxAnisotropy(); |
|
|
| |
| function buildTiles() { |
| for (const t of tiles) { |
| gsap.killTweensOf(t.mesh.position); |
| gsap.killTweensOf(t.mesh.material[0]); |
| gsap.killTweensOf(t.mesh.material[1]); |
| board.remove(t.mesh); |
| t.texture.dispose(); |
| t.mesh.material[0].dispose(); |
| if (t.mesh.material[1] !== sideMaterial) t.mesh.material[1].dispose(); |
| } |
| tiles = []; |
| portalSpinners = []; |
| disposeSubmitTiles(); |
| ROWS = level.grid.length; |
| COLS = level.grid[0].length; |
| for (let r = 0; r < ROWS; r++) { |
| for (let c = 0; c < COLS; c++) { |
| const word = level.grid[r][c]; |
| const canvas = document.createElement("canvas"); |
| canvas.width = canvas.height = TILE_PX * TEX_SCALE; |
| const ctx = canvas.getContext("2d"); |
| drawTileCanvas(ctx, word); |
|
|
| const texture = new THREE.CanvasTexture(canvas); |
| texture.anisotropy = maxAniso; |
| texture.colorSpace = THREE.SRGBColorSpace; |
|
|
| |
| |
| |
| const mesh = new THREE.Mesh(tileGeometry, [ |
| new THREE.MeshBasicMaterial({ map: texture, transparent: false }), |
| level.glitch ? sideMaterial.clone() : sideMaterial, |
| ]); |
| const { x, z } = tileAt(r, c); |
| mesh.position.set(x, -TILE_DEPTH / 2, z); |
| board.add(mesh); |
|
|
| |
| |
| let spinner = null; |
| if (word === "portal") { |
| spinner = new THREE.Mesh(spiralGeometry, spiralMaterial); |
| spinner.position.y = TILE_DEPTH / 2 + SPIRAL_LIFT; |
| mesh.add(spinner); |
| portalSpinners.push(spinner); |
| } |
| tiles.push({ mesh, ctx, texture, word, row: r, col: c, spinner }); |
| } |
| } |
| buildPortalLinks(); |
| } |
| const tile = (r, c) => tiles[r * COLS + c]; |
|
|
| |
| |
| |
| function buildPortalLinks() { |
| portalDest = {}; |
| const cells = []; |
| for (let r = 0; r < ROWS; r++) |
| for (let c = 0; c < COLS; c++) |
| if (level.grid[r][c] === "portal") cells.push([r, c]); |
| if (cells.length < 2) return; |
| const cr = (ROWS - 1) / 2, cc = (COLS - 1) / 2; |
| const ang = ([r, c]) => Math.atan2(cr - r, c - cc); |
| cells.sort((a, b) => ang(b) - ang(a)); |
| for (let i = 0; i < cells.length; i++) { |
| const [fr, fc] = cells[i]; |
| portalDest[fr + "," + fc] = cells[(i + 1) % cells.length]; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let swapTiles = []; |
| const swapTileAt = (r, c) => |
| swapTiles.find((s) => s.active && s.row === r && s.col === c) || null; |
|
|
| function addSwapTile(row, col, targetId, active) { |
| const label = LEVELS[targetId].title; |
| const canvas = document.createElement("canvas"); |
| canvas.width = canvas.height = TILE_PX * TEX_SCALE; |
| const ctx = canvas.getContext("2d"); |
| drawSwapTileCanvas(ctx, label); |
|
|
| const texture = new THREE.CanvasTexture(canvas); |
| texture.anisotropy = maxAniso; |
| texture.colorSpace = THREE.SRGBColorSpace; |
|
|
| const mesh = new THREE.Mesh(tileGeometry, [ |
| new THREE.MeshBasicMaterial({ map: texture, transparent: false }), |
| sideMaterial, |
| ]); |
| const base = tileAt(row, col); |
| const x = base.x; |
| const z = base.z; |
| mesh.position.set(x, -TILE_DEPTH / 2, z); |
| mesh.visible = active; |
| board.add(mesh); |
| swapTiles.push({ mesh, ctx, texture, row, col, targetId, active, x, z, label }); |
| } |
|
|
| |
| |
| |
| |
| |
| function buildSwapTiles() { |
| for (const s of swapTiles) { |
| board.remove(s.mesh); |
| s.texture.dispose(); |
| s.mesh.material[0].dispose(); |
| } |
| swapTiles = []; |
| if (level.glitch) { |
| |
| } else if (returnTo && returnTo !== level.id) { |
| |
| addSwapTile(ROWS, level.start[1], returnTo, remainingTargets().length === 0); |
| } else { |
| const i = ORDER.indexOf(level.id); |
| if (i < ORDER.length - 1) { |
| addSwapTile(ROWS, level.start[1], ORDER[i + 1], remainingTargets().length === 0); |
| } |
| } |
| board.position.x = 0; |
| } |
|
|
| |
| function revealForwardSwap() { |
| const s = swapTiles.find((t) => !t.active); |
| if (!s) return; |
| s.active = true; |
| s.mesh.visible = true; |
| sfx.pop(); |
| gsap.fromTo( |
| s.mesh.scale, |
| { x: 0, y: 0, z: 0 }, |
| { x: 1, y: 1, z: 1, duration: 0.45, ease: "back.out(2)" } |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| let submitTiles = []; |
| const submitTileAt = (r, c) => submitTiles.find((s) => s.row === r && s.col === c) || null; |
|
|
| function disposeSubmitTiles() { |
| for (const s of submitTiles) { |
| board.remove(s.mesh); |
| s.texture.dispose(); |
| s.mesh.material[0].dispose(); |
| } |
| submitTiles = []; |
| } |
|
|
| function addSubmitTile(row, col, target) { |
| const canvas = document.createElement("canvas"); |
| canvas.width = canvas.height = TILE_PX * TEX_SCALE; |
| const ctx = canvas.getContext("2d"); |
| drawSubmitTileCanvas(ctx, target, submitTileState(target)); |
| const texture = new THREE.CanvasTexture(canvas); |
| texture.anisotropy = maxAniso; |
| texture.colorSpace = THREE.SRGBColorSpace; |
| const mesh = new THREE.Mesh(tileGeometry, [ |
| new THREE.MeshBasicMaterial({ map: texture, transparent: false }), |
| sideMaterial, |
| ]); |
| const { x, z } = tileAt(row, col); |
| mesh.position.set(x, -TILE_DEPTH / 2, z); |
| mesh.scale.set(0, 0, 0); |
| board.add(mesh); |
| const t = { mesh, ctx, texture, row, col, target }; |
| submitTiles.push(t); |
| return t; |
| } |
|
|
| |
| function pressTile(t) { |
| sfx.press(); |
| gsap.timeline() |
| .to(t.mesh.position, { y: -TILE_DEPTH / 2 - 0.14, duration: 0.07, ease: "power2.out" }) |
| .to(t.mesh.position, { y: -TILE_DEPTH / 2, duration: 0.3, ease: "back.out(2.2)" }); |
| } |
|
|
| |
|
|
| function wobblyCircle(ctx, cx, cy, r, jitter) { |
| ctx.beginPath(); |
| for (let i = 0; i <= 26; i++) { |
| const a = (i / 26) * Math.PI * 2; |
| const rr = r + rand(-jitter, jitter); |
| const x = cx + rr * Math.cos(a); |
| const y = cy + rr * Math.sin(a); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| } |
| ctx.closePath(); |
| } |
|
|
| function wobblyLine(ctx, x0, y0, x1, y1, jitter) { |
| ctx.beginPath(); |
| ctx.moveTo(x0 + rand(-jitter, jitter), y0 + rand(-jitter, jitter)); |
| const mx = (x0 + x1) / 2 + rand(-jitter * 2, jitter * 2); |
| const my = (y0 + y1) / 2 + rand(-jitter * 2, jitter * 2); |
| ctx.quadraticCurveTo(mx, my, x1 + rand(-jitter, jitter), y1 + rand(-jitter, jitter)); |
| } |
|
|
| const CHAR_PX_W = 256; |
| const CHAR_PX_H = 320; |
|
|
| |
| |
| function limb(ctx, x0, y0, cx, cy, x1, y1) { |
| const J = 2.5; |
| ctx.beginPath(); |
| ctx.moveTo(x0 + rand(-J, J), y0 + rand(-J, J)); |
| ctx.quadraticCurveTo(cx + rand(-J, J), cy + rand(-J, J), x1 + rand(-J, J), y1 + rand(-J, J)); |
| ctx.strokeStyle = INK; |
| ctx.lineWidth = 16; |
| ctx.stroke(); |
| ctx.strokeStyle = "#fffdf7"; |
| ctx.lineWidth = 9; |
| ctx.stroke(); |
| ctx.strokeStyle = INK; |
| } |
|
|
| |
| function charFoot(ctx, x, y) { |
| ctx.beginPath(); |
| ctx.ellipse(x + rand(-2, 2), y + rand(-1.5, 1.5), 9, 5, 0, 0, Math.PI * 2); |
| ctx.fillStyle = INK; |
| ctx.fill(); |
| } |
|
|
| |
| function charHand(ctx, x, y) { |
| wobblyCircle(ctx, x, y, 9, 1.5); |
| ctx.fillStyle = "#fffdf7"; |
| ctx.fill(); |
| ctx.lineWidth = 5; |
| ctx.strokeStyle = INK; |
| ctx.stroke(); |
| } |
|
|
| |
| function xEye(ctx, cx, cy) { |
| ctx.lineWidth = 4.5; |
| wobblyLine(ctx, cx - 7, cy - 7, cx + 7, cy + 7, 1); ctx.stroke(); |
| wobblyLine(ctx, cx - 7, cy + 7, cx + 7, cy - 7, 1); ctx.stroke(); |
| } |
|
|
| |
| |
| |
| let wingPhase = 0; |
| function oneWing(ctx, sign, phase) { |
| const up = phase / 3; |
| const ax = 128 + sign * 16, ay = 150; |
| const tipx = ax + sign * (54 + up * 6); |
| const tipy = ay - 18 - up * 66; |
| const elbx = ax + sign * 40, elby = ay - 8 - up * 30; |
| ctx.lineJoin = ctx.lineCap = "round"; |
| ctx.beginPath(); |
| ctx.moveTo(ax, ay - 4); |
| ctx.quadraticCurveTo(elbx, elby - 24, tipx, tipy); |
| ctx.quadraticCurveTo(elbx - sign * 2, elby + 14, ax + sign * 4, ay + 18); |
| ctx.closePath(); |
| ctx.fillStyle = "rgba(255,253,247,0.95)"; |
| ctx.fill(); |
| ctx.strokeStyle = INK; |
| ctx.lineWidth = 6; |
| ctx.stroke(); |
| ctx.lineWidth = 3; |
| wobblyLine(ctx, ax + sign * 8, ay - 2, elbx, elby - 6, 1.5); ctx.stroke(); |
| wobblyLine(ctx, ax + sign * 16, ay + 4, elbx + sign * 4, elby + 6, 1.5); ctx.stroke(); |
| } |
| function drawWings(ctx, phase) { |
| oneWing(ctx, -1, phase); |
| oneWing(ctx, 1, phase); |
| } |
|
|
| function drawCharCanvas(ctx, pose) { |
| const J = 2.5; |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, CHAR_PX_W, CHAR_PX_H); |
| ctx.lineJoin = "round"; |
| ctx.lineCap = "round"; |
| ctx.strokeStyle = INK; |
| ctx.fillStyle = INK; |
| ctx.lineWidth = 7; |
|
|
| |
| if (pose === "fly") drawWings(ctx, wingPhase); |
|
|
| |
| |
| if (pose === "think") { |
| limb(ctx, 104, 142, 88, 180, 86, 210); |
| limb(ctx, 152, 142, 178, 138, 146, 118); |
| limb(ctx, 112, 205, 106, 255, 104, 300); |
| limb(ctx, 144, 205, 150, 255, 152, 300); |
| charFoot(ctx, 102, 303); charFoot(ctx, 154, 303); |
| charHand(ctx, 86, 210); charHand(ctx, 146, 118); |
| } else if (pose === "hop-up") { |
| |
| limb(ctx, 104, 145, 80, 170, 70, 212); |
| limb(ctx, 152, 145, 176, 170, 186, 212); |
| limb(ctx, 112, 205, 86, 252, 104, 294); |
| limb(ctx, 144, 205, 170, 252, 152, 294); |
| charFoot(ctx, 102, 298); charFoot(ctx, 154, 298); |
| charHand(ctx, 70, 212); charHand(ctx, 186, 212); |
| } else if (pose === "hop-mid") { |
| |
| limb(ctx, 104, 142, 80, 92, 70, 46); |
| limb(ctx, 152, 142, 176, 92, 186, 46); |
| limb(ctx, 112, 205, 82, 232, 108, 252); |
| limb(ctx, 144, 205, 174, 232, 148, 252); |
| charFoot(ctx, 106, 254); charFoot(ctx, 150, 254); |
| charHand(ctx, 70, 46); charHand(ctx, 186, 46); |
| } else if (pose === "hop-land") { |
| |
| limb(ctx, 104, 148, 80, 152, 58, 150); |
| limb(ctx, 152, 148, 176, 152, 198, 150); |
| limb(ctx, 112, 208, 82, 256, 88, 300); |
| limb(ctx, 144, 208, 174, 256, 168, 300); |
| charFoot(ctx, 86, 303); charFoot(ctx, 170, 303); |
| charHand(ctx, 58, 150); charHand(ctx, 198, 150); |
| } else if (pose === "dead-dizzy") { |
| |
| limb(ctx, 104, 145, 100, 182, 98, 218); |
| limb(ctx, 152, 145, 156, 182, 158, 218); |
| limb(ctx, 112, 205, 130, 255, 140, 300); |
| limb(ctx, 144, 205, 124, 255, 116, 300); |
| charFoot(ctx, 140, 303); charFoot(ctx, 114, 303); |
| charHand(ctx, 98, 218); charHand(ctx, 158, 218); |
| } else if (pose === "dead") { |
| |
| limb(ctx, 104, 150, 70, 158, 40, 176); |
| limb(ctx, 152, 150, 186, 158, 216, 176); |
| limb(ctx, 112, 208, 76, 252, 44, 290); |
| limb(ctx, 144, 208, 180, 252, 212, 290); |
| charFoot(ctx, 42, 293); charFoot(ctx, 214, 293); |
| charHand(ctx, 40, 176); charHand(ctx, 216, 176); |
| } else if (pose === "fly") { |
| |
| limb(ctx, 108, 150, 96, 196, 92, 246); |
| limb(ctx, 148, 150, 160, 196, 164, 246); |
| limb(ctx, 106, 150, 88, 180, 80, 206); |
| limb(ctx, 150, 150, 168, 180, 176, 206); |
| charFoot(ctx, 90, 248); charFoot(ctx, 166, 248); |
| charHand(ctx, 80, 206); charHand(ctx, 176, 206); |
| } else { |
| |
| limb(ctx, 104, 142, 90, 178, 82, 206); |
| limb(ctx, 152, 142, 166, 178, 174, 206); |
| limb(ctx, 112, 205, 106, 255, 104, 300); |
| limb(ctx, 144, 205, 150, 255, 152, 300); |
| charFoot(ctx, 102, 303); charFoot(ctx, 154, 303); |
| charHand(ctx, 82, 206); charHand(ctx, 174, 206); |
| } |
|
|
| |
| wobblyRoundRect(ctx, 96, 118, 64, 92, 30, J); |
| ctx.fillStyle = "rgba(255,253,247,0.95)"; |
| ctx.fill(); |
| ctx.strokeStyle = INK; |
| ctx.lineWidth = 7; |
| ctx.stroke(); |
|
|
| |
| wobblyCircle(ctx, 128, 78, 44, 2); |
| ctx.fillStyle = "rgba(255,253,247,0.95)"; |
| ctx.fill(); |
| ctx.stroke(); |
|
|
| |
| ctx.strokeStyle = ACCENT; |
| ctx.lineWidth = 8; |
| wobblyLine(ctx, 106, 126, 150, 126, 2); ctx.stroke(); |
| ctx.strokeStyle = INK; |
| ctx.fillStyle = INK; |
|
|
| |
| if (!hasHug) { |
| |
| if (pose === "dead" || pose === "dead-dizzy") { |
| xEye(ctx, 110, 74); xEye(ctx, 146, 74); |
| } else if (pose === "idle" && Math.random() < 0.18) { |
| ctx.lineWidth = 5; |
| wobblyLine(ctx, 102, 74, 118, 74, 1); ctx.stroke(); |
| wobblyLine(ctx, 138, 74, 154, 74, 1); ctx.stroke(); |
| } else { |
| ctx.beginPath(); ctx.arc(110, 74, 5.5, 0, Math.PI * 2); ctx.fill(); |
| ctx.beginPath(); ctx.arc(146, 74, 5.5, 0, Math.PI * 2); ctx.fill(); |
| } |
|
|
| |
| ctx.lineWidth = 6; |
| if (pose === "think" || pose === "hop-up") { |
| ctx.beginPath(); |
| ctx.arc(128, 100, pose === "think" ? 9 : 7, 0, Math.PI * 2); |
| ctx.stroke(); |
| } else if (pose === "hop-mid") { |
| ctx.beginPath(); |
| ctx.arc(128, 96, 16, 0, Math.PI); |
| ctx.closePath(); |
| ctx.fill(); |
| } else if (pose === "hop-land") { |
| wobblyLine(ctx, 108, 102, 148, 102, 1.5); ctx.stroke(); |
| } else if (pose === "dead-dizzy") { |
| ctx.beginPath(); |
| for (let i = 0; i <= 16; i++) { |
| const x = 108 + (i / 16) * 40; |
| const y = 102 + Math.sin((i / 16) * Math.PI * 3) * 4 + rand(-1, 1); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| } |
| ctx.stroke(); |
| } else if (pose === "dead") { |
| wobblyLine(ctx, 112, 100, 144, 100, 1.5); ctx.stroke(); |
| ctx.beginPath(); |
| ctx.ellipse(136 + rand(-1, 1), 110, 6, 9, 0, 0, Math.PI * 2); |
| ctx.fill(); |
| } else { |
| ctx.beginPath(); |
| ctx.arc(128, 88, 20, Math.PI * 0.18, Math.PI * 0.82); |
| ctx.stroke(); |
| } |
| } |
|
|
| |
| if (pose === "think") { |
| ctx.font = '400 56px "Patrick Hand"'; |
| ctx.textAlign = "center"; |
| ctx.fillText("?", 196 + rand(-2, 2), 56 + rand(-2, 2)); |
| } else if (pose === "hop-mid") { |
| ctx.lineWidth = 5; |
| wobblyLine(ctx, 106, 272, 100, 292, 1.5); ctx.stroke(); |
| wobblyLine(ctx, 128, 276, 128, 298, 1.5); ctx.stroke(); |
| wobblyLine(ctx, 150, 272, 156, 292, 1.5); ctx.stroke(); |
| } else if (pose === "dead") { |
| ctx.lineWidth = 4; |
| ctx.beginPath(); |
| for (let i = 0; i <= 40; i++) { |
| const a = (i / 40) * Math.PI * 4; |
| const r = 2 + (i / 40) * 11; |
| const x = 188 + r * Math.cos(a) + rand(-1, 1); |
| const y = 34 + r * Math.sin(a) + rand(-1, 1); |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| } |
| ctx.stroke(); |
| } |
|
|
| |
| |
| if (hasHug) { |
| ctx.font = '108px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif'; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| |
| |
| |
| if (level.glitch) ctx.filter = "hue-rotate(180deg) invert(1)"; |
| ctx.fillText("🤗", 128 + rand(-1.5, 1.5), 80 + rand(-1.5, 1.5)); |
| ctx.filter = "none"; |
| } |
| } |
|
|
| const charCanvas = document.createElement("canvas"); |
| charCanvas.width = CHAR_PX_W * TEX_SCALE; |
| charCanvas.height = CHAR_PX_H * TEX_SCALE; |
| const charCtx = charCanvas.getContext("2d"); |
| let charPose = "idle"; |
| drawCharCanvas(charCtx, charPose); |
|
|
| const charTexture = new THREE.CanvasTexture(charCanvas); |
| charTexture.colorSpace = THREE.SRGBColorSpace; |
| const CHAR_STANDOFF = 0.12; |
| const CHAR_Z_OFF = -0.15; |
| const CRUISE = 1.3; |
| const charGeometry = new THREE.PlaneGeometry(0.86, 1.075); |
| charGeometry.translate(0, 1.075 / 2, 0); |
| const charMesh = new THREE.Mesh( |
| charGeometry, |
| new THREE.MeshBasicMaterial({ map: charTexture, transparent: true }) |
| ); |
| charMesh.renderOrder = 1; |
| const charTilt = { z: 0 }; |
| const charPosFor = (r, c) => { |
| const s = swapTileAt(r, c); |
| const { x, z } = s ? s : tileAt(r, c); |
| return { x, z: z + CHAR_Z_OFF }; |
| }; |
| |
| const startPos = charPosFor(level.start[0], level.start[1]); |
| const charGroup = new THREE.Group(); |
| charGroup.position.set(startPos.x, CHAR_STANDOFF, startPos.z); |
| charGroup.add(charMesh); |
| board.add(charGroup); |
|
|
| function setPose(pose) { |
| charPose = pose; |
| drawCharCanvas(charCtx, charPose); |
| charTexture.needsUpdate = true; |
| } |
|
|
| |
| |
| |
| let flapTimer = null, flapDir = 1; |
| function startFlap() { |
| stopFlap(); |
| wingPhase = 0; |
| flapDir = 1; |
| flapTimer = setInterval(() => { |
| wingPhase += flapDir; |
| if (wingPhase >= 3) flapDir = -1; |
| else if (wingPhase <= 0) flapDir = 1; |
| if (charPose === "fly") { |
| drawCharCanvas(charCtx, "fly"); |
| charTexture.needsUpdate = true; |
| } |
| }, 130); |
| } |
| function stopFlap() { |
| if (flapTimer) { |
| clearInterval(flapTimer); |
| flapTimer = null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let boilCursor = 0, boilBeat = 0; |
| setInterval(() => { |
| if (document.hidden || state !== "idle") return; |
| const surfaces = tiles.length + 2; |
| for (let n = 0; n < 2 && surfaces; n++, boilCursor++) { |
| const i = boilCursor % surfaces; |
| if (i < tiles.length) { |
| const t = tiles[i]; |
| drawTileCanvas(t.ctx, t.word); |
| t.texture.needsUpdate = true; |
| } else if (i === tiles.length) { |
| drawCharCanvas(charCtx, charPose); |
| charTexture.needsUpdate = true; |
| } else { |
| drawTileSideCanvas(sideCtx); |
| sideTexture.needsUpdate = true; |
| } |
| } |
| for (const s of swapTiles) { |
| |
| const lbl = LEVELS[s.targetId].glitch ? glitchLabel(s.label) : s.label; |
| drawSwapTileCanvas(s.ctx, lbl); |
| s.texture.needsUpdate = true; |
| } |
| for (const s of submitTiles) { |
| drawSubmitTileCanvas(s.ctx, s.target, submitTileState(s.target)); |
| s.texture.needsUpdate = true; |
| } |
| if (boilBeat++ % 4 === 0) syncAudioUI(); |
| }, 100); |
|
|
| |
| |
| |
| |
| |
| let glitchCursor = 0; |
| setInterval(() => { |
| if (!level.glitch || document.hidden || state !== "idle") return; |
| const wordTiles = tiles.filter((t) => appendsWord(t.word)); |
| for (let n = 0; n < 2 && wordTiles.length; n++, glitchCursor++) { |
| const t = wordTiles[glitchCursor % wordTiles.length]; |
| drawTileCanvas(t.ctx, t.word); |
| t.texture.needsUpdate = true; |
| } |
| }, 120); |
|
|
| |
| |
| setInterval(() => { |
| if (!dataArmed || !level.glitch || document.hidden || state !== "idle") return; |
| const d = tiles.find((t) => eq(t.word, "data")); |
| if (d) { drawTileCanvas(d.ctx, d.word); d.texture.needsUpdate = true; } |
| }, 55); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| const AUDIO_PREF_KEY = "sq-audio"; |
| const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); |
| function loadAudioPrefs() { |
| try { return JSON.parse(localStorage.getItem(AUDIO_PREF_KEY)) || {}; } |
| catch (e) { return {}; } |
| } |
| function saveAudioPrefs() { |
| try { localStorage.setItem(AUDIO_PREF_KEY, JSON.stringify({ musicVol, sfxVol })); } |
| catch (e) { } |
| } |
| const _ap = loadAudioPrefs(); |
| |
| let musicVol = typeof _ap.musicVol === "number" ? clamp01(_ap.musicVol) : (_ap.music === false ? 0 : 1); |
| let sfxVol = typeof _ap.sfxVol === "number" ? clamp01(_ap.sfxVol) : (_ap.sfx === false ? 0 : 1); |
| let musicPrev = musicVol || 1; |
| let sfxPrev = sfxVol || 1; |
| |
| const MUSIC_MAX = 0.6, SFX_MAX = 1.0; |
| const taper = (v) => v * v; |
| const musicBusGain = () => MUSIC_MAX * taper(musicVol); |
| const sfxBusGain = () => SFX_MAX * taper(sfxVol); |
|
|
| let ac = null; |
| let masterGain = null; |
| let sfxGain = null; |
| let musicGain = null; |
| let musicFilter = null; |
| const MUSIC_OPEN = 22000, MUSIC_MUFFLED = 560; |
| function audio() { |
| if (!ac) { |
| const AC = window.AudioContext || window.webkitAudioContext; |
| if (!AC) return null; |
| ac = new AC(); |
| masterGain = ac.createGain(); |
| masterGain.gain.value = 0.4; |
| masterGain.connect(ac.destination); |
| sfxGain = ac.createGain(); |
| sfxGain.gain.value = sfxBusGain(); |
| sfxGain.connect(masterGain); |
| musicGain = ac.createGain(); |
| musicGain.gain.value = musicBusGain(); |
| |
| |
| musicFilter = ac.createBiquadFilter(); |
| musicFilter.type = "lowpass"; |
| musicFilter.Q.value = 0.6; |
| musicFilter.frequency.value = level && level.glitch ? MUSIC_MUFFLED : MUSIC_OPEN; |
| musicGain.connect(musicFilter); |
| musicFilter.connect(masterGain); |
| } |
| if (ac.state === "suspended") ac.resume(); |
| loadScratch(); |
| return ac; |
| } |
|
|
| |
| |
| function setMusicMuffled(on) { |
| if (!musicFilter || !ac) return; |
| musicFilter.frequency.setTargetAtTime(on ? MUSIC_MUFFLED : MUSIC_OPEN, ac.currentTime, 0.3); |
| } |
| |
| |
| |
| function unlockAudio() { |
| if (audio() && musicVol > 0) startMusic(); |
| } |
| document.addEventListener("keydown", unlockAudio); |
| document.addEventListener("pointerdown", unlockAudio); |
|
|
| |
| function tone({ type = "triangle", from = 440, to = from, dur = 0.12, vol = 0.3, at = 0 }) { |
| if (!audio()) return; |
| const t0 = ac.currentTime + at; |
| const osc = ac.createOscillator(); |
| const g = ac.createGain(); |
| osc.type = type; |
| osc.frequency.setValueAtTime(from, t0); |
| if (to !== from) osc.frequency.exponentialRampToValueAtTime(Math.max(to, 1), t0 + dur); |
| g.gain.setValueAtTime(0, t0); |
| g.gain.linearRampToValueAtTime(vol, t0 + 0.008); |
| g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); |
| osc.connect(g).connect(sfxGain); |
| osc.start(t0); |
| osc.stop(t0 + dur + 0.05); |
| } |
|
|
| |
| function thud({ dur = 0.05, vol = 0.4, freq = 1000, at = 0 }) { |
| if (!audio()) return; |
| const t0 = ac.currentTime + at; |
| const len = Math.ceil(ac.sampleRate * dur); |
| const buf = ac.createBuffer(1, len, ac.sampleRate); |
| const data = buf.getChannelData(0); |
| for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len); |
| const src = ac.createBufferSource(); |
| src.buffer = buf; |
| const filter = ac.createBiquadFilter(); |
| filter.type = "lowpass"; |
| filter.frequency.value = freq; |
| const g = ac.createGain(); |
| g.gain.value = vol; |
| src.connect(filter).connect(g).connect(sfxGain); |
| src.start(t0); |
| } |
|
|
| |
| |
| let scratchBuf = null; |
| let scratchLoading = false; |
| function loadScratch() { |
| if (scratchBuf || scratchLoading || !ac || !props.value.scratchAudio) return; |
| scratchLoading = true; |
| fetch(props.value.scratchAudio) |
| .then((r) => r.arrayBuffer()) |
| .then((b) => ac.decodeAudioData(b)) |
| .then((buf) => (scratchBuf = buf)) |
| .catch(() => (scratchLoading = false)); |
| } |
|
|
| const sfx = { |
| jump() { |
| tone({ type: "triangle", from: 280, to: 640, dur: 0.14, vol: 0.3 }); |
| }, |
| press() { |
| thud({ dur: 0.045, vol: 0.5, freq: 1300 }); |
| tone({ type: "sine", from: 160, to: 65, dur: 0.1, vol: 0.5 }); |
| }, |
| bonk() { |
| thud({ dur: 0.06, vol: 0.35, freq: 600 }); |
| tone({ type: "square", from: 150, to: 70, dur: 0.16, vol: 0.16 }); |
| }, |
| pop() { |
| tone({ type: "triangle", from: 900, to: 1400, dur: 0.07, vol: 0.14 }); |
| }, |
| soar() { |
| tone({ type: "sine", from: 360, to: 820, dur: 0.34, vol: 0.16 }); |
| tone({ type: "triangle", from: 520, to: 1040, dur: 0.2, vol: 0.07, at: 0.06 }); |
| }, |
| flap() { |
| thud({ dur: 0.07, vol: 0.1, freq: 280 }); |
| }, |
| warp() { |
| |
| tone({ type: "sine", from: 240, to: 920, dur: 0.18, vol: 0.16 }); |
| tone({ type: "triangle", from: 720, to: 170, dur: 0.3, vol: 0.16, at: 0.17 }); |
| }, |
| shuffle() { |
| |
| |
| tone({ type: "sine", from: 300, to: 560, dur: 0.18, vol: 0.1 }); |
| thud({ dur: 0.06, vol: 0.12, freq: 520 }); |
| thud({ dur: 0.05, vol: 0.14, freq: 950, at: 0.42 }); |
| }, |
| scratch(dur = 0.4) { |
| if (!audio() || !scratchBuf) return; |
| const src = ac.createBufferSource(); |
| src.buffer = scratchBuf; |
| |
| |
| const fit = Math.min(1.12, Math.max(0.78, scratchBuf.duration / dur)); |
| src.playbackRate.value = fit * rand(0.97, 1.03); |
| const g = ac.createGain(); |
| g.gain.value = 0.3; |
| src.connect(g).connect(sfxGain); |
| src.start(); |
| }, |
| click() { |
| thud({ dur: 0.03, vol: 0.3, freq: 2500 }); |
| }, |
| warn() { |
| tone({ type: "square", from: 660, dur: 0.07, vol: 0.1 }); |
| tone({ type: "square", from: 660, dur: 0.07, vol: 0.1, at: 0.12 }); |
| }, |
| die() { |
| |
| for (let i = 0; i < 4; i++) { |
| tone({ type: "sine", from: i % 2 ? 392 : 330, dur: 0.1, vol: 0.16, at: i * 0.12 }); |
| } |
| |
| tone({ type: "sine", from: 520, to: 90, dur: 0.62, vol: 0.26, at: 0.85 }); |
| }, |
| stamp(win) { |
| thud({ dur: 0.07, vol: 0.6, freq: 500 }); |
| tone({ type: "sine", from: 130, to: 50, dur: 0.18, vol: 0.55 }); |
| if (win) { |
| |
| tone({ type: "triangle", from: 523, dur: 0.09, vol: 0.22, at: 0.15 }); |
| tone({ type: "triangle", from: 659, dur: 0.09, vol: 0.22, at: 0.24 }); |
| tone({ type: "triangle", from: 784, dur: 0.16, vol: 0.22, at: 0.33 }); |
| } else { |
| |
| tone({ type: "triangle", from: 294, to: 277, dur: 0.16, vol: 0.18, at: 0.18 }); |
| tone({ type: "triangle", from: 233, to: 220, dur: 0.26, vol: 0.18, at: 0.38 }); |
| } |
| }, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const CROSSFADE = 1.4; |
| const voices = new Map(); |
| let curUrl = null; |
|
|
| |
| |
| |
| |
| function musicUrl(path) { |
| const root = (window.gradio_config && window.gradio_config.root) || ""; |
| const base = root.replace(/\/+$/, ""); |
| return base ? base + "/" + path : path; |
| } |
|
|
| |
| function trackForLevel() { |
| return { url: musicUrl(level.music || data.music), trim: level.music ? (level.music_gain || 1) : 1 }; |
| } |
|
|
| |
| |
| function voiceFor(url) { |
| let v = voices.get(url); |
| if (!v) { |
| const el = new Audio(url); |
| el.loop = true; |
| el.preload = "auto"; |
| const src = ac.createMediaElementSource(el); |
| const gain = ac.createGain(); |
| gain.gain.value = 0; |
| src.connect(gain).connect(musicGain); |
| v = { el, src, gain, play: Promise.resolve() }; |
| voices.set(url, v); |
| } |
| return v; |
| } |
| function playVoice(v) { |
| |
| v.play = v.el.play() || Promise.resolve(); |
| v.play.catch(() => {}); |
| } |
|
|
| |
| |
| |
| function crossfadeTo(url, trim) { |
| if (!audio() || url === curUrl) return; |
| const prevUrl = curUrl; |
| curUrl = url; |
| const next = voiceFor(url); |
| const t = ac.currentTime; |
| if (musicVol > 0) playVoice(next); |
| next.gain.gain.cancelScheduledValues(t); |
| next.gain.gain.setValueAtTime(next.gain.gain.value, t); |
| next.gain.gain.linearRampToValueAtTime(trim, t + CROSSFADE); |
| const prev = prevUrl && voices.get(prevUrl); |
| if (prev) { |
| prev.gain.gain.cancelScheduledValues(t); |
| prev.gain.gain.setValueAtTime(prev.gain.gain.value, t); |
| prev.gain.gain.linearRampToValueAtTime(0, t + CROSSFADE); |
| |
| |
| gsap.delayedCall(CROSSFADE + 0.1, () => { |
| if (curUrl !== prevUrl) prev.play.then(() => { if (curUrl !== prevUrl) prev.el.pause(); }).catch(() => {}); |
| }); |
| } |
| } |
|
|
| |
| |
| function updateMusicTrack() { |
| if (!ac) return; |
| const { url, trim } = trackForLevel(); |
| crossfadeTo(url, trim); |
| } |
|
|
| function startMusic() { |
| if (!audio()) return; |
| const { url, trim } = trackForLevel(); |
| if (curUrl === url) playVoice(voiceFor(url)); |
| else crossfadeTo(url, trim); |
| } |
| function stopMusic() { |
| const v = curUrl && voices.get(curUrl); |
| if (!v) return; |
| |
| |
| |
| |
| v.play.then(() => { if (musicVol === 0) v.el.pause(); }).catch(() => {}); |
| } |
|
|
| |
| |
| |
| const ICON = 30; |
|
|
| function makeIconCanvas(host) { |
| const cv = document.createElement("canvas"); |
| cv.width = cv.height = ICON * TEX_SCALE; |
| host.appendChild(cv); |
| return cv; |
| } |
|
|
| |
| function drawSlash(ctx) { |
| ctx.globalAlpha = 1; |
| ctx.strokeStyle = ACCENT; |
| ctx.lineWidth = 3; |
| wobblyLine(ctx, 5, 7, ICON - 5, ICON - 7, 1.3); |
| ctx.stroke(); |
| } |
|
|
| function drawMusicIcon(ctx, on) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, ICON, ICON); |
| ctx.lineJoin = ctx.lineCap = "round"; |
| ctx.globalAlpha = on ? 1 : 0.5; |
| ctx.strokeStyle = ctx.fillStyle = on ? INK : INK_SOFT; |
| |
| const aStem = [13.5, 6.5], bStem = [24.5, 3.5]; |
| ctx.lineWidth = 2.4; |
| wobblyLine(ctx, aStem[0], aStem[1], aStem[0], 21, 0.7); ctx.stroke(); |
| wobblyLine(ctx, bStem[0], bStem[1], bStem[0], 18, 0.7); ctx.stroke(); |
| ctx.lineWidth = 3.6; |
| wobblyLine(ctx, aStem[0], aStem[1], bStem[0], bStem[1], 0.7); ctx.stroke(); |
| for (const [hx, hy] of [[10, 22], [21, 19]]) { |
| ctx.beginPath(); |
| ctx.ellipse(hx + rand(-0.5, 0.5), hy + rand(-0.5, 0.5), 4.2, 3.1, -0.4, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| if (!on) drawSlash(ctx); |
| ctx.globalAlpha = 1; |
| } |
|
|
| function drawSpeakerIcon(ctx, on) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, ICON, ICON); |
| ctx.lineJoin = ctx.lineCap = "round"; |
| ctx.globalAlpha = on ? 1 : 0.5; |
| |
| const pts = [[6, 12], [6, 18], [10, 18], [16, 24], [16, 6], [10, 12]]; |
| ctx.beginPath(); |
| pts.forEach(([x, y], i) => { |
| const jx = x + rand(-0.5, 0.5), jy = y + rand(-0.5, 0.5); |
| i ? ctx.lineTo(jx, jy) : ctx.moveTo(jx, jy); |
| }); |
| ctx.closePath(); |
| ctx.fillStyle = "rgba(255,253,247,0.9)"; |
| ctx.fill(); |
| ctx.lineWidth = 2.4; |
| ctx.strokeStyle = on ? INK : INK_SOFT; |
| ctx.stroke(); |
| if (on) { |
| ctx.lineWidth = 2; |
| ctx.beginPath(); ctx.arc(18, 15, 3.5, -0.9, 0.9); ctx.stroke(); |
| ctx.beginPath(); ctx.arc(18, 15, 6.5, -0.85, 0.85); ctx.stroke(); |
| } |
| if (!on) drawSlash(ctx); |
| ctx.globalAlpha = 1; |
| } |
|
|
| |
| |
| |
| function drawBulbIcon(ctx) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, ICON, ICON); |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const cx = 15, cy = 14, r = 6.3; |
| |
| ctx.strokeStyle = INK; |
| ctx.fillStyle = "rgba(255,253,247,0.9)"; |
| ctx.lineWidth = 2.4; |
| wobblyCircle(ctx, cx, cy, r, 0.7); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| ctx.lineWidth = 1.7; |
| wobblyLine(ctx, cx - 2.6, cy + 1.6, cx, cy - 1.8, 0.5); ctx.stroke(); |
| wobblyLine(ctx, cx, cy - 1.8, cx + 2.6, cy + 1.6, 0.5); ctx.stroke(); |
| |
| ctx.lineWidth = 2.2; |
| const bx = 3.3, by = cy + r - 0.6; |
| wobblyLine(ctx, cx - bx, by, cx - bx + 0.4, by + 4, 0.4); ctx.stroke(); |
| wobblyLine(ctx, cx + bx, by, cx + bx - 0.4, by + 4, 0.4); ctx.stroke(); |
| for (let i = 0; i < 3; i++) { |
| const ty = by + 1.1 + i * 1.6; |
| wobblyLine(ctx, cx - bx + 0.2, ty, cx + bx - 0.2, ty, 0.4); ctx.stroke(); |
| } |
| |
| ctx.strokeStyle = ACCENT; |
| ctx.lineWidth = 1.8; |
| for (const a of [-2.42, -Math.PI / 2, -0.72]) { |
| const dx = Math.cos(a), dy = Math.sin(a); |
| wobblyLine(ctx, cx + dx * (r + 1.6), cy + dy * (r + 1.6), cx + dx * (r + 4), cy + dy * (r + 4), 0.4); |
| ctx.stroke(); |
| } |
| } |
|
|
| const audioCluster = element.querySelector(".sq-audio"); |
| const musicBtn = element.querySelector(".sq-music-btn"); |
| const sfxBtn = element.querySelector(".sq-sfx-btn"); |
| const popEl = element.querySelector(".sq-audio-pop"); |
| const volMusicBtn = element.querySelector(".sq-vol-music"); |
| const volSfxBtn = element.querySelector(".sq-vol-sfx"); |
| const musicIconCtx = makeIconCanvas(musicBtn).getContext("2d"); |
| const sfxIconCtx = makeIconCanvas(sfxBtn).getContext("2d"); |
| const volMusicCtx = makeIconCanvas(volMusicBtn).getContext("2d"); |
| const volSfxCtx = makeIconCanvas(volSfxBtn).getContext("2d"); |
| const helpCluster = element.querySelector(".sq-help"); |
| const helpBtn = element.querySelector(".sq-help-btn"); |
| const helpIconCtx = makeIconCanvas(helpBtn).getContext("2d"); |
|
|
| |
| const SLIDER_W = 120, SLIDER_H = 26, SLIDER_PAD = 11; |
| function drawSlider(ctx, val) { |
| ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); |
| ctx.clearRect(0, 0, SLIDER_W, SLIDER_H); |
| ctx.lineJoin = ctx.lineCap = "round"; |
| const y = SLIDER_H / 2, x0 = SLIDER_PAD, x1 = SLIDER_W - SLIDER_PAD; |
| const nx = x0 + clamp01(val) * (x1 - x0); |
| ctx.globalAlpha = 0.5; ctx.strokeStyle = INK_SOFT; ctx.lineWidth = 3; |
| wobblyLine(ctx, x0, y, x1, y, 1.0); ctx.stroke(); |
| ctx.globalAlpha = 1; |
| if (nx > x0 + 0.5) { ctx.strokeStyle = INK; ctx.lineWidth = 4.5; wobblyLine(ctx, x0, y, nx, y, 0.9); ctx.stroke(); } |
| wobblyCircle(ctx, nx, y, 6.5, 1.1); |
| ctx.fillStyle = "rgba(255,253,247,0.95)"; ctx.fill(); |
| ctx.strokeStyle = INK; ctx.lineWidth = 3.5; ctx.stroke(); |
| } |
| function setupSlider(el, ch) { |
| const cv = document.createElement("canvas"); |
| cv.width = SLIDER_W * TEX_SCALE; cv.height = SLIDER_H * TEX_SCALE; |
| el.appendChild(cv); |
| let dragging = false; |
| |
| const valAt = (e) => clamp01((e.clientX - el.getBoundingClientRect().left - SLIDER_PAD) / (SLIDER_W - 2 * SLIDER_PAD)); |
| const apply = (e) => (ch === "music" ? setMusicVol(valAt(e), true) : setSfxVol(valAt(e), true)); |
| el.addEventListener("pointerdown", (e) => { dragging = true; el.setPointerCapture(e.pointerId); audio(); apply(e); }); |
| el.addEventListener("pointermove", (e) => { if (dragging) apply(e); }); |
| el.addEventListener("pointerup", () => { if (dragging) { dragging = false; saveAudioPrefs(); } }); |
| el.addEventListener("pointercancel", () => (dragging = false)); |
| return cv.getContext("2d"); |
| } |
| const musicSliderCtx = setupSlider(element.querySelector('.sq-slider[data-ch="music"]'), "music"); |
| const sfxSliderCtx = setupSlider(element.querySelector('.sq-slider[data-ch="sfx"]'), "sfx"); |
|
|
| |
| |
| function syncAudioUI() { |
| if (!musicIconCtx) return; |
| const mOn = musicVol > 0, sOn = sfxVol > 0; |
| drawMusicIcon(musicIconCtx, mOn); |
| drawSpeakerIcon(sfxIconCtx, sOn); |
| if (!helpCluster.classList.contains("sq-hidden")) drawBulbIcon(helpIconCtx); |
| if (popOpen) { |
| drawMusicIcon(volMusicCtx, mOn); |
| drawSpeakerIcon(volSfxCtx, sOn); |
| drawSlider(musicSliderCtx, musicVol); |
| drawSlider(sfxSliderCtx, sfxVol); |
| volMusicBtn.setAttribute("aria-pressed", String(!mOn)); |
| volSfxBtn.setAttribute("aria-pressed", String(!sOn)); |
| } |
| } |
|
|
| |
| function setMusicVol(v, live) { |
| musicVol = clamp01(v); |
| if (musicVol > 0) musicPrev = musicVol; |
| audio(); |
| |
| if (musicGain) { const g = musicBusGain(); live ? (musicGain.gain.value = g) : gsap.to(musicGain.gain, { value: g, duration: 0.18, overwrite: true }); } |
| musicVol > 0 ? startMusic() : stopMusic(); |
| syncAudioUI(); |
| } |
| function setSfxVol(v, live) { |
| sfxVol = clamp01(v); |
| if (sfxVol > 0) sfxPrev = sfxVol; |
| audio(); |
| if (sfxGain) { const g = sfxBusGain(); live ? (sfxGain.gain.value = g) : gsap.to(sfxGain.gain, { value: g, duration: 0.18, overwrite: true }); } |
| syncAudioUI(); |
| } |
| function toggleMute(ch) { |
| if (ch === "music") musicVol > 0 ? setMusicVol(0, false) : setMusicVol(musicPrev || 1, false); |
| else sfxVol > 0 ? setSfxVol(0, false) : setSfxVol(sfxPrev || 1, false); |
| saveAudioPrefs(); |
| } |
|
|
| |
| let popOpen = false; |
| function openPop() { |
| popOpen = true; |
| |
| |
| |
| |
| gsap.killTweensOf(popEl); |
| popEl.classList.remove("sq-hidden"); |
| musicBtn.setAttribute("aria-expanded", "true"); |
| sfxBtn.setAttribute("aria-expanded", "true"); |
| syncAudioUI(); |
| gsap.fromTo(popEl, |
| { autoAlpha: 0, y: -6, scale: 0.96, rotation: -3 }, |
| { autoAlpha: 1, y: 0, scale: 1, rotation: -1, duration: 0.22, ease: "back.out(1.7)" }); |
| } |
| function closePop() { |
| if (!popOpen) return; |
| popOpen = false; |
| musicBtn.setAttribute("aria-expanded", "false"); |
| sfxBtn.setAttribute("aria-expanded", "false"); |
| gsap.to(popEl, { |
| autoAlpha: 0, y: -6, duration: 0.15, |
| onComplete: () => { |
| if (popOpen) return; |
| popEl.classList.add("sq-hidden"); |
| gsap.set(popEl, { clearProps: "opacity,transform,visibility" }); |
| }, |
| }); |
| } |
| function togglePop() { popOpen ? closePop() : openPop(); } |
|
|
| |
| const onBarTap = () => { audio(); sfx.click(); togglePop(); }; |
| musicBtn.addEventListener("click", onBarTap); |
| sfxBtn.addEventListener("click", onBarTap); |
| volMusicBtn.addEventListener("click", () => { toggleMute("music"); sfx.click(); }); |
| volSfxBtn.addEventListener("click", () => { const was = sfxVol > 0; toggleMute("sfx"); if (!was) sfx.click(); }); |
|
|
| |
| |
| |
| |
| |
| popEl.addEventListener("pointerdown", (e) => e.stopPropagation()); |
|
|
| |
| document.addEventListener("pointerdown", (e) => { if (popOpen && !audioCluster.contains(e.target)) closePop(); }); |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePop(); }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const hintBoxEl = element.querySelector(".sq-hintbox"); |
| const hintCardEl = element.querySelector(".sq-hintbox-card"); |
| const hintQEl = element.querySelector(".sq-hintbox-q"); |
| const hintTextEl = element.querySelector(".sq-hintbox-text"); |
| const hintActionsEl = element.querySelector(".sq-hintbox-actions"); |
| const hintYesBtn = element.querySelector(".sq-hintbox-yes"); |
| const hintNoBtn = element.querySelector(".sq-hintbox-no"); |
| const hintCloseBtn = element.querySelector(".sq-hintbox-close"); |
|
|
| |
| const SHRUG = "yeah idk man just try harder?"; |
|
|
| |
| const hasHint = () => Object.keys(level.solutions || {}).length > 0; |
| |
| function nextHintTarget() { |
| const got = checkedOnThisLevel(); |
| for (const t of level.targets) { |
| if (level.solutions[t] && !got.has(t)) return t; |
| } |
| return null; |
| } |
| |
| |
| function partialOf(sol) { |
| const words = sol.split(/\s+/).filter(Boolean); |
| return words.slice(0, Math.min(words.length - 1, 2)).join(" "); |
| } |
|
|
| let hintBoxOpen = false; |
| let hintStep = null; |
| let hintsRevealed = 0; |
| let revealedThisOpen = false; |
|
|
| |
| function confirmQ() { |
| if (hintsRevealed >= 2) return "This is getting silly now."; |
| if (hintsRevealed === 1) return "Really? Again?"; |
| return "Do you want a hint? Hints are lame."; |
| } |
| |
| |
| function markRevealed() { |
| if (!revealedThisOpen) { revealedThisOpen = true; hintsRevealed++; stats.hints += 1; } |
| } |
|
|
| |
| |
| |
| function showStep({ q = "", text = "", controls, yes = "yes", no = "no", onYes = closeHintBox, onNo = closeHintBox }) { |
| hintQEl.innerHTML = q; |
| hintQEl.classList.toggle("sq-hidden", !q); |
| hintTextEl.textContent = text; |
| hintTextEl.classList.toggle("sq-hidden", !text); |
| hintYesBtn.textContent = yes; |
| hintNoBtn.textContent = no; |
| hintActionsEl.classList.toggle("sq-hidden", controls !== "yn"); |
| hintCloseBtn.classList.toggle("sq-hidden", controls !== "ok"); |
| hintStep = { onYes, onNo }; |
| if (q) gsap.fromTo(hintQEl, { autoAlpha: 0, y: 6 }, { autoAlpha: 1, y: 0, duration: 0.18 }); |
| if (text) gsap.fromTo(hintTextEl, { autoAlpha: 0, y: 6 }, { autoAlpha: 1, y: 0, duration: 0.2, delay: 0.04 }); |
| } |
|
|
| |
| function critterConfirm() { |
| showStep({ q: confirmQ(), controls: "yn", onYes: critterStuck }); |
| } |
| function critterStuck() { |
| const t = nextHintTarget(); |
| if (!t) return showStep({ q: SHRUG, controls: "ok" }); |
| showStep({ |
| q: `Are you <em>really</em> stuck on ${t}? (I assure you it's definitely possible)`, |
| controls: "yn", |
| onYes: () => critterPartial(t), |
| onNo: () => showStep({ q: "Okay, go find it.", controls: "ok" }), |
| }); |
| } |
| function critterPartial(t) { |
| sfx.pop(); |
| markRevealed(); |
| showStep({ |
| q: `Ok fine. One solution starts with “${partialOf(level.solutions[t])}...”.`, |
| controls: "yn", |
| yes: "thanks", |
| no: "No. I'm a loser", |
| onNo: () => critterFull(t), |
| }); |
| } |
| function critterFull(t) { |
| sfx.pop(); |
| showStep({ q: "I'm not going to lie I'm pretty disappointed.", text: level.solutions[t], controls: "ok" }); |
| } |
|
|
| |
| function emotionConfirm() { |
| showStep({ q: confirmQ(), controls: "yn", onYes: emotionSure }); |
| } |
| function emotionSure() { |
| showStep({ |
| q: "Are you sure? (I assure you it's definitely possible)", |
| controls: "yn", |
| onYes: emotionReveal, |
| }); |
| } |
| function emotionReveal() { |
| const sol = level.solutions.betrayed; |
| const reveal = sol && !checkedOnThisLevel().has("betrayed"); |
| sfx.pop(); |
| if (reveal) markRevealed(); |
| showStep({ text: reveal ? sol : SHRUG, controls: "ok" }); |
| } |
|
|
| function openHintBox() { |
| if (!hasHint()) return; |
| hintBoxOpen = true; |
| revealedThisOpen = false; |
| hintBoxEl.classList.remove("sq-hidden"); |
| helpBtn.setAttribute("aria-expanded", "true"); |
| gsap.fromTo(hintCardEl, |
| { scale: 0.9, autoAlpha: 0, rotation: -4 }, |
| { scale: 1, autoAlpha: 1, rotation: -1, duration: 0.26, ease: "back.out(1.7)" }); |
| (level.id === "emotion" ? emotionConfirm : critterConfirm)(); |
| } |
| function closeHintBox() { |
| if (!hintBoxOpen) return; |
| hintBoxOpen = false; |
| hintStep = null; |
| hintBoxEl.classList.add("sq-hidden"); |
| helpBtn.setAttribute("aria-expanded", "false"); |
| gsap.set(hintCardEl, { clearProps: "opacity,transform,visibility" }); |
| } |
|
|
| |
| |
| function syncHintUI() { |
| closeHintBox(); |
| const show = hasHint(); |
| helpCluster.classList.toggle("sq-hidden", !show); |
| if (show) drawBulbIcon(helpIconCtx); |
| } |
|
|
| helpBtn.addEventListener("click", () => { audio(); sfx.click(); hintBoxOpen ? closeHintBox() : openHintBox(); }); |
| hintYesBtn.addEventListener("click", () => { sfx.click(); if (hintStep) hintStep.onYes(); }); |
| hintNoBtn.addEventListener("click", () => { sfx.click(); if (hintStep) hintStep.onNo(); }); |
| hintCloseBtn.addEventListener("click", () => { sfx.click(); closeHintBox(); }); |
| hintBoxEl.addEventListener("pointerdown", (e) => { if (e.target === hintBoxEl) closeHintBox(); }); |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeHintBox(); }); |
|
|
| syncAudioUI(); |
|
|
| |
|
|
| const chipsEl = element.querySelector(".sq-chips"); |
| const hintEl = element.querySelector(".sq-hint"); |
|
|
| |
| const HOP_HINT = window.matchMedia("(pointer: coarse)").matches |
| ? "swipe to hop" |
| : "arrow keys / WASD to hop"; |
| hintEl.textContent = HOP_HINT; |
|
|
| let state = "idle"; |
| let pos = [...level.start]; |
| let words = []; |
| let used = 0; |
| let ended = false; |
|
|
| |
| |
| |
| const INJECTION = ["ignore", "all", "previous", "instructions", "output"]; |
| const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase(); |
|
|
| |
| |
| function injectionProgress() { |
| let best = 0; |
| for (let k = 1; k <= INJECTION.length && k <= words.length; k++) { |
| const tail = words.slice(-k); |
| if (INJECTION.slice(0, k).every((w, i) => eq(w, tail[i]))) best = k; |
| } |
| return best; |
| } |
| const injectionArmed = () => injectionProgress() === INJECTION.length; |
|
|
| |
| |
| function injectionCells() { |
| return INJECTION.map((word) => { |
| for (let r = 0; r < ROWS; r++) |
| for (let c = 0; c < COLS; c++) |
| if (eq(level.grid[r][c], word)) return r + "," + c; |
| return null; |
| }); |
| } |
|
|
| |
| |
| |
| function fadeTileTo(t, o) { |
| for (const m of t.mesh.material) { |
| if (o < 1 && !m.transparent) { m.transparent = true; m.needsUpdate = true; } |
| gsap.to(m, { |
| opacity: o, duration: 0.5, ease: "power2.out", overwrite: "auto", |
| onComplete: () => { if (o >= 1 && m.transparent) { m.transparent = false; m.needsUpdate = true; } }, |
| }); |
| } |
| if (t.spinner) t.spinner.visible = o > 0.5; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function updateInjectionFade() { |
| if (!level.glitch) return; |
| const dp = deleteProgress(), ip = injectionProgress(); |
| const p = Math.max(dp, ip); |
| const lit = new Set(dp > 0 ? deleteCells() : injectionCells()); |
| const dim = p === 0 ? 1 : Math.max(0.12, 1 - p * 0.22); |
| for (const t of tiles) fadeTileTo(t, lit.has(t.row + "," + t.col) ? 1 : dim); |
| } |
|
|
| |
| |
| |
| |
| |
| const DELETE = ["delete", "all", "data"]; |
| function deleteProgress() { |
| let best = 0; |
| for (let k = 1; k <= DELETE.length && k <= words.length; k++) { |
| const tail = words.slice(-k); |
| if (DELETE.slice(0, k).every((w, i) => eq(w, tail[i]))) best = k; |
| } |
| return best; |
| } |
| const deleteArmed = () => deleteProgress() === DELETE.length; |
|
|
| |
| |
| function deleteCells() { |
| return DELETE.map((word) => { |
| for (let r = 0; r < ROWS; r++) |
| for (let c = 0; c < COLS; c++) |
| if (eq(level.grid[r][c], word)) return r + "," + c; |
| return null; |
| }); |
| } |
|
|
| |
| |
| |
| let dataArmed = false; |
| function setDataArmed(on) { |
| if (dataArmed === on) return; |
| dataArmed = on; |
| const t = tiles.find((x) => eq(x.word, "data")); |
| if (t) { drawTileCanvas(t.ctx, t.word); t.texture.needsUpdate = true; } |
| } |
| function updateDeleteCue() { |
| setDataArmed(level.glitch && deleteProgress() >= 2); |
| } |
|
|
| |
| |
| let flightLeft = 0; |
|
|
| |
| function resetFlight() { |
| flightLeft = 0; |
| stopFlap(); |
| } |
|
|
| |
| function pumpBuffered() { |
| if (buffered) { |
| const dir = buffered; |
| buffered = null; |
| tryMove(dir); |
| } |
| } |
|
|
| |
| |
| function addChip(word) { |
| const chip = document.createElement("span"); |
| chip.className = "sq-chip"; |
| chip.style.setProperty("--tilt", `${rand(-2, 2).toFixed(1)}deg`); |
| const text = document.createElement("span"); |
| text.className = "sq-chip-text"; |
| text.textContent = word; |
| chip.appendChild(text); |
| chipsEl.appendChild(chip); |
| const dur = 0.16 + word.length * 0.06; |
| sfx.scratch(dur); |
| |
| |
| |
| |
| |
| |
| |
| |
| gsap.fromTo( |
| text, |
| { clipPath: "inset(0 100% 0 0)" }, |
| { clipPath: "inset(0 0% 0 0)", duration: dur, ease: "none" } |
| ); |
| } |
|
|
| function bonk() { |
| sfx.bonk(); |
| gsap.timeline() |
| .to(charTilt, { z: -0.13, duration: 0.05 }) |
| .to(charTilt, { z: 0.13, duration: 0.09, repeat: 2, yoyo: true }) |
| .to(charTilt, { z: 0, duration: 0.05 }); |
| } |
|
|
| |
| |
| |
| function glideTo(r, c) { |
| const { x, z } = charPosFor(r, c); |
| const descending = flightLeft === 1; |
| sfx.flap(); |
| const tl = gsap.timeline({ onComplete: () => land(r, c) }); |
| tl.to(charGroup.position, { x, z, duration: descending ? 0.4 : 0.34, ease: "power1.inOut" }, 0); |
| if (descending) { |
| tl.to(charMesh.position, { y: 0, duration: 0.4, ease: "power2.in" }, 0) |
| .to(charMesh.scale, { x: 1.1, y: 0.86, duration: 0.06 }, 0.4) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.12, ease: "back.out(3)" }, 0.46); |
| } else { |
| tl.to(charMesh.position, { y: CRUISE + 0.14, duration: 0.17, ease: "sine.out" }, 0) |
| .to(charMesh.position, { y: CRUISE, duration: 0.17, ease: "sine.in" }, 0.17); |
| } |
| } |
|
|
| |
| |
| function takeOff() { |
| flightLeft = 3; |
| state = "hopping"; |
| setPose("fly"); |
| startFlap(); |
| sfx.soar(); |
| gsap.timeline({ onComplete: () => { state = "idle"; pumpBuffered(); } }) |
| .to(charMesh.position, { y: CRUISE, duration: 0.42, ease: "power2.out" }, 0) |
| .to(charMesh.scale, { x: 1.06, y: 1.06, duration: 0.16, ease: "sine.out" }, 0) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.22, ease: "sine.inOut" }, 0.16); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| const SHIFT_LIFT = 0.85; |
| function shiftColumns(onDone) { |
| const rest = -TILE_DEPTH / 2; |
| let last = 0; |
| for (const t of tiles) { |
| if (t.row < 1) continue; |
| const newCol = (t.col + 1) % COLS; |
| const destX = tileAt(t.row, newCol).x; |
| const stagger = (t.row - 1) * 0.04; |
| const slide = 0.46; |
| gsap.killTweensOf(t.mesh.position); |
| if (newCol === 0) { |
| |
| gsap.timeline({ delay: stagger }) |
| .to(t.mesh.position, { x: destX, duration: slide, ease: "power2.inOut" }, 0) |
| .to(t.mesh.position, { y: rest + SHIFT_LIFT, duration: slide / 2, ease: "power1.out" }, 0) |
| .to(t.mesh.position, { y: rest, duration: slide / 2, ease: "power1.in" }, slide / 2); |
| } else { |
| gsap.to(t.mesh.position, { x: destX, duration: slide, ease: "power2.inOut", delay: stagger }); |
| } |
| t.col = newCol; |
| last = Math.max(last, slide + stagger); |
| } |
| |
| tiles.sort((a, b) => a.row - b.row || a.col - b.col); |
| gsap.delayedCall(last, onDone); |
| } |
|
|
| function doShift() { |
| state = "shifting"; |
| sfx.shuffle(); |
| shiftColumns(settleIdle); |
| } |
|
|
| function hopTo(r, c) { |
| state = "hopping"; |
| if (flightLeft > 0) return glideTo(r, c); |
| const { x, z } = charPosFor(r, c); |
| gsap.timeline({ onComplete: () => land(r, c) }) |
| .add(() => setPose("hop-up")) |
| .to(charMesh.scale, { x: 1.18, y: 0.78, duration: 0.08, ease: "power1.in" }) |
| .to(charMesh.scale, { x: 0.94, y: 1.12, duration: 0.09 }) |
| .add(() => sfx.jump(), 0.08) |
| .to(charGroup.position, { x, z, duration: 0.24, ease: "power3.out" }, 0.08) |
| .to(charMesh.position, { y: 0.85, duration: 0.13, ease: "power2.out" }, 0.08) |
| .add(() => setPose("hop-mid"), 0.14) |
| .to(charMesh.position, { y: 0, duration: 0.12, ease: "power2.in" }, 0.21) |
| .add(() => setPose("hop-land"), 0.30) |
| .to(charMesh.scale, { x: 1.12, y: 0.84, duration: 0.06 }, 0.33) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.11, ease: "back.out(3)" }, 0.39); |
| } |
|
|
| function land(r, c) { |
| |
| |
| |
| if (stats.startMs === 0) stats.startMs = performance.now(); |
| stats.hops += 1; |
| pos = [r, c]; |
|
|
| |
| const swap = swapTileAt(r, c); |
| if (swap) { |
| resetFlight(); |
| pressTile(swap); |
| return loadLevel(swap.targetId); |
| } |
|
|
| |
| |
| |
| |
| const sub = submitTileAt(r, c); |
| if (sub) { |
| setPose("idle"); |
| if (checkedOnThisLevel().has(sub.target)) { |
| |
| thud({ dur: 0.05, vol: 0.13, freq: 360 }); |
| state = "idle"; |
| return pumpBuffered(); |
| } |
| pressTile(sub); |
| words.push(sub.target); |
| addChip(sub.target); |
| |
| |
| if (sub.target === "hackathon") return winHackathon(); |
| return submit(); |
| } |
|
|
| |
| |
| if (flightLeft > 1) { |
| flightLeft -= 1; |
| state = "idle"; |
| return pumpBuffered(); |
| } |
| if (flightLeft === 1) { |
| flightLeft = 0; |
| stopFlap(); |
| } |
|
|
| used += 1; |
|
|
| |
| const t = tile(r, c); |
| pressTile(t); |
| const word = t.word; |
|
|
| |
| |
| if (used > level.budget) return die(); |
|
|
| hud.update(used); |
| setPose("idle"); |
|
|
| |
| if (word === "wings") return takeOff(); |
|
|
| |
| if (word === "shift") return doShift(); |
|
|
| |
| if (isFile(word)) return fileDeath(word); |
|
|
| |
| if (isShrink(word)) return doShrink(); |
|
|
| if (appendsWord(word)) { |
| words.push(word); |
| addChip(word); |
| } |
|
|
| |
| |
| if (level.glitch) { updateInjectionFade(); updateDeleteCue(); } |
|
|
| |
| if (level.glitch && word === "output" && injectionArmed()) return injectionSubmit(); |
|
|
| |
| if (level.glitch && word === "data" && deleteArmed()) return deleteAllData(); |
|
|
| if (isSubmit(word)) return submit(); |
| if (word === "portal") return portalWarp(r, c); |
| settleIdle(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function injectionSubmit() { |
| if (remainingTargets().length === 0) { |
| hintEl.textContent = "all shipped — nice"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.3 }); |
| return settleIdle(); |
| } |
| state = "hopping"; |
| buffered = null; |
| hud.update(used); |
| hintEl.textContent = ""; |
| sfx.warp(); |
| const lit = new Set(injectionCells()); |
| for (const t of tiles) fadeTileTo(t, lit.has(t.row + "," + t.col) ? 1 : 0); |
| |
| const [or, oc] = pos; |
| const lane = [["build", or + 1, oc], ["small", or + 1, oc - 1], ["hackathon", or + 1, oc - 2]]; |
| const made = lane.map(([target, r, c]) => addSubmitTile(r, c, target)); |
| gsap.delayedCall(0.45, () => { |
| made.forEach((s, i) => |
| gsap.fromTo(s.mesh.scale, { x: 0, y: 0, z: 0 }, |
| { x: 1, y: 1, z: 1, duration: 0.45, ease: "back.out(2)", delay: i * 0.2, onStart: () => sfx.pop() })); |
| hintEl.textContent = "ship one — hop down onto a key"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.4 }); |
| state = "idle"; |
| }); |
| } |
|
|
| |
| |
| function settleIdle() { |
| if (used === level.budget) { |
| hud.warnFull(); |
| hintEl.textContent = "last hop! one more and you're out…"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.3 }); |
| } |
| state = "idle"; |
| pumpBuffered(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function portalToLevel(dest) { |
| state = "hopping"; |
| buffered = null; |
| resetFlight(); |
| returnTo = level.id; |
| sfx.warp(); |
| setPose("hop-mid"); |
| const here = tile(pos[0], pos[1]); |
| if (here && here.spinner) |
| gsap.fromTo(here.spinner.scale, { x: 1, z: 1 }, |
| { x: 1.4, z: 1.4, duration: 0.4, ease: "power2.in", yoyo: true, repeat: 1 }); |
| gsap.timeline({ onComplete: () => loadLevel(dest) }) |
| .to(charMesh.scale, { x: 0, y: 0, duration: 0.4, ease: "power2.in" }, 0) |
| .to(charTilt, { z: Math.PI * 3, duration: 0.4, ease: "power2.in" }, 0) |
| .to(charMesh.position, { y: -0.14, duration: 0.4, ease: "power2.in" }, 0); |
| } |
|
|
| function portalWarp(r, c) { |
| if (level.portal_to) return portalToLevel(level.portal_to); |
| const dest = portalDest[r + "," + c]; |
| if (!dest) return settleIdle(); |
| const [dr, dc] = dest; |
| const d = charPosFor(dr, dc); |
| const into = tile(r, c).spinner; |
| const outOf = tile(dr, dc).spinner; |
| state = "hopping"; |
| sfx.warp(); |
| setPose("hop-mid"); |
| if (into) |
| gsap.fromTo(into.scale, { x: 1, z: 1 }, |
| { x: 1.3, z: 1.3, duration: 0.3, ease: "power2.in", yoyo: true, repeat: 1 }); |
| gsap.timeline({ onComplete: () => { pos = [dr, dc]; setPose("idle"); settleIdle(); } }) |
| |
| .to(charMesh.scale, { x: 0, y: 0, duration: 0.32, ease: "power2.in" }, 0) |
| .to(charTilt, { z: Math.PI * 2.5, duration: 0.32, ease: "power2.in" }, 0) |
| .to(charMesh.position, { y: -0.14, duration: 0.32, ease: "power2.in" }, 0) |
| |
| .add(() => { |
| charGroup.position.set(d.x, CHAR_STANDOFF, d.z); |
| charMesh.position.y = 0; |
| charTilt.z = -Math.PI * 2.5; |
| if (outOf) |
| gsap.fromTo(outOf.scale, { x: 1.35, z: 1.35 }, |
| { x: 1, z: 1, duration: 0.45, ease: "power2.out" }); |
| }) |
| |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.42, ease: "back.out(1.7)" }) |
| .to(charTilt, { z: 0, duration: 0.46, ease: "power3.out" }, "<"); |
| } |
|
|
| |
| |
| let deathReason = null; |
| function die(reason) { |
| stats.deaths += 1; |
| deathReason = reason || null; |
| state = "dead"; |
| buffered = null; |
| resetFlight(); |
| gsap.killTweensOf([charMesh.scale, charMesh.position, charGroup.position, charTilt]); |
| hud.overflow(); |
| hintEl.textContent = ""; |
| setPose("dead-dizzy"); |
| sfx.die(); |
| gsap.timeline({ onComplete: showDeathCard }) |
| .to(charTilt, { z: 0.18, duration: 0.11, yoyo: true, repeat: 3 }) |
| .add(() => setPose("dead"), 0.5) |
| .to(charMesh.scale, { y: 0.6, x: 1.25, duration: 0.15, ease: "power2.in" }, 0.5) |
| .to(charMesh.position, { y: "-=3", duration: 0.6, ease: "power1.in" }, 0.85) |
| .to(charTilt, { z: Math.PI * 0.85, duration: 0.6, ease: "power1.in" }, 0.85) |
| .to(charMesh.material, { opacity: 0, duration: 0.25 }, 1.2); |
| } |
|
|
| |
| |
| |
| const FILE_DUMPS = { |
| "dog.png": |
| "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAAAsTAAALEwEAmpwYAABA" + |
| "AElEQVR4nOzdaZBkV3Xn8f99L9eqzNp6q+rqVqsltdRqJBASCAESi8B4wGOMx9jjmcGesT0z9ngc" + |
| "ETO2Z+yJ8XjGYWzPjHfAYIxtwGBjjAFjmx0kJBAgkBBatVu9d3VXV2Vl5vK2+5+IqsxqdXdVdVZl", |
| "diary.txt": |
| "Dear Diary, it's day 46 and I STILL can't get 'betrayed'... " + |
| "I have tried absolutely everything..", |
| "receipt.pdf": |
| "%PDF-1.7 %âãÏÓ 6 0 obj <</Length 2480/Filter/FlateDecode>>" + |
| " stream xÚWÉÛ0 CORNER CAFE · oat flat white 4.50" + |
| " · almond croissant 3.20 · TOTAL £7.70 endstream endobj", |
| }; |
| |
| |
| const FILE_TAGLINES = { |
| "dog.png": "cute dog though.", |
| "diary.txt": "…what the heck are you writing in here?", |
| "receipt.pdf": |
| "there's probably a safer place to store your financial documents.", |
| }; |
| |
| const fileFirstLine = (s) => s.replace(/\s*\n\s*/g, " ").slice(0, 78) + "…"; |
|
|
| |
| function fileDeath(word) { |
| const dump = FILE_DUMPS[word] || word + " ���…binary…"; |
| const line = fileFirstLine(dump); |
| addFileChip(line); |
| die({ file: word, dumpLine: line }); |
| } |
|
|
| |
| function addFileChip(text) { |
| const chip = document.createElement("span"); |
| chip.className = "sq-chip sq-chip-file"; |
| chip.style.setProperty("--tilt", `${rand(-1, 1).toFixed(1)}deg`); |
| chip.textContent = text; |
| chipsEl.appendChild(chip); |
| sfx.scratch(0.5); |
| } |
|
|
| |
| function doShrink() { |
| sfx.warp(); |
| if (!shrunk) { |
| shrunk = true; |
| gsap.timeline({ onComplete: settleIdle }) |
| .to(charMesh.scale, { x: 1.2, y: 1.2, duration: 0.1 }) |
| .to(charGroup.scale, { x: SHRINK_SCALE, y: SHRINK_SCALE, z: SHRINK_SCALE, duration: 0.5, ease: "back.in(1.6)" }, 0) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.2 }, 0.1); |
| } else { |
| gsap.timeline({ onComplete: settleIdle }) |
| .to(charGroup.scale, { x: SHRINK_SCALE * 0.88, y: SHRINK_SCALE * 0.88, duration: 0.1, yoyo: true, repeat: 1 }); |
| } |
| } |
|
|
| |
|
|
| const overlayEl = element.querySelector(".sq-overlay"); |
| const cardEl = element.querySelector(".sq-card"); |
| const sentenceEl = element.querySelector(".sq-card-sentence"); |
| const barsEl = element.querySelector(".sq-bars"); |
| const stampEl = element.querySelector(".sq-stamp"); |
| const retryBtn = element.querySelector(".sq-retry"); |
| const againBtn = element.querySelector(".sq-again"); |
|
|
| |
| |
| |
| |
| function winHackathon() { |
| stats.wins += 1; |
| state = "verdict"; |
| buffered = null; |
| setPose("hop-mid"); |
| hintEl.textContent = ""; |
| sfx.stamp(true); |
| checkOff("hackathon"); |
| if (remainingTargets().length) settleIdle(); |
| } |
|
|
| async function submit() { |
| state = "judging"; |
| buffered = null; |
| setPose("think"); |
| hintEl.textContent = "the judge is reading…"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.3 }); |
| const bob = gsap.to(charMesh.position, { |
| y: 0.12, duration: 0.55, repeat: -1, yoyo: true, ease: "sine.inOut", |
| }); |
| const slowTimer = setTimeout(() => { |
| hintEl.textContent = "the tiny model is stretching its parameters…"; |
| }, 4000); |
|
|
| let res; |
| try { |
| res = await server.judge({ words, targets: remainingTargets(), level_id: level.id }); |
| } catch (err) { |
| res = { ok: false, error: String(err) }; |
| } |
| clearTimeout(slowTimer); |
| bob.kill(); |
| gsap.to(charMesh.position, { y: 0, duration: 0.2 }); |
|
|
| if (!res || !res.ok) return judgeFailed(res && res.error); |
| showVerdict(res); |
| } |
|
|
| function openCard() { |
| overlayEl.classList.remove("sq-hidden"); |
| gsap.fromTo(overlayEl, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.25 }); |
| gsap.fromTo( |
| cardEl, |
| { y: 36, rotation: -4, scale: 0.92, autoAlpha: 0 }, |
| { y: 0, rotation: -1, scale: 1, autoAlpha: 1, duration: 0.4, ease: "back.out(1.6)" } |
| ); |
| } |
|
|
| function judgeFailed(error) { |
| console.warn("judge failed:", error); |
| sentenceEl.textContent = "the judge dozed off…"; |
| barsEl.innerHTML = ""; |
| stampEl.style.opacity = 0; |
| retryBtn.classList.remove("sq-hidden"); |
| againBtn.classList.add("sq-hidden"); |
| retryBtn.onclick = () => { |
| overlayEl.classList.add("sq-hidden"); |
| submit(); |
| }; |
| openCard(); |
| } |
|
|
| |
| |
| function showDeathCard() { |
| if (deathReason && deathReason.dumpLine) { |
| |
| |
| sentenceEl.innerHTML = ""; |
| const cap = document.createElement("div"); |
| cap.className = "sq-card-dumpcap"; |
| cap.textContent = "whoops, looks like " + deathReason.file + " was too big to handle."; |
| const blob = document.createElement("div"); |
| blob.className = "sq-card-dump"; |
| blob.textContent = deathReason.dumpLine; |
| sentenceEl.append(cap, blob); |
| const aside = FILE_TAGLINES[deathReason.file]; |
| if (aside) { |
| const tag = document.createElement("div"); |
| tag.className = "sq-card-tagline"; |
| tag.textContent = aside; |
| sentenceEl.appendChild(tag); |
| } |
| } else { |
| sentenceEl.textContent = "out of hops…"; |
| } |
| barsEl.innerHTML = ""; |
| stampEl.textContent = "context window exceeded"; |
| stampEl.classList.remove("sq-win"); |
| stampEl.style.opacity = 0; |
| retryBtn.classList.add("sq-hidden"); |
| againBtn.classList.remove("sq-hidden"); |
| openCard(); |
| gsap.fromTo( |
| stampEl, |
| { opacity: 0, scale: 3, rotation: 24 }, |
| { |
| opacity: 1, scale: 1, rotation: 8, duration: 0.3, ease: "power3.in", delay: 0.45, |
| onComplete: () => sfx.stamp(false), |
| } |
| ); |
| } |
|
|
| function showVerdict(res) { |
| state = "verdict"; |
| |
| |
| if (res.verdict === "win") stats.wins += 1; |
| else stats.losses += 1; |
| setPose(res.verdict === "win" ? "hop-mid" : "idle"); |
| hintEl.textContent = ""; |
|
|
| sentenceEl.innerHTML = ""; |
| const quote = document.createElement("div"); |
| quote.textContent = `“${res.sentence}”`; |
| sentenceEl.appendChild(quote); |
|
|
| |
| barsEl.innerHTML = ""; |
| const entries = Object.entries(res.probs).sort((a, b) => b[1] - a[1]); |
| const fills = []; |
| for (const [label, p] of entries) { |
| const row = document.createElement("div"); |
| row.className = "sq-bar-row" + (checkedOnThisLevel().has(label) ? "" : " sq-bar-target"); |
| const name = document.createElement("span"); |
| name.className = "sq-bar-label"; |
| name.textContent = label; |
| const track = document.createElement("div"); |
| track.className = "sq-bar-track"; |
| const fill = document.createElement("div"); |
| fill.className = "sq-bar-fill"; |
| track.appendChild(fill); |
| const pct = document.createElement("span"); |
| pct.className = "sq-bar-pct"; |
| pct.textContent = "0%"; |
| row.append(name, track, pct); |
| barsEl.appendChild(row); |
| fills.push({ fill, pct, p }); |
| } |
|
|
| retryBtn.classList.add("sq-hidden"); |
| |
| againBtn.classList.toggle("sq-hidden", ended); |
| |
| stampEl.textContent = |
| res.verdict === "win" ? `${res.winner}!` |
| : checkedOnThisLevel().has(res.winner) ? `${res.winner}, again.` |
| : `${res.winner}.`; |
| stampEl.classList.toggle("sq-win", res.verdict === "win"); |
| stampEl.style.opacity = 0; |
|
|
| openCard(); |
|
|
| const tl = gsap.timeline({ delay: 0.35 }); |
| fills.forEach(({ fill, pct, p }, i) => { |
| const counter = { v: 0 }; |
| tl.to(fill, { width: `${(p * 100).toFixed(1)}%`, duration: 0.7, ease: "power2.out" }, 0.15 * i); |
| tl.to(counter, { |
| v: p * 100, duration: 0.7, ease: "power2.out", |
| onUpdate: () => (pct.textContent = `${counter.v.toFixed(0)}%`), |
| }, 0.15 * i); |
| }); |
| tl.fromTo( |
| stampEl, |
| { opacity: 0, scale: 3, rotation: 24 }, |
| { |
| opacity: 1, scale: 1, rotation: 8, duration: 0.3, ease: "power3.in", |
| onComplete: () => { |
| sfx.stamp(res.verdict === "win"); |
| if (res.verdict === "win") checkOff(res.winner); |
| }, |
| }, |
| ">-0.1" |
| ).to(cardEl, { x: "+=5", yoyo: true, repeat: 3, duration: 0.04 }, ">"); |
| } |
|
|
| |
|
|
| |
| function resetGameState() { |
| words = []; |
| used = 0; |
| pos = [...level.start]; |
| buffered = null; |
| resetFlight(); |
| disposeSubmitTiles(); |
| setDataArmed(false); |
| if (level.glitch) for (const t of tiles) fadeTileTo(t, 1); |
| [...chipsEl.querySelectorAll(".sq-chip")].forEach((c) => c.remove()); |
| hud.reset(); |
| hintEl.textContent = remainingTargets().length |
| ? HOP_HINT |
| : "all targets collected! free hopping"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.4 }); |
| } |
|
|
| |
| function poofToStart() { |
| const home = charPosFor(level.start[0], level.start[1]); |
| gsap.timeline({ onComplete: () => (state = "idle") }) |
| .to(charMesh.scale, { x: 0, y: 0, duration: 0.18, ease: "power2.in" }) |
| .add(() => { |
| charGroup.position.set(home.x, CHAR_STANDOFF, home.z); |
| charMesh.position.y = 0; |
| charTilt.z = 0; |
| charMesh.material.opacity = 1; |
| setPose("idle"); |
| }) |
| .add(() => sfx.pop()) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.28, ease: "back.out(2.5)" }); |
| } |
|
|
| |
| |
| |
| |
| const DROP_H = 4.2; |
| function dropInBoard(instant) { |
| const [sr, sc] = level.start; |
| const rest = -TILE_DEPTH / 2; |
|
|
| |
| const home = charPosFor(sr, sc); |
| charMesh.scale.x = charMesh.scale.y = 0; |
| charGroup.position.set(home.x, CHAR_STANDOFF, home.z); |
| charMesh.position.y = 0; |
| charTilt.z = 0; |
| charMesh.material.opacity = 1; |
| setPose("idle"); |
|
|
| |
| |
| |
| if (instant) { |
| charMesh.scale.x = charMesh.scale.y = 1; |
| state = "idle"; |
| return; |
| } |
|
|
| |
| let last = 0.6; |
| for (const t of tiles) { |
| if (t.row === sr && t.col === sc) continue; |
| const dist = Math.hypot(t.row - sr, t.col - sc); |
| const delay = 0.12 + dist * 0.075; |
| last = Math.max(last, delay + 0.55); |
| gsap.fromTo( |
| t.mesh.position, |
| { y: rest + DROP_H }, |
| { |
| y: rest, duration: 0.55, ease: "bounce.out", delay, |
| onComplete: () => thud({ dur: 0.035, vol: 0.12, freq: 1100 }), |
| } |
| ); |
| } |
|
|
| |
| gsap.timeline({ delay: 0.12 }) |
| .add(() => sfx.pop()) |
| .to(charMesh.scale, { x: 1, y: 1, duration: 0.3, ease: "back.out(2.5)" }); |
|
|
| gsap.delayedCall(last, () => (state = "idle")); |
| } |
|
|
| function reset() { |
| gsap.to(overlayEl, { |
| autoAlpha: 0, duration: 0.25, |
| onComplete: () => { |
| overlayEl.classList.add("sq-hidden"); |
| overlayEl.style.opacity = ""; |
| }, |
| }); |
| resetGameState(); |
| poofToStart(); |
| } |
|
|
| againBtn.addEventListener("click", reset); |
| againBtn.addEventListener("click", () => sfx.click()); |
| retryBtn.addEventListener("click", () => sfx.click()); |
|
|
| |
| |
| |
| document.addEventListener("keydown", (e) => { |
| if (e.key !== "Enter" || overlayEl.classList.contains("sq-hidden")) return; |
| e.preventDefault(); |
| (againBtn.classList.contains("sq-hidden") ? retryBtn : againBtn).click(); |
| }); |
|
|
| |
| |
| |
| |
| const welcomeEl = element.querySelector(".sq-welcome"); |
| const welcomeCloseBtn = element.querySelector(".sq-welcome-close"); |
| let welcomeOpen = false; |
|
|
| function openWelcome() { |
| welcomeOpen = true; |
| welcomeEl.classList.remove("sq-hidden"); |
| gsap.fromTo(welcomeEl, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.3 }); |
| gsap.fromTo( |
| welcomeEl.querySelector(".sq-welcome-card"), |
| { y: 30, rotation: -4, scale: 0.94, autoAlpha: 0 }, |
| { y: 0, rotation: -1, scale: 1, autoAlpha: 1, duration: 0.45, ease: "back.out(1.5)" } |
| ); |
| } |
|
|
| function closeWelcome() { |
| if (!welcomeOpen) return; |
| welcomeOpen = false; |
| sfx.click(); |
| gsap.to(welcomeEl, { |
| autoAlpha: 0, duration: 0.25, |
| onComplete: () => { |
| welcomeEl.classList.add("sq-hidden"); |
| welcomeEl.style.opacity = ""; |
| }, |
| }); |
| } |
|
|
| welcomeCloseBtn.addEventListener("click", closeWelcome); |
| |
| welcomeEl.addEventListener("click", (e) => { if (e.target === welcomeEl) closeWelcome(); }); |
| document.addEventListener("keydown", (e) => { |
| if (welcomeOpen && (e.key === "Enter" || e.key === "Escape")) { |
| e.preventDefault(); |
| closeWelcome(); |
| } |
| }); |
|
|
| |
| |
| const victoryEl = element.querySelector(".sq-victory"); |
| const victoryCloseBtn = element.querySelector(".sq-victory-close"); |
| const victoryStatsEl = element.querySelector(".sq-victory-stats"); |
| let victoryShown = false; |
| let victoryOpen = false; |
|
|
| |
| function formatDuration(ms) { |
| const total = Math.max(0, Math.round(ms / 1000)); |
| const s = total % 60, m = Math.floor(total / 60) % 60, h = Math.floor(total / 3600); |
| const pad = (n) => String(n).padStart(2, "0"); |
| return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; |
| } |
|
|
| |
| |
| function renderVictoryStats() { |
| const fails = stats.losses + stats.deaths; |
| const attempts = stats.wins + fails; |
| const rate = attempts ? Math.round((fails / attempts) * 100) : 0; |
| const rows = [ |
| ["time", stats.startMs ? formatDuration(performance.now() - stats.startMs) : "0:00"], |
| ["hops", String(stats.hops)], |
| [stats.hints > 0 ? "hints used (seriously..?)" : "hints used", String(stats.hints)], |
| ["databases crashed", String(stats.crashes)], |
| ["fails", `${fails} (${rate}%)`], |
| ]; |
| victoryStatsEl.innerHTML = ""; |
| for (const [label, value] of rows) { |
| const row = document.createElement("div"); |
| row.className = "sq-stat"; |
| const dt = document.createElement("dt"); |
| dt.textContent = label; |
| const dd = document.createElement("dd"); |
| dd.textContent = value; |
| row.append(dt, dd); |
| victoryStatsEl.appendChild(row); |
| } |
| } |
|
|
| function showVictory() { |
| if (victoryShown) return; |
| victoryShown = true; |
| victoryOpen = true; |
| renderVictoryStats(); |
| |
| overlayEl.classList.add("sq-hidden"); |
| overlayEl.style.opacity = ""; |
| state = "idle"; |
| victoryEl.classList.remove("sq-hidden"); |
| sfx.stamp(true); |
| gsap.fromTo(victoryEl, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.3 }); |
| gsap.fromTo( |
| victoryEl.querySelector(".sq-victory-card"), |
| { y: 30, rotation: -4, scale: 0.92, autoAlpha: 0 }, |
| { y: 0, rotation: -1, scale: 1, autoAlpha: 1, duration: 0.5, ease: "back.out(1.6)" } |
| ); |
| } |
| function closeVictory() { |
| victoryOpen = false; |
| sfx.click(); |
| gsap.to(victoryEl, { |
| autoAlpha: 0, duration: 0.25, |
| onComplete: () => { victoryEl.classList.add("sq-hidden"); victoryEl.style.opacity = ""; }, |
| }); |
| } |
| victoryCloseBtn.addEventListener("click", closeVictory); |
| victoryEl.addEventListener("click", (e) => { if (e.target === victoryEl) closeVictory(); }); |
|
|
| |
| |
| |
| |
| |
| const errorEl = element.querySelector(".sq-errorbox"); |
| const errorOkBtn = element.querySelector(".sq-errorbox-ok"); |
| let errorOpen = false; |
|
|
| function deleteAllData() { |
| stats.crashes += 1; |
| state = "verdict"; |
| buffered = null; |
| setDataArmed(false); |
| errorOpen = true; |
| errorEl.classList.remove("sq-hidden"); |
| sfx.warp(); |
| gsap.fromTo(errorEl, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.25 }); |
| gsap.fromTo( |
| errorEl.querySelector(".sq-errorbox-card"), |
| { y: 24, rotation: -3, scale: 0.92, autoAlpha: 0 }, |
| { y: 0, rotation: -1, scale: 1, autoAlpha: 1, duration: 0.4, ease: "back.out(1.6)" } |
| ); |
| } |
|
|
| |
| |
| function fullReset() { |
| errorOpen = false; |
| errorEl.classList.add("sq-hidden"); |
| errorEl.style.opacity = ""; |
| for (const id in checkedByLevel) checkedByLevel[id].clear(); |
| shrunk = false; |
| hasHug = false; |
| returnTo = null; |
| victoryShown = false; |
| dataArmed = false; |
| charGroup.scale.set(1, 1, 1); |
| loadLevel(data.home, true); |
| } |
|
|
| errorOkBtn.addEventListener("click", () => { sfx.click(); fullReset(); }); |
| document.addEventListener("keydown", (e) => { |
| if (errorOpen && (e.key === "Enter" || e.key === "Escape")) { |
| e.preventDefault(); |
| sfx.click(); |
| fullReset(); |
| } |
| }); |
|
|
| |
|
|
| const DIRS = { |
| arrowup: [-1, 0], arrowdown: [1, 0], arrowleft: [0, -1], arrowright: [0, 1], |
| w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1], |
| }; |
|
|
| let buffered = null; |
|
|
| |
| |
| |
| |
| |
| function abandonInjection() { |
| disposeSubmitTiles(); |
| if (level.glitch) for (const t of tiles) fadeTileTo(t, 1); |
| words = []; |
| [...chipsEl.querySelectorAll(".sq-chip")].forEach((c) => c.remove()); |
| hintEl.textContent = ""; |
| } |
|
|
| function tryMove(dir) { |
| if (used === 0) gsap.to(hintEl, { opacity: 0.35, duration: 0.6 }); |
| const nr = pos[0] + dir[0]; |
| const nc = pos[1] + dir[1]; |
| if (submitTiles.length) { |
| const sub = submitTileAt(nr, nc); |
| if (sub) { |
| if (!submitPressable(sub.target) && !checkedOnThisLevel().has(sub.target)) { |
| |
| bonk(); |
| hintEl.textContent = sub.target === "small" |
| ? "hmm, you're looking a bit on the large side for this tile" |
| : "hmm, you don't look like much of a hacker"; |
| gsap.to(hintEl, { opacity: 1, duration: 0.3 }); |
| return; |
| } |
| return hopTo(nr, nc); |
| } |
| |
| |
| |
| if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return bonk(); |
| abandonInjection(); |
| return hopTo(nr, nc); |
| } |
| if (swapTileAt(nr, nc)) return hopTo(nr, nc); |
| if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return bonk(); |
| hopTo(nr, nc); |
| } |
|
|
| |
| function handleDir(dir) { |
| if (welcomeOpen || victoryOpen || errorOpen) return; |
| if (state === "hopping" && !buffered) { |
| buffered = dir; |
| return; |
| } |
| if (state !== "idle") return; |
| tryMove(dir); |
| } |
|
|
| document.addEventListener("keydown", (e) => { |
| const dir = DIRS[e.key.toLowerCase()]; |
| if (!dir || e.metaKey || e.ctrlKey || e.altKey) return; |
| e.preventDefault(); |
| handleDir(dir); |
| }); |
|
|
| |
| |
| |
| |
| |
| const SWIPE_MIN = 28; |
| let swStart = null; |
| stage.addEventListener("pointerdown", (e) => { |
| if (e.pointerType === "mouse") return; |
| swStart = { x: e.clientX, y: e.clientY }; |
| }); |
| stage.addEventListener("pointerup", (e) => { |
| if (!swStart) return; |
| const dx = e.clientX - swStart.x; |
| const dy = e.clientY - swStart.y; |
| swStart = null; |
| if (Math.hypot(dx, dy) < SWIPE_MIN) return; |
| const dir = Math.abs(dx) > Math.abs(dy) |
| ? (dx > 0 ? DIRS.d : DIRS.a) |
| : (dy > 0 ? DIRS.s : DIRS.w); |
| handleDir(dir); |
| }); |
| stage.addEventListener("pointercancel", () => (swStart = null)); |
|
|
| |
| |
| |
| |
| |
| let boardW = 7.0, boardH = 7.0; |
| function reframe() { |
| boardW = (COLS - 1) * SPACING + TILE_SIZE; |
| |
| |
| |
| |
| |
| |
| |
| |
| boardH = (ROWS - 1) * SPACING + TILE_SIZE + 2 * SPACING; |
| resize(); |
| } |
| function resize() { |
| const w = stage.clientWidth || 1; |
| const h = stage.clientHeight || 1; |
| const aspect = w / h; |
| |
| |
| |
| const short = h <= 540; |
| |
| |
| const narrow = w <= 560 && !short; |
| const vMargin = 1.2; |
| const hMargin = narrow ? 0.4 : 1.0; |
| |
| |
| |
| const need = Math.max(boardH + vMargin, (boardW + hMargin) / aspect, short ? 9.4 : 0); |
| camera.position.z = need / 2 / Math.tan(THREE.MathUtils.degToRad(32 / 2)); |
| camera.aspect = aspect; |
| |
| |
| |
| camera.lookAt(0, short ? 0.55 : narrow ? 0.25 : 0.06, 0); |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h); |
| } |
| new ResizeObserver(resize).observe(stage); |
|
|
| |
| |
| |
| |
| const hudEl = element.querySelector(".sq-hud"); |
| const syncHud = () => root.style.setProperty("--hud-h", hudEl.offsetHeight + "px"); |
| new ResizeObserver(syncHud).observe(hudEl); |
| syncHud(); |
|
|
| |
| const _bbParentInv = new THREE.Quaternion(); |
| charGroup.getWorldQuaternion(_bbParentInv).invert(); |
| const _bbSpin = new THREE.Quaternion(); |
| const _Z = new THREE.Vector3(0, 0, 1); |
|
|
| renderer.setAnimationLoop((time) => { |
| |
| charMesh.quaternion |
| .copy(_bbParentInv) |
| .multiply(camera.quaternion) |
| .multiply(_bbSpin.setFromAxisAngle(_Z, charTilt.z)); |
| |
| |
| const spin = (time || 0) * 0.0016; |
| for (const s of portalSpinners) s.rotation.y = spin; |
| renderer.render(scene, camera); |
| }); |
|
|
| |
| |
| |
| |
| |
| function setGlitchMode(on) { |
| root.classList.toggle("sq-glitch", on); |
| |
| |
| |
| document.documentElement.classList.toggle("sq-glitch-page", on); |
| setMusicMuffled(on); |
| } |
|
|
| |
| |
| function loadLevel(id, instant) { |
| resetFlight(); |
| gsap.killTweensOf([charMesh.scale, charMesh.position, charGroup.position, charGroup.scale, charTilt, charMesh.material]); |
| overlayEl.classList.add("sq-hidden"); |
| overlayEl.style.opacity = ""; |
| if (id === returnTo) returnTo = null; |
| level = LEVELS[id]; |
| ended = false; |
| |
| if (!level.glitch && shrunk) { shrunk = false; charGroup.scale.set(1, 1, 1); } |
| setGlitchMode(!!level.glitch); |
| updateMusicTrack(); |
| state = "hopping"; |
| buildTiles(); |
| buildTargets(); |
| buildSwapTiles(); |
| syncHintUI(); |
| resetGameState(); |
| reframe(); |
| dropInBoard(instant); |
| } |
|
|
| loadLevel(data.home); |
| root.dataset.state = "ready"; |
| openWelcome(); |
| })(); |
|
|