A block puzzle with a secret picture! Mia the Panda hid a friend under the frosted board.
Drag pieces onto the grid, fill a whole row or column, and the line dissolves โ
peeling the picture open. Clear every cell to reveal WHO'S HIDING!
Scoring
Place a piece: +3
Clear a line: +8 each (clear 2โ3 at once for combo Gallery Tickets!)
Reveal the whole picture: +15
Promotions every 150 โญ: Tile Tinker โ Mosaic Maker โ Picture Pathfinder โ Gallery Curator โ each one unlocks a frame and a real power-up.
Reading the board
blue dashed = where your piece will land
yellow โจ = this line will CLEAR and reveal!
red stripes โ = locked cell โ it can never hold a piece, but its line can still clear around it
cyan frame = the hidden picture
Mia's powers
๐งค Pixel Glove Glitch: clear 2+ lines in one drop to charge it, then tap BLAST and tap the board โ a 3ร3 patch glitches away and reveals what's under it.
โฉ Undo: free and endless on Boards 1โ2. Later boards give you a few per board.
๐ Tray jam? If no piece fits, reshuffle is always free. Nobody ever loses here.
๐ Mia's cherry power keeps her speedrunning โ and Bit, her pixel sprite, rides along for luck.
๐ก Hint button: tap ๐ก up top and a TRY HERE spot glows. Costs 1 ๐ โ unless you own the Hint Star!
๐ The Ticket Shop
Finishing boards, combos, zones and finding Bronx earn ๐ Gallery Tickets.
Spend them in the SHOP (main menu, or right after a board): rainbow & golden frames, Mia hoodies, a star trail โ plus real power-ups: +1 Undo, a bigger Glove charge, and free hints forever.
Nothing in the shop is needed to finish a board โ it's all bonus fun. And it never, ever costs real money.
๐
LEVEL UP!
LEVEL 2
๐ผ Gallery & Settings
Pictures revealed: 0
๐จโ๐ฉโ๐ง Family-Photo Mode (grown-ups)
Load a family photo and it becomes the hidden picture under the board โ just like the one
Bentley spotted at football! The photo is processed only on this device: it is never
uploaded, never shared, and never leaves this phone. Family photos never appear on share cards.
Remove it any time.
c[0])), my = Math.min(...cells.map(c=>c[1]));
return cells.map(c=>[c[0]-mx, c[1]-my]).sort((a,b)=> a[1]-b[1] || a[0]-b[0]);
}
function cellsKey(cells){ return normCells(cells).map(c=>c[0]+","+c[1]).join("|"); }
function rotationsOf(id){
const out = [], seen = new Set();
let cur = PIECE_DEFS[id].cells.map(c=>[...c]);
for (let r=0;r<4;r++){
const n = normCells(cur), k = cellsKey(n);
if (!seen.has(k)){ seen.add(k); out.push(n); }
cur = cur.map(c=>[-c[1], c[0]]);
}
return out;
}
const PIECE_ROTS = {}; for (const id in PIECE_DEFS) PIECE_ROTS[id] = rotationsOf(id);
function pieceCells(id, rot){ const rs = PIECE_ROTS[id]; return rs[((rot%rs.length)+rs.length)%rs.length]; }
function pieceDims(id, rot){ const cs = pieceCells(id,rot);
return { w: Math.max(...cs.map(c=>c[0]))+1, h: Math.max(...cs.map(c=>c[1]))+1 }; }
/* Grid values: 0 empty, 1 filled. blocked[][]: true = locked cell. */
function canPlace(grid, blocked, id, rot, ax, ay){
const cs = pieceCells(id, rot), rows = grid.length, cols = grid[0].length;
for (const [dx,dy] of cs){
const x = ax+dx, y = ay+dy;
if (x<0 || y<0 || x>=cols || y>=rows) return false;
if (blocked[y][x] || grid[y][x]) return false;
}
return true;
}
function applyPlace(grid, blocked, id, rot, ax, ay){
const rows = grid.length, cols = grid[0].length;
const g = grid.map(r=>[...r]);
const cs = pieceCells(id, rot);
for (const [dx,dy] of cs) g[ay+dy][ax+dx] = 1;
const rowsHit = [...new Set(cs.map(c=>ay+c[1]))], colsHit = [...new Set(cs.map(c=>ax+c[0]))];
const clearedRows = rowsHit.filter(y => g[y].every((v,x)=> blocked[y][x] || v===1));
const clearedCols = colsHit.filter(x => g.every((row,y)=> blocked[y][x] || row[x]===1));
for (const y of clearedRows) for (let x=0;x best.gain) best = { slot:s, rot:r, x, y, gain, lines:res.lines };
}
}
}
return best;
}
const LOGIC = { PIECE_DEFS, PIECE_ROTS, pieceCells, pieceDims, canPlace, applyPlace, fitsAnywhere, findBestMove };
/* ---------------- Levels (Brief #030 GDD #04 outline) ----------- */
/* Map chars: '.' empty '#' pre-filled 'X' locked 'R' pre-revealed empty */
const LEVELS = [
null,
{ map:[".....",".....","#....","#....","#...#"], pool:["i3","i2","dot","i3","i2"], first:["i3","dot","i2"],
pic:"mia", whats:"Straight lines only โ fill the bottom row!", zones:false },
{ map:[".....",".....","..#..","..#..","#.#.#","###.#"], pool:["c3","i2","i3","dot","c3","sq2"],
pic:"room", whats:"NEW: corner pieces + Mia's COZY ROOM โ peek the furniture!", zones:false },
{ map:[".....#",".....#",".....#",".....#",".....#","#####."], pool:["dot","sq2","t4","i3","i2","c3"],
pic:"leo", whats:"NEW: T-pieces โ and a crossroads clear worth TWO lines!", zones:false },
{ map:[".......",".......",".......","...X...",".......","..#.#..",".#####."], pool:["l4","c3","i3","sq2","t4","i2"],
pic:"violet", whats:"NEW: L-pieces + a LOCKED center square. Lines clear around it!", zones:false },
{ map:[".......",".#...#.",".#...#.",".......",".#...#.",".##.##.","......."], pool:["s4","z4","l4","c3","i3","sq2"],
pic:"bronx", whats:"NEW: S and Z zigzags โ mirror pairs everywhere!", zones:false },
{ map:[".......","#..#..#",".#..#..","..#..#.","#..#..#",".#..#..","..#..#.","#.....#"], pool:["l5","s4","z4","t4","i4","c3"],
pic:"oliver", whats:"NEW: big L-pieces โ staggered bridges to cross!", zones:false },
{ map:["........","........","..####..","..#..#..","..#..#..","..####..","........","........"], pool:["i4","i5","l5","t4","sq2","p5"],
pic:"emma", whats:"NEW: long bars, plus-pieces, two-color layers!", zones:false },
{ map:["R.......",".R......","..R.....","...R....","....R...",".....R..","......R.",".......R"], pool:["p5","r23","i4","l5","t4","s4"],
pic:"lucas", whats:"NEW: chunky rectangles โ a diagonal window is already peeking!", zones:false },
{ map:["........",".#....#.","........","...##...","...##...","........",".#....#.","........"], pool:["sq3","r23","p5","i5","l5","z4"],
pic:"sofia", whats:"NEW: the tray ROTATES after every drop โ plan ahead!", zones:false, rotateTray:true },
{ map:["........",".######.",".#....#.",".#....#.",".#....#.",".#....#.",".######.","........"], pool:["sq3","r23","p5","i5","l5","t4","s4"],
pic:"theodore", whats:"MOSAIC board! Clear each of the 3 zones for bonus ๐!", zones:true },
];
function mulberry32(a){ return function(){ a|=0; a = a+0x6D2B79F5|0; let t = Math.imul(a^a>>>15, 1|a);
t = t + Math.imul(t^t>>>7, 61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
function levelDef(n){
if (n <= 10) return LEVELS[n];
const base = LEVELS[((n-1)%10)+1];
const tier = n<=20 ? "Expert" : n<=30 ? "Master" : "Legend";
return { map: base.map, pool: base.pool, pic: PIC_ROTATION[(n*7)%PIC_ROTATION.length],
whats: tier+" remix โ "+base.whats, zones: base.zones, rotateTray: base.rotateTray, remix:true };
}
function tierName(n){ return n<=10 ? "Express" : n<=20 ? "Expert" : n<=30 ? "Master" : "Legend"; }
/* ---------------- Save ---------------- */
let SAVE = { career:0, tickets:0, promo:0, gallery:{}, dailyDone:"", boardsDone:0, sound:true,
shop:{ owned:{}, equipped:{} } };
try { const sv = JSON.parse(localStorage.getItem("blockbrigade_save")||"null"); if (sv) SAVE = Object.assign(SAVE, sv); } catch(e){}
if (!SAVE.shop || typeof SAVE.shop!=="object") SAVE.shop = { owned:{}, equipped:{} };
if (!SAVE.shop.owned) SAVE.shop.owned = {};
if (!SAVE.shop.equipped) SAVE.shop.equipped = {};
function save(){ try { localStorage.setItem("blockbrigade_save", JSON.stringify(SAVE)); } catch(e){} }
let familyDataURL = null, familyMode = false;
try { familyDataURL = localStorage.getItem("blockbrigade_family") || null; } catch(e){}
let familyImg = null;
if (familyDataURL){ const im = new Image(); im.onload = ()=>{ familyImg = im; }; im.src = familyDataURL; }
/* ---------------- Game state ---------------- */
const S = {
screen:"menu", level:1, tier:"Express",
grid:[], blocked:[], revealed:[], rows:5, cols:5,
tray:[], score:0, boardScore:0, boardLines:0, maxCombo:0, placements:0,
charges:0, undosLeft:0, jam:false, rotateTray:false, zones:false, zonesDone:[],
picChar:"mia", isDaily:false, boardDone:false, luShown:false, luText:"", luNew:"",
promoShown:false, promoText:"", bronxFound:false, bronxPeek:false,
labelsOn:true, intro:false, introT0:0, seenIntro:false, hintUntil:0,
drag:null, selected:-1, pendingAnchor:null, blastArm:false, menuMore:false,
fx:[], bubble:"", bubbleT:0, lastCard:null, busy:false,
};
function promoTitle(tier){
if (tier<=0) return "New Builder";
if (tier===1) return "Tile Tinker"; if (tier===2) return "Mosaic Maker";
if (tier===3) return "Picture Pathfinder"; if (tier===4) return "Gallery Curator";
return "Gallery Curator Lv "+tier;
}
function nextPromoAt(tier){ return 150*(tier+1); }
function chargesStart(){ return SAVE.promo>=4 ? 3 : SAVE.promo>=2 ? 2 : SAVE.promo>=1 ? 1 : 0; }
function gloveCap(){ return 6 + (SAVE.shop.owned.fx_blast_slot ? 2 : 0); } /* shop: Glove Charge Slot */
function undosForLevel(n){ if (n<=2) return Infinity;
return 3 + (SAVE.promo>=2 ? 2 : 0) + (SAVE.shop.owned.fx_undo_plus ? 1 : 0); } /* shop: Undo Plus */
function say(text, secs){ S.bubble = text; S.bubbleT = Date.now() + (secs||2.6)*1000; }
function parseMap(def){
const rows = def.map.length, cols = def.map[0].length;
const grid = [], blocked = [], revealed = [];
for (let y=0;y ({ id, rot:0, placed:false });
if (firstIds){ S.tray = firstIds.map(mk); return; }
const rng = Math.random;
for (let attempt=0; attempt<50; attempt++){
const t = [0,1,2].map(()=> mk(def.pool[Math.floor(rng()*def.pool.length)]));
if (t.some(p => fitsAnywhere(S.grid, S.blocked, p.id))){ S.tray = t; return; }
}
S.tray = [mk("dot"), mk(def.pool[0]), mk("i2")];
}
function revealableCount(){ let n=0; for (let y=0;y row.map(v => v ? "#9fb3c8" : null));
S.rotateTray = !!def.rotateTray; S.zones = !!def.zones; S.zonesDone = [false,false,false];
S.picChar = opts.picChar || (familyMode && familyImg ? "family" : def.pic);
S.isDaily = !!opts.daily;
S.boardScore = 0; S.boardLines = 0; S.maxCombo = 0; S.placements = 0;
S.charges = chargesStart(); S.undosLeft = undosForLevel(level);
S.undoStack = []; S.jam = false; S.boardDone = false; S.luShown = false;
S.selected = -1; S.pendingAnchor = null; S.blastArm = false; S.drag = null; S.fx = [];
S.bronxFound = false;
S.labelsOn = (level===1 && SAVE.boardsDone===0);
dealTray(level===1 && def.first ? def.first : null);
buildPicture();
S.screen = "play";
if (level===1 && !S.seenIntro){ S.intro = true; S.introT0 = Date.now(); S.seenIntro = true;
S.introMove = findBestMove(S.grid, S.blocked, S.revealed, S.tray); }
else { S.intro = false; S.introMove = null; }
showBanner("๐งฉ BLOCK PUZZLE โ clear lines to reveal who's hiding!");
say(S.isDaily ? "Today's Daily Card โ reveal it for double ๐!"
: level===1 ? "Drag a piece onto the board. Fill a whole row!"
: "Who's hiding this time? Clear lines to peek!", 3.2);
refreshHUD();
}
function addScore(n){
S.score = SAVE.career + S.boardScore; /* display helper kept in sync below */
SAVE.career += n; S.boardScore += n;
let p = false;
while (SAVE.career >= nextPromoAt(SAVE.promo)){
SAVE.promo++; p = true;
const perks = { 1:"Blue frame + Glitch Glove charged every board!",
2:"Glitter border + 2 glove charges + 2 extra undos!", 3:"Sticker sheet + bonus ๐ every board!",
4:"A brand-new gallery wall + Glitch Glove starts at 3 charges!" };
S.promoShown = true;
S.promoText = promoTitle(SAVE.promo)+"! "+(perks[SAVE.promo] || "A new decorative tile for your gallery!");
showPromoToast("๐ PROMOTION โ "+S.promoText);
sfxFanfare();
if (SAVE.promo>=1 && S.charges < chargesStart()) S.charges = chargesStart();
}
if (p) save();
S.score = SAVE.career;
refreshHUD();
}
function snapshot(){
return JSON.stringify({ grid:S.grid, revealed:S.revealed, tray:S.tray, cellColor:S.cellColor,
career:S.careerSnapshot, boardScore:S.boardScore, charges:S.charges,
boardLines:S.boardLines, maxCombo:S.maxCombo, placements:S.placements, zonesDone:S.zonesDone });
}
function doPlace(slot, ax, ay, rot){
if (S.screen!=="play" || S.boardDone || S.intro) return false;
const t = S.tray[slot]; if (!t || t.placed) return false;
const useRot = (rot===undefined ? t.rot : rot);
if (!canPlace(S.grid, S.blocked, t.id, useRot, ax, ay)) return false;
S.careerSnapshot = SAVE.career;
S.undoStack.push(snapshot());
const res = applyPlace(S.grid, S.blocked, t.id, useRot, ax, ay);
S.grid = res.grid;
for (const [dx,dy] of pieceCells(t.id, useRot)) S.cellColor[ay+dy][ax+dx] = PIECE_DEFS[t.id].color;
for (const y of res.clearedRows) for (let x=0;x0){
gained += 8*res.lines;
S.boardLines += res.lines;
S.maxCombo = Math.max(S.maxCombo, res.lines);
if (res.lines>=2){ S.charges = Math.min(gloveCap(), S.charges+1);
say("COMBO ร"+res.lines+"! Glitch Glove charged ๐งค", 2.2); }
else say(pick(["Line clear! Peel that picture!","Dissolved! Who's under there?","Nice โ more picture!"]), 1.8);
sfxClear(res.lines);
spawnLineFx(res);
} else { sfxPlace(); }
addScore(gained);
if (S.rotateTray) for (const p of S.tray) if (!p.placed) p.rot++;
if (S.labelsOn && S.placements>=2){ S.labelsOn = false; say("You got it โ GG! Clear it all!", 2.2); }
S.selected = -1; S.pendingAnchor = null;
afterMove();
return true;
}
function afterMove(){
checkZones();
if (revealedCount() >= revealableCount()){ completeBoard(); return; }
if (S.tray.every(t=>t.placed)) dealTray();
checkJam();
}
function checkJam(){
const open = S.tray.filter(t=>!t.placed);
S.jam = open.length>0 && !open.some(t => fitsAnywhere(S.grid, S.blocked, t.id));
if (S.jam) say("Tray jam! Hit RESHUFFLE โ it's free, always.", 3);
return S.jam;
}
function reshuffle(){
if (S.screen!=="play" || S.boardDone) return false;
const def = levelDef(S.level);
for (let attempt=0; attempt<60; attempt++){
const t = S.tray.map(old => old.placed ? old : { id: def.pool[Math.floor(Math.random()*def.pool.length)], rot:0, placed:false });
if (t.some(p=>!p.placed && fitsAnywhere(S.grid, S.blocked, p.id))){ S.tray = t; S.jam = false; sfxClick(); refreshHUD(); return true; }
}
S.tray = S.tray.map(old => old.placed ? old : { id:"dot", rot:0, placed:false });
S.jam = false; return true;
}
function undo(){
if (!S.undoStack || !S.undoStack.length) return false;
if (S.undosLeft!==Infinity && S.undosLeft<=0) return false;
const s = JSON.parse(S.undoStack.pop());
S.grid = s.grid; S.revealed = s.revealed; S.tray = s.tray; S.cellColor = s.cellColor || S.cellColor;
SAVE.career = s.career; S.boardScore = s.boardScore; S.charges = s.charges;
S.boardLines = s.boardLines; S.maxCombo = s.maxCombo; S.placements = s.placements; S.zonesDone = s.zonesDone;
if (S.undosLeft!==Infinity) S.undosLeft--;
S.score = SAVE.career; S.jam = false; S.selected = -1; S.pendingAnchor = null;
save(); sfxClick(); refreshHUD();
return true;
}
function rotateSelected(){
const slot = S.drag ? S.drag.slot : (S.selected>=0 ? S.selected : S.tray.findIndex(t=>!t.placed));
if (slot<0) return false;
S.tray[slot].rot++; S.pendingAnchor = null; sfxClick();
return true;
}
function useBlastAt(cx, cy){
if (S.charges<=0 || S.boardDone) return false;
S.charges--;
for (let y=cy-1;y<=cy+1;y++) for (let x=cx-1;x<=cx+1;x++){
if (x<0||y<0||x>=S.cols||y>=S.rows) continue;
S.grid[y][x] = 0;
if (!S.blocked[y][x]) S.revealed[y][x] = true;
S.fx.push({ x, y, t:Date.now(), kind:"blast" });
}
S.blastArm = false;
sfxBlast();
say("GLITCH! Mia's glove peeled that patch!", 2);
afterMove();
return true;
}
function completeBoard(){
if (S.boardDone) return;
S.boardDone = true;
addScore(15);
let tickets = 1 + (S.maxCombo>=2 ? 1 : 0) + (S.maxCombo>=3 ? 1 : 0) + (SAVE.promo>=3 ? 1 : 0);
if (S.isDaily) tickets *= 2; /* Daily Card boards pay double */
SAVE.tickets += tickets; SAVE.boardsDone++;
const charId = S.picChar;
if (charId!=="family"){
SAVE.gallery[charId] = (SAVE.gallery[charId]||0)+1;
S.lastCard = { char:charId, score:S.boardScore, tickets, level:S.level, daily:S.isDaily, date:new Date().toDateString() };
} else {
S.lastCard = { char:"family", score:S.boardScore, tickets, level:S.level, daily:false, date:new Date().toDateString() };
}
if (S.isDaily) SAVE.dailyDone = new Date().toDateString();
save(); refreshHUD();
sfxReveal(); sfxFanfare();
const next = levelDef(S.level+1);
S.luShown = true;
S.luText = "LEVEL "+(S.level+1);
S.luNew = "What's new: "+next.whats;
document.getElementById("luLevel").textContent = "LEVEL UP! โ LEVEL "+(S.level+1)+" ยท "+tierName(S.level+1);
document.getElementById("luNew").textContent = S.luNew;
document.getElementById("luStats").textContent =
(charId==="family" ? "Family photo revealed!" : "It's "+picName(charId)+"!")+
" Board score "+S.boardScore+" ยท +"+tickets+" ๐";
window.LSProgress&&LSProgress.recordSession("block-blast",{score:S.boardScore,level:S.level});
document.getElementById("luOverlay").classList.add("show");
say("GG โ glitch and go! Picture complete!", 3);
}
function dismissLevelUp(){
document.getElementById("luOverlay").classList.remove("show");
S.luShown = false;
startBoard(S.level+1);
}
function tapBronx(){
if (S.bronxFound || S.screen!=="play") return false;
S.bronxFound = true; SAVE.tickets += 1; save(); refreshHUD();
showPromoToast("๐ You found BRONX hiding by the frame! +1 ๐");
sfxBronx();
return true;
}
function pick(a){ return a[Math.floor(Math.random()*a.length)]; }
/* ---------------- Hint (๐ก button; shop can make it free) ------ */
function hintCost(){ return (S.labelsOn || SAVE.shop.owned.fx_hint_free) ? 0 : 1; }
function useHint(){
if (S.screen!=="play" || S.boardDone || S.intro) return false;
const cost = hintCost();
if (SAVE.tickets < cost){
showPromoToast("๐ก A hint costs 1 ๐ โ finish boards & find combos to earn tickets!");
return false;
}
if (cost>0){ SAVE.tickets -= cost; save(); }
S.hintUntil = Date.now()+4500;
refreshHUD(); sfxZone();
say("Look โ TRY HERE is glowing! ๐", 2.2);
return true;
}
/* ---------------- Daily ---------------- */
function dailyCharId(){
const d = new Date(); const seed = d.getFullYear()*372 + (d.getMonth()+1)*31 + d.getDate();
return PIC_ROTATION[seed % PIC_ROTATION.length];
}
function startDaily(){
startBoard(Math.max(1, Math.min(S.level, 10)), { picChar: dailyCharId(), daily:true });
}
/* ---------------- Audio (WebAudio, all original) ---------------- */
let AC = null, musicTimer = null, musicStep = 0;
function ensureAudio(){
if (AC) { if (AC.resume) AC.resume(); return; }
try {
AC = new (window.AudioContext || window.webkitAudioContext)();
if (SAVE.sound) startMusic();
} catch(e){ AC = null; }
}
function tone(freq, t0, dur, type, vol, slideTo){
if (!AC || !SAVE.sound) return;
const o = AC.createOscillator(), g = AC.createGain();
o.type = type || "triangle"; o.frequency.setValueAtTime(freq, t0);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0+dur);
g.gain.setValueAtTime(vol||.18, t0); g.gain.exponentialRampToValueAtTime(.001, t0+dur);
o.connect(g); g.connect(AC.destination); o.start(t0); o.stop(t0+dur+.02);
}
function noiseHit(t0, dur, vol, freq){
if (!AC || !SAVE.sound) return;
const len = Math.max(1, Math.floor(AC.sampleRate*dur));
const buf = AC.createBuffer(1, len, AC.sampleRate), d = buf.getChannelData(0);
for (let i=0;itone(f, t+i*.06, .18, "triangle", .15)); }
function sfxBlast(){ if (!AC) return; const t = AC.currentTime; tone(880, t, .3, "sawtooth", .1, 110); noiseHit(t, .2, .1, 3000); }
function sfxBronx(){ if (!AC) return; const t = AC.currentTime; tone(520, t, .1, "square", .12); tone(780, t+.1, .16, "square", .12); }
function sfxReveal(){ if (!AC) return; const t = AC.currentTime;
for (let i=0;i<14;i++) tone(500+i*55, t+i*.045, .3, "sine", .07); noiseHit(t, .5, .04, 3600); }
function sfxFanfare(){ if (!AC) return; const t = AC.currentTime;
[523,659,784,1047].forEach((f,i)=>tone(f, t+i*.11, .3, "triangle", .16)); }
function sfxBuy(){ if (!AC || !SAVE.sound) return; const t = AC.currentTime; /* shop cha-ching */
[988,1319,1760].forEach((f,i)=>tone(f, t+i*.07, .16, "sine", .14)); noiseHit(t, .15, .05, 3200); }
const BASS = [130.8,0,130.8,196, 98,0,98,146.8, 110,0,110,164.8, 98,0,146.8,196];
const PENTA = [523,587,659,784,880,1047];
function startMusic(){
if (!AC || musicTimer) return;
let walk = 2;
musicTimer = setInterval(()=>{
if (!SAVE.sound || !AC) return;
const t = AC.currentTime + .03, s = musicStep++;
const b = BASS[s % BASS.length];
if (b) tone(b, t, .22, "square", .05);
if (s % 2 === 0){ walk = Math.max(0, Math.min(PENTA.length-1, walk + (Math.random()<.5?-1:1)));
tone(PENTA[walk], t, .24, "triangle", .055); }
if (s % 2 === 1) noiseHit(t, .03, .018, 6000);
}, 215);
}
/* ---------------- DOM helpers ---------------- */
const $ = id => document.getElementById(id);
let bannerTimer = null;
function showBanner(text){
const b = $("objBanner"); b.textContent = text; b.style.display = "block";
clearTimeout(bannerTimer); bannerTimer = setTimeout(()=>{ b.style.display = "none"; }, 3400);
}
let toastTimer = null;
function showPromoToast(text){
const t = $("promoToast"); t.textContent = text; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(()=> t.classList.remove("show"), 3800);
}
function refreshHUD(){
$("hScore").textContent = "โญ " + SAVE.career;
$("hTickets").textContent = "๐ " + SAVE.tickets;
$("hPill").textContent = S.screen==="menu" ? "BLOCK BRIGADE"
: "Lv "+S.level+" ยท "+S.tier+" ยท "+promoTitle(SAVE.promo);
$("btnSound").textContent = SAVE.sound ? "๐" : "๐";
/* v2.1 slim play HUD: in play only โญ stars + level pill + โ gear stay;
tickets / hint / help / sound live on the menu + panels. */
const inPlay = S.screen==="play";
for (const id of ["hTickets","btnHint","btnHelp","btnSound"]) $(id).style.display = inPlay ? "none" : "";
}
/* ---------------- Picture painters (original canvas art) ------- */
const PC = 92; /* v2.1: sharper plates (was 56) */
let picCanvas = null, blurCanvas = null;
function circ(g,x,y,r,c){ g.fillStyle = c; g.beginPath(); g.arc(x,y,r,0,7); g.fill(); }
function ell(g,x,y,rx,ry,c,rot){ g.fillStyle = c; g.beginPath(); g.ellipse(x,y,rx,ry,rot||0,0,7); g.fill(); }
function rrect(g,x,y,w,h,r,c){ g.fillStyle = c; g.beginPath(); g.roundRect(x,y,w,h,r); g.fill(); }
function paintChar(g, w, h, id){
const u = w/320, cx = w/2, cy = h*0.54, R = Math.min(w,h)*0.30;
const skies = { mia:"#bfe8c9", amelia:"#ffd7ec", leo:"#ffe3ae", violet:"#cdeac0", bronx:"#3d2b4f",
oliver:"#d8f3b0", emma:"#aee3ff", lucas:"#ffedb5", sofia:"#ffe0f1", theodore:"#ffb37a" };
g.fillStyle = skies[id] || "#dff3ff"; g.fillRect(0,0,w,h);
if (id==="bronx"){ g.fillStyle = "#57406e"; g.fillRect(0,h*0.72,w,h*0.28);
g.fillStyle = "#ffe98a"; for (let i=0;i<14;i++) circ(g, (i*53)%w, (i*37)%(h*0.5), 2.2*u, "#ffe98a"); }
else if (id==="emma"){ g.fillStyle = "#7ecbf2"; g.fillRect(0,h*0.7,w,h*0.3);
for (let i=0;i<10;i++) { g.strokeStyle="rgba(255,255,255,.7)"; g.lineWidth=2*u;
g.beginPath(); g.arc((i*67)%w, h*0.2+(i*41)%(h*0.6), 6*u, 0, 7); g.stroke(); } }
else if (id==="theodore"){ circ(g, w*0.82, h*0.2, 34*u, "#e8590c"); ell(g, w*0.82, h*0.34, 44*u, 18*u, "#868e96");
g.fillStyle = "#f4f7f4"; g.fillRect(0, h*0.78, w, h*0.22); }
else { g.fillStyle = "rgba(255,255,255,.5)"; g.fillRect(0, h*0.8, w, h*0.2);
circ(g, w*0.14, h*0.16, 20*u, "#fff6bf"); }
if (id==="mia"){ drawMia(g, cx, cy, R, true); return; }
if (id==="amelia"){ /* axolotl */
for (const s of [-1,1]) for (let i=0;i<3;i++){ const gx = cx+s*R*1.02, gy = cy-R*0.3+i*R*0.32;
g.strokeStyle = "#f06595"; g.lineWidth = 7*u; g.beginPath(); g.moveTo(cx+s*R*0.8, gy); g.lineTo(gx, gy); g.stroke();
circ(g, gx, gy, 8*u, "#ff8fb8"); }
circ(g, cx, cy, R, "#ffb3d1"); circ(g, cx, cy-R*0.75, R*0.34, "#ffc2dd");
rrect(g, cx-R*0.52, cy-R*1.12, R*1.04, R*0.3, R*0.14, "#74c0fc"); circ(g, cx-R*0.3, cy-R*0.97, R*0.13, "#d8f4ff"); circ(g, cx+R*0.3, cy-R*0.97, R*0.13, "#d8f4ff");
circ(g, cx-R*0.36, cy-R*0.08, R*0.1, "#40222e"); circ(g, cx+R*0.36, cy-R*0.08, R*0.1, "#40222e");
circ(g, cx-R*0.62, cy+R*0.3, R*0.16, "#ff8fb8"); circ(g, cx+R*0.62, cy+R*0.3, R*0.16, "#ff8fb8");
g.strokeStyle = "#7a2d4a"; g.lineWidth = 4*u; g.beginPath(); g.arc(cx, cy+R*0.18, R*0.42, .3, Math.PI-.3); g.stroke();
return; }
if (id==="leo"){ /* tiger */
circ(g, cx-R*0.72, cy-R*0.72, R*0.3, "#f59f00"); circ(g, cx+R*0.72, cy-R*0.72, R*0.3, "#f59f00");
circ(g, cx-R*0.72, cy-R*0.72, R*0.16, "#ffe8cc"); circ(g, cx+R*0.72, cy-R*0.72, R*0.16, "#ffe8cc");
circ(g, cx, cy, R, "#ffa94d");
g.strokeStyle = "#7a4a00"; g.lineWidth = 5*u;
for (const s of [-1,1]){ g.beginPath(); g.moveTo(cx+s*R*0.92, cy-R*0.2); g.lineTo(cx+s*R*0.6, cy-R*0.05); g.stroke();
g.beginPath(); g.moveTo(cx+s*R*0.95, cy+R*0.3); g.lineTo(cx+s*R*0.62, cy+R*0.4); g.stroke(); }
g.beginPath(); g.moveTo(cx, cy-R*1.0); g.lineTo(cx, cy-R*0.72); g.stroke();
ell(g, cx, cy+R*0.42, R*0.44, R*0.3, "#fff4e0");
circ(g, cx-R*0.34, cy-R*0.1, R*0.1, "#26200f"); circ(g, cx+R*0.34, cy-R*0.1, R*0.1, "#26200f");
ell(g, cx, cy+R*0.3, R*0.14, R*0.1, "#e8590c");
g.strokeStyle = "#7a4a00"; g.lineWidth = 3.5*u; g.beginPath(); g.arc(cx, cy+R*0.34, R*0.3, .4, Math.PI-.4); g.stroke();
return; }
if (id==="violet"){ /* fox */
for (const s of [-1,1]){ g.fillStyle = "#e8590c"; g.beginPath();
g.moveTo(cx+s*R*0.35, cy-R*0.7); g.lineTo(cx+s*R*0.62, cy-R*1.35); g.lineTo(cx+s*R*1.0, cy-R*0.62); g.closePath(); g.fill();
g.fillStyle = "#5c2e0e"; g.beginPath();
g.moveTo(cx+s*R*0.55, cy-R*0.95); g.lineTo(cx+s*R*0.62, cy-R*1.35); g.lineTo(cx+s*R*0.78, cy-R*0.8); g.closePath(); g.fill(); }
circ(g, cx, cy, R, "#f76707");
ell(g, cx-R*0.42, cy+R*0.42, R*0.4, R*0.34, "#fff4e0"); ell(g, cx+R*0.42, cy+R*0.42, R*0.4, R*0.34, "#fff4e0");
ell(g, cx, cy+R*0.52, R*0.4, R*0.3, "#fff4e0");
circ(g, cx-R*0.36, cy-R*0.12, R*0.1, "#221503"); circ(g, cx+R*0.36, cy-R*0.12, R*0.1, "#221503");
ell(g, cx, cy+R*0.34, R*0.13, R*0.09, "#221503");
g.strokeStyle = "#5c2e0e"; g.lineWidth = 3.5*u; g.beginPath(); g.arc(cx, cy+R*0.4, R*0.26, .4, Math.PI-.4); g.stroke();
return; }
if (id==="bronx"){ /* sphynx โ plain skin, plum ONLY on hat/cape (pack law) */
for (const s of [-1,1]){ g.fillStyle = "#e3bda6"; g.beginPath();
g.moveTo(cx+s*R*0.3, cy-R*0.75); g.lineTo(cx+s*R*0.52, cy-R*1.5); g.lineTo(cx+s*R*0.98, cy-R*0.8); g.closePath(); g.fill();
g.fillStyle = "#d69f83"; g.beginPath();
g.moveTo(cx+s*R*0.44, cy-R*0.85); g.lineTo(cx+s*R*0.55, cy-R*1.3); g.lineTo(cx+s*R*0.82, cy-R*0.85); g.closePath(); g.fill(); }
circ(g, cx, cy, R, "#eecdb6");
g.strokeStyle = "rgba(160,110,80,.5)"; g.lineWidth = 2.5*u;
g.beginPath(); g.arc(cx, cy-R*0.55, R*0.4, Math.PI*1.15, Math.PI*1.85); g.stroke();
ell(g, cx-R*0.36, cy-R*0.05, R*0.17, R*0.2, "#f7e731"); ell(g, cx+R*0.36, cy-R*0.05, R*0.17, R*0.2, "#f7e731");
circ(g, cx-R*0.36, cy-R*0.05, R*0.06, "#222"); circ(g, cx+R*0.36, cy-R*0.05, R*0.06, "#222");
ell(g, cx, cy+R*0.32, R*0.1, R*0.06, "#c98a6b");
g.strokeStyle = "#8a5a3a"; g.lineWidth = 3*u; g.beginPath(); g.arc(cx, cy+R*0.3, R*0.3, .5, Math.PI-.5); g.stroke();
rrect(g, cx-R*0.34, cy-R*1.28, R*0.68, R*0.42, 4*u, "#5b2a6e"); /* top hat โ plum costume */
rrect(g, cx-R*0.52, cy-R*0.9, R*1.04, R*0.1, 4*u, "#5b2a6e");
g.fillStyle = "#5b2a6e"; g.beginPath(); /* cape โ plum costume */
g.moveTo(cx-R*0.9, cy+R*1.02); g.lineTo(cx+R*0.9, cy+R*1.02); g.lineTo(cx+R*0.55, cy+R*0.62); g.lineTo(cx-R*0.55, cy+R*0.62); g.closePath(); g.fill();
return; }
if (id==="oliver"){ /* vole */
circ(g, cx-R*0.7, cy-R*0.7, R*0.3, "#a0855c"); circ(g, cx+R*0.7, cy-R*0.7, R*0.3, "#a0855c");
circ(g, cx-R*0.7, cy-R*0.7, R*0.15, "#d9c39a"); circ(g, cx+R*0.7, cy-R*0.7, R*0.15, "#d9c39a");
circ(g, cx, cy, R, "#b59a6d");
circ(g, cx-R*0.34, cy-R*0.14, R*0.09, "#241a0c"); circ(g, cx+R*0.34, cy-R*0.14, R*0.09, "#241a0c");
ell(g, cx, cy+R*0.22, R*0.13, R*0.09, "#7a5b34");
rrect(g, cx-R*0.11, cy+R*0.34, R*0.22, R*0.24, 2*u, "#ffffff"); /* big vole teeth */
g.strokeStyle = "rgba(60,42,20,.65)"; g.lineWidth = 2*u;
for (const s of [-1,1]) for (let i=0;i<3;i++){ g.beginPath();
g.moveTo(cx+s*R*0.55, cy+R*0.15+i*R*0.12); g.lineTo(cx+s*R*1.05, cy+R*0.05+i*R*0.16); g.stroke(); }
return; }
if (id==="emma"){ /* goby fish */
ell(g, cx, cy+R*0.1, R*1.15, R*0.8, "#ff9f68");
g.fillStyle = "#ff7b3f"; g.beginPath(); g.moveTo(cx-R*0.95, cy+R*0.1); g.lineTo(cx-R*1.45, cy-R*0.3); g.lineTo(cx-R*1.45, cy+R*0.5); g.closePath(); g.fill();
circ(g, cx+R*0.42, cy-R*0.05, R*0.2, "#ffffff"); circ(g, cx+R*0.46, cy-R*0.05, R*0.11, "#14313f");
circ(g, cx+R*0.5, cy-R*0.09, R*0.04, "#ffffff");
ell(g, cx+R*0.62, cy+R*0.42, R*0.16, R*0.09, "#e8590c");
g.fillStyle = "#ffb37a"; g.beginPath(); g.moveTo(cx-R*0.2, cy-R*0.62); g.lineTo(cx+R*0.15, cy-R*1.05); g.lineTo(cx+R*0.4, cy-R*0.55); g.closePath(); g.fill();
return; }
if (id==="lucas"){ /* cheetah โ tear marks (pack) */
circ(g, cx-R*0.72, cy-R*0.7, R*0.28, "#f2c14e"); circ(g, cx+R*0.72, cy-R*0.7, R*0.28, "#f2c14e");
circ(g, cx, cy, R, "#ffd964");
g.fillStyle = "#8a6d1f";
for (const [sx,sy] of [[-.6,-.62],[.55,-.7],[.15,-.85],[-.85,.1],[.88,.15],[.7,.62],[-.72,.55]]) circ(g, cx+sx*R, cy+sy*R, R*0.07, "#8a6d1f");
circ(g, cx-R*0.34, cy-R*0.12, R*0.1, "#2b2103"); circ(g, cx+R*0.34, cy-R*0.12, R*0.1, "#2b2103");
g.strokeStyle = "#2b2103"; g.lineWidth = 5*u;
for (const s of [-1,1]){ g.beginPath(); g.moveTo(cx+s*R*0.34, cy+R*0.02); g.quadraticCurveTo(cx+s*R*0.4, cy+R*0.3, cx+s*R*0.24, cy+R*0.48); g.stroke(); }
ell(g, cx, cy+R*0.36, R*0.36, R*0.24, "#fff6d8"); ell(g, cx, cy+R*0.3, R*0.12, R*0.08, "#8a5a2a");
return; }
if (id==="sofia"){ /* spoonbill */
circ(g, cx, cy-R*0.15, R*0.92, "#ffb3d9");
for (let i=0;i<4;i++) circ(g, cx-R*0.3+i*R*0.2, cy-R*1.0, R*0.13, "#ff8fc2");
ell(g, cx, cy+R*0.28, R*0.62, R*0.2, "#9e5b7a"); /* the famous spoon bill */
ell(g, cx, cy+R*0.24, R*0.62, R*0.17, "#c98aa6");
circ(g, cx-R*0.34, cy-R*0.3, R*0.09, "#40222e"); circ(g, cx+R*0.34, cy-R*0.3, R*0.09, "#40222e");
ell(g, cx-R*0.6, cy+R*0.05, R*0.14, R*0.08, "#ff8fc2"); ell(g, cx+R*0.6, cy+R*0.05, R*0.14, R*0.08, "#ff8fc2");
return; }
/* theodore โ T-Rex */
ell(g, cx+R*0.1, cy, R*1.05, R*0.88, "#66bb4d");
ell(g, cx+R*0.42, cy+R*0.28, R*0.62, R*0.42, "#7ed957");
g.fillStyle = "#ffffff";
for (let i=0;i<4;i++){ g.beginPath(); const tx = cx+R*(0.05+i*0.24);
g.moveTo(tx, cy+R*0.52); g.lineTo(tx+R*0.09, cy+R*0.52); g.lineTo(tx+R*0.045, cy+R*0.7); g.closePath(); g.fill(); }
circ(g, cx+R*0.3, cy-R*0.32, R*0.11, "#17301a"); circ(g, cx+R*0.33, cy-R*0.35, R*0.04, "#fff");
ell(g, cx+R*0.72, cy+R*0.08, R*0.07, R*0.045, "#3d7a2e");
circ(g, cx-R*0.5, cy-R*0.5, R*0.16, "#ffd43b"); /* volcano-orange crest patch */
}
function drawMia(g, cx, cy, R, inPicture){
/* v2.1: glossy 3D Mia sprite. Shop hoodie cosmetics still use the
vector Mia (it recolors); everything else gets the 3D host. */
const hoodieOn = SAVE.shop.equipped.hoodie==="mia_pink" || SAVE.shop.equipped.hoodie==="mia_ocean";
if (typeof PLATES!=="undefined" && PLATES.mia3d && !hoodieOn){
const img = PLATES.mia3d, s = R*3.0, w = s*img.width/img.height;
g.drawImage(img, cx-w/2, cy-s*0.40, w, s);
return;
}
drawMiaVector(g, cx, cy, R, inPicture);
}
function drawMiaVector(g, cx, cy, R, inPicture){
const u = R/96;
const hoodieCol = SAVE.shop.equipped.hoodie==="mia_pink" ? "#f783ac"
: SAVE.shop.equipped.hoodie==="mia_ocean" ? "#4dabf7" : "#3f9e4d"; /* shop hoodie */
ell(g, cx, cy+R*1.06, R*1.02, R*0.5, hoodieCol); /* bamboo-green hoodie */
{ const hg = g.createLinearGradient(cx, cy+R*0.5, cx, cy+R*1.6); hg.addColorStop(0, "rgba(255,255,255,.22)"); hg.addColorStop(0.55, "rgba(0,0,0,0)"); hg.addColorStop(1, "rgba(0,0,0,.20)");
g.fillStyle = hg; g.beginPath(); g.ellipse(cx, cy+R*1.06, R*1.02, R*0.5, 0, 0, 6.29); g.fill(); } /* hoodie light/shade */
rrect(g, cx-R*0.16, cy+R*0.78, R*0.32, R*0.44, R*0.08, "#2b6cb0");
circ(g, cx-R*0.7, cy-R*0.68, R*0.3, "#22252a"); circ(g, cx+R*0.7, cy-R*0.68, R*0.3, "#22252a");
circ(g, cx-R*0.79, cy-R*0.79, R*0.12, "#4a5058"); circ(g, cx+R*0.61, cy-R*0.79, R*0.12, "#4a5058"); /* ear sheen */
{ const fg = g.createRadialGradient(cx-R*0.32, cy-R*0.42, R*0.2, cx, cy, R*1.03);
fg.addColorStop(0, "#ffffff"); fg.addColorStop(0.72, "#fdfdf8"); fg.addColorStop(1, "#dce6de");
g.fillStyle = fg; g.beginPath(); g.arc(cx, cy, R, 0, 6.29); g.fill(); } /* shaded face */
circ(g, cx+R*0.16, cy-R*1.04, R*0.33, "#22252a"); /* man bun */
circ(g, cx+R*0.04, cy-R*1.15, R*0.13, "#4a5058"); /* bun sheen */
rrect(g, cx-R*0.06, cy-R*1.12, R*0.44, R*0.15, R*0.07, "#3f9e4d");
g.save(); g.translate(cx-R*0.36, cy-R*0.06); g.rotate(-.35); ell(g, 0, 0, R*0.2, R*0.3, "#22252a", 0); g.restore();
g.save(); g.translate(cx+R*0.36, cy-R*0.06); g.rotate(.35); ell(g, 0, 0, R*0.2, R*0.3, "#22252a", 0); g.restore();
circ(g, cx-R*0.36, cy-R*0.1, R*0.075, "#fdfdf8"); circ(g, cx+R*0.36, cy-R*0.1, R*0.075, "#fdfdf8");
circ(g, cx-R*0.36, cy-R*0.08, R*0.04, "#14161a"); circ(g, cx+R*0.36, cy-R*0.08, R*0.04, "#14161a");
ell(g, cx, cy+R*0.26, R*0.13, R*0.09, "#22252a");
ell(g, cx-R*0.6, cy+R*0.3, R*0.17, R*0.1, "rgba(247,131,172,.5)"); ell(g, cx+R*0.6, cy+R*0.3, R*0.17, R*0.1, "rgba(247,131,172,.5)"); /* blush */
g.strokeStyle = "#7a4a21"; g.lineWidth = 3.5*u; g.beginPath(); g.arc(cx, cy+R*0.3, R*0.3, .35, Math.PI-.35); g.stroke();
g.strokeStyle = "#343a40"; g.lineWidth = 7*u; /* headset, one ear flipped up */
g.beginPath(); g.arc(cx, cy-R*0.12, R*1.02, Math.PI*1.12, Math.PI*1.88); g.stroke();
rrect(g, cx+R*0.92, cy-R*0.42, R*0.17, R*0.34, R*0.06, "#343a40");
g.save(); g.translate(cx-R*0.98, cy-R*0.62); g.rotate(-.7); rrect(g, -R*0.08, -R*0.17, R*0.17, R*0.34, R*0.06, "#343a40"); g.restore();
g.strokeStyle = "#343a40"; g.lineWidth = 4*u;
g.beginPath(); g.moveTo(cx+R*1.0, cy-R*0.08); g.quadraticCurveTo(cx+R*0.95, cy+R*0.42, cx+R*0.42, cy+R*0.4); g.stroke();
circ(g, cx+R*0.4, cy+R*0.4, R*0.06, "#22252a");
if (!inPicture){ /* pixel glove up, Bit the sprite alongside */
circ(g, cx+R*1.12, cy+R*0.62, R*0.24, "#69db7c");
circ(g, cx+R*1.12, cy+R*0.62, R*0.13, "#d3ffd8");
const bob = Math.sin(Date.now()/300)*3*u;
g.fillStyle = "#51ff7a"; g.fillRect(cx-R*1.32, cy+R*0.28+bob, 9*u, 9*u);
g.fillStyle = "#baffd0"; g.fillRect(cx-R*1.32+2.5*u, cy+R*0.3+bob, 4*u, 4*u);
}
}
/* buildPicture + photo plates + mystery mosaic live in bb-pic.js (v2.1) */
/* ---------------- Layout + drawing ---------------- */
let LAY = { x0:20, y0:118, cell:44, tw:220, th:220 };
let BTNS = {}, MENU_BTNS = {}, TRAY_SLOTS = [];
function layout(){
const cell = Math.max(30, Math.floor(Math.min((W-28)/S.cols, 296/S.rows)));
LAY.cell = cell; LAY.tw = cell*S.cols; LAY.th = cell*S.rows;
LAY.x0 = Math.round((W-LAY.tw)/2); LAY.y0 = 118;
const trayY = LAY.y0 + LAY.th + 44, btnY = trayY + 96;
TRAY_SLOTS = [0,1,2].map(i => ({ x: 10+i*136, y: trayY, w: 128, h: 88 }));
BTNS = {
rotate: { x:8, y:btnY, w:96, h:46, label:"โณ ROTATE" },
undo: { x:110, y:btnY, w:96, h:46, label:"โฉ UNDO" },
blast: { x:212, y:btnY, w:96, h:46, label:"๐งค BLAST" },
shuffle: { x:314, y:btnY, w:98, h:46, label:"๐ MIX" },
place: { x:W-126, y:trayY-56, w:118, h:48, label:"โ PLACE" },
progY: LAY.y0 + LAY.th + 14,
};
MENU_BTNS = {
play: { x:55, y:436, w:310, h:66, label:"โถ PLAY" },
more: { x:110, y:516, w:200, h:46, label:"โฆ MORE" },
daily: { x:85, y:452, w:250, h:48, label:"๐ DAILY CARD" },
shop: { x:85, y:510, w:250, h:48, label:"๐ TICKET SHOP" },
help: { x:85, y:568, w:250, h:44, label:"โ HOW TO PLAY" },
closeMore: { x:150, y:624, w:120, h:40, label:"โ CLOSE" },
};
}
/* drawBlock (glossy 3D) lives in bb-pic.js (v2.1) */function drawPieceAt(g, id, rot, x, y, s, alpha){
const cs = pieceCells(id, rot);
for (const [dx,dy] of cs) drawBlock(g, x+dx*s, y+dy*s, s, PIECE_DEFS[id].color, alpha);
}
function drawBtn(g, b, opts){
opts = opts || {};
g.save();
if (opts.pulse){ const p = .5+.5*Math.sin(Date.now()/160); g.shadowColor = "#ffd43b"; g.shadowBlur = 10+12*p; }
if (opts.armed){ g.shadowColor = "#ff5252"; g.shadowBlur = 16; }
rrect(g, b.x, b.y, b.w, b.h, 14, opts.disabled ? "#9aa5a0" : (opts.color || "#fffbf0"));
g.shadowBlur = 0;
g.lineWidth = 3; g.strokeStyle = opts.armed ? "#d63031" : "#3f9e4d";
g.beginPath(); g.roundRect(b.x, b.y, b.w, b.h, 14); g.stroke();
g.fillStyle = opts.disabled ? "#e8ecea" : "#1d5c2a";
g.font = "900 15px Trebuchet MS, sans-serif"; g.textAlign = "center"; g.textBaseline = "middle";
g.fillText(opts.label || b.label, b.x+b.w/2, b.y+b.h/2+1);
g.restore();
}
/* ---- ART PASS: painted backdrop + twinkles + sheen ---- */
const ART_RM = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const artBg = new Image(); let artBgOk = false; artBg.onload = function(){ artBgOk = true; }; artBg.src = 'block-blast-hero.webp'; /* BGFIX2: square 1024 hero plate crops far sharper in portrait than the 960x540 bg plate */
const artHero = new Image(); let artHeroOk = false; artHero.onload = function(){ artHeroOk = true; }; artHero.src = 'block-blast-hero.webp';
const artTwk = []; for (let _i = 0; _i < 34; _i++) artTwk.push({ x: Math.random(), y: Math.random(), r: 0.8 + Math.random()*1.7, p: Math.random()*6.28 });
function artDrawBg(w, h){ if (!artBgOk) return false; const _t = performance.now(); const dx = ART_RM ? 0 : Math.sin(_t*0.000045)*12, dy = ART_RM ? 0 : Math.cos(_t*0.00006)*5; const sc = Math.max((w+30)/artBg.width, (h+14)/artBg.height); const dw = artBg.width*sc, dh = artBg.height*sc; ctx.drawImage(artBg, (w-dw)/2+dx, (h-dh)/2+dy, dw, dh); return true; }
function artTwinkles(w, h){ const _t = performance.now(); ctx.save(); ctx.fillStyle = '#eaf6ff'; for (const s of artTwk){ ctx.globalAlpha = ART_RM ? 0.45 : 0.22 + 0.55*Math.abs(Math.sin(_t*0.001 + s.p)); ctx.beginPath(); ctx.arc(s.x*w, s.y*h, s.r, 0, 6.29); ctx.fill(); } ctx.restore(); }
function artSheen(w, h){ const _t = performance.now(); const sx = ART_RM ? w*0.62 : ((_t*0.018) % (w*1.9)) - w*0.45; const sg = ctx.createLinearGradient(sx - w*0.28, 0, sx + w*0.28, h*0.35); sg.addColorStop(0, 'rgba(170,255,235,0)'); sg.addColorStop(0.5, 'rgba(190,245,255,0.055)'); sg.addColorStop(1, 'rgba(255,205,245,0)'); ctx.fillStyle = sg; ctx.fillRect(0, 0, w, h); const vg = ctx.createRadialGradient(w/2, h*0.44, Math.min(w, h)*0.42, w/2, h*0.52, Math.max(w, h)*0.78); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(2,8,28,0.15)'); /* BGFIX2: lighter vignette */ ctx.fillStyle = vg; ctx.fillRect(0, 0, w, h); }
function draw(){
const g = ctx, now = Date.now();
layout();
if (artDrawBg(W, H)) { g.save(); g.globalAlpha = 0.20; g.fillStyle = SAVE.promo>=4 ? "#241a33" : "#0d2016"; g.fillRect(0,0,W,H); g.restore(); } /* BGFIX2: wash 0.55 -> 0.20 so the plate reads crisp, not hazy */ else { g.fillStyle = SAVE.promo>=4 ? "#241a33" : "#0d2016"; g.fillRect(0,0,W,H); }
if (SAVE.promo>=4){ g.fillStyle = "rgba(255,255,255,.06)";
for (let i=0;i<8;i++) g.fillRect(20+i*52, 60, 26, 34); }
artTwinkles(W, H);
if (S.screen==="menu"){ drawMenu(g, now); requestAnimationFrame(draw); return; }
/* Mia header */
drawMia(g, 52, 66, 34, false);
g.fillStyle = "#eaffea"; g.font = "900 13px Trebuchet MS, sans-serif"; g.textAlign = "left"; g.textBaseline = "alphabetic";
g.fillText("MIA THE PANDA #13", 100, 68); /*S2P*/
g.fillStyle = "#9fe8a8"; g.font = "700 11px Trebuchet MS, sans-serif";
g.fillText(S.picChar==="family" ? "Family-Photo Board!" :
"Hidden: "+(S.isDaily ? "DAILY CARD" : picShort(S.picChar)+"โฆ?"), 100, 84); /*S2P*/
if (S.bubble && now < S.bubbleT){
g.font = "700 12px Trebuchet MS, sans-serif";
const tw = Math.min(268, g.measureText(S.bubble).width+18);
rrect(g, 98, 90, tw, 30, 12, "rgba(255,251,240,.97)");
g.fillStyle = "#33402f"; g.fillText(S.bubble, 107, 109);
}
/* Picture frame (shop frame overrides the promotion frame) */
const frameCols = ["#8a5a2a","#2b6cb0","#c0a062","#7a4d00","#ffd43b"];
let fc = frameCols[Math.min(SAVE.promo, 4)];
if (SAVE.shop.equipped.frame==="frame_rainbow") fc = "hsl("+(Math.floor(now/8)%360)+",85%,55%)";
else if (SAVE.shop.equipped.frame==="frame_gold") fc = "#f5b301";
rrect(g, LAY.x0-11, LAY.y0-11, LAY.tw+22, LAY.th+22, 14, fc);
if (SAVE.promo>=2){ g.fillStyle = "#fff3b0";
for (let i=0;i<10;i++){ const sx = LAY.x0-6+((i*67)%(LAY.tw+8)), sy = (i%2? LAY.y0+LAY.th+3 : LAY.y0-8);
g.fillRect(sx, sy, 3, 3); } }
if (SAVE.promo>=3){ g.font = "13px sans-serif"; g.fillText("๐", LAY.x0-9, LAY.y0-2); g.fillText("โญ", LAY.x0+LAY.tw-4, LAY.y0+LAY.th+14); }
g.lineWidth = 3; g.strokeStyle = "#22b8cf"; g.beginPath();
g.roundRect(LAY.x0-4, LAY.y0-4, LAY.tw+8, LAY.th+8, 8); g.stroke();
drawBronxPeek(g, now);
/* Grid cells */
const cs = LAY.cell;
for (let y=0;y{
const sl = TRAY_SLOTS[i];
rrect(g, sl.x, sl.y, sl.w, sl.h, 14, S.selected===i ? "#fff3b0" : "rgba(255,251,240,.94)");
g.lineWidth = S.selected===i ? 4 : 2; g.strokeStyle = S.selected===i ? "#e0a800" : "#3f9e4d";
g.beginPath(); g.roundRect(sl.x, sl.y, sl.w, sl.h, 14); g.stroke();
if (t.placed){ g.fillStyle = "#9fb3a5"; g.font = "900 13px Trebuchet MS"; g.textAlign = "center";
g.fillText("PLACED โ", sl.x+sl.w/2, sl.y+sl.h/2); g.textAlign = "left"; return; }
if (S.drag && S.drag.slot===i) return;
const dims = pieceDims(t.id, t.rot), ts = Math.min(cs*0.92, (sl.w-16)/dims.w, (sl.h-16)/dims.h);
drawPieceAt(g, t.id, t.rot, sl.x+(sl.w-dims.w*ts)/2, sl.y+(sl.h-dims.h*ts)/2, ts, 1);
if (S.labelsOn && i===0){ g.fillStyle = "#c0392b"; g.font = "900 11px Trebuchet MS"; g.textAlign = "center";
g.fillText("๐ DRAG ME!", sl.x+sl.w/2, sl.y-6); g.textAlign = "left"; }
});
if (S.labelsOn){
g.font = "900 11px Trebuchet MS";
g.fillStyle = "#7fd4ff"; g.fillText("โฆ = drop spot", 14, BTNS.rotate.y+68);
g.fillStyle = "#ffe066"; g.fillText("โจ = line clears!", 130, BTNS.rotate.y+68);
g.fillStyle = "#ff9d9d"; g.fillText("โ = locked", 268, BTNS.rotate.y+68);
}
/* Buttons */
drawBtn(g, BTNS.rotate, {});
drawBtn(g, BTNS.undo, { disabled: !S.undoStack || !S.undoStack.length || S.undosLeft===0 });
drawBtn(g, BTNS.blast, { armed: S.blastArm, disabled: S.charges<=0, label:"๐งคร"+S.charges });
drawBtn(g, BTNS.shuffle, { pulse: S.jam, color: S.jam ? "#ffd43b" : "#fffbf0" });
if (S.undosLeft!==Infinity){ g.fillStyle = "#cfe8cf"; g.font = "900 10.5px Trebuchet MS"; g.textAlign = "center";
g.fillText("undos: "+S.undosLeft, BTNS.undo.x+BTNS.undo.w/2, BTNS.undo.y+BTNS.undo.h+13); g.textAlign = "left"; }
if (S.selected>=0 && S.pendingAnchor) drawBtn(g, BTNS.place, { color:"#ffd43b" });
if (S.blastArm){ g.fillStyle = "#ffb3b3"; g.font = "900 13px Trebuchet MS"; g.textAlign = "center";
g.fillText("TAP THE BOARD TO GLITCH A 3ร3 PATCH!", W/2, BTNS.progY-8); g.textAlign = "left"; }
drawFx(g, now);
if (S.intro) drawIntro(g, now);
requestAnimationFrame(draw);
}
function drawBronxPeek(g, now){
if (S.bronxFound) return;
const bx = LAY.x0+LAY.tw-2, by = LAY.y0-12+Math.sin(now/500)*1.5;
g.fillStyle = "#e3bda6";
g.beginPath(); g.moveTo(bx-13, by+8); g.lineTo(bx-9, by-9); g.lineTo(bx-2, by+6); g.closePath(); g.fill();
g.beginPath(); g.moveTo(bx+13, by+8); g.lineTo(bx+9, by-9); g.lineTo(bx+2, by+6); g.closePath(); g.fill();
ell(g, bx, by+8, 11, 8, "#eecdb6");
rrect(g, bx-6, by-8, 12, 6, 2, "#5b2a6e");
rrect(g, bx-9, by-3, 18, 2.6, 1, "#5b2a6e");
}
function previewLines(slot, ax, ay, rot){
const t = S.tray[slot]; if (!t) return null;
if (!canPlace(S.grid, S.blocked, t.id, rot, ax, ay)) return null;
return applyPlace(S.grid, S.blocked, t.id, rot, ax, ay);
}
function drawGhostCells(g, slot, ax, ay, rot, px, py){
const t = S.tray[slot]; if (!t) return;
const res = previewLines(slot, ax, ay, rot);
const cs = LAY.cell, cells = pieceCells(t.id, rot);
if (res){
g.fillStyle = "rgba(255,224,102,.4)";
for (const y of res.clearedRows) g.fillRect(LAY.x0, LAY.y0+y*cs, LAY.tw, cs);
for (const x of res.clearedCols) g.fillRect(LAY.x0+x*cs, LAY.y0, cs, LAY.th);
g.save(); g.setLineDash([6,4]); g.lineWidth = 3; g.strokeStyle = "#2b6cb0";
for (const [dx,dy] of cells) g.strokeRect(LAY.x0+(ax+dx)*cs+2, LAY.y0+(ay+dy)*cs+2, cs-4, cs-4);
g.restore();
for (const [dx,dy] of cells) drawBlock(g, LAY.x0+(ax+dx)*cs, LAY.y0+(ay+dy)*cs, cs, PIECE_DEFS[t.id].color, .75);
if (res.lines>0){ g.fillStyle = "#ffe066"; g.font = "900 13px Trebuchet MS";
g.fillText("โจ "+res.lines+" LINE"+(res.lines>1?"S":"")+"!", LAY.x0+(ax)*cs, LAY.y0+ay*cs-6); }
} else if (px!==undefined){
for (const [dx,dy] of cells){
const gx = LAY.x0+(ax+dx)*cs, gy = LAY.y0+(ay+dy)*cs;
if (ax+dx>=0 && ay+dy>=0 && ax+dx=0 && S.pendingAnchor){
drawGhostCells(g, S.selected, S.pendingAnchor.x, S.pendingAnchor.y, S.tray[S.selected].rot);
} else if ((S.labelsOn || now < S.hintUntil) && S.screen==="play" && !S.intro){
const mv = findBestMove(S.grid, S.blocked, S.revealed, S.tray);
if (mv && mv.lines>0){ const p = .45+.3*Math.sin(now/220);
const cs = LAY.cell;
g.save(); g.globalAlpha = p; g.setLineDash([6,4]); g.lineWidth = 3; g.strokeStyle = "#2b6cb0";
for (const [dx,dy] of pieceCells(S.tray[mv.slot].id, mv.rot))
g.strokeRect(LAY.x0+(mv.x+dx)*cs+2, LAY.y0+(mv.y+dy)*cs+2, cs-4, cs-4);
g.restore();
g.fillStyle = "#bfe0ff"; g.font = "900 11.5px Trebuchet MS";
g.fillText("TRY HERE! ๐", LAY.x0+mv.x*cs, LAY.y0+mv.y*cs-6);
}
}
}
function drawPieceFloating(g, t, d){
const dims = pieceDims(t.id, t.rot), s = LAY.cell;
drawPieceAt(g, t.id, t.rot, d.px-d.gx*s-s*0, d.py-d.gy*s, s, .95);
}
function spawnLineFx(res){
const now = Date.now();
for (const y of res.clearedRows) S.fx.push({ kind:"txt", txt:"+8", gx:LAY.x0+LAY.tw/2, gy:LAY.y0+y*LAY.cell, t:now });
for (const x of res.clearedCols) S.fx.push({ kind:"txt", txt:"+8", gx:LAY.x0+x*LAY.cell+LAY.cell/2, gy:LAY.y0+LAY.th/2, t:now });
for (const y of res.clearedRows) for (let x=0;x now-f.t < 650);
for (const f of S.fx){
const k = (now-f.t)/650;
if (f.kind==="spark"){ g.save(); g.globalAlpha = 1-k; g.fillStyle = "#ffe066";
const cxp = LAY.x0+f.x*LAY.cell+LAY.cell/2, cyp = LAY.y0+f.y*LAY.cell+LAY.cell/2;
g.fillRect(cxp-2, cyp-8*k-2, 4, 4); g.fillRect(cxp-8*k-2, cyp-2, 4, 4); g.fillRect(cxp+8*k-2, cyp-2, 4, 4);
g.restore(); }
else if (f.kind==="blast"){ g.save(); g.globalAlpha = .6*(1-k);
g.fillStyle = SAVE.shop.equipped.trail==="trail_stars" ? "#ffd43b" : "#b197fc"; /* shop trail: gold blasts */
g.fillRect(LAY.x0+f.x*LAY.cell+3, LAY.y0+f.y*LAY.cell+3, LAY.cell-6, LAY.cell-6); g.restore(); }
else if (f.kind==="txt"){ g.save(); g.globalAlpha = 1-k; g.fillStyle = "#ffe066";
g.font = "900 19px Trebuchet MS"; g.textAlign = "center";
g.fillText(f.txt, f.gx, f.gy-26*k); g.restore(); g.textAlign = "left"; }
}
}
function drawIntro(g, now){
const el = (now-S.introT0)/1000;
if (el>=2.6){ S.intro = false; return; }
const mv = S.introMove;
g.fillStyle = "rgba(8,24,14,.25)"; g.fillRect(0,0,W,H);
if (mv){
const t = S.tray[mv.slot], cs = LAY.cell;
if (el<0.9){
const k = el/0.9, sx = TRAY_SLOTS[mv.slot].x+40, sy = TRAY_SLOTS[mv.slot].y+30;
const txp = LAY.x0+mv.x*cs, typ = LAY.y0+mv.y*cs;
drawPieceAt(g, t.id, mv.rot, sx+(txp-sx)*k, sy+(typ-sy)*k, cs, 1);
} else if (el<1.7){
drawGhostCells(g, mv.slot, mv.x, mv.y, mv.rot);
g.fillStyle = "rgba(255,255,255,"+(0.5*(1-(el-0.9)/0.8))+")";
for (const y of (previewLines(mv.slot, mv.x, mv.y, mv.rot)||{clearedRows:[]}).clearedRows)
g.fillRect(LAY.x0, LAY.y0+y*cs, LAY.tw, cs);
} else {
g.save(); g.beginPath(); g.arc(LAY.x0+cs*1.5, LAY.y0+cs*1.5, cs*1.4, 0, 7); g.clip();
if (picCanvas) g.drawImage(picCanvas, 0, 0, LAY.tw, LAY.th, LAY.x0, LAY.y0, LAY.tw, LAY.th);
g.restore();
g.lineWidth = 4; g.strokeStyle = "#22b8cf"; g.beginPath(); g.arc(LAY.x0+cs*1.5, LAY.y0+cs*1.5, cs*1.4, 0, 7); g.stroke();
rrect(g, W/2-92, LAY.y0+LAY.th+52, 184, 34, 14, "#fffbf0");
g.fillStyle = "#c0392b"; g.font = "900 17px Trebuchet MS"; g.textAlign = "center";
g.fillText("WHO'S THAT?! ๐", W/2, LAY.y0+LAY.th+74); g.textAlign = "left";
}
}
g.fillStyle = "#fff"; g.font = "900 13px Trebuchet MS"; g.textAlign = "center";
g.fillText("TAP TO SKIP โถ", W/2, H-12); g.textAlign = "left";
}
function drawMenu(g, now){
if (artHeroOk) { g.save(); const _sc = Math.max(W/artHero.width, H/artHero.height), _dw = artHero.width*_sc, _dh = artHero.height*_sc; g.drawImage(artHero, (W-_dw)/2, (H-_dh)/2, _dw, _dh); const _tg = g.createLinearGradient(0,0,0,180); _tg.addColorStop(0,"rgba(20,4,40,.34)"); _tg.addColorStop(1,"rgba(20,4,40,0)"); g.fillStyle = _tg; g.fillRect(0,0,W,180); const _bg2 = g.createLinearGradient(0,540,0,H); _bg2.addColorStop(0,"rgba(20,4,40,0)"); _bg2.addColorStop(1,"rgba(20,4,40,.30)"); g.fillStyle = _bg2; g.fillRect(0,540,W,H-540); g.restore(); }
const bobY = Math.sin(now/620)*2.5;
g.textAlign = "center";
const _ttg = g.createLinearGradient(0, 76+bobY, 0, 124+bobY);
_ttg.addColorStop(0, "#fff3b0"); _ttg.addColorStop(.55, "#ffd43b"); _ttg.addColorStop(1, "#ff8a3d");
g.font = "900 39px 'Trebuchet MS','Arial Black','Segoe UI',sans-serif"; g.lineWidth = 8; g.strokeStyle = "#3d0a63"; g.lineJoin = "round";
g.strokeText("BLOCK BRIGADE", W/2, 118+bobY);
g.fillStyle = _ttg; g.fillText("BLOCK BRIGADE", W/2, 118+bobY);
g.fillStyle = "#aef3ff"; g.font = "700 15px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.shadowColor = "rgba(20,0,40,.55)"; g.shadowBlur = 5;
g.fillText("Mia's picture-reveal block puzzle", W/2, 144+bobY);
g.shadowBlur = 0;
drawMia(g, W/2, 274 + Math.sin(now/430)*4.5, 102, false);
/* drifting toy blocks in the side margins โ the menu is alive now */
const fbCols = ["#ff5964","#ffd43b","#37d5ff","#7CFC9a","#ff7eb6","#b197fc"];
for (let i=0;i<6;i++){ const fx = (i%2===0) ? 12 + (i*13)%36 : W-52 - (i*11)%36;
const fy2 = 660 - ((now*0.016*(1+(i%3)*0.4) + i*97) % 450);
g.save(); g.globalAlpha = 0.5 + 0.35*Math.sin(now/700 + i*1.7); drawBlock(g, fx, fy2, 17 + (i%3)*4, fbCols[i]); g.restore(); }
g.fillStyle = "#fff8dc"; g.font = "900 15px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.fillText("โญ "+SAVE.career+" ๐ "+SAVE.tickets+" ๐ "+promoTitle(SAVE.promo), W/2, 402);
g.fillStyle = "#ffe9a8"; g.font = "700 12.5px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.fillText("Pictures revealed: "+Object.values(SAVE.gallery).reduce((a,b)=>a+b,0)+
" ยท Daily card: "+(SAVE.dailyDone===new Date().toDateString() ? "done โ" : picName(dailyCharId())), W/2, 424);
if (!S.menuMore){
for (const k of ["play","more"]){
const b = MENU_BTNS[k];
if (k==="play"){ g.save(); g.shadowColor = "#ffd43b"; g.shadowBlur = 14 + 12*(.5+.5*Math.sin(now/170)); }
rrect(g, b.x, b.y, b.w, b.h, 16, k==="play" ? "#3f9e4d" : "rgba(255,251,240,.94)");
g.lineWidth = 3; g.strokeStyle = "#3f9e4d"; g.beginPath(); g.roundRect(b.x, b.y, b.w, b.h, 16); g.stroke();
if (k==="play") g.restore();
g.fillStyle = k==="play" ? "#fff" : "#1d5c2a"; g.font = "900 "+(k==="play" ? 25 : 17)+"px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.fillText(b.label, W/2, b.y+b.h/2+(k==="play" ? 9 : 6));
}
} else {
rrect(g, 55, 428, 310, 248, 18, "rgba(16,6,44,.90)");
g.lineWidth = 3; g.strokeStyle = "#b197fc"; g.beginPath(); g.roundRect(55, 428, 310, 248, 18); g.stroke();
for (const k of ["daily","shop","help","closeMore"]){
const b = MENU_BTNS[k];
rrect(g, b.x, b.y, b.w, b.h, 14, k==="shop" ? "#ffb52e" : "#fffbf0");
g.lineWidth = 3; g.strokeStyle = "#3f9e4d"; g.beginPath(); g.roundRect(b.x, b.y, b.w, b.h, 14); g.stroke();
g.fillStyle = k==="shop" ? "#5c3a00" : "#1d5c2a"; g.font = "900 17px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.fillText(b.label, W/2, b.y+b.h/2+6);
}
}
g.fillStyle = "#ffe9a8"; g.font = "700 11.5px 'Trebuchet MS','Segoe UI',Verdana,sans-serif";
g.fillText("Drag ยท clear ยท reveal! Bronx hides on every board ๐ ยท v2.1 ยท 3D + FURNITURE PASS", W/2, 690);
g.textAlign = "left";
}
/* ---------------- Input ---------------- */
function getPos(e){
const r = cvs.getBoundingClientRect();
return { x: (e.clientX-r.left)*(W/r.width), y: (e.clientY-r.top)*(H/r.height) };
}
function inRect(x, y, b){ return x>=b.x && x<=b.x+b.w && y>=b.y && y<=b.y+b.h; }
function trayPieceOrigin(slot){
const t = S.tray[slot], sl = TRAY_SLOTS[slot], dims = pieceDims(t.id, t.rot);
const cs = LAY.cell, ts = Math.min(cs*0.92, (sl.w-16)/dims.w, (sl.h-16)/dims.h);
return { x: sl.x+(sl.w-dims.w*ts)/2, y: sl.y+(sl.h-dims.h*ts)/2, ts };
}
function findNearestLegal(slot, tx, ty){
const t = S.tray[slot]; let best = null, bd = 1e9;
for (let r=0;r{
ensureAudio();
const p = getPos(e);
if (S.intro){ S.intro = false; return; }
if (S.screen==="menu"){
if (!S.menuMore){
if (inRect(p.x, p.y, MENU_BTNS.play)){ startBoard(1); }
else if (inRect(p.x, p.y, MENU_BTNS.more)){ S.menuMore = true; sfxClick(); }
} else {
if (inRect(p.x, p.y, MENU_BTNS.daily)){ S.menuMore = false; startDaily(); }
else if (inRect(p.x, p.y, MENU_BTNS.shop)){ SHOP.open(); }
else if (inRect(p.x, p.y, MENU_BTNS.help)){ $("instrPanel").classList.add("show"); }
else if (inRect(p.x, p.y, MENU_BTNS.closeMore)){ S.menuMore = false; sfxClick(); }
}
return;
}
if (S.screen!=="play" || S.boardDone) return;
layout();
/* Bronx peek hit */
const bx = LAY.x0+LAY.tw-2, by = LAY.y0-12;
if (!S.bronxFound && Math.hypot(p.x-bx, p.y-by) < 30){ tapBronx(); return; }
/* Buttons */
if (inRect(p.x, p.y, BTNS.rotate)){ rotateSelected(); return; }
if (inRect(p.x, p.y, BTNS.undo)){ if (!undo()) say("Nothing to undo!", 1.4); return; }
if (inRect(p.x, p.y, BTNS.blast)){
if (S.charges>0){ S.blastArm = !S.blastArm; sfxClick(); } else say("Clear 2+ lines at once to charge the Glove!", 2.2);
return; }
if (inRect(p.x, p.y, BTNS.shuffle)){ reshuffle(); return; }
if (S.selected>=0 && S.pendingAnchor && inRect(p.x, p.y, BTNS.place)){
const t = S.tray[S.selected];
doPlace(S.selected, S.pendingAnchor.x, S.pendingAnchor.y, t.rot); return; }
/* Grid */
const gx = Math.floor((p.x-LAY.x0)/LAY.cell), gy = Math.floor((p.y-LAY.y0)/LAY.cell);
const onGrid = gx>=0 && gy>=0 && gx=0){
const t = S.tray[S.selected], dims = pieceDims(t.id, t.rot);
let ax = gx-Math.floor(dims.w/2), ay = gy-Math.floor(dims.h/2);
if (canPlace(S.grid, S.blocked, t.id, t.rot, ax, ay)){ S.pendingAnchor = { x:ax, y:ay }; sfxClick(); }
else { const near = findNearestLegal(S.selected, gx, gy);
if (near){ if (near.rot!==undefined && PIECE_ROTS[t.id].length>1 && !canPlace(S.grid, S.blocked, t.id, t.rot, near.x, near.y)) t.rot = near.rot;
if (canPlace(S.grid, S.blocked, t.id, t.rot, near.x, near.y)){ S.pendingAnchor = { x:near.x, y:near.y }; say("Won't fit there โ here's a spot that works!", 1.8); sfxClick(); }
else S.pendingAnchor = null;
} else S.pendingAnchor = null; }
return;
}
/* Tray drag start */
for (let i=0;i<3;i++){
const sl = TRAY_SLOTS[i], t = S.tray[i];
if (t && !t.placed && inRect(p.x, p.y, sl)){
const o = trayPieceOrigin(i);
const cellX = Math.floor((p.x-o.x)/o.ts), cellY = Math.floor((p.y-o.y)/o.ts);
const dims = pieceDims(t.id, t.rot);
S.drag = { slot:i, px:p.x, py:p.y, sx:p.x, sy:p.y, moved:false, prevSel:S.selected,
gx: Math.max(0, Math.min(dims.w-1, cellX)), gy: Math.max(0, Math.min(dims.h-1, cellY)) };
S.selected = i; S.pendingAnchor = null;
return;
}
}
S.selected = -1; S.pendingAnchor = null;
});
cvs.addEventListener("pointermove", e=>{
if (!S.drag) return;
const p = getPos(e);
S.drag.px = p.x; S.drag.py = p.y;
if (Math.hypot(p.x-S.drag.sx, p.y-S.drag.sy) > 7) S.drag.moved = true;
});
cvs.addEventListener("pointerup", e=>{
if (!S.drag) return;
const d = S.drag; S.drag = null;
if (!d.moved){ /* tap = select/deselect */
S.selected = (d.prevSel===d.slot) ? -1 : d.slot;
S.pendingAnchor = null;
if (S.selected>=0) say("Now tap the board where it goes โ or drag it!", 1.8);
return;
}
const t = S.tray[d.slot];
const ax = Math.floor((d.px-LAY.x0)/LAY.cell)-d.gx, ay = Math.floor((d.py-LAY.y0)/LAY.cell)-d.gy;
if (!doPlace(d.slot, ax, ay, t.rot)){
if (S.labelsOn) say("That spot doesn't fit โ watch the blue dashed ghost!", 2);
else sfxClick();
}
});
cvs.addEventListener("pointercancel", ()=>{ S.drag = null; });
/* ================================================================
LS SHOP MODULE v1 โ shared upgrade shop (drop-in, zero deps)
(canonical copy also saved as ls-shop-module.js for reuse)
---------------------------------------------------------------
INTEGRATION CONTRACT โ the host game provides 4 hooks in cfg:
wallet() โ number: current balance of the game's ONE
existing currency. The shop never keeps a
second wallet โ it reads/charges the host's.
charge(n) โ deduct exactly n from that wallet. The module
checks affordability before ever calling this.
store() โ the persistent shop-state object inside the host
save: { owned:{id:true}, equipped:{slot:id} }.
save() โ persist the host save (wallet + store together).
Optional cfg: title, sub, currencyIcon (default "๐"), mount
(element the overlay is appended to; default document.body),
sfxBuy() (host buy sound; default = module's own beeper),
onChange() (called after every buy/equip โ e.g. refresh HUD).
ITEMS are data-driven (cfg.items): { id, icon, name, desc,
price, kind:"cosmetic"|"effect", slot }. A cosmetic equips into
its named slot (one per slot; buying auto-equips). An effect is
passive once owned. EFFECTS ARE HOST-APPLIED: the module never
touches game logic โ the host asks shop.owned(id) /
shop.equippedIn(slot) inside its own rules + drawing.
NOTHING here costs real money โ game currency only, ever.
================================================================ */
const LSShop = (()=>{
const CSS = [
".lsshop-overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(8,24,14,.78);z-index:60;padding:14px}",
".lsshop-overlay.show{display:flex}",
".lsshop-panel{background:#fffdf4;border:4px solid #ffb52e;border-radius:20px;padding:16px;max-width:392px;width:100%;max-height:92%;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.5);font-family:'Trebuchet MS','Segoe UI',Verdana,sans-serif;color:#33402f}",
".lsshop-head{display:flex;align-items:center;justify-content:space-between;gap:8px}",
".lsshop-title{font-size:22px;font-weight:900;color:#1d5c2a}",
".lsshop-wallet{background:#fff3b0;border:3px solid #e0a800;border-radius:14px;padding:4px 12px;font-weight:900;font-size:17px;color:#7a4d00;white-space:nowrap}",
".lsshop-sub{font-size:13px;line-height:1.4;margin:6px 0 4px}",
".lsshop-item{display:flex;align-items:center;gap:10px;background:#f4fbf0;border:3px solid #bfe3c0;border-radius:16px;padding:9px 10px;margin-top:9px}",
".lsshop-item.equipped{border-color:#ffb52e;background:#fff8dc}",
".lsshop-ic{font-size:30px;width:42px;text-align:center;flex:none}",
".lsshop-info{flex:1;min-width:0}",
".lsshop-name{font-weight:900;font-size:15.5px;color:#1d5c2a}",
".lsshop-desc{font-size:12.5px;line-height:1.35}",
".lsshop-tag{display:inline-block;font-size:10.5px;font-weight:900;border-radius:8px;padding:1px 7px;margin-left:6px;vertical-align:2px}",
".lsshop-tag.fx{background:#d3f9d8;color:#1d5c2a}.lsshop-tag.cos{background:#ffe3f1;color:#a4133c}",
".lsshop-btn{flex:none;min-width:96px;padding:12px 8px;font-size:15px;font-weight:900;border:none;border-radius:14px;cursor:pointer;background:#3f9e4d;color:#fff}",
".lsshop-btn.owned{background:#eef7ee;color:#1d5c2a;border:3px solid #3f9e4d}",
".lsshop-btn.equipped{background:#ffb52e;color:#5c3a00}",
".lsshop-btn:disabled{background:#c9d1c9;color:#eef2ee;cursor:default}",
".lsshop-close{display:block;width:100%;margin-top:14px;padding:13px;font-size:17px;font-weight:900;background:#3f9e4d;color:#fff;border:none;border-radius:14px;cursor:pointer}"
].join("\n");
function ensureStyle(){
if (document.getElementById("lsshop-style")) return;
const st = document.createElement("style"); st.id = "lsshop-style"; st.textContent = CSS;
document.head.appendChild(st);
}
function beep(){ /* fallback buy sound if the host provides none */
try {
const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return;
const ac = new Ctx(), t = ac.currentTime;
[988,1319,1760].forEach((f,i)=>{
const o = ac.createOscillator(), g = ac.createGain();
o.type = "sine"; o.frequency.setValueAtTime(f, t+i*.07);
g.gain.setValueAtTime(.12, t+i*.07); g.gain.exponentialRampToValueAtTime(.001, t+i*.07+.15);
o.connect(g); g.connect(ac.destination); o.start(t+i*.07); o.stop(t+i*.07+.17);
});
} catch(e){}
}
function create(cfg){
ensureStyle();
const icon = cfg.currencyIcon || "๐";
const mount = cfg.mount || document.body;
const ov = document.createElement("div"); ov.className = "lsshop-overlay";
ov.innerHTML = "
"
+ "
"
+ ""
+ "
";
mount.appendChild(ov);
ov.querySelector(".lsshop-title").textContent = cfg.title || "๐ Shop";
ov.querySelector(".lsshop-sub").textContent = cfg.sub || "";
ov.querySelector(".lsshop-close").addEventListener("click", ()=> api.close());
const listEl = ov.querySelector(".lsshop-list"), walEl = ov.querySelector(".lsshop-wallet");
function store(){ return cfg.store(); }
function owned(id){ return !!store().owned[id]; }
function equippedIn(slot){ return store().equipped[slot] || null; }
function changed(){ cfg.save(); if (cfg.onChange) cfg.onChange(); render(); }
function buy(id){
const it = cfg.items.find(i => i.id===id); if (!it) return { ok:false, reason:"unknown" };
if (owned(id)){ /* owned cosmetic โ toggle equip */
if (it.kind==="cosmetic" && it.slot){
if (equippedIn(it.slot)===id) delete store().equipped[it.slot];
else store().equipped[it.slot] = id;
changed(); return { ok:true, reason:"equip-toggle" };
}
return { ok:false, reason:"owned" };
}
if (cfg.wallet() < it.price) return { ok:false, reason:"cant-afford" };
cfg.charge(it.price);
store().owned[id] = true;
if (it.kind==="cosmetic" && it.slot) store().equipped[it.slot] = id; /* auto-equip: instant fun */
(cfg.sfxBuy || beep)();
changed();
return { ok:true, reason:"bought" };
}
function equip(id){
const it = cfg.items.find(i => i.id===id);
if (!it || !owned(id) || it.kind!=="cosmetic" || !it.slot) return false;
store().equipped[it.slot] = id; changed(); return true;
}
function unequip(slot){ if (!equippedIn(slot)) return false; delete store().equipped[slot]; changed(); return true; }
function render(){
walEl.textContent = icon + " " + cfg.wallet();
listEl.innerHTML = "";
for (const it of cfg.items){
const row = document.createElement("div");
const isEq = it.kind==="cosmetic" && it.slot && equippedIn(it.slot)===it.id;
row.className = "lsshop-item" + (isEq ? " equipped" : "");
const info = document.createElement("div"); info.className = "lsshop-info";
const nm = document.createElement("div"); nm.className = "lsshop-name";
nm.textContent = it.name;
const tag = document.createElement("span");
tag.className = "lsshop-tag " + (it.kind==="effect" ? "fx" : "cos");
tag.textContent = it.kind==="effect" ? "POWER-UP" : "LOOK";
nm.appendChild(tag);
const ds = document.createElement("div"); ds.className = "lsshop-desc"; ds.textContent = it.desc;
info.appendChild(nm); info.appendChild(ds);
const ic = document.createElement("div"); ic.className = "lsshop-ic"; ic.textContent = it.icon;
const btn = document.createElement("button"); btn.className = "lsshop-btn"; btn.dataset.item = it.id;
if (!owned(it.id)){
if (cfg.wallet() >= it.price){ btn.textContent = "BUY ยท " + it.price + " " + icon; }
else { btn.textContent = it.price + " " + icon; btn.disabled = true; }
} else if (it.kind==="effect"){ btn.textContent = "OWNED โ"; btn.disabled = true; btn.classList.add("owned"); }
else if (isEq){ btn.textContent = "ON โ"; btn.classList.add("equipped"); }
else { btn.textContent = "EQUIP"; btn.classList.add("owned"); }
btn.addEventListener("click", ()=> buy(it.id));
row.appendChild(ic); row.appendChild(info); row.appendChild(btn);
listEl.appendChild(row);
}
}
const api = {
open(){ render(); ov.classList.add("show"); },
close(){ ov.classList.remove("show"); },
isOpen(){ return ov.classList.contains("show"); },
buy, equip, unequip, owned, equippedIn, render,
items(){ return cfg.items.slice(); },
wallet(){ return cfg.wallet(); },
el: ov,
};
return api;
}
return { create };
})();
/* ---------------- Block Brigade shop integration ---------------
Currency: Gallery Tickets (SAVE.tickets โ the game's only wallet).
Earn rates from the earn path: board finish pays 1 ๐ base,
+1 for a 2-line combo board, +1 for a 3-line combo board,
+1 at Pathfinder promo, Daily pays double, Bronx +1/board,
L10 zones +1 each โ a typical board earns โ2 ๐.
Prices: first power-up = 4 ๐ โ 2 boards of earnings. Nothing
gates progress: undos/hints/blast are helpers, reshuffle is
free forever, and there is no fail state to buy your way past. */
const SHOP_ITEMS = [
{ id:"fx_undo_plus", icon:"โฉ๏ธ", name:"Undo Plus", price:4, kind:"effect",
desc:"+1 UNDO on every board (Boards 3 and up). Stacks with promotion undos!" },
{ id:"fx_hint_free", icon:"๐ก", name:"Hint Star Charm", price:5, kind:"effect",
desc:"TRY HERE hints become FREE forever โ they cost 1 ๐ each without it." },
{ id:"fx_blast_slot", icon:"๐งค", name:"Glove Charge Slot", price:7, kind:"effect",
desc:"Mia's Pixel Glove holds 8 Glitch charges instead of 6." },
{ id:"frame_rainbow", icon:"๐", name:"Rainbow Frame", price:6, kind:"cosmetic", slot:"frame",
desc:"Your picture frame cycles through all the rainbow colors!" },
{ id:"mia_pink", icon:"๐", name:"Bubblegum Hoodie", price:8, kind:"cosmetic", slot:"hoodie",
desc:"Mia's hoodie turns bubblegum pink." },
{ id:"mia_ocean", icon:"๐", name:"Ocean Hoodie", price:8, kind:"cosmetic", slot:"hoodie",
desc:"Mia's hoodie turns ocean blue." },
{ id:"frame_gold", icon:"๐", name:"Golden Frame", price:9, kind:"cosmetic", slot:"frame",
desc:"A champion's golden frame around every picture." },
{ id:"trail_stars", icon:"โจ", name:"Glove Star Trail", price:11, kind:"cosmetic", slot:"trail",
desc:"Star sparkles trail your pieces while you drag โ and Glitch Blasts turn gold!" },
];
const SHOP = LSShop.create({
title:"๐ Ticket Shop",
sub:"Spend your Gallery Tickets! Power-ups help you play โ looks make it yours. Nothing here is needed to finish a board.",
currencyIcon:"๐",
mount: document.getElementById("wrap"),
wallet: ()=> SAVE.tickets,
charge: n => { SAVE.tickets -= n; },
store: ()=> SAVE.shop,
save: ()=> save(),
sfxBuy: ()=> sfxBuy(),
onChange: ()=> refreshHUD(),
items: SHOP_ITEMS,
});
/* ---------------- Overlay wiring ---------------- */
$("btnHint").onclick = ()=>{ ensureAudio(); useHint(); };
$("btnHelp").onclick = ()=>{ ensureAudio(); $("instrPanel").classList.add("show"); };
$("btnCloseInstr").onclick = ()=> $("instrPanel").classList.remove("show");
$("btnSound").onclick = ()=>{ SAVE.sound = !SAVE.sound; save(); refreshHUD(); if (SAVE.sound) ensureAudio(); };
$("btnSettings").onclick = ()=>{ ensureAudio(); fillSettings(); $("settingsPanel").classList.add("show"); };
$("btnCloseSettings").onclick = ()=> $("settingsPanel").classList.remove("show");
$("btnLuGo").onclick = ()=> dismissLevelUp();
{ const b = document.createElement("button"); b.className = "bigBtn alt"; b.textContent = "๐ธ PICTURE CARD";
b.onclick = ()=>{ drawCard(); $("cardModal").classList.add("show"); };
$("luOverlay").querySelector(".panel").appendChild(b); }
{ const b = document.createElement("button"); b.className = "bigBtn ghost"; b.textContent = "๐ SPEND TICKETS โ SHOP";
b.onclick = ()=>{ ensureAudio(); SHOP.open(); };
$("luOverlay").querySelector(".panel").appendChild(b); }
$("btnCardClose").onclick = ()=> $("cardModal").classList.remove("show");
$("btnCardDownload").onclick = ()=>{
try { const a = document.createElement("a");
a.download = "block-brigade-card.png"; a.href = $("cardCv").toDataURL("image/png"); a.click();
} catch(e){}
};
function fillSettings(){
const total = Object.values(SAVE.gallery).reduce((a,b)=>a+b,0);
$("galSummary").textContent = "Pictures revealed: "+total+" ยท ๐ "+promoTitle(SAVE.promo)+
" ("+SAVE.career+" โญ) ยท Daily card today: "+(SAVE.dailyDone===new Date().toDateString() ? "DONE โ" : picName(dailyCharId()));
$("galList").textContent = Object.keys(SAVE.gallery).length
? Object.keys(SAVE.gallery).map(id => picName(id)+" ร"+SAVE.gallery[id]).join(" ยท ") : "No pictures yet โ go reveal one!";
$("famStatus").textContent = familyMode && familyImg ? "Family photo is ON for new boards."
: familyImg ? "A family photo is loaded on this device (not active)." : "No family photo loaded.";
}
$("famFile").addEventListener("change", e=>{
const f = e.target.files && e.target.files[0]; if (!f) return;
const rd = new FileReader();
rd.onload = ()=>{ const im = new Image();
im.onload = ()=>{ familyImg = im; familyDataURL = rd.result;
try { localStorage.setItem("blockbrigade_family", rd.result); } catch(err){}
$("famStatus").textContent = "Photo loaded โ on this device only. Tap USE to play with it.";
};
im.src = rd.result; };
rd.readAsDataURL(f);
});
$("btnFamUse").onclick = ()=>{ if (familyImg){ familyMode = true; $("famStatus").textContent = "Family photo ON โ it hides under your next board!"; } };
$("btnFamRemove").onclick = ()=>{ familyMode = false; familyImg = null; familyDataURL = null;
try { localStorage.removeItem("blockbrigade_family"); } catch(e){}
$("famStatus").textContent = "Family photo removed from this device."; };
function drawCard(){
const cv = $("cardCv"), g = cv.getContext("2d"), card = S.lastCard;
g.fillStyle = "#fffdf4"; g.fillRect(0,0,360,440);
g.lineWidth = 10; g.strokeStyle = SAVE.promo>=4 ? "#ffd43b" : "#3f9e4d"; g.strokeRect(5,5,350,430);
g.fillStyle = "#1d5c2a"; g.font = "900 24px Trebuchet MS"; g.textAlign = "center";
g.fillText("BLOCK BRIGADE", 180, 42);
g.font = "700 13px Trebuchet MS"; g.fillStyle = "#5c6b57";
g.fillText(card ? card.date : "", 180, 64);
if (card && card.char!=="family" && picCanvas){
g.drawImage(picCanvas, 0, 0, picCanvas.width, picCanvas.height, 55, 80, 250, 250);
g.lineWidth = 4; g.strokeStyle = "#22b8cf"; g.strokeRect(55, 80, 250, 250);
g.fillStyle = "#1d5c2a"; g.font = "900 21px Trebuchet MS";
g.fillText("It's "+picName(card.char)+"!", 180, 362);
g.font = "700 12.5px Trebuchet MS"; g.fillStyle = "#5c6b57";
const c = CHARS[card.char];
g.fillText(c ? "#"+c.num+" ยท "+c.animal+" ยท "+c.state+(c.phrase ? " ยท โ"+c.phrase+"โ" : "") : (PICS[card.char] ? PICS[card.char].sub : ""), 180, 384);
} else {
rrect(g, 55, 80, 250, 250, 16, "#5b2a6e");
g.fillStyle = "#ffe98a"; g.font = "900 22px Trebuchet MS";
g.fillText("๐จโ๐ฉโ๐ง FAMILY PHOTO", 180, 195);
g.font = "700 13px Trebuchet MS"; g.fillText("revealed! (kept private โ", 180, 224);
g.fillText("family photos never appear on cards)", 180, 244);
g.fillStyle = "#1d5c2a"; g.font = "900 21px Trebuchet MS";
g.fillText("Picture Complete!", 180, 362);
}
g.fillStyle = "#33402f"; g.font = "900 15px Trebuchet MS";
g.fillText(card ? "Board score "+card.score+" ยท +"+card.tickets+" ๐ ยท Level "+card.level : "", 180, 414);
g.textAlign = "left";
}
/* ---------------- Solver (QC bot + stuck-helper) ---------------- */
function solveBoard(maxSteps){
if (S.intro) S.intro = false; /* the helper plays for real โ intro is a watch-once show */
let steps = 0;
while (!S.boardDone && steps < (maxSteps||400)){
steps++;
const mv = findBestMove(S.grid, S.blocked, S.revealed, S.tray);
if (mv){ if (!doPlace(mv.slot, mv.x, mv.y, mv.rot)) break; continue; }
if (S.charges>0){
let bx = -1, by = -1, bs = -1;
for (let y=0;y=0 && ny>=0 && nxbs){ bs = sc; bx = x; by = y; } }
if (bx>=0){ useBlastAt(bx, by); continue; }
}
if (!reshuffle()) break;
}
return { done: S.boardDone, steps };
}
/* ---------------- QC hooks ---------------- */
window.__BB = {
S, SAVE, LOGIC, LEVELS, levelDef, tierName, promoTitle, nextPromoAt,
SHOP, SHOP_ITEMS, gloveCap, undosForLevel, useHint, hintCost,
startBoard, startDaily, doPlace, rotateSelected, undo, reshuffle, useBlastAt, checkJam,
dismissLevelUp, completeBoard, tapBronx, dailyCharId, solveBoard, trayPieceOrigin,
LAY: ()=>LAY, BTNS: ()=>BTNS, MENU: ()=>MENU_BTNS, TRAY: ()=>TRAY_SLOTS, PLATES: ()=>Object.keys(PLATES), dismissIntro(){ S.intro = false; },
state(){ return {
screen:S.screen, level:S.level, tier:S.tier, score:SAVE.career, boardScore:S.boardScore,
tickets:SAVE.tickets, promo:SAVE.promo, promoTitle:promoTitle(SAVE.promo), promoShown:S.promoShown,
promoText:S.promoText, tray:S.tray.map(t=>({ id:t.id, rot:t.rot, placed:t.placed })),
jam:S.jam, charges:S.charges, undosLeft: S.undosLeft===Infinity ? "inf" : S.undosLeft,
revealed: revealedCount(), revealable: revealableCount(),
revealedPct: Math.round(100*revealedCount()/Math.max(1,revealableCount())),
boardDone:S.boardDone, luShown:S.luShown, luText:S.luText, luNew:S.luNew,
shopOwned: Object.keys(SAVE.shop.owned), shopEquipped: Object.assign({}, SAVE.shop.equipped),
gloveCap: gloveCap(), hintActive: Date.now() < S.hintUntil, hintCost: hintCost(),
bronxFound:S.bronxFound, labelsOn:S.labelsOn, picChar:S.picChar, isDaily:S.isDaily,
dailyChar: dailyCharId(), dailyDone: SAVE.dailyDone, gallery: Object.assign({}, SAVE.gallery),
busy: !!(S.intro || S.luShown), intro:S.intro, maxCombo:S.maxCombo, boardLines:S.boardLines,
placements:S.placements, zonesDone:[...S.zonesDone], familyMode, menuMore:S.menuMore, platesLoaded:Object.keys(PLATES),
grid:S.grid.map(r=>[...r]), blocked:S.blocked.map(r=>[...r]), revealed:S.revealed.map(r=>[...r]),
}; },
};
refreshHUD();
requestAnimationFrame(draw);