Last active
July 1, 2026 01:45
-
-
Save lardratboy/9cd228cbb777473fc0291c30484ee3ca to your computer and use it in GitHub Desktop.
RulesEngine, templates and game variations, v 0.1.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use strict"; | |
| // ████ MATH EASING CURVES — intrinsically invertible pure functions ████ | |
| const EASE = { | |
| cubicInOut: t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2, | |
| cubicOut: t => 1 - Math.pow(1 - t, 3), | |
| backOut: t => { | |
| const c1 = 1.70158; | |
| const c3 = c1 + 1; | |
| return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); | |
| }, | |
| bounceOut: t => { | |
| const n1 = 7.5625; | |
| const d1 = 2.75; | |
| if (t < 1 / d1) { | |
| return n1 * t * t; | |
| } else if (t < 2 / d1) { | |
| let t2 = t - 1.5 / d1; | |
| return n1 * t2 * t2 + 0.75; | |
| } else if (t < 2.5 / d1) { | |
| let t2 = t - 2.25 / d1; | |
| return n1 * t2 * t2 + 0.9375; | |
| } else { | |
| let t2 = t - 2.625 / d1; | |
| return n1 * t2 * t2 + 0.984375; | |
| } | |
| } | |
| }; | |
| // ████ ENGINE — pure rules, no DOM, no THREE. ████ | |
| const GRAVITY={down:{dr:1,dc:0},up:{dr:-1,dc:0},right:{dr:0,dc:1},left:{dr:0,dc:-1},none:{dr:0,dc:0}}; | |
| const ADJACENCY={moore:[[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]], | |
| vonNeumann:[[-1,0],[1,0],[0,-1],[0,1]]}; | |
| function grid(r,c,fn){return Array.from({length:r},(_,i)=>Array.from({length:c},(_,j)=>fn(i,j)));} | |
| class RulesEngine{ | |
| constructor(def,rng){this.def=def;this.rng=rng;this._nextId=1;this.reset();} | |
| // Derive the cached rule fields from the definition. Called by reset() AND after any live edit | |
| // that mutates def.rules (e.g. Test Board applying the editor's meta) so these never go stale — | |
| // the input dispatch reads engine.interaction, settle reads engine.gravityDir, etc. Without the | |
| // post-edit re-sync, a definition swapped to swapAdjacent kept the old cached 'tapGroup' value. | |
| _syncRules(){ | |
| this.gravityDir = this.def.rules.gravity || 'down'; | |
| // interaction → input/swapAt (P4); matchShape → findAllMatches (P2/P5); | |
| // refillMode → _resolve/refill (P3); resolveMode → cascade loop (P2); | |
| // swapAdjacency → swapAt/solvability (P4); comboMode/comboMax → scoring (P7). | |
| this.interaction = this.def.rules.interaction || 'tapGroup'; | |
| this.matchShape = this.def.rules.matchShape || 'connected'; | |
| this.refillMode = this._refillMode(); // endless ⇒ 'fromSource' (see _refillMode) | |
| this.resolveMode = this.def.rules.resolve || 'single'; | |
| this.swapAdjacency = this.def.rules.swapAdjacency || 'vonNeumann'; | |
| // swapToEmpty (default false): when interaction==='swapAdjacent', permit a piece to slide into | |
| // an adjacent EMPTY playable cell if the relocated piece forms a match at the destination. The | |
| // source cell becomes empty (this is a move, not a true swap). Liveness (hasValidSwap) also | |
| // considers slide-into-empty moves so a board with empty playable cells can stay alive. | |
| this.swapToEmpty = !!this.def.rules.swapToEmpty; | |
| this.comboMode = this.def.rules.comboMultiplier || 'linear'; | |
| this.comboMax = this.def.rules.comboMax ?? Infinity; | |
| } | |
| reset(){ | |
| const L=this.def.lattice; | |
| this.rows=L.rows;this.cols=L.cols;this.topology=L.topology||'plane'; | |
| this._syncRules(); // gravityDir + the match-3 axes, derived from def | |
| this.mask=grid(this.rows,this.cols,(r,c)=>this.def.layers.mask.grid[r][c]); | |
| this.gravityField=this._normGravity(this.def.layers.gravityZones | |
| ?grid(this.rows,this.cols,(r,c)=>this.def.layers.gravityZones.grid[r][c]):null); | |
| this.grid=grid(this.rows,this.cols,()=>null); | |
| this.pieceData=new Map(); | |
| this.score=0;this.collected=0;this.totalPrizes=0; | |
| const fill=this.def.layers.pieces.grid; | |
| const rfill=this.def.layers.randomFill?this.def.layers.randomFill.grid:null; | |
| const prizeCells=new Set((this.def.layers.prizes?.cells||[]).map(([r,c])=>r+','+c)); | |
| // ── Markers (marked.md): substrate-anchored collectibles, stored as a per-cell grid where | |
| // null = not a marker, false = uncollected, true = collected. Storing per-cell rather | |
| // than as a sparse list lets the existing _apply() transform routine remap them for free | |
| // alongside the mask and gravityField — markers ride D4 rotates and collapses; scroll | |
| // deliberately leaves them put (world-frame, §3 of marked.md). | |
| const markerCells=new Set((this.def.layers.markers?.cells||[]).map(([r,c])=>r+','+c)); | |
| this.markers=grid(this.rows,this.cols,(r,c)=>markerCells.has(r+','+c)?false:null); | |
| this.totalMarkers=0; this.collectedMarkers=0; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(this.markers[r][c]===false && this.mask[r][c]) this.totalMarkers++; | |
| else if(this.markers[r][c]!==null && !this.mask[r][c]) this.markers[r][c]=null; // strip markers off non-playable cells defensively | |
| } | |
| const preventInitialMatches = (this.interaction === 'swapAdjacent'); | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(!this.mask[r][c])continue; | |
| let pal=fill[r][c]; | |
| if(pal===null&&rfill&&rfill[r][c]){ | |
| if (preventInitialMatches) { | |
| const candidates = []; | |
| const numPalettes = this.def.palette.length; | |
| for (let p = 0; p < numPalettes; p++) { | |
| candidates.push(p); | |
| } | |
| // Shuffle candidates to keep randomness | |
| for (let i = candidates.length - 1; i > 0; i--) { | |
| const j = Math.floor(this.rng.next() * (i + 1)); | |
| const tmp = candidates[i]; | |
| candidates[i] = candidates[j]; | |
| candidates[j] = tmp; | |
| } | |
| let chosenPal = candidates[0]; | |
| for (const candidate of candidates) { | |
| // Temporarily place to run flood fill / line scan checks | |
| const tempId = this._spawn(candidate, false); | |
| this.grid[r][c] = tempId; | |
| const matches = this.findAllMatches(); | |
| // Clean up | |
| this.grid[r][c] = null; | |
| this.pieceData.delete(tempId); | |
| this._nextId--; | |
| if (matches.length === 0) { | |
| chosenPal = candidate; | |
| break; | |
| } | |
| } | |
| pal = chosenPal; | |
| } else { | |
| pal=Math.floor(this.rng.next()*this.def.palette.length); | |
| } | |
| } | |
| if(pal===null||pal===undefined)continue; | |
| const prize=prizeCells.has(r+','+c); if(prize)this.totalPrizes++; | |
| this.grid[r][c]=this._spawn(pal,prize); | |
| } | |
| } | |
| _spawn(p,z){const id=this._nextId++;this.pieceData.set(id,{palette:p,prize:!!z});return id;} | |
| palAt(r,c){const id=this.grid[r][c];return id==null?null:this.pieceData.get(id).palette;} | |
| prizeAt(r,c){const id=this.grid[r][c];return id==null?false:this.pieceData.get(id).prize;} | |
| effGravity(r,c){if(this.gravityField&&this.gravityField[r][c])return GRAVITY[this.gravityField[r][c]];return GRAVITY[this.gravityDir];} | |
| // A gravityField with no actual direction in any cell is just scalar gravity everywhere. The | |
| // editor materializes an all-null field for rendering, and serializing it yields an all-null | |
| // gravityZones layer; left as a non-null grid it would trip per-cell-gravity guards (refill and | |
| // lane-collapse both defer when a field is present). Collapse all-null/empty fields to null so | |
| // those guards only fire on REAL per-cell gravity. | |
| _normGravity(field){ | |
| if(!field)return null; | |
| for(const row of field)for(const v of row)if(v)return field; | |
| return null; | |
| } | |
| inBounds(r,c){return r>=0&&r<this.rows&&c>=0&&c<this.cols;} | |
| findGroup(r,c){ | |
| const sid=this.grid[r][c];if(sid==null)return[]; | |
| const target=this.pieceData.get(sid).palette; | |
| const offs=ADJACENCY[this.def.rules.matchAdjacency]||ADJACENCY.moore; | |
| const seen=new Set([r+','+c]),out=[{r,c}],st=[{r,c}]; | |
| while(st.length){const{r:cr,c:cc}=st.pop(); | |
| for(const[dr,dc]of offs){let nr=cr+dr,nc=cc+dc; | |
| if(this.def.rules.wrapMatch){nr=(nr+this.rows)%this.rows;nc=(nc+this.cols)%this.cols;} | |
| if(!this.inBounds(nr,nc))continue;const k=nr+','+nc;if(seen.has(k))continue; | |
| if(this.palAt(nr,nc)===target){seen.add(k);out.push({r:nr,c:nc});st.push({r:nr,c:nc});}}} | |
| return out; | |
| } | |
| settle(){ | |
| const origin=new Map(); | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++)if(this.grid[r][c]!=null)origin.set(this.grid[r][c],{r,c}); | |
| let moved=true,guard=0; | |
| while(moved&&guard++<this.rows*this.cols+5){moved=false; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| const id=this.grid[r][c];if(id==null)continue; | |
| const g=this.effGravity(r,c);if(g.dr===0&&g.dc===0)continue; | |
| const nr=r+g.dr,nc=c+g.dc; | |
| if(!this.inBounds(nr,nc)||!this.mask[nr][nc]||this.grid[nr][nc]!=null)continue; | |
| const ng=this.effGravity(nr,nc);if(ng.dr!==g.dr||ng.dc!==g.dc)continue; | |
| this.grid[nr][nc]=id;this.grid[r][c]=null;moved=true;}} | |
| const moves=[]; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id==null)continue; | |
| const o=origin.get(id);if(o.r!==r||o.c!==c)moves.push({id,toR:r,toC:c});} | |
| return moves; | |
| } | |
| collapse(){ | |
| if(this.def.rules.laneCollapse===false)return null; | |
| if(this.gravityField)return null; | |
| const g=GRAVITY[this.gravityDir];if(g.dr===0&&g.dc===0)return null; | |
| const vertical=g.dr!==0; | |
| if(vertical){const keep=[]; | |
| for(let c=0;c<this.cols;c++){let m=false,p=false; | |
| for(let r=0;r<this.rows;r++){if(this.mask[r][c])m=true;if(this.grid[r][c]!=null)p=true;} | |
| if(!(m&&!p))keep.push(c);} | |
| if(keep.length===this.cols)return null; | |
| this._reindex(this.rows,keep.length,(r,c)=>[r,keep[c]]); | |
| }else{const keep=[]; | |
| for(let r=0;r<this.rows;r++){let m=false,p=false; | |
| for(let c=0;c<this.cols;c++){if(this.mask[r][c])m=true;if(this.grid[r][c]!=null)p=true;} | |
| if(!(m&&!p))keep.push(r);} | |
| if(keep.length===this.rows)return null; | |
| this._reindex(keep.length,this.cols,(r,c)=>[keep[r],c]);} | |
| return this._snapshotMoves(); | |
| } | |
| rotate(dir){ | |
| const R=this.rows,C=this.cols,nR=C,nC=R; | |
| const src=(r,c)=>dir==='cw'?[R-1-c,r]:[c,C-1-r]; | |
| this._apply(nR,nC,src); | |
| return this._snapshotMoves(); | |
| } | |
| _reindex(nR,nC,src){this._apply(nR,nC,src);} | |
| _apply(nR,nC,src){ | |
| const nm=grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.mask[or][oc];}); | |
| const ng=grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.grid[or][oc];}); | |
| const nf=this.gravityField?grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.gravityField[or][oc];}):null; | |
| // Markers are substrate-anchored — same frame as the mask under D4 rotates and lane collapses | |
| // (board-frame; reorient with the board). Scroll is the exception and stays untouched there; | |
| // see scroll() for that side. The `collected` state rides with the cell, naturally. | |
| const nMk=this.markers?grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.markers[or][oc];}):null; | |
| this.rows=nR;this.cols=nC;this.mask=nm;this.grid=ng;this.gravityField=nf; | |
| if(nMk)this.markers=nMk; | |
| } | |
| _snapshotMoves(){const m=[];for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id!=null)m.push({id,toR:r,toC:c});}return m;} | |
| maskCopy(){return this.mask.map(row=>row.slice());} | |
| // Snapshot of the markers grid — emitted on steps that reshape the lattice (rotate, collapse, | |
| // markersCollected) so the timeline carries the post-step marker state and replay/scrub work. | |
| markersCopy(){return this.markers?this.markers.map(row=>row.slice()):null;} | |
| // a collapse step also carries the post-collapse mask, since collapse reshapes the wall pattern | |
| _collapseStep(){const cm=this.collapse();return cm?{kind:'collapse',dims:{rows:this.rows,cols:this.cols},moves:cm,mask:this.maskCopy(),markers:this.markersCopy()}:null;} | |
| // ── Board-wide match detection (freshdesign §16.1 — the cascade re-scan). Returns a flat, | |
| // de-duplicated list of {r,c} cells belonging to any clearable group, scanning the WHOLE | |
| // board because cascades form matches wherever pieces fall. Reuses findGroup's flood-fill | |
| // through a shared `seen` set, so every cell is touched once (O(cells)). matchAdjacency and | |
| // wrapMatch come along for free via findGroup. `line` matchShape (collinear runs) is Phase 5. | |
| findAllMatches(){ | |
| if(this.matchShape==='line')return this._findLineMatches(); // P5: scan rows/cols for runs | |
| const min=this.def.rules.minGroupSize,seen=new Set(),out=[]; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(this.grid[r][c]==null)continue; | |
| if(seen.has(r+','+c))continue; | |
| const grp=this.findGroup(r,c); | |
| for(const cell of grp)seen.add(cell.r+','+cell.c); // whole group visited, size aside | |
| if(grp.length>=min)for(const cell of grp)out.push(cell); | |
| } | |
| return out; | |
| } | |
| // P5: collinear-run detection (classic match-3 lines). | |
| _findLineMatches(){ | |
| const min = this.def.rules.minGroupSize || 3; | |
| const matchedCells = new Set(); | |
| const dirs = [[0, 1], [1, 0]]; // horizontal and vertical | |
| if (this.def.rules.matchAdjacency === 'moore') { | |
| dirs.push([1, 1], [1, -1]); // diagonals | |
| } | |
| for (let r = 0; r < this.rows; r++) { | |
| for (let c = 0; c < this.cols; c++) { | |
| const id = this.grid[r][c]; | |
| if (id == null) continue; | |
| const pal = this.palAt(r, c); | |
| for (const [dr, dc] of dirs) { | |
| let prevR = r - dr; | |
| let prevC = c - dc; | |
| let hasPrevMatch = false; | |
| if (this.def.rules.wrapMatch) { | |
| prevR = (prevR + this.rows) % this.rows; | |
| prevC = (prevC + this.cols) % this.cols; | |
| } | |
| if (this.inBounds(prevR, prevC) && this.mask[prevR][prevC]) { | |
| if (this.palAt(prevR, prevC) === pal) { | |
| hasPrevMatch = true; | |
| } | |
| } | |
| if (hasPrevMatch && !this.def.rules.wrapMatch) { | |
| continue; // Already processed as part of a larger run | |
| } | |
| const run = [{r, c}]; | |
| let currR = r, currC = c; | |
| const limit = Math.max(this.rows, this.cols); | |
| for (let step = 1; step < limit; step++) { | |
| let nr = currR + dr; | |
| let nc = currC + dc; | |
| if (this.def.rules.wrapMatch) { | |
| nr = (nr + this.rows) % this.rows; | |
| nc = (nc + this.cols) % this.cols; | |
| } | |
| if (!this.inBounds(nr, nc) || !this.mask[nr][nc]) break; | |
| if (this.palAt(nr, nc) !== pal) break; | |
| if (nr === r && nc === c) break; | |
| run.push({r: nr, c: nc}); | |
| currR = nr; | |
| currC = nc; | |
| } | |
| if (run.length >= min) { | |
| for (const cell of run) { | |
| matchedCells.add(cell.r + ',' + cell.c); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| const out = []; | |
| for (const k of matchedCells) { | |
| const [sr, sc] = k.split(',').map(Number); | |
| out.push({r: sr, c: sc}); | |
| } | |
| return out; | |
| } | |
| // ── Combo multiplier for cascade scoring (freshdesign §16.1, Decision 7). | |
| // Phase 7: escalate per cascade tick (linear ramp), capped by comboMax. | |
| _combo(tick){ | |
| if (this.comboMode === 'linear') { | |
| return Math.min(this.comboMax || Infinity, tick); | |
| } | |
| return 1; | |
| } | |
| // ── Refill (freshdesign §16.1, refill:fromSource — locked Decisions 4 & 5). Scalar gravity | |
| // only. For each lane parallel to gravity, fill the SOURCE-CONNECTED segment (the run of | |
| // playable cells open to the source edge, up to the first wall) to full: after a settle the | |
| // holes sit contiguously at the source end, and a piece entering from outside falls until it | |
| // hits a wall or a resting piece — so those source-end holes are exactly the fillable cells. | |
| // Pieces are placed DIRECTLY at their settled cells (engine never holds off-board state), and | |
| // each carries an off-board {fromR,fromC} origin = cell − k·gravity (k = holes in the lane) so | |
| // the View animates a rigid stack dropping in from beyond the source edge (model i). Palettes | |
| // are drawn from this.rng (same as reset), so refill replays deterministically. Returns a | |
| // {kind:'spawn',spawned:[…]} step, or null when there's nothing to do or the config is out of | |
| // scope (per-cell-gravity field, or gravity:none — both have no single source edge). | |
| // Effective refill mode. ENDLESS modes always refill: a non-refilling endless game just shrinks | |
| // to nothing, so "endless" with refill:none would be a contradiction. Read live from the def so | |
| // it stays correct however the definition was assembled or restored (reset, JSON, frame-restore). | |
| _refillMode(){ | |
| if(this.def.win && this.def.win.type==='endless') return 'fromSource'; | |
| return this.def.rules.refill || 'none'; | |
| } | |
| _refill(){ | |
| if(this._refillMode()!=='fromSource')return null; | |
| if(this.gravityField)return null; // per-cell-gravity refill deferred (scalar only) | |
| const g=GRAVITY[this.gravityDir]; | |
| if(g.dr===0&&g.dc===0)return null; // gravity:none has no source edge | |
| const vertical=g.dc===0; // down/up → lanes are columns; left/right → rows | |
| const nLanes=vertical?this.cols:this.rows; | |
| const laneLen=vertical?this.rows:this.cols; | |
| const spawned=[]; | |
| for(let L=0;L<nLanes;L++){ | |
| const empties=[]; | |
| for(let s=0;s<laneLen;s++){ // s walks source→sink along +gravity | |
| const r=vertical?(g.dr>0?s:laneLen-1-s):L; | |
| const c=vertical?L:(g.dc>0?s:laneLen-1-s); | |
| if(!this.mask[r][c])break; // first wall closes the source-connected segment | |
| if(this.grid[r][c]==null)empties.push({r,c}); | |
| } | |
| const k=empties.length; | |
| if(!k)continue; | |
| for(const cell of empties){ | |
| const pal=Math.floor(this.rng.next()*this.def.palette.length); | |
| const id=this._spawn(pal,false); | |
| this.grid[cell.r][cell.c]=id; | |
| spawned.push({id,r:cell.r,c:cell.c,palette:pal,prize:false, | |
| fromR:cell.r-k*g.dr,fromC:cell.c-k*g.dc}); // off-board origin for the drop-in | |
| } | |
| } | |
| return spawned.length?{kind:'spawn',spawned}:null; | |
| } | |
| // ── Shared post-match resolution. Given the localized match the action already found | |
| // (`initial`: a list of {r,c}), clear it, settle, [refill — P3], and if resolveMode | |
| // is 'cascade' re-scan the whole board and repeat until no matches remain; then collapse. | |
| // tapAt and the future swapAt both funnel through here; the trailing {kind:'state'} is | |
| // appended by the caller. With resolveMode 'single' the loop runs exactly once, which is | |
| // byte-identical to tapAt's old inline tail. `center` is preserved on the first clear step. | |
| // Guards: a hard tick cap (mirrors settle's) and a break if a tick clears nothing, so a | |
| // cascade can never spin — important once P3 refill stops the board strictly shrinking. | |
| _resolve(initial,center){ | |
| const steps=[]; | |
| let matched=initial,tick=0; | |
| const maxTicks=this.rows*this.cols+5; | |
| while(matched&&matched.length&&tick<maxTicks){ | |
| tick++; | |
| const cleared=[]; | |
| for(const{r:gr,c:gc}of matched){const id=this.grid[gr][gc];if(id==null)continue; | |
| const pd=this.pieceData.get(id); | |
| if(pd.prize){this.collected++;this.score+=50;} | |
| cleared.push({id,r:gr,c:gc,palette:pd.palette,prize:pd.prize}); | |
| this.grid[gr][gc]=null;this.pieceData.delete(id);} | |
| if(!cleared.length)break; // nothing actually cleared → stop | |
| this.score+=cleared.length*10*this._combo(tick); // P7: ×combo; P1/P2: ×1 ⇒ flat, as before | |
| const clearStep={kind:'clear',cells:cleared}; | |
| if(tick===1&¢er)clearStep.center=center; // keep tap's center metadata intact | |
| steps.push(clearStep); | |
| const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm}); | |
| if(this._refillMode()==='fromSource'){const sp=this._refill();if(sp)steps.push(sp);} // top up to full | |
| if(this.resolveMode!=='cascade')break; | |
| matched=this.findAllMatches(); // re-scan board-wide for chained matches | |
| } | |
| // ── Marker collection (marked.md §2): scan at the END of _resolve, after the cascade loop has | |
| // fully terminated and any final refill has settled. Mid-cascade exposure does NOT count — | |
| // a marker only collects on a *resting* board. Runs BEFORE _collapseStep so a marker on a | |
| // lane that's about to vanish gets collected (and then the now-marker-free lane collapses | |
| // normally). Step is omitted when nothing was collected, to keep timelines uncluttered. | |
| const mc=this._collectMarkers(); if(mc) steps.push(mc); | |
| const cs=this._collapseStep();if(cs)steps.push(cs); | |
| // Auto-shuffle deadlock resolution (Decision 8) | |
| if (this.interaction === 'swapAdjacent' && !this.canMove()) { | |
| const isWon = (this.def.win.type==='collectAllPrizes'&&this.totalPrizes>0&&this.collected>=this.totalPrizes) || | |
| (this.def.win.type==='collectAllMarkers'&&this.totalMarkers>0&&this.collectedMarkers>=this.totalMarkers) || | |
| (this.def.win.type==='clearAll'&&this.pieceData.size===0); | |
| if (!isWon) { | |
| const shuffleMoves = this.shufflePieces(); | |
| if (shuffleMoves) { | |
| steps.push({kind: 'shuffle', moves: shuffleMoves}); | |
| } | |
| } | |
| } | |
| return steps; | |
| } | |
| // Marker collection scan (called at end of _resolve and after every other settle-producing | |
| // path — setGravity, rotateBoard, scrollBoard). For each uncollected marker whose cell is | |
| // currently empty, flip it to collected, bump the counter, and emit a {kind:'markersCollected'} | |
| // step carrying the cells (for view animation) and a fresh markers snapshot (for replay/scrub). | |
| _collectMarkers(){ | |
| if(!this.markers) return null; | |
| const cells=[]; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(this.markers[r][c]===false && this.grid[r][c]==null && this.mask[r][c]){ | |
| this.markers[r][c]=true; this.collectedMarkers++; | |
| cells.push({r,c}); | |
| this.score+=50; // mirror prize-collection score boost | |
| } | |
| } | |
| if(!cells.length) return null; | |
| return {kind:'markersCollected', cells, markers:this.markersCopy()}; | |
| } | |
| shufflePieces(){ | |
| const cells = []; | |
| const pieces = []; | |
| for (let r = 0; r < this.rows; r++) { | |
| for (let c = 0; c < this.cols; c++) { | |
| if (this.mask[r][c] && this.grid[r][c] != null) { | |
| cells.push({r, c}); | |
| pieces.push(this.grid[r][c]); | |
| } | |
| } | |
| } | |
| if (cells.length === 0) return null; | |
| let attempts = 0; | |
| const maxAttempts = 200; | |
| let ok = false; | |
| while (!ok && attempts++ < maxAttempts) { | |
| // Fisher-Yates shuffle | |
| for (let i = pieces.length - 1; i > 0; i--) { | |
| const j = Math.floor(this.rng.next() * (i + 1)); | |
| const tmp = pieces[i]; | |
| pieces[i] = pieces[j]; | |
| pieces[j] = tmp; | |
| } | |
| for (let i = 0; i < cells.length; i++) { | |
| const {r, c} = cells[i]; | |
| this.grid[r][c] = pieces[i]; | |
| } | |
| if (this.findAllMatches().length === 0 && this.canMove()) { | |
| ok = true; | |
| } | |
| } | |
| if (!ok) return null; | |
| const moves = []; | |
| for (let r = 0; r < this.rows; r++) { | |
| for (let c = 0; c < this.cols; c++) { | |
| const id = this.grid[r][c]; | |
| if (id != null) { | |
| moves.push({id, toR: r, toC: c}); | |
| } | |
| } | |
| } | |
| return moves; | |
| } | |
| tapAt(r,c){ | |
| const group=this.findGroup(r,c); | |
| if(group.length<this.def.rules.minGroupSize)return null; | |
| const steps=this._resolve(group,{r,c}); | |
| steps.push({kind:'state',...this.checkState()}); | |
| return steps; | |
| } | |
| // ── Swap two adjacent cells (freshdesign §16.1, interaction:swapAdjacent — Decisions 2 & 3). | |
| // Commit-only with swapMustMatch always on for v1: the swap is applied, then kept ONLY if it | |
| // forms a match touching either swapped cell; otherwise it's reverted and a 'swapReject' bounce | |
| // is returned (board + score unchanged). On commit the 'swap' step animates the two pieces | |
| // crossing, then _resolve runs clear→settle→refill→cascade exactly as a tap does. Adjacency is | |
| // enforced by the caller via isSwapAdjacent; this method still no-ops (null) on out-of-range, | |
| // masked, or empty cells so it can never corrupt state. | |
| // | |
| // swapToEmpty extension: when this.swapToEmpty is on, ONE side may be empty (mask=true, | |
| // grid=null). That's a slide-into-empty move: the lone piece relocates, the source becomes | |
| // empty, and the move is kept only if the relocated piece forms a match at its destination. | |
| // The step shapes use b.id=null to signal "no second piece" so the renderer can degrade | |
| // cleanly (no arc-around for swap, single-piece bounce for swapReject). | |
| swapAt(r1,c1,r2,c2){ | |
| if(!this.inBounds(r1,c1)||!this.inBounds(r2,c2))return null; | |
| if(!this.mask[r1][c1]||!this.mask[r2][c2])return null; | |
| const id1=this.grid[r1][c1],id2=this.grid[r2][c2]; | |
| // Both empty is never meaningful. Both filled is the classic swap. One-filled-one-empty is the | |
| // slide-into-empty case, only permitted when swapToEmpty is on. | |
| if(id1==null && id2==null) return null; | |
| const slide = (id1==null || id2==null); | |
| if(slide && !this.swapToEmpty) return null; | |
| this.grid[r1][c1]=id2;this.grid[r2][c2]=id1; // tentatively swap (works for slide too: null↔piece) | |
| const min=this.def.rules.minGroupSize; | |
| // For the match test, only count cells that hold a piece after the (tentative) move. A slide | |
| // leaves one of the two cells empty — that cell can't be in a group, so we just skip it. | |
| const checkCells=[]; | |
| if(this.grid[r1][c1]!=null) checkCells.push({r:r1,c:c1}); | |
| if(this.grid[r2][c2]!=null) checkCells.push({r:r2,c:c2}); | |
| let ok1=false, ok2=false; | |
| let g1=[], g2=[]; | |
| if (this.matchShape === 'line') { | |
| const allMatches = this.findAllMatches(); | |
| const matchSet = new Set(allMatches.map(cell => cell.r + ',' + cell.c)); | |
| if(this.grid[r1][c1]!=null) ok1 = matchSet.has(r1 + ',' + c1); | |
| if(this.grid[r2][c2]!=null) ok2 = matchSet.has(r2 + ',' + c2); | |
| if (ok1 || ok2) { | |
| if (ok1) g1 = allMatches; // seed cascade with the whole current match set | |
| if (ok2) g2 = allMatches; // (seen-set below dedupes overlap; cascade re-scan handles later ticks) | |
| } | |
| } else { | |
| if(this.grid[r1][c1]!=null){ g1=this.findGroup(r1,c1); ok1=g1.length>=min; } | |
| if(this.grid[r2][c2]!=null){ g2=this.findGroup(r2,c2); ok2=g2.length>=min; } | |
| } | |
| if(ok1||ok2){ // forms a match → commit | |
| // For a slide, the "empty side" of the step carries id:null so the View can recognize the | |
| // one-piece variant and skip the two-piece arc-around. The position interpolation falls out | |
| // of frame A→B placements automatically (the relocated piece appears at its new cell in B). | |
| const swapStep={kind:'swap', | |
| a:{id:id1,toR:r2,toC:c2}, | |
| b:{id:id2,toR:r1,toC:c1}}; | |
| const initial=[],seen=new Set(); | |
| for(const g of [ok1?g1:[],ok2?g2:[]])for(const cell of g){ | |
| const k=cell.r+','+cell.c;if(!seen.has(k)){seen.add(k);initial.push(cell);}} | |
| const steps=[swapStep,...this._resolve(initial,null)]; | |
| steps.push({kind:'state',...this.checkState()}); | |
| return steps; | |
| } | |
| this.grid[r1][c1]=id1;this.grid[r2][c2]=id2; // no match → revert, bounce only | |
| return [{kind:'swapReject',a:{id:id1,r:r1,c:c1},b:{id:id2,r:r2,c:c2}}, | |
| {kind:'state',...this.checkState()}]; | |
| } | |
| setGravity(dir){this.gravityDir=dir;const steps=[]; | |
| const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm}); | |
| const mc=this._collectMarkers();if(mc)steps.push(mc); | |
| const cs=this._collapseStep();if(cs)steps.push(cs); | |
| steps.push({kind:'state',...this.checkState()});return steps;} | |
| rotateBoard(dir){const rm=this.rotate(dir); | |
| const steps=[{kind:'rotate',dir,dims:{rows:this.rows,cols:this.cols},moves:rm,mask:this.maskCopy(),markers:this.markersCopy()}]; | |
| const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm}); | |
| const mc=this._collectMarkers();if(mc)steps.push(mc); | |
| const cs=this._collapseStep();if(cs)steps.push(cs); | |
| steps.push({kind:'state',...this.checkState()});return steps;} | |
| scroll(spec){ | |
| let dr=0,dc=0; | |
| if(spec==='scrollC+')dc=1; else if(spec==='scrollC-')dc=-1; | |
| else if(spec==='scrollR+')dr=1; else if(spec==='scrollR-')dr=-1; else return null; | |
| const ng=grid(this.rows,this.cols,()=>null); | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id==null)continue; | |
| ng[(r+dr+this.rows)%this.rows][(c+dc+this.cols)%this.cols]=id;} | |
| this.grid=ng; return this._snapshotMoves(); | |
| // Note: scroll deliberately does NOT touch this.markers — markers are world-frame under scroll | |
| // (marked.md §3, freshdesign §5.1). Pieces scroll past stationary markers, which is the whole | |
| // point: empty cells can be walked under markers to expose them. | |
| } | |
| scrollBoard(spec){ | |
| const sm0=this.scroll(spec); if(!sm0)return[{kind:'state',...this.checkState()}]; | |
| const steps=[{kind:'scroll',moves:sm0}]; | |
| const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm}); | |
| const mc=this._collectMarkers();if(mc)steps.push(mc); | |
| const cs=this._collapseStep();if(cs)steps.push(cs); | |
| steps.push({kind:'state',...this.checkState()});return steps;} | |
| hasValidMoves(){for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++) | |
| if(this.grid[r][c]!=null&&this.findGroup(r,c).length>=this.def.rules.minGroupSize)return true;return false;} | |
| // Adjacency test for swap input (uses swapAdjacency, independent of matchAdjacency — Decision 3). | |
| isSwapAdjacent(r1,c1,r2,c2){ | |
| const offs=ADJACENCY[this.swapAdjacency]||ADJACENCY.vonNeumann; | |
| return offs.some(([dr,dc])=>r1+dr===r2&&c1+dc===c2); | |
| } | |
| // Swap-game liveness: does ANY adjacent swap produce a match? This — not "a group already exists" | |
| // — is the correct "valid move" test for swap games, whose resting board is deliberately match-free. | |
| // When swapToEmpty is on, slide-into-empty moves (piece ↔ adjacent empty playable cell) also count | |
| // as candidate moves, so a board with empty cells stays alive as long as one slide forms a match. | |
| hasValidSwap(){ | |
| const offs=ADJACENCY[this.swapAdjacency]||ADJACENCY.vonNeumann; | |
| const min=this.def.rules.minGroupSize; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(this.grid[r][c]==null||!this.mask[r][c])continue; // we iterate from each *piece* | |
| for(const [dr,dc] of offs){ | |
| const nr=r+dr,nc=c+dc; | |
| if(!this.inBounds(nr,nc)||!this.mask[nr][nc])continue; | |
| const neighborPiece = this.grid[nr][nc]!=null; | |
| // Piece↔piece pair: test each unordered pair once (the (nr<r||(nr===r&&nc<c)) guard). | |
| // Piece↔empty (slide): NEVER symmetric — the empty side has no piece to seed iteration from | |
| // — so we test it from the piece side regardless of (r,c) ordering. | |
| if(neighborPiece){ | |
| if(nr<r||(nr===r&&nc<c))continue; | |
| }else{ | |
| if(!this.swapToEmpty)continue; | |
| } | |
| const a=this.grid[r][c],b=this.grid[nr][nc]; | |
| this.grid[r][c]=b;this.grid[nr][nc]=a; | |
| let ok; | |
| if (this.matchShape === 'line') { | |
| ok = this.findAllMatches().length > 0; | |
| } else { | |
| // For a slide, one side is empty after the swap; only test the filled side. findGroup on | |
| // an empty cell would return [] and skew nothing, but skipping is cheaper and clearer. | |
| const okR = (this.grid[r][c] !=null) && this.findGroup(r,c).length>=min; | |
| const okNR = (this.grid[nr][nc] !=null) && this.findGroup(nr,nc).length>=min; | |
| ok = okR || okNR; | |
| } | |
| this.grid[r][c]=a;this.grid[nr][nc]=b; | |
| if(ok)return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // Unified liveness used by checkState + the game-start retry. Tap games keep exact old semantics. | |
| canMove(){return this.interaction==='swapAdjacent'?this.hasValidSwap():this.hasValidMoves();} | |
| checkState(){ | |
| const w=this.def.win.type; | |
| if(w==='collectAllPrizes'&&this.totalPrizes>0&&this.collected>=this.totalPrizes)return{status:'won'}; | |
| if(w==='collectAllMarkers'&&this.totalMarkers>0&&this.collectedMarkers>=this.totalMarkers)return{status:'won'}; | |
| if(w==='clearAll'&&this.pieceData.size===0)return{status:'won'}; | |
| if(w==='endless')return{status:'playing'}; // never wins; placed before the no-moves check so it never auto-loses → truly endless | |
| if(!this.canMove())return{status:'lost'}; | |
| return{status:'playing'}; | |
| } | |
| restoreFromFrame(frame,score,collected,rngState){ | |
| this.rows=frame.dims.rows; | |
| this.cols=frame.dims.cols; | |
| this.grid=grid(this.rows,this.cols,()=>null); | |
| this.pieceData.clear(); | |
| let maxId=0; | |
| for(const p of frame.placements){ | |
| this.grid[p.r][p.c]=p.id; | |
| this.pieceData.set(p.id,{palette:p.palette,prize:p.prize}); | |
| if(p.id>maxId)maxId=p.id; | |
| } | |
| this._nextId=maxId+1; | |
| this.score=score; | |
| this.collected=collected; | |
| if(rngState !== null) this.rng.setState(rngState); | |
| if(frame.mask){ | |
| this.mask=frame.mask.map(row=>row.slice()); | |
| }else{ | |
| this.mask=grid(this.rows,this.cols,(r,c)=>{ | |
| if(this.def.layers.mask.grid[r]!==undefined&&this.def.layers.mask.grid[r][c]!==undefined){ | |
| return this.def.layers.mask.grid[r][c]; | |
| } | |
| return true; | |
| }); | |
| } | |
| if(frame.gravityField){ | |
| this.gravityField=frame.gravityField.map(row=>row.slice()); | |
| }else if(this.def.layers.gravityZones){ | |
| this.gravityField=grid(this.rows,this.cols,(r,c)=>{ | |
| if(this.def.layers.gravityZones.grid[r]!==undefined&&this.def.layers.gravityZones.grid[r][c]!==undefined){ | |
| return this.def.layers.gravityZones.grid[r][c]; | |
| } | |
| return null; | |
| }); | |
| }else{this.gravityField=null;} | |
| this.gravityField=this._normGravity(this.gravityField); // all-null field ⇒ scalar gravity (see _normGravity) | |
| // Restore markers. Replay/scrub frames carry the full grid (collected flags and all); fresh | |
| // restores (e.g. Test Board) fall back to building from def.layers.markers.cells. The | |
| // collected counter is derived rather than stored so we don't drift if a frame is hand-edited. | |
| if(frame.markers){ | |
| this.markers=frame.markers.map(row=>row.slice()); | |
| }else if(this.def.layers.markers){ | |
| const mc=new Set((this.def.layers.markers.cells||[]).map(([r,c])=>r+','+c)); | |
| this.markers=grid(this.rows,this.cols,(r,c)=>mc.has(r+','+c)?false:null); | |
| }else{ | |
| this.markers=grid(this.rows,this.cols,()=>null); | |
| } | |
| this.totalMarkers=0; this.collectedMarkers=0; | |
| for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){ | |
| if(this.markers[r][c]===false){ this.totalMarkers++; } | |
| else if(this.markers[r][c]===true){ this.totalMarkers++; this.collectedMarkers++; } | |
| } | |
| } | |
| } | |
| // ████ DEFINITIONS — rules config mapping to rules engine ████ | |
| const PALETTE=[{id:'sphere',color:0xff0000},{id:'cube',color:0x00ff00},{id:'tetra',color:0x0080ff}, | |
| {id:'octa',color:0xffff00},{id:'icosa',color:0xff00ff},{id:'dodeca',color:0xffffff}]; | |
| function baseDef(name,rows,cols,prizeCells,over){ | |
| const d={ | |
| name, format:'lattice-game/1', | |
| lattice:{rows,cols,topology:'plane'}, | |
| palette:PALETTE, | |
| layers:{ | |
| mask:{binding:'substrate',grid:grid(rows,cols,()=>true)}, | |
| pieces:{binding:'mobile',grid:grid(rows,cols,()=>null)}, | |
| randomFill:{binding:'field',grid:grid(rows,cols,()=>true)}, | |
| prizes:{binding:'rider',cells:prizeCells||[]}, | |
| markers:{binding:'marker',cells:[]} | |
| }, | |
| rules:{matchAdjacency:'moore',matchBy:'type',minGroupSize:2,gravity:'down',laneCollapse:true, | |
| transforms:['rotateCCW','rotateCW']}, | |
| win:{type:'collectAllPrizes'}, lose:{type:'noValidMoves'} | |
| }; | |
| return over?over(d):d; | |
| } | |
| const GAMES=[ | |
| {key:'eight', label:'Eight Neighbors', | |
| build:(r,c,pz)=>baseDef('Eight Neighbors',r,c,pz)}, | |
| {key:'same', label:'SameGame · orthogonal', | |
| build:(r,c,pz)=>baseDef('SameGame',r,c,pz,d=>{d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];return d;})}, | |
| {key:'sideways', label:'Sideways collapse', | |
| build:(r,c,pz)=>baseDef('Sideways collapse',r,c,pz,d=>{d.rules.gravity='left';return d;})}, | |
| {key:'carousel', label:'Carousel · scroll & match', | |
| build:(r,c,pz)=>baseDef('Carousel',r,c,pz,d=>{d.rules.laneCollapse=false; | |
| d.rules.transforms=['scrollC-','scrollC+','scrollR-','scrollR+'];return d;})}, | |
| {key:'twozone', label:'Two-zone gravity', | |
| build:(r,c,pz)=>baseDef('Two-zone gravity',r,c,pz,d=>{ | |
| d.layers.gravityZones={binding:'field',anchored:true, | |
| grid:grid(r,c,(rr,cc)=> cc>=Math.floor(c/2)?'right':null)}; | |
| return d;})}, | |
| {key:'cascade', label:'Cascade SameGame · chains', // P2: resolve:cascade — clears chain as pieces fall | |
| build:(r,c,pz)=>baseDef('Cascade SameGame',r,c,pz,d=>{ | |
| d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];d.rules.resolve='cascade';return d;})}, | |
| {key:'refill', label:'Refill cascade · tap', // P3: cascade + refill:fromSource — board tops up from the top | |
| build:(r,c,pz)=>baseDef('Refill cascade',r,c,pz,d=>{ | |
| d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];d.rules.minGroupSize=3; | |
| d.rules.resolve='cascade';d.rules.refill='fromSource';d.rules.laneCollapse=false;return d;})}, | |
| {key:'bejeweled', label:'Bejeweled-lite · swap', // P4: swapAdjacent — the playable match-3 (swap → cascade → refill) | |
| build:(r,c,pz)=>baseDef('Bejeweled-lite',r,c,pz,d=>{ | |
| d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann'; | |
| d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3; | |
| d.rules.resolve='cascade';d.rules.refill='fromSource'; | |
| d.rules.transforms=[];d.rules.laneCollapse=false;return d;})}, | |
| {key:'endlessA', label:'Endless Collapse · swap', // swap + connected match-N; win:endless ⇒ truly endless (no prizes, never wins/loses, auto-shuffle keeps it playable) | |
| build:(r,c,pz)=>baseDef('Endless Collapse',r,c,pz,d=>{ | |
| d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann'; | |
| d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3; | |
| d.rules.matchShape='connected';d.rules.resolve='cascade';d.rules.refill='fromSource'; | |
| d.rules.transforms=[];d.rules.laneCollapse=false; | |
| d.rules.comboMultiplier='linear';d.rules.comboMax=8; | |
| d.win={type:'endless'};return d;})}, | |
| {key:'endlessB', label:'Endless Lines · swap', // swap + line detection (P5); win:endless ⇒ truly endless; also exercises the swapAt line-branch clear fix | |
| build:(r,c,pz)=>baseDef('Endless Lines',r,c,pz,d=>{ | |
| d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann'; | |
| d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3; | |
| d.rules.matchShape='line';d.rules.resolve='cascade';d.rules.refill='fromSource'; | |
| d.rules.transforms=[];d.rules.laneCollapse=false; | |
| d.rules.comboMultiplier='linear';d.rules.comboMax=8; | |
| d.win={type:'endless'};return d;})}, | |
| ]; | |
| const TEMPLATES={ | |
| rectangle:(r,c)=>grid(r,c,()=>true), | |
| diamond:(r,c)=>{const mr=(r-1)/2,mc=(c-1)/2,maxD=Math.min(mr,mc); | |
| return grid(r,c,(i,j)=>Math.abs(i-mr)+Math.abs(j-mc)<=maxD+1e-9);}, | |
| cross:(r,c)=>{const mr=(r-1)/2,mc=(c-1)/2,arm=Math.max(0,Math.floor(Math.min(r,c)/5)); | |
| return grid(r,c,(i,j)=>Math.abs(i-mr)<=arm+0.5||Math.abs(j-mc)<=arm+0.5);}, | |
| diagonal:(r,c)=>grid(r,c,(i,j)=> j/Math.max(1,c-1) <= i/Math.max(1,r-1)+1e-9), | |
| }; | |
| const TEMPLATE_LABELS={rectangle:'Rectangle',diamond:'Diamond',cross:'Cross',diagonal:'Diagonal'}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment