const STYLES = `
:host {
display: block;
container-type: inline-size;
position: relative;
}
.board {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
aspect-ratio: 1;
gap: 6px;
padding: 6px;
background: #bbada0;
border-radius: 6px;
}
.cell {
background: rgba(238, 228, 218, 0.35);
border-radius: 3px;
}
.tile {
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
font-family: "Source Sans 3", system-ui, sans-serif;
font-weight: 700;
will-change: transform, opacity;
}
/* Status overlay -- up to two stacked badges pinned to the board's
top-left. The "over" slot is neutral ("game over"); the "result"
slot is the player's outcome (green "you win!" or red "you lost").
The slots coexist: game-over + you-win or game-over + you-lost
both render. When the result is showing alone (mid-game "you
win"), the over slot is reserved (visibility: hidden) so the
result badge sits in slot 2 with empty space above -- matching
the position it ends up in once "game over" eventually paints.
Same look across every surface that embeds the component
(/play, /watch, /my/games card, splash). */
.badges {
position: absolute;
top: 0.75rem;
left: 0.5rem;
z-index: 5;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
pointer-events: none;
}
.badge {
padding: 0.2rem 0.7rem;
font-size: 0.875rem;
font-weight: 700;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 4px;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.25);
display: none;
}
.badge.over.show { display: block; background: #776e65; transform: rotate(-3deg); }
.badge.over.reserve { display: block; visibility: hidden; background: #776e65; transform: rotate(-3deg); }
.badge.result.won { display: block; background: #2a9d4a; transform: rotate(-6deg); }
.badge.result.lost { display: block; background: #e05252; transform: rotate(-3deg); }
/* Optional played-time overlay, pinned top-right. Painted only when
state.playedMs is present AND the host has the show-played
attribute (so a card list can opt in without every embed paying
the cost). Style mirrors the legacy .overlay.active that lived
outside the shadow root. */
.played {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 5;
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
line-height: 1.2;
pointer-events: none;
display: none;
}
:host([show-played]) .played.has-value { display: block; }
/* Thumbnail / "dim" variant. Used by /my/games + /by/<id> game
cards. Everything except the highest-value tile is muted by a
tinted overlay so the card's headline -- "how far this game
got" -- pops without an extra max-tile badge. The board re-lights
on hover. The max tile is z-lifted above the overlay; the WC
tags it with .is-max on every #apply. */
:host([dim]) .board { isolation: isolate; }
:host([dim]) .board::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 119, 182, 0.75);
border-radius: inherit;
pointer-events: none;
z-index: 1;
transition: opacity 120ms ease-out;
}
:host([dim]:hover) .board::after { opacity: 0; }
:host([dim]) .tile.is-max { position: relative; z-index: 2; }
`;
const PALETTE = {
2: { bg: "#eee4da", fg: "#776e65" },
4: { bg: "#ede0c8", fg: "#776e65" },
8: { bg: "#f2b179", fg: "#f9f6f2" },
16: { bg: "#f59563", fg: "#f9f6f2" },
32: { bg: "#f67c5f", fg: "#f9f6f2" },
64: { bg: "#f65e3b", fg: "#f9f6f2" },
128: { bg: "#edcf72", fg: "#f9f6f2" },
256: { bg: "#edcc61", fg: "#f9f6f2" },
512: { bg: "#edc850", fg: "#f9f6f2" },
1024: { bg: "#edc53f", fg: "#f9f6f2" },
2048: { bg: "#edc22e", fg: "#f9f6f2" },
4096: { bg: "#e8902a", fg: "#f9f6f2" },
8192: { bg: "#d96e1f", fg: "#f9f6f2" },
16384: { bg: "#b84a1c", fg: "#f9f6f2" },
32768: { bg: "#842d18", fg: "#f9f6f2" },
65536: { bg: "#4a1810", fg: "#f9f6f2" },
};
const paletteFor = (v) => PALETTE[v] || { bg: "#4a1810", fg: "#f9f6f2" };
const fontSizeCqw = (v) => (v >= 1024 ? 5 : v >= 128 ? 6 : 7);
const SLIDE_MS = 180;
const MERGE_MS = 140;
const SPAWN_MS = 140;
const POP_SCALE = 1.18;
const SPAWN_FROM = 0.4;
const PLAYED_TICK_MS = 5000;
function relativeFromMs(ms) {
const diff = Math.floor((Date.now() - ms) / 1000);
if (diff < 60) return "in play";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 604800)}w ago`;
}
class GameBoard extends HTMLElement {
static get observedAttributes() { return ["state"]; }
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `<style>${STYLES}</style><div class="board" part="board"></div><div class="badges"><span class="badge over"></span><span class="badge result"></span></div><span class="played" part="played"></span>`;
this.boardEl = this.shadowRoot.querySelector(".board");
this.overEl = this.shadowRoot.querySelector(".badge.over");
this.resultEl = this.shadowRoot.querySelector(".badge.result");
this.playedEl = this.shadowRoot.querySelector(".played");
this.playedMs = null;
this.playedTimer = null;
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.style.gridColumn = c + 1;
cell.style.gridRow = r + 1;
this.boardEl.appendChild(cell);
}
}
this.tiles = new Map();
this.activeAnimations = new Set();
this.applyToken = 0;
this.lastAppliedJson = null;
this.hasWon = false;
this.movesSinceWin = -1;
}
attributeChangedCallback(name, oldVal, newVal) {
if (name !== "state") return;
if (newVal == null) return;
if (oldVal === newVal) return;
if (newVal === this.lastAppliedJson) return;
let parsed;
try { parsed = JSON.parse(newVal); }
catch { return; }
this.lastAppliedJson = newVal;
this.#apply(parsed);
}
#cancelActive() {
this.activeAnimations.forEach((a) => a.cancel());
this.activeAnimations.clear();
}
#styleTile(el, t) {
const p = paletteFor(t.value);
el.style.gridColumn = t.c + 1;
el.style.gridRow = t.r + 1;
el.style.background = p.bg;
el.style.color = p.fg;
el.style.fontSize = `${fontSizeCqw(t.value)}cqw`;
el.textContent = String(t.value);
}
#makeTileEl(t) {
const el = document.createElement("div");
el.className = "tile";
this.#styleTile(el, t);
return el;
}
#applyMaxClass(tiles) {
if (!tiles.length) return;
const maxV = Math.max(...tiles.map((t) => t.value));
for (const t of tiles) {
const entry = this.tiles.get(t.id);
if (!entry) continue;
entry.el.classList.toggle("is-max", t.value === maxV);
}
}
#tickWinCounter(state) {
const wonNow = (state.tiles ?? []).some((t) => t.value >= 2048);
if (wonNow && !this.hasWon) {
this.hasWon = true;
this.movesSinceWin = 0;
} else if (this.hasWon) {
this.movesSinceWin++;
}
}
#applyBadge(state) {
const over = !!state.gameOver;
const showOver = over;
const showWin = over ? this.hasWon : (this.hasWon && this.movesSinceWin < 3);
const showLost = over && !this.hasWon;
const reserveOver = !showOver && (showWin || showLost);
this.overEl.classList.toggle("show", showOver);
this.overEl.classList.toggle("reserve", reserveOver);
this.overEl.textContent = (showOver || reserveOver) ? "game over" : "";
this.resultEl.classList.toggle("won", showWin);
this.resultEl.classList.toggle("lost", showLost);
this.resultEl.textContent = showWin ? "you win!" : showLost ? "you lost" : "";
}
#updatePlayedLabel = () => {
if (this.playedMs == null) {
this.playedEl.classList.remove("has-value");
this.playedEl.textContent = "";
return;
}
this.playedEl.classList.add("has-value");
const next = relativeFromMs(this.playedMs);
if (this.playedEl.textContent !== next) this.playedEl.textContent = next;
};
connectedCallback() {
this.#updatePlayedLabel();
this.playedTimer = setInterval(this.#updatePlayedLabel, PLAYED_TICK_MS);
}
disconnectedCallback() {
if (this.playedTimer != null) {
clearInterval(this.playedTimer);
this.playedTimer = null;
}
}
async #apply(state) {
this.#tickWinCounter(state);
this.#applyBadge(state);
this.playedMs = state.playedMs ?? null;
this.#updatePlayedLabel();
this.#cancelActive();
const token = ++this.applyToken;
const tiles = state.tiles ?? [];
const ghosts = state.ghosts ?? [];
const validIds = new Set();
for (const t of tiles) validIds.add(t.id);
for (const g of ghosts) validIds.add(g.id);
for (const [id, entry] of this.tiles) {
if (!validIds.has(id)) { entry.el.remove(); this.tiles.delete(id); }
}
const slideAnims = [];
for (const g of ghosts) {
const entry = this.tiles.get(g.id);
if (!entry) continue; const oldR = entry.r, oldC = entry.c;
entry.el.style.gridColumn = g.c + 1;
entry.el.style.gridRow = g.r + 1;
entry.r = g.r; entry.c = g.c;
const dx = (oldC - g.c) * 100;
const dy = (oldR - g.r) * 100;
const a = entry.el.animate(
[
{ transform: `translate(${dx}%, ${dy}%)`, opacity: 1 },
{ transform: "translate(0, 0)", opacity: 0 },
],
{ duration: SLIDE_MS, easing: "ease-out", fill: "both" },
);
slideAnims.push(a);
a.addEventListener("finish", () => {
if (this.tiles.get(g.id) === entry) {
entry.el.remove();
this.tiles.delete(g.id);
}
});
}
for (const t of tiles) {
if (t.spawned) continue;
let entry = this.tiles.get(t.id);
if (!entry) {
const el = this.#makeTileEl(t);
this.boardEl.appendChild(el);
this.tiles.set(t.id, { el, r: t.r, c: t.c, value: t.value });
continue;
}
const oldR = entry.r, oldC = entry.c;
this.#styleTile(entry.el, t);
entry.r = t.r; entry.c = t.c; entry.value = t.value;
if (oldR === t.r && oldC === t.c) continue;
const dx = (oldC - t.c) * 100;
const dy = (oldR - t.r) * 100;
const a = entry.el.animate(
[
{ transform: `translate(${dx}%, ${dy}%)` },
{ transform: "translate(0, 0)" },
],
{ duration: SLIDE_MS, easing: "ease-out", fill: "both" },
);
slideAnims.push(a);
}
slideAnims.forEach((a) => this.activeAnimations.add(a));
if (slideAnims.length) {
await Promise.all(slideAnims.map((a) => a.finished.catch(() => {})));
if (token !== this.applyToken) return;
}
const popAnims = [];
for (const t of tiles) {
if (!t.merged) continue;
const entry = this.tiles.get(t.id);
if (!entry) continue;
popAnims.push(entry.el.animate(
[
{ transform: "scale(1)" },
{ transform: `scale(${POP_SCALE})`, offset: 0.5 },
{ transform: "scale(1)" },
],
{ duration: MERGE_MS, easing: "ease-out" },
));
}
popAnims.forEach((a) => this.activeAnimations.add(a));
if (popAnims.length) {
await Promise.all(popAnims.map((a) => a.finished.catch(() => {})));
if (token !== this.applyToken) return;
}
const spawnAnims = [];
for (const t of tiles) {
if (!t.spawned) continue;
if (this.tiles.has(t.id)) continue; const el = this.#makeTileEl(t);
this.boardEl.appendChild(el);
this.tiles.set(t.id, { el, r: t.r, c: t.c, value: t.value });
spawnAnims.push(el.animate(
[
{ transform: `scale(${SPAWN_FROM})`, opacity: 0 },
{ transform: "scale(1)", opacity: 1 },
],
{ duration: SPAWN_MS, easing: "ease-out", fill: "both" },
));
}
spawnAnims.forEach((a) => this.activeAnimations.add(a));
if (spawnAnims.length) {
await Promise.all(spawnAnims.map((a) => a.finished.catch(() => {})));
if (token !== this.applyToken) return;
}
this.#applyMaxClass(tiles);
}
}
customElements.define("game-board", GameBoard);