diff --git a/www/html/Game/public/img/TowerBlock/README.txt b/www/html/Game/public/img/TowerBlock/README.txt index 2982bc0..d7eb937 100644 --- a/www/html/Game/public/img/TowerBlock/README.txt +++ b/www/html/Game/public/img/TowerBlock/README.txt @@ -24,6 +24,9 @@ English: Upload Stack / tower assets under **`TowerBlock`** (no spaces). **`Towe | `result-check.png` (ถ้ามี) | ไอคอนติ๊ก «ภารกิจสำเร็จ» บนสรุป — ไม่มีจะใช้ของ gauntlet | | `sling.png` | เชือกเหวี่ยงบล็อกในเกม (ยืดจากจุดแขวนถึงบล็อก) — แนวตั้งในไฟล์ = แนวสาย; ไม่มีไฟล์จะใช้เส้นประแทน | | `heart-1.png` | โชว์กลางเหนือกองเมื่อ **พลาด** (Tower live) — ไม่มีจะ fallback ข้อความ `♥ −1` | +| `life-full.png` | HUD มุมขวา: หัวใจ **เต็ม** ในแถบชีวิต (คู่ `life-hit.png`) — ไม่มีจะ fallback เป็น `heart-1.png` | +| `life-bar.png` | HUD: สไตล์ข้อความ **SYSTEM INTEGRITY** + วงเล็บ — วาง **ซ้ายของอวตาร** ในแผง cyan (mock รูป2) | +| `life-hit.png` | หัวใจ **หาย / โดนตี** ในแถบชีวิต (คู่ `life-full.png`) | | `btn-drop.png` | ปุ่ม **DROP** มุมขวาล่างใน `play.html` (เทียบ Space / Enter) | | `score+10.png` | Tower live: โชว์เหนือบล็อกเมื่อได้ **+10** | | `score+20.png` | Tower live: เหนือบล็อกเมื่อ **×2 (+20)** หรือ **progress ถึง ~100%**; ถ้า perfect คู่ (support เต็ม) จะโชว์บนชั้นบน **และ** ชั้นก่อนหน้า | diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index e8652e3..de9ccbe 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -324,8 +324,12 @@ let stackTowerScorePlus20Img = null; let stackTowerScoreLightImg = null; let stackTowerScoreX2Img = null; - /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) */ + /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */ let stackTowerHeartMinusImg = null; + /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */ + let stackTowerLifeBarImg = null; + let stackTowerLifeHitImg = null; + let stackTowerLifeFullImg = null; /** @type {{ until: number } | null} */ let stackTowerHeartMinusFx = null; const STACK_TOWER_HEART_MINUS_FX_MS = 1500; @@ -4188,6 +4192,102 @@ return im && im.complete && im.naturalWidth > 0 ? im : null; } + function ensureStackTowerLifeHudImagesPlay() { + if (!stackTowerLifeBarImg) { + stackTowerLifeBarImg = new Image(); + stackTowerLifeBarImg.decoding = 'async'; + stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png'); + } + if (!stackTowerLifeHitImg) { + stackTowerLifeHitImg = new Image(); + stackTowerLifeHitImg.decoding = 'async'; + stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png'); + } + if (!stackTowerLifeFullImg) { + stackTowerLifeFullImg = new Image(); + stackTowerLifeFullImg.decoding = 'async'; + stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png'); + } + } + + /** + * พื้นที่ระหว่างวงเล็บบน life-bar.png (normalized 0–1) — ปรับถ้า asset เปลี่ยนเลย์เอาต์ + * UV rect on life-bar.png for heart row between brackets (tune if art changes). + */ + const STACK_TOWER_LIFE_BAR_HEART_SLOT_UV = { u0: 0.11, u1: 0.89, v0: 0.40, v1: 0.86 }; + + /** + * วาด life-bar + หัวใจ life-full (เต็ม) / life-hit (หาย) ในกรอบ dest — คืนความสูงจริงที่วาด + */ + function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem) { + ensureStackTowerLifeHudImagesPlay(); + const bar = stackTowerLifeBarImg; + const fullIm = (stackTowerLifeFullImg && stackTowerLifeFullImg.complete && stackTowerLifeFullImg.naturalWidth > 0) + ? stackTowerLifeFullImg + : ensureStackTowerHeartMinusImgPlay(); + const hitIm = stackTowerLifeHitImg && stackTowerLifeHitImg.complete && stackTowerLifeHitImg.naturalWidth > 0 + ? stackTowerLifeHitImg + : null; + const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1)); + const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0)); + + if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) { + ctx.save(); + ctx.fillStyle = '#bb9af7'; + ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + let heartStr = ''; + for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ '; + ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5); + ctx.restore(); + return Math.min(maxH, 28); + } + + const bw0 = bar.naturalWidth; + const bh0 = bar.naturalHeight; + const scale = Math.min(maxW / bw0, maxH / bh0, 1.35); + const bw = bw0 * scale; + const bh = bh0 * scale; + const bx = destX; + const by = destY + Math.max(0, (maxH - bh) * 0.5); + + ctx.save(); + try { + ctx.drawImage(bar, 0, 0, bw0, bh0, bx, by, bw, bh); + } catch (e) { /* ignore */ } + + const uv = STACK_TOWER_LIFE_BAR_HEART_SLOT_UV; + const ix = bx + uv.u0 * bw; + const iy = by + uv.v0 * bh; + const iwSlot = Math.max(4, (uv.u1 - uv.u0) * bw); + const ihSlot = Math.max(4, (uv.v1 - uv.v0) * bh); + const slotW = iwSlot / slots; + const iconMax = Math.min(slotW * 0.82, ihSlot * 0.88, 36); + + for (let i = 0; i < slots; i++) { + const cx = ix + slotW * (i + 0.5); + const cy = iy + ihSlot * 0.5; + const useFull = i < lives; + const im = useFull ? fullIm : hitIm; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + const ih = iconMax; + const iw = (ih * im.naturalWidth) / im.naturalHeight; + try { + ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e2) { /* ignore */ } + } else { + ctx.fillStyle = useFull ? '#9ece6a' : '#565f89'; + ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(useFull ? '♥' : '♡', cx, cy); + } + } + ctx.restore(); + return bh; + } + function triggerStackTowerHeartMinusFxPlay() { if (!isStackTowerMissionHudActivePlay()) return; stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS }; @@ -7989,6 +8089,10 @@ stackPreviewHudLog = []; } ensureStackTowerSlingImagePlay(); + if (towerMission || previewFillBots) { + ensureStackTowerLifeHudImagesPlay(); + ensureStackTowerHeartMinusImgPlay(); + } } function stackEaseDrop(t) { @@ -14236,18 +14340,29 @@ drawRows(rows); } - const rightW = 136; + const framePad = 10; + const bigAv = Math.min(72, Math.max(52, ch * 0.10)); + const avBox = bigAv + 10; + const integrityMinW = 120; + const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34))); let rightX = cw - pad - rightW; const topY = 8; if (rightX < pad + boardW + 14) { rightX = Math.max(pad, cw - pad - rightW); } - const bigAv = Math.min(78, Math.max(56, ch * 0.11)); - const turnPanelH = bigAv + 118; + const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10); + const innerTop = topY + framePad; + const integrityX = rightX + framePad; + const integrityH = avBox + 2; + const avFrameX = integrityX + integrityW + 10; + const avFrameY = innerTop; + const footerH = 26; + const turnPanelH = framePad + integrityH + 10 + footerH + framePad; + ctx.fillStyle = 'rgba(8, 12, 28, 0.9)'; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.fill(); - ctx.strokeStyle = 'rgba(187, 154, 247, 0.55)'; + ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)'; ctx.lineWidth = 1.5; roundRectPath(rightX, topY, rightW, turnPanelH, 8); ctx.stroke(); @@ -14257,39 +14372,34 @@ curEntry = order.find((e) => e.seat === currentSeat) || null; } const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 }); - const framePad = 8; - const avCx = rightX + rightW / 2; - const bigFrameTop = topY + framePad - 2; - const bigFrameH = bigAv + 8; - ctx.strokeStyle = 'rgba(255, 120, 200, 0.75)'; - ctx.lineWidth = 2; - roundRectPath(rightX + framePad - 2, topY + framePad - 2, rightW - (framePad - 2) * 2, bigAv + 8, 6); - ctx.stroke(); - drawHudAvatar(avCx, bigFrameTop + bigFrameH - 4, bigAv, curV.characterId, curV.playTint); - ctx.fillStyle = '#f5c2e7'; - ctx.font = 'bold 10px ui-sans-serif, system-ui, sans-serif'; + drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem); + + const avCx = avFrameX + avBox / 2; + ctx.save(); + ctx.shadowColor = 'rgba(0, 234, 255, 0.5)'; + ctx.shadowBlur = 12; + ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)'; + ctx.lineWidth = 2; + roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6); + ctx.stroke(); + ctx.restore(); + ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)'; + ctx.lineWidth = 1; + roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6); + ctx.stroke(); + drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint); + + const footY = innerTop + integrityH + 12; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText('ACTIVE NODE', rightX + rightW / 2, topY + bigAv + 22); - ctx.fillStyle = '#7dfcff'; - ctx.font = 'bold 11px ' + mono; - ctx.fillText('P' + currentSeat + ' · ' + curV.name.slice(0, 10), rightX + rightW / 2, topY + bigAv + 38); - - ctx.fillStyle = '#bb9af7'; - ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; - ctx.fillText('SYSTEM INTEGRITY', rightX + rightW / 2, topY + bigAv + 58); - ctx.font = '14px sans-serif'; - let heartStr = ''; - for (let i = 0; i < lifeSlots; i++) { - heartStr += i < livesRem ? '♥ ' : '♡ '; - } - ctx.fillStyle = livesRem <= 1 ? '#f7768e' : '#9ece6a'; - ctx.fillText(heartStr.trim(), rightX + rightW / 2, topY + bigAv + 74); - - ctx.fillStyle = 'rgba(125, 252, 255, 0.85)'; + ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)'; ctx.font = monoSm; - ctx.fillText('TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, topY + bigAv + 96); + ctx.fillText( + 'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo, + rightX + rightW / 2, + footY, + ); const termW = Math.min(380, Math.max(220, cw * 0.42)); const logLineH = 12; @@ -14503,6 +14613,10 @@ 'play-cyber-hud--question-mission', !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay()), ); + root.classList.toggle( + 'play-cyber-hud--stack-tower-canvas-hud', + !!(previewFillBots && stackMini && isStack() && isStackTowerMissionUiMapPlay()), + ); root.classList.toggle('play-cyber-hud--gauntlet-crown-strip', !!(on && isGauntletCrownHeistMapPlay())); const timeLabelEl = root.querySelector('.play-cyber-time-label'); if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : ''; diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 9cbc299..2dc260d 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -1674,6 +1674,10 @@ #play-cyber-hud.play-cyber-hud--question-mission .play-cyber-preview-line { display: none !important; } + /* Stack Tower: แผงโปรไฟล์วาดบน canvas — ซ่อนกล่อง portrait DOM ขวา ไม่ให้ซ้ำกับอวตารในแผง SYSTEM INTEGRITY */ + #play-cyber-hud.play-cyber-hud--stack-tower-canvas-hud .play-cyber-self { + display: none !important; + } /* Last Light (mno9kb07): แถบคะแนนแนวนอน — score.png แล้วตามด้วยสูงสุด 6 ช่อง (อวาตาร์ | ชื่อบน / คะแนนล่าง) */ #play-cyber-hud.play-cyber-hud--gauntlet-crown-strip .play-cyber-hint { display: none !important; @@ -2749,7 +2753,7 @@ - +