← back to Model Arena
Agent-driven game: DW Pattern Match (frontend-developer) — 8x8 match-3, cascading combos, 6 SVG textures. Registered.
a0f576c71a83dcbccd4f038a01ffd4cee6b0e78e · 2026-07-25 11:42:44 -0700 · Steve Abrams
Files touched
M data/arcade-games.jsonA public/games/dw-pattern-match.html
Diff
commit a0f576c71a83dcbccd4f038a01ffd4cee6b0e78e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 11:42:44 2026 -0700
Agent-driven game: DW Pattern Match (frontend-developer) — 8x8 match-3, cascading combos, 6 SVG textures. Registered.
---
data/arcade-games.json | 6 +
public/games/dw-pattern-match.html | 839 +++++++++++++++++++++++++++++++++++++
2 files changed, 845 insertions(+)
diff --git a/data/arcade-games.json b/data/arcade-games.json
index c2ea4d5..730ca7d 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -59,6 +59,12 @@
"title": "DW Design IQ",
"credit": "frontend-developer",
"note": "Agent-built trivia game \u2014 12 interior-design/wallcovering questions, SVG pattern prompts, timer + streak scoring"
+ },
+ {
+ "slug": "dw-pattern-match",
+ "title": "DW Pattern Match",
+ "credit": "frontend-developer",
+ "note": "Agent-built match-3 game \u2014 swap luxury swatch tiles, cascading combos, 6 textures"
}
]
}
\ No newline at end of file
diff --git a/public/games/dw-pattern-match.html b/public/games/dw-pattern-match.html
new file mode 100644
index 0000000..d16361c
--- /dev/null
+++ b/public/games/dw-pattern-match.html
@@ -0,0 +1,839 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Pattern Match — Designer Wallcoverings</title>
+<style>
+:root{
+ --bg:#f4f1ea;--bg2:#ece8de;--ink:#1c1a17;--ink2:#3a3630;--ink3:#7a7469;
+ --accent:#c9a961;--accent2:#b8944e;--accent3:#e8d5a3;
+ --danger:#8b3a2a;--success:#2d5a3d;
+ --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+ --sans:'Gill Sans','Optima',Optima,Candara,system-ui,sans-serif;
+ --ease:cubic-bezier(.4,0,.2,1);
+ --tile:60px;--gap:4px;
+ --radius:3px;
+}
+*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
+html,body{height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans)}
+body{display:flex;flex-direction:column;align-items:center;min-height:100vh;overflow-x:hidden}
+
+/* HEADER */
+header{
+ width:100%;padding:14px 24px 12px;
+ border-bottom:1px solid var(--accent);
+ display:flex;align-items:baseline;justify-content:space-between;
+ background:var(--bg);position:sticky;top:0;z-index:50;
+}
+.wordmark{
+ font-family:var(--serif);font-size:clamp(18px,3vw,28px);
+ font-weight:400;text-transform:uppercase;letter-spacing:.18em;
+ color:var(--ink);line-height:1;
+}
+.wordmark span{display:block;font-size:.45em;letter-spacing:.22em;color:var(--ink3);margin-top:2px}
+#mute-btn{
+ background:none;border:1px solid var(--accent);border-radius:2px;
+ color:var(--ink2);font-size:13px;padding:4px 10px;cursor:pointer;
+ font-family:var(--sans);letter-spacing:.08em;transition:all .2s var(--ease);
+}
+#mute-btn:hover{background:var(--accent);color:var(--bg)}
+
+/* STATS BAR */
+.stats{
+ display:flex;gap:24px;align-items:center;padding:12px 20px;
+ width:100%;max-width:600px;flex-wrap:wrap;
+}
+.stat{display:flex;flex-direction:column;align-items:center}
+.stat-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3)}
+.stat-value{font-family:var(--serif);font-size:22px;color:var(--ink);line-height:1.1}
+.stat-value.accent{color:var(--accent2)}
+#combo-display{
+ font-family:var(--serif);font-size:16px;color:var(--accent);
+ letter-spacing:.08em;min-height:20px;transition:opacity .3s;
+ opacity:0;padding:0 8px;
+}
+#combo-display.show{opacity:1}
+.goal-bar-wrap{
+ width:100%;max-width:600px;padding:0 20px 4px;
+}
+.goal-bar-track{
+ height:6px;background:var(--bg2);border-radius:3px;
+ border:1px solid rgba(201,169,97,.3);overflow:hidden;
+}
+.goal-bar-fill{
+ height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));
+ border-radius:3px;transition:width .4s var(--ease);width:0%;
+}
+
+/* BOARD */
+.board-wrap{
+ padding:16px 20px 20px;width:100%;max-width:600px;
+ display:flex;justify-content:center;
+}
+#board{
+ display:grid;
+ grid-template-columns:repeat(8,var(--tile));
+ grid-template-rows:repeat(8,var(--tile));
+ gap:var(--gap);
+ position:relative;
+}
+.tile{
+ width:var(--tile);height:var(--tile);
+ border-radius:var(--radius);
+ cursor:pointer;position:relative;
+ transition:transform .15s var(--ease),box-shadow .15s var(--ease),filter .15s var(--ease);
+ overflow:hidden;
+ user-select:none;
+ border:1px solid rgba(28,26,23,.08);
+}
+.tile svg{width:100%;height:100%;display:block}
+.tile:hover{
+ transform:scale(1.07);
+ box-shadow:0 4px 14px rgba(201,169,97,.35);
+ z-index:5;border-color:var(--accent);
+}
+.tile.selected{
+ transform:scale(1.1);
+ box-shadow:0 0 0 2px var(--accent),0 4px 20px rgba(201,169,97,.5);
+ z-index:10;
+}
+.tile.shimmer{animation:shimmer .45s var(--ease) forwards}
+.tile.fall{animation:fall .3s var(--ease) both}
+.tile.drop-in{animation:drop-in .35s var(--ease) both}
+.tile.swap-anim{animation:swap-pulse .2s var(--ease) both}
+.tile.no-swap{animation:no-swap .25s ease both}
+.tile.combo-flare{animation:combo-flare .4s var(--ease) both}
+
+@keyframes shimmer{
+ 0%{opacity:1;transform:scale(1)}
+ 40%{opacity:.9;transform:scale(1.12);filter:brightness(1.5) saturate(1.4)}
+ 100%{opacity:0;transform:scale(.6);filter:brightness(2)}
+}
+@keyframes fall{
+ 0%{transform:translateY(-4px);opacity:.7}
+ 100%{transform:translateY(0);opacity:1}
+}
+@keyframes drop-in{
+ 0%{transform:translateY(calc(-1 * var(--tile) * 2));opacity:0}
+ 70%{opacity:1}
+ 85%{transform:translateY(4px)}
+ 100%{transform:translateY(0);opacity:1}
+}
+@keyframes swap-pulse{
+ 0%{transform:scale(1)}
+ 50%{transform:scale(1.08);filter:brightness(1.2)}
+ 100%{transform:scale(1)}
+}
+@keyframes no-swap{
+ 0%{transform:translateX(0)}
+ 25%{transform:translateX(-5px)}
+ 75%{transform:translateX(5px)}
+ 100%{transform:translateX(0)}
+}
+@keyframes combo-flare{
+ 0%{transform:scale(1);filter:brightness(1)}
+ 50%{transform:scale(1.2);filter:brightness(1.8) saturate(1.6)}
+ 100%{transform:scale(1);filter:brightness(1)}
+}
+
+/* OVERLAYS */
+.overlay{
+ position:fixed;inset:0;
+ background:rgba(13,11,9,.82);
+ display:flex;align-items:center;justify-content:center;
+ z-index:100;visibility:hidden;opacity:0;
+ transition:opacity .3s var(--ease),visibility .3s;
+}
+.overlay.open{visibility:visible;opacity:1}
+.overlay-card{
+ background:var(--bg);border:1px solid var(--accent);
+ border-radius:4px;padding:44px 48px;text-align:center;
+ max-width:380px;width:90%;
+ font-family:var(--serif);
+ box-shadow:0 24px 60px rgba(0,0,0,.4);
+}
+.overlay-title{
+ font-size:32px;text-transform:uppercase;letter-spacing:.14em;
+ color:var(--ink);margin-bottom:8px;
+}
+.overlay-sub{
+ font-size:16px;color:var(--ink3);letter-spacing:.06em;margin-bottom:6px;
+ font-family:var(--sans);
+}
+.overlay-score{
+ font-size:52px;color:var(--accent);letter-spacing:.02em;margin:16px 0 6px;
+}
+.overlay-best{
+ font-size:14px;color:var(--ink3);letter-spacing:.1em;
+ font-family:var(--sans);margin-bottom:28px;
+}
+.btn{
+ display:inline-block;padding:12px 36px;
+ background:var(--accent);color:var(--ink);
+ font-family:var(--sans);font-size:13px;letter-spacing:.14em;
+ text-transform:uppercase;border:none;border-radius:2px;cursor:pointer;
+ transition:background .2s var(--ease),transform .1s;
+}
+.btn:hover{background:var(--accent2);transform:translateY(-1px)}
+.btn:active{transform:translateY(0)}
+.btn.outline{
+ background:none;border:1px solid var(--accent);color:var(--ink2);
+ margin-top:10px;
+}
+.btn.outline:hover{background:rgba(201,169,97,.12)}
+
+/* move warning */
+.moves-warning #moves-val{color:var(--danger);animation:pulse-warn .6s ease infinite alternate}
+@keyframes pulse-warn{0%{opacity:1}100%{opacity:.5}}
+
+/* combo text floater */
+#cascade-floater{
+ position:fixed;pointer-events:none;font-family:var(--serif);
+ font-size:28px;color:var(--accent);letter-spacing:.06em;
+ text-shadow:0 2px 8px rgba(0,0,0,.4);
+ opacity:0;z-index:200;transition:none;
+}
+</style>
+</head>
+<body>
+
+<header>
+ <div class="wordmark">
+ Designer Wallcoverings
+ <span>Pattern Match</span>
+ </div>
+ <button id="mute-btn" aria-label="Toggle sound">♪ Sound</button>
+</header>
+
+<div class="stats">
+ <div class="stat">
+ <span class="stat-label">Score</span>
+ <span class="stat-value accent" id="score-val">0</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Best</span>
+ <span class="stat-value" id="best-val">0</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Moves</span>
+ <span class="stat-value" id="moves-val">30</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Goal</span>
+ <span class="stat-value" id="goal-val">3000</span>
+ </div>
+ <div id="combo-display" aria-live="polite"></div>
+</div>
+<div class="goal-bar-wrap">
+ <div class="goal-bar-track"><div class="goal-bar-fill" id="goal-bar"></div></div>
+</div>
+
+<div class="board-wrap">
+ <div id="board" role="grid" aria-label="Pattern match game board"></div>
+</div>
+
+<div class="overlay" id="start-overlay" aria-modal="true">
+ <div class="overlay-card">
+ <div class="overlay-title">Pattern Match</div>
+ <div class="overlay-sub" style="margin-bottom:20px">Swap adjacent tiles to match 3 or more luxurious wallcovering patterns. Chain cascades for bonus points.</div>
+ <div class="overlay-sub">Goal: <strong>3,000 pts</strong> in <strong>30 moves</strong></div>
+ <div style="margin:20px 0;display:flex;gap:8px;justify-content:center;flex-wrap:wrap" id="legend"></div>
+ <button class="btn" id="start-btn">Begin</button>
+ </div>
+</div>
+
+<div class="overlay" id="win-overlay" aria-modal="true">
+ <div class="overlay-card">
+ <div class="overlay-title">Exquisite!</div>
+ <div class="overlay-sub">Goal achieved</div>
+ <div class="overlay-score" id="win-score">0</div>
+ <div class="overlay-best" id="win-best">New best score!</div>
+ <button class="btn" id="win-btn">Play Again</button>
+ </div>
+</div>
+
+<div class="overlay" id="lose-overlay" aria-modal="true">
+ <div class="overlay-card">
+ <div class="overlay-title">So Close</div>
+ <div class="overlay-sub">No moves remaining</div>
+ <div class="overlay-score" id="lose-score">0</div>
+ <div class="overlay-best" id="lose-best"></div>
+ <button class="btn" id="lose-btn">Try Again</button>
+ <br>
+ </div>
+</div>
+
+<div id="cascade-floater"></div>
+
+<script>
+/* ─── DESIGN TOKENS ─── */
+const TYPES = ['grasscloth','damask','stripe','chinoiserie','lattice','botanical'];
+const COLS = {
+ grasscloth:'#8fa882',damask:'#7b5ea7',stripe:'#4a7fa5',
+ chinoiserie:'#c95c3a',lattice:'#b8944e',botanical:'#4a8c6f'
+};
+const GOAL = 3000;
+const MAX_MOVES = 30;
+const ROWS = 8, COLS_N = 8;
+
+/* ─── SVG TEXTURES ─── */
+function makeSVG(type){
+ const c = COLS[type];
+ const bg = lighten(c,.72);
+ const mid = lighten(c,.45);
+ switch(type){
+ case 'grasscloth': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ ${weaveLines(c,60,4,'horizontal')}
+ ${weaveLines(mid,60,9,'vertical')}
+ </svg>`;
+ case 'damask': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ <g fill="${c}" opacity=".55">
+ <ellipse cx="30" cy="30" rx="10" ry="14"/>
+ <ellipse cx="30" cy="30" rx="14" ry="10"/>
+ <ellipse cx="30" cy="8" rx="5" ry="7"/>
+ <ellipse cx="30" cy="52" rx="5" ry="7"/>
+ <ellipse cx="8" cy="30" rx="7" ry="5"/>
+ <ellipse cx="52" cy="30" rx="7" ry="5"/>
+ </g>
+ <g fill="none" stroke="${c}" stroke-width="1" opacity=".35">
+ <ellipse cx="30" cy="30" rx="16" ry="20"/>
+ <ellipse cx="30" cy="30" rx="20" ry="16"/>
+ </g>
+ </svg>`;
+ case 'stripe': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ <rect x="0" y="0" width="10" height="60" fill="${c}" opacity=".6"/>
+ <rect x="14" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+ <rect x="20" y="0" width="10" height="60" fill="${mid}" opacity=".5"/>
+ <rect x="34" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+ <rect x="40" y="0" width="10" height="60" fill="${c}" opacity=".6"/>
+ <rect x="54" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+ </svg>`;
+ case 'chinoiserie': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ <g stroke="${c}" stroke-width="1.5" fill="none" opacity=".6">
+ <path d="M10 45 Q15 30 20 40 Q25 50 30 35 Q35 20 40 32 Q45 44 50 30"/>
+ <path d="M5 55 Q12 42 18 52 Q24 62 30 48 Q36 34 42 44 Q48 54 55 42"/>
+ </g>
+ <g fill="${c}" opacity=".5">
+ <circle cx="20" cy="38" r="3"/>
+ <circle cx="35" cy="28" r="3"/>
+ <circle cx="50" cy="35" r="3"/>
+ <circle cx="12" cy="20" r="2"/>
+ <circle cx="48" cy="15" r="2"/>
+ </g>
+ <g fill="none" stroke="${c}" stroke-width="1" opacity=".4">
+ <path d="M15 20 Q22 12 30 18 Q38 24 45 16"/>
+ <line x1="18" y1="14" x2="18" y2="22"/><line x1="30" y1="16" x2="30" y2="20"/>
+ <line x1="42" y1="13" x2="42" y2="19"/>
+ </g>
+ </svg>`;
+ case 'lattice': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ <g stroke="${c}" stroke-width="1.8" opacity=".55">
+ ${latticeLines(60,15)}
+ </g>
+ <g fill="${c}" opacity=".4">
+ ${latticeDots(60,15,3)}
+ </g>
+ </svg>`;
+ case 'botanical': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+ <g fill="${c}" opacity=".5">
+ <ellipse cx="20" cy="38" rx="10" ry="5" transform="rotate(-30 20 38)"/>
+ <ellipse cx="40" cy="25" rx="10" ry="5" transform="rotate(25 40 25)"/>
+ <ellipse cx="30" cy="48" rx="8" ry="4" transform="rotate(-10 30 48)"/>
+ <ellipse cx="12" cy="18" rx="7" ry="3.5" transform="rotate(40 12 18)"/>
+ <ellipse cx="50" cy="45" rx="7" ry="3.5" transform="rotate(-20 50 45)"/>
+ </g>
+ <g stroke="${c}" stroke-width="1.2" fill="none" opacity=".45">
+ <path d="M30 55 Q28 42 20 35"/>
+ <path d="M30 55 Q32 40 40 32"/>
+ <path d="M30 55 Q30 45 30 38"/>
+ <path d="M30 38 Q22 30 14 20"/>
+ <path d="M30 38 Q36 28 46 18"/>
+ </g>
+ <g fill="${c}" opacity=".5">
+ <circle cx="14" cy="20" r="2.5"/>
+ <circle cx="46" cy="18" r="2.5"/>
+ <circle cx="20" cy="35" r="2"/>
+ <circle cx="40" cy="30" r="2"/>
+ </g>
+ </svg>`;
+ }
+}
+
+function lighten(hex, amt){
+ const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
+ const to=v=>Math.min(255,Math.round(v+(255-v)*amt));
+ return `#${to(r).toString(16).padStart(2,'0')}${to(g).toString(16).padStart(2,'0')}${to(b).toString(16).padStart(2,'0')}`;
+}
+function weaveLines(c,size,spacing,dir){
+ let s='';
+ for(let i=0;i<size;i+=spacing){
+ if(dir==='horizontal') s+=`<line x1="0" y1="${i}" x2="${size}" y2="${i}" stroke="${c}" stroke-width="1.5" opacity=".35"/>`;
+ else s+=`<line x1="${i}" y1="0" x2="${i}" y2="${size}" stroke="${c}" stroke-width=".8" opacity=".25"/>`;
+ }
+ return s;
+}
+function latticeLines(size,spacing){
+ let s='';
+ for(let i=-size;i<size*2;i+=spacing){
+ s+=`<line x1="${i}" y1="0" x2="${i+size}" y2="${size}"/>`;
+ s+=`<line x1="${i}" y1="${size}" x2="${i+size}" y2="0"/>`;
+ }
+ return s;
+}
+function latticeDots(size,spacing,r){
+ let s='';
+ for(let x=0;x<=size;x+=spacing){
+ for(let y=0;y<=size;y+=spacing){
+ s+=`<circle cx="${x}" cy="${y}" r="${r}"/>`;
+ }
+ }
+ return s;
+}
+
+/* ─── AUDIO ─── */
+let muted=false;
+let ctx=null;
+function getCtx(){if(!ctx)ctx=new(window.AudioContext||window.webkitAudioContext)();return ctx;}
+function playTone(freq,dur,type='sine',vol=.15,detune=0){
+ if(muted)return;
+ try{
+ const c=getCtx();const g=c.createGain();const o=c.createOscillator();
+ o.type=type;o.frequency.value=freq;o.detune.value=detune;
+ g.gain.setValueAtTime(vol,c.currentTime);
+ g.gain.exponentialRampToValueAtTime(.001,c.currentTime+dur);
+ o.connect(g);g.connect(c.destination);
+ o.start();o.stop(c.currentTime+dur);
+ }catch(e){}
+}
+function soundSwap(){playTone(660,.12,'triangle',.1)}
+function soundNoSwap(){playTone(200,.18,'sawtooth',.08);setTimeout(()=>playTone(180,.12,'sawtooth',.06),80)}
+function soundClear(combo=1){
+ const base=440+combo*40;
+ playTone(base,.18,'sine',.15);
+ setTimeout(()=>playTone(base*1.25,.15,'sine',.12),80);
+ setTimeout(()=>playTone(base*1.5,.2,'sine',.1),160);
+}
+function soundCascade(n){
+ for(let i=0;i<Math.min(n,5);i++) setTimeout(()=>playTone(523+i*110,.2,'triangle',.12),i*60);
+}
+function soundWin(){
+ const notes=[523,659,784,1047];
+ notes.forEach((f,i)=>setTimeout(()=>playTone(f,.4,'sine',.2),i*120));
+}
+function soundLose(){
+ playTone(330,.3,'sawtooth',.12);
+ setTimeout(()=>playTone(220,.5,'sawtooth',.1),200);
+}
+
+/* ─── GAME STATE ─── */
+let grid=[];
+let selected=null;
+let score=0;
+let moves=MAX_MOVES;
+let best=parseInt(localStorage.getItem('dw-pm-best')||'0');
+let busy=false;
+let gameActive=false;
+let dragStart=null;
+
+/* ─── DOM ─── */
+const boardEl=document.getElementById('board');
+const scoreEl=document.getElementById('score-val');
+const bestEl=document.getElementById('best-val');
+const movesEl=document.getElementById('moves-val');
+const goalEl=document.getElementById('goal-val');
+const goalBar=document.getElementById('goal-bar');
+const comboEl=document.getElementById('combo-display');
+const floater=document.getElementById('cascade-floater');
+const startOv=document.getElementById('start-overlay');
+const winOv=document.getElementById('win-overlay');
+const loseOv=document.getElementById('lose-overlay');
+
+function $(id){return document.getElementById(id)}
+
+/* ─── LEGEND ─── */
+(function buildLegend(){
+ const el=document.getElementById('legend');
+ TYPES.forEach(t=>{
+ const d=document.createElement('div');
+ d.style.cssText='display:flex;flex-direction:column;align-items:center;gap:4px';
+ d.innerHTML=`<div style="width:32px;height:32px;border-radius:2px;overflow:hidden">${makeSVG(t)}</div>
+ <span style="font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:#7a7469">${t}</span>`;
+ el.appendChild(d);
+ });
+})();
+
+/* ─── GRID INIT ─── */
+function makeGrid(){
+ let g=[];
+ for(let r=0;r<ROWS;r++){
+ g[r]=[];
+ for(let c=0;c<COLS_N;c++){
+ g[r][c]=randomType([]);
+ }
+ }
+ // guarantee no pre-existing matches
+ for(let r=0;r<ROWS;r++){
+ for(let c=0;c<COLS_N;c++){
+ const forbidden=[];
+ if(r>=2&&g[r-1][c]===g[r-2][c]) forbidden.push(g[r-1][c]);
+ if(c>=2&&g[r][c-1]===g[r][c-2]) forbidden.push(g[r][c-1]);
+ g[r][c]=randomType(forbidden);
+ }
+ }
+ return g;
+}
+function randomType(exclude){
+ const pool=TYPES.filter(t=>!exclude.includes(t));
+ return pool[Math.floor(Math.random()*pool.length)];
+}
+
+/* ─── RENDER ─── */
+let tileEls=[];
+function renderBoard(){
+ boardEl.innerHTML='';
+ tileEls=[];
+ for(let r=0;r<ROWS;r++){
+ tileEls[r]=[];
+ for(let c=0;c<COLS_N;c++){
+ const el=createTileEl(r,c);
+ boardEl.appendChild(el);
+ tileEls[r][c]=el;
+ }
+ }
+}
+function createTileEl(r,c){
+ const el=document.createElement('div');
+ el.className='tile';
+ el.setAttribute('role','gridcell');
+ el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+ el.setAttribute('tabindex','0');
+ el.dataset.r=r;el.dataset.c=c;
+ el.innerHTML=makeSVG(grid[r][c]);
+ el.dataset.type=grid[r][c];
+
+ // events
+ el.addEventListener('click',()=>onTileClick(r,c));
+ el.addEventListener('keydown',e=>{
+ if(e.key===' '||e.key==='Enter'){e.preventDefault();onTileClick(r,c);}
+ if(e.key==='ArrowUp'&&r>0)onKeySwap(r,c,r-1,c);
+ if(e.key==='ArrowDown'&&r<ROWS-1)onKeySwap(r,c,r+1,c);
+ if(e.key==='ArrowLeft'&&c>0)onKeySwap(r,c,r,c-1);
+ if(e.key==='ArrowRight'&&c<COLS_N-1)onKeySwap(r,c,r,c+1);
+ });
+ el.addEventListener('pointerdown',e=>{dragStart={r,c,x:e.clientX,y:e.clientY};});
+ el.addEventListener('pointerup',e=>{
+ if(!dragStart)return;
+ const dx=e.clientX-dragStart.x,dy=e.clientY-dragStart.y;
+ const adx=Math.abs(dx),ady=Math.abs(dy);
+ if(adx<8&&ady<8){dragStart=null;return;}
+ let nr=r,nc=c;
+ if(adx>ady){nc=dx>0?c+1:c-1;}else{nr=dy>0?r+1:r-1;}
+ if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS_N) trySwap(r,c,nr,nc);
+ dragStart=null;
+ });
+ return el;
+}
+function updateTileEl(r,c){
+ const el=tileEls[r][c];
+ el.innerHTML=makeSVG(grid[r][c]);
+ el.dataset.type=grid[r][c];
+ el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+}
+
+/* ─── INTERACTION ─── */
+function onTileClick(r,c){
+ if(busy||!gameActive)return;
+ if(selected===null){
+ selected={r,c};
+ tileEls[r][c].classList.add('selected');
+ } else {
+ const sr=selected.r,sc=selected.c;
+ tileEls[sr][sc].classList.remove('selected');
+ if(sr===r&&sc===c){selected=null;return;}
+ if(isAdjacent(sr,sc,r,c)){
+ selected=null;
+ trySwap(sr,sc,r,c);
+ } else {
+ selected={r,c};
+ tileEls[r][c].classList.add('selected');
+ }
+ }
+}
+function onKeySwap(r,c,nr,nc){
+ if(busy||!gameActive)return;
+ if(selected){tileEls[selected.r][selected.c].classList.remove('selected');selected=null;}
+ trySwap(r,c,nr,nc);
+}
+function isAdjacent(r1,c1,r2,c2){
+ return (Math.abs(r1-r2)+Math.abs(c1-c2))===1;
+}
+
+async function trySwap(r1,c1,r2,c2){
+ if(busy||!gameActive)return;
+ busy=true;
+ soundSwap();
+ animateTile(r1,c1,'swap-anim');
+ animateTile(r2,c2,'swap-anim');
+ // do swap
+ const tmp=grid[r1][c1];grid[r1][c1]=grid[r2][c2];grid[r2][c2]=tmp;
+ updateTileEl(r1,c1);updateTileEl(r2,c2);
+
+ await delay(160);
+ const matches=findMatches();
+ if(matches.length===0){
+ // revert
+ const tmp2=grid[r1][c1];grid[r1][c1]=grid[r2][c2];grid[r2][c2]=tmp2;
+ updateTileEl(r1,c1);updateTileEl(r2,c2);
+ soundNoSwap();
+ animateTile(r1,c1,'no-swap');
+ animateTile(r2,c2,'no-swap');
+ await delay(260);
+ busy=false;
+ return;
+ }
+
+ // deduct move
+ moves--;
+ updateStats();
+
+ await resolveMatches(matches,1);
+ busy=false;
+ checkEndGame();
+}
+
+async function resolveMatches(matches,cascade){
+ if(matches.length===0)return;
+ // score
+ const pts=matches.length*50*cascade;
+ score+=pts;
+ updateStats();
+ soundClear(cascade);
+ if(cascade>1){
+ soundCascade(cascade);
+ showCascade(cascade);
+ }
+
+ // shimmer out
+ await Promise.all(matches.map(([r,c])=>shimmerTile(r,c)));
+ // remove + gravity
+ matches.forEach(([r,c])=>{grid[r][c]=null;});
+ await gravity();
+ await refill();
+ updateStats();
+
+ // chain
+ const next=findMatches();
+ if(next.length>0){
+ await delay(120);
+ await resolveMatches(next,cascade+1);
+ }
+}
+
+async function shimmerTile(r,c){
+ return new Promise(res=>{
+ const el=tileEls[r][c];
+ el.classList.add('shimmer');
+ el.addEventListener('animationend',()=>{el.classList.remove('shimmer');el.style.opacity='0';res();},{once:true});
+ });
+}
+async function gravity(){
+ // columns fall down
+ const promises=[];
+ for(let c=0;c<COLS_N;c++){
+ let writeRow=ROWS-1;
+ for(let r=ROWS-1;r>=0;r--){
+ if(grid[r][c]!==null){
+ if(writeRow!==r){
+ grid[writeRow][c]=grid[r][c];
+ grid[r][c]=null;
+ }
+ writeRow--;
+ }
+ }
+ }
+ // re-render fallen tiles
+ for(let r=0;r<ROWS;r++){
+ for(let c=0;c<COLS_N;c++){
+ const el=tileEls[r][c];
+ if(grid[r][c]!==null){
+ el.innerHTML=makeSVG(grid[r][c]);
+ el.dataset.type=grid[r][c];
+ el.style.opacity='1';
+ el.classList.remove('shimmer');
+ animateTile(r,c,'fall');
+ } else {
+ el.style.opacity='0';
+ }
+ }
+ }
+ await delay(200);
+}
+async function refill(){
+ const promises=[];
+ for(let r=0;r<ROWS;r++){
+ for(let c=0;c<COLS_N;c++){
+ if(grid[r][c]===null){
+ grid[r][c]=randomType([]);
+ const el=tileEls[r][c];
+ el.innerHTML=makeSVG(grid[r][c]);
+ el.dataset.type=grid[r][c];
+ el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+ el.style.opacity='1';
+ animateTile(r,c,'drop-in');
+ }
+ }
+ }
+ await delay(280);
+}
+
+/* ─── MATCH FINDING ─── */
+function findMatches(){
+ const matched=new Set();
+ // rows
+ for(let r=0;r<ROWS;r++){
+ for(let c=0;c<COLS_N-2;c++){
+ const t=grid[r][c];
+ if(t&&t===grid[r][c+1]&&t===grid[r][c+2]){
+ let e=c+2;
+ while(e+1<COLS_N&&grid[r][e+1]===t)e++;
+ for(let x=c;x<=e;x++) matched.add(`${r},${x}`);
+ c=e-1;
+ }
+ }
+ }
+ // cols
+ for(let c=0;c<COLS_N;c++){
+ for(let r=0;r<ROWS-2;r++){
+ const t=grid[r][c];
+ if(t&&t===grid[r+1][c]&&t===grid[r+2][c]){
+ let e=r+2;
+ while(e+1<ROWS&&grid[e+1][c]===t)e++;
+ for(let y=r;y<=e;y++) matched.add(`${y},${c}`);
+ r=e-1;
+ }
+ }
+ }
+ return [...matched].map(k=>{const[r,c]=k.split(',').map(Number);return[r,c];});
+}
+
+/* ─── HELPERS ─── */
+function animateTile(r,c,cls){
+ const el=tileEls[r][c];
+ el.classList.remove(cls);
+ void el.offsetWidth;
+ el.classList.add(cls);
+ el.addEventListener('animationend',()=>el.classList.remove(cls),{once:true});
+}
+function delay(ms){return new Promise(r=>setTimeout(r,ms));}
+
+function showCascade(n){
+ comboEl.textContent=`Cascade ×${n}`;
+ comboEl.classList.add('show');
+ clearTimeout(comboEl._t);
+ comboEl._t=setTimeout(()=>comboEl.classList.remove('show'),1400);
+
+ // floating text
+ const x=window.innerWidth/2,y=window.innerHeight/2-60;
+ floater.textContent=`×${n} Cascade`;
+ floater.style.cssText=`left:${x}px;top:${y}px;transform:translate(-50%,-50%);opacity:0;font-size:${22+n*4}px`;
+ floater.style.transition='none';
+ void floater.offsetWidth;
+ floater.style.transition='transform .6s ease, opacity .6s ease';
+ floater.style.opacity='1';
+ floater.style.transform=`translate(-50%,calc(-50% - ${20+n*8}px))`;
+ setTimeout(()=>{floater.style.opacity='0';},700);
+}
+
+/* ─── STATS ─── */
+function updateStats(){
+ scoreEl.textContent=score.toLocaleString();
+ bestEl.textContent=Math.max(score,best).toLocaleString();
+ movesEl.textContent=moves;
+ goalEl.textContent=GOAL.toLocaleString();
+ const pct=Math.min(100,(score/GOAL)*100);
+ goalBar.style.width=pct+'%';
+ // moves warning
+ document.body.classList.toggle('moves-warning',moves<=5&&moves>0);
+}
+
+/* ─── END GAME ─── */
+function checkEndGame(){
+ if(score>=GOAL){
+ if(score>best){best=score;localStorage.setItem('dw-pm-best',best);}
+ $('win-score').textContent=score.toLocaleString();
+ $('win-best').textContent=score>best?'New best score!':`Best: ${best.toLocaleString()}`;
+ gameActive=false;
+ soundWin();
+ setTimeout(()=>winOv.classList.add('open'),400);
+ return;
+ }
+ if(moves<=0){
+ if(score>best){best=score;localStorage.setItem('dw-pm-best',best);}
+ $('lose-score').textContent=score.toLocaleString();
+ $('lose-best').textContent=score>0?`Best: ${Math.max(score,best).toLocaleString()}`:'';
+ gameActive=false;
+ soundLose();
+ setTimeout(()=>loseOv.classList.add('open'),400);
+ }
+}
+
+/* ─── GAME START ─── */
+function startGame(){
+ score=0;moves=MAX_MOVES;busy=false;selected=null;gameActive=true;
+ best=parseInt(localStorage.getItem('dw-pm-best')||'0');
+ grid=makeGrid();
+ renderBoard();
+ updateStats();
+ comboEl.classList.remove('show');
+ // clear any lingering initial matches (shouldn't happen but safety)
+ const init=findMatches();
+ if(init.length>0){
+ // silent clear without scoring
+ init.forEach(([r,c])=>{grid[r][c]=randomType([]);updateTileEl(r,c);});
+ }
+}
+
+/* ─── EVENT WIRING ─── */
+$('mute-btn').addEventListener('click',()=>{
+ muted=!muted;
+ $('mute-btn').textContent=muted?'♪ Muted':'♪ Sound';
+ if(!muted){try{getCtx().resume();}catch(e){}}
+});
+$('start-btn').addEventListener('click',()=>{
+ startOv.classList.remove('open');
+ startGame();
+});
+$('win-btn').addEventListener('click',()=>{
+ winOv.classList.remove('open');
+ startGame();
+});
+$('lose-btn').addEventListener('click',()=>{
+ loseOv.classList.remove('open');
+ startGame();
+});
+
+// keyboard: escape deselects
+document.addEventListener('keydown',e=>{
+ if(e.key==='Escape'&&selected){
+ tileEls[selected.r][selected.c].classList.remove('selected');
+ selected=null;
+ }
+});
+
+// responsive tile size
+function resizeTiles(){
+ const avail=Math.min(window.innerWidth-40,560);
+ const size=Math.floor((avail-7*4)/8);
+ document.documentElement.style.setProperty('--tile',size+'px');
+}
+window.addEventListener('resize',resizeTiles);
+resizeTiles();
+
+/* ─── INIT ─── */
+bestEl.textContent=best.toLocaleString();
+goalEl.textContent=GOAL.toLocaleString();
+startOv.classList.add('open');
+</script>
+</body>
+</html>
← e02303a Agent-driven trivia game: DW Design IQ (frontend-developer)
·
back to Model Arena
·
Agent-driven editorial: DW The Lookbook (frontend-developer) ef2e59b →