diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css index 1ff99e4..5ec7d1c 100644 --- a/www/html/Admin/admin.css +++ b/www/html/Admin/admin.css @@ -2464,3 +2464,41 @@ code { flex: 0 0 2.25rem; font-size: 0.82rem; } + +/* Stack minigame (Tower block) — คอลัมน์ปกติ/ใหญ่เท่ากัน + พรีวิวสัดส่วนตามรูปอ้างอิง (ปกติ 314×103 · ใหญ่ 382×72) */ +.space-shooter-ship-row.stack-block-visual-row { + display: grid; + grid-template-columns: + 1.75rem + minmax(0, 1fr) + min(42vw, 168px) + auto + minmax(0, 1fr) + min(48vw, 200px) + auto; + align-items: start; + gap: 0.45rem 0.55rem; +} +.stack-block-visual-row .space-shooter-ship-url-label { + flex: unset; + min-width: 0; + max-width: 100%; +} +.stack-block-visual-row .space-shooter-ship-url-label input[type='text'] { + max-width: 100%; +} +.stack-block-visual-row .space-shooter-ship-prev.stack-block-prev { + flex: unset; + width: 100%; + height: auto; + display: block; + box-sizing: border-box; +} +.stack-block-visual-row .space-shooter-ship-prev.stack-block-prev--normal { + aspect-ratio: 314 / 103; + max-width: 168px; +} +.stack-block-visual-row .space-shooter-ship-prev.stack-block-prev--heavy { + aspect-ratio: 382 / 72; + max-width: 200px; +} diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 64d0126..e4bcf9b 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -10,7 +10,7 @@ - + @@ -784,12 +784,12 @@
-
P1
-
P2
-
P3
-
P4
-
P5
-
P6
+
P1
+
P2
+
P3
+
P4
+
P5
+
P6
diff --git a/www/html/Game/public/editor.html b/www/html/Game/public/editor.html index 728ad80..14e1c35 100644 --- a/www/html/Game/public/editor.html +++ b/www/html/Game/public/editor.html @@ -358,8 +358,8 @@

บันทึกแล้วจะได้รหัสฉาก ใช้รหัสนี้ตอนสร้างห้องในล็อบบี้

- - + +
v —
diff --git a/www/html/Game/public/img/TowerBlock/README.txt b/www/html/Game/public/img/TowerBlock/README.txt index 3ed6d87..2982bc0 100644 --- a/www/html/Game/public/img/TowerBlock/README.txt +++ b/www/html/Game/public/img/TowerBlock/README.txt @@ -23,5 +23,14 @@ English: Upload Stack / tower assets under **`TowerBlock`** (no spaces). **`Towe | `popup-result.png` | แผงสรุปภารกิจ (GCM) | | `result-check.png` (ถ้ามี) | ไอคอนติ๊ก «ภารกิจสำเร็จ» บนสรุป — ไม่มีจะใช้ของ gauntlet | | `sling.png` | เชือกเหวี่ยงบล็อกในเกม (ยืดจากจุดแขวนถึงบล็อก) — แนวตั้งในไฟล์ = แนวสาย; ไม่มีไฟล์จะใช้เส้นประแทน | +| `heart-1.png` | โชว์กลางเหนือกองเมื่อ **พลาด** (Tower live) — ไม่มีจะ fallback ข้อความ `♥ −1` | +| `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 เต็ม) จะโชว์บนชั้นบน **และ** ชั้นก่อนหน้า | +| `score-light.png` | คู่กับ **+20 / perfect full** — แสงข้างชั้นบน (mock รูป2) | +| `scorex2.png` | กราฟิก **×2** ข้างชั้นบน — ใช้คู่ `score-light.png` | +| `result-timeup.png` | หมดเวลาก่อน decrypt ครบ 100% → แฟลชเต็มจอ **5 วิ** ก่อนหน้า GCM | +| `result-gameover.png` | พลาดจนหมด **team misses** (เช่น เกิน 3 ครั้งตามตั้งค่า) → แฟลช 5 วิ ก่อน GCM | +| `result-complete.png` | decrypt ถึง **100%** ภายในเวลา → แฟลช 5 วิ ก่อน GCM | -English: Mission map **mnn93hpi** loads **`popup-Howto.png`**, **`popup-result.png`**, optional **`result-check.png`**, **`sling.png`** (crane rope; dashed fallback if missing) from this folder. +English: **`score+10.png`** / **`score+20.png`** — score popups above blocks; dual **+20** on perfect “full support” pair. **`result-timeup.png`**, **`result-gameover.png`**, **`result-complete.png`** — 5s fullscreen flash before the mission summary (GCM). Missing images fall back to opening GCM immediately for the flash layer. diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index d0314c2..e8652e3 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -46,6 +46,28 @@ const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0; /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */ const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62; + /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */ + const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314; + /** Tower live + progress ≥50%: ลดความกว้างบล็อก 40% (เหลือ 60%) — ความสูงชั้น `layerWorldH` เท่าเดิม */ + const STACK_TOWER_POST50_WIDTH_MULT = 0.6; + + function stackBlockWidthTilesForPlay(baseTiles, heavy) { + const b = Number(baseTiles); + const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2; + const towerPost50 = + isStackTowerMissionUiMapPlay() && + stackTowerMissionPhase === 'live' && + stackMini && + Number.isFinite(Number(stackMini.progressPct)) && + Number(stackMini.progressPct) >= 50; + if (towerPost50) { + const w = base * STACK_TOWER_POST50_WIDTH_MULT; + return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200)); + } + if (!heavy) return base; + const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT; + return Math.min(3.2, Math.round(w * 200) / 200); + } /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */ const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3; const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400; @@ -198,11 +220,11 @@ function pickStackBlockHeavyVisual(seat) { const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1)); const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || ''); - /** Tower mnn93hpi: เกิน 50% ของ Progress 100% (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */ + /** Tower mnn93hpi: ตั้งแต่ 50% ของ Progress 100% (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */ if (isStackTowerMissionUiMapPlay()) { if (!(stackTowerMissionPhase === 'live' && stackMini)) return false; const p = Number(stackMini.progressPct); - if (!Number.isFinite(p) || p <= 50) return false; + if (!Number.isFinite(p) || p < 50) return false; return !!hU; } const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0))); @@ -216,6 +238,7 @@ if (!stackMini || !stackMini.nextDropVisualDirty) return; stackMini.pendingDropSeat = getStackDropActorSeat(); stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat); + stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy); stackMini.nextDropVisualDirty = false; } @@ -243,7 +266,11 @@ return null; } - /** @returns {boolean} true = วาดสไปรต์แล้ว (ไม่ยืด — fit ในกล่องแบบ contain) · false = วาดสี่เหลี่ยมสี fallback */ + /** + * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback + * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain + * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult + */ function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) { const o = opts || {}; const rec = resolveStackBlockSpriteRec(seat, heavy); @@ -252,21 +279,26 @@ const iw = img.naturalWidth; const ih = img.naturalHeight; if (drawW > 0 && drawH > 0) { - const scale = Math.min(drawW / iw, drawH / ih); + const scale = Math.max(drawW / iw, drawH / ih); const dw = iw * scale; const dh = ih * scale; const dx = sx + (drawW - dw) * 0.5; const dy = sy + (drawH - dh) * 0.5; const prevSmooth = ctx.imageSmoothingEnabled; + ctx.save(); + ctx.beginPath(); + ctx.rect(sx, sy, drawW, drawH); + ctx.clip(); try { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); } finally { ctx.imageSmoothingEnabled = prevSmooth; } + ctx.restore(); if (o.missOverlay) { ctx.fillStyle = 'rgba(247, 118, 190, 0.42)'; - ctx.fillRect(dx, dy, dw, dh); + ctx.fillRect(sx, sy, drawW, drawH); } return true; } @@ -287,10 +319,20 @@ let stackFall = null; /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */ let stackTowerSlingImg = null; - /** Tower mnn93hpi — perfect stack FX (`img/TowerBlock/score-light.png`, `scorex2.png`) */ + /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */ + let stackTowerScorePlus10Img = null; + let stackTowerScorePlus20Img = null; let stackTowerScoreLightImg = null; let stackTowerScoreX2Img = null; + /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) */ + let stackTowerHeartMinusImg = null; + /** @type {{ until: number } | null} */ + let stackTowerHeartMinusFx = null; + const STACK_TOWER_HEART_MINUS_FX_MS = 1500; const STACK_TOWER_PERFECT_GLOW_MS = 1500; + /** ป๊อปอัป +10 / +20 เหนือบล็อก */ + const STACK_TOWER_SCORE_POPUP_MS = 2000; + /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */ const STACK_TOWER_PERFECT_X2_MS = 2200; /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */ const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998; @@ -1863,6 +1905,9 @@ let stackTowerMissionDeferredPhase = null; let stackTowerSessionStartAt = 0; let stackTowerMissionEndedOnce = false; + /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */ + let stackTowerResultFlashTimer = null; + const STACK_TOWER_RESULT_FLASH_MS = 5000; function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) { const urls = playSpaceShooterAsteroidSpriteUrls; @@ -3782,6 +3827,14 @@ gchJumpTeardown.setAttribute('aria-hidden', 'true'); gchJumpTeardown.style.pointerEvents = ''; } + const stackDropTeardown = document.getElementById('stack-tower-drop-btn'); + if (stackDropTeardown) { + stackDropTeardown.classList.add('is-hidden'); + stackDropTeardown.setAttribute('aria-hidden', 'true'); + stackDropTeardown.style.pointerEvents = ''; + stackDropTeardown.classList.remove('stack-tower-drop-btn--active'); + } + hideStackTowerResultFlashDomOnlyPlay(); } function setupPlayQuizUi() { @@ -4013,6 +4066,20 @@ return im && im.complete && im.naturalWidth > 0 ? im : null; } + function ensureStackTowerScorePopupImagesPlay() { + if (!stackTowerScorePlus10Img) { + stackTowerScorePlus10Img = new Image(); + stackTowerScorePlus10Img.decoding = 'async'; + stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png'); + } + if (!stackTowerScorePlus20Img) { + stackTowerScorePlus20Img = new Image(); + stackTowerScorePlus20Img.decoding = 'async'; + stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png'); + } + return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img }; + } + function ensureStackTowerPerfectFxImagesPlay() { if (!stackTowerScoreLightImg) { stackTowerScoreLightImg = new Image(); @@ -4027,29 +4094,8 @@ return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img }; } - /** วาด score-light ทับขอบบล็อก (หลังสไปรต์ชั้น) */ - function drawStackTowerPerfectGlowOverlayPlay(ctx, sx, sy, drawW, drawH, lightImg, nowT, untilT) { - if (!lightImg || !lightImg.complete || !lightImg.naturalWidth || drawW <= 0 || drawH <= 0) return; - const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS)); - const pulse = 0.38 + 0.52 * Math.sin((1 - life) * Math.PI); - const nw = lightImg.naturalWidth; - const nh = lightImg.naturalHeight; - const pad = 1.14 + (1 - life) * 0.06; - const gw = drawW * pad; - const gh = drawH * pad; - const gx = sx + (drawW - gw) * 0.5; - const gy = sy + (drawH - gh) * 0.5; - ctx.save(); - ctx.globalAlpha = Math.min(0.94, pulse); - ctx.globalCompositeOperation = 'screen'; - try { - ctx.drawImage(lightImg, 0, 0, nw, nh, gx, gy, gw, gh); - } catch (e) { /* ignore */ } - ctx.restore(); - } - - /** x2 ขวาชั้นบน — ทับจุดเดียวกับ mock (แสง + ไอคอน) */ - function drawStackTowerPerfectX2OverlayPlay(ctx, x2Img, sx, sy, drawW, drawH) { + /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */ + function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) { if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return; const iw = x2Img.naturalWidth; const ih = x2Img.naturalHeight; @@ -4059,14 +4105,157 @@ const dh = ih * scale; const cx = sx + drawW + drawW * 0.06; const cy = sy + drawH * 0.5; + const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1; + const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI); + if (lightImg && lightImg.complete && lightImg.naturalWidth) { + const lp = 1.06; + const lw = dw * lp; + const lh = dh * lp; + const lnx = lightImg.naturalWidth; + const lny = lightImg.naturalHeight; + ctx.save(); + ctx.globalAlpha = Math.min(0.9, pulse + 0.12); + ctx.globalCompositeOperation = 'screen'; + try { + ctx.drawImage(lightImg, 0, 0, lnx, lny, cx - lw * 0.5, cy - lh * 0.5, lw, lh); + } catch (e) { /* ignore */ } + ctx.restore(); + } ctx.save(); ctx.globalAlpha = 0.98; + ctx.globalCompositeOperation = 'source-over'; try { ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh); } catch (e) { /* ignore */ } ctx.restore(); } + function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) { + if (!m.scorePopups || !m.scorePopups.length) return; + const now = performance.now(); + const dur = STACK_TOWER_SCORE_POPUP_MS; + const imgs = ensureStackTowerScorePopupImagesPlay(); + m.scorePopups = m.scorePopups.filter((p) => p.until > now); + for (let pi = 0; pi < m.scorePopups.length; pi++) { + const p = m.scorePopups[pi]; + const li = Math.floor(Number(p.layerIndex)) || 0; + if (li < 0 || li >= m.layers.length) continue; + const L = m.layers[li]; + const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize; + const cxW = L.cx * tileSize; + const yb = floorY - (li + 1) * layerWorldH; + const xl = cxW - lw / 2; + const [sx, sy] = worldToScreen(xl, yb); + const drawW = lw * zoom; + const drawH = layerWorldH * zoom; + const t0 = p.until - dur; + const t = Math.max(0, Math.min(1, (now - t0) / dur)); + const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t); + const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom; + const im = p.kind === '20' ? imgs.p20 : imgs.p10; + const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom)); + let iw = targetW; + let ih = targetW * 0.38; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + ih = (targetW * im.naturalHeight) / im.naturalWidth; + } + const cx = sx + drawW / 2; + const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5; + ctx.save(); + ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha)); + if (im && im.complete && im.naturalWidth > 0) { + try { + ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e) { /* ignore */ } + } else { + ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd'; + ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy); + } + ctx.restore(); + } + } + + function ensureStackTowerHeartMinusImgPlay() { + if (!stackTowerHeartMinusImg) { + stackTowerHeartMinusImg = new Image(); + stackTowerHeartMinusImg.decoding = 'async'; + stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png'); + } + const im = stackTowerHeartMinusImg; + return im && im.complete && im.naturalWidth > 0 ? im : null; + } + + function triggerStackTowerHeartMinusFxPlay() { + if (!isStackTowerMissionHudActivePlay()) return; + stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS }; + } + + function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) { + if (!stackTowerHeartMinusFx) return; + const nt = performance.now(); + if (nt >= stackTowerHeartMinusFx.until) { + stackTowerHeartMinusFx = null; + return; + } + const dur = STACK_TOWER_HEART_MINUS_FX_MS; + const t0 = stackTowerHeartMinusFx.until - dur; + const u = Math.max(0, Math.min(1, (nt - t0) / dur)); + const alpha = 1 - u * u; + const worldCx = m.topCenterX * tileSize; + const stackTopY = floorY - nLay * layerWorldH; + const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5; + const [sx, sy] = worldToScreen(worldCx, worldCy); + const im = ensureStackTowerHeartMinusImgPlay(); + const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom)); + let dw = maxW; + let dh = maxW * 0.52; + if (im && im.naturalWidth > 0 && im.naturalHeight > 0) { + dh = (maxW * im.naturalHeight) / im.naturalWidth; + } + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha)); + if (im) { + ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh); + } else { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('♥ −1', sx, sy); + } + ctx.restore(); + } + + /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */ + function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) { + if (drawW <= 0 || drawH <= 0) return; + const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS)); + const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI); + ctx.save(); + ctx.beginPath(); + ctx.rect(sx, sy, drawW, drawH); + ctx.clip(); + ctx.globalCompositeOperation = 'source-over'; + ctx.shadowColor = 'rgba(255, 210, 40, 0.9)'; + ctx.shadowBlur = Math.max(3, Math.min(10, 4 + pulse * 5)); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.globalAlpha = Math.min(0.85, 0.4 + pulse * 0.34); + ctx.fillStyle = 'rgba(255, 228, 72, 0.52)'; + ctx.fillRect(sx, sy, drawW, drawH); + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.globalAlpha = Math.min(0.72, 0.26 + pulse * 0.32); + ctx.strokeStyle = 'rgba(255, 250, 200, 0.78)'; + const sw = Math.max(1.2, Math.min(2.8, drawW * 0.022)); + ctx.lineWidth = sw; + ctx.strokeRect(sx + sw * 0.5, sy + sw * 0.5, drawW - sw, drawH - sw); + ctx.restore(); + } + /** * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว */ @@ -4118,11 +4307,7 @@ } function hideStackTowerHowtoRulesDom() { - const rulesEl = document.getElementById('stack-tower-howto-rules'); - if (rulesEl) { - rulesEl.classList.add('is-hidden'); - rulesEl.setAttribute('aria-hidden', 'true'); - } + /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */ } function teardownStackTowerMissionUiPlay() { @@ -4136,6 +4321,59 @@ clearTimeout(stackTowerMissionCountdownTimer); stackTowerMissionCountdownTimer = null; } + hideStackTowerResultFlashDomOnlyPlay(); + } + + function hideStackTowerResultFlashDomOnlyPlay() { + if (stackTowerResultFlashTimer) { + clearTimeout(stackTowerResultFlashTimer); + stackTowerResultFlashTimer = null; + } + const ov = document.getElementById('stack-tower-result-flash'); + const img = document.getElementById('stack-tower-result-flash-img'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + if (img) { + img.onerror = null; + img.removeAttribute('src'); + } + } + + /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */ + function beginStackTowerResultFlashThenGcm(mission) { + if (!mission || mission.uiSkin !== 'stack_tower') { + showGauntletCrownMissionOverlay(mission); + return; + } + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!ov || !imgEl) { + showGauntletCrownMissionOverlay(mission); + return; + } + const outc = String(mission.stackTowerOutcome || 'time_up'); + let file = 'result-timeup.png'; + if (outc === 'decrypt_complete') file = 'result-complete.png'; + else if (outc === 'misses_exhausted') file = 'result-gameover.png'; + const url = stackTowerAssetUrl(file); + const finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + imgEl.onerror = function () { + imgEl.onerror = null; + finishToGcm(); + }; + imgEl.src = url; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { + stackTowerResultFlashTimer = null; + finishToGcm(); + }, STACK_TOWER_RESULT_FLASH_MS); } function stackTowerMissionTimeLimitSecPlay() { @@ -4204,24 +4442,52 @@ ov.classList.remove('is-hidden'); setGauntletCrownHowtoStackTowerArtMaskPlay(true); const st = document.getElementById('gauntlet-crown-howto-status'); - if (st) { - st.textContent = ''; - st.classList.remove('gch-status--jumper'); - st.classList.add('is-hidden'); - } + if (st) st.classList.remove('gch-status--jumper'); const btn = document.getElementById('btn-gch-ready'); - if (btn) { - const humans = quizCarryPregameHumanIds(); - const soleHuman = humans.length === 1; - const canGo = soleHuman || isMePlayHost(); - btn.classList.remove('is-start-phase'); - btn.classList.toggle('is-read-only', !canGo); - btn.disabled = !canGo; - btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; - btn.setAttribute('aria-pressed', 'false'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = isMePlayHost() || humans.length === 1; + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateStackTowerMissionHowtoHud(); } } + /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */ + function updateStackTowerMissionHowtoHud() { + if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + const humans = quizCarryPregameHumanIds(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + const num = gauntletCrownPregameReadyNumerator(); + st.classList.remove('is-hidden'); + st.textContent = 'Ready Status : ' + num + '/' + tot; + const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]); + btn.classList.toggle('is-start-phase', humansReady); + btn.classList.toggle('is-read-only', !isMePlayHost()); + btn.disabled = !isMePlayHost(); + btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false')); + btn.title = isMePlayHost() + ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + function beginStackTowerMissionCountdownThenRun() { if (!isStackTowerMissionUiMapPlay()) return; if (stackTowerMissionCountdownTimer) { @@ -4451,7 +4717,7 @@ let outcome = 'time_up'; if (pct >= 100) outcome = 'decrypt_complete'; else if (missesOut) outcome = 'misses_exhausted'; - showGauntletCrownMissionOverlay(stackTowerMissionBuildPayload({ outcome: outcome })); + beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome })); } function isJumpSurviveMissionUiMapPlay() { @@ -7147,6 +7413,30 @@ btn.style.pointerEvents = ''; } + function syncStackTowerDropButtonPlay() { + const btn = document.getElementById('stack-tower-drop-btn'); + if (!btn) return; + if (!mapData || !isStack() || !stackMini) { + btn.classList.add('is-hidden'); + btn.setAttribute('aria-hidden', 'true'); + btn.style.pointerEvents = 'none'; + btn.classList.remove('stack-tower-drop-btn--active'); + return; + } + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') { + btn.classList.add('is-hidden'); + btn.setAttribute('aria-hidden', 'true'); + btn.style.pointerEvents = 'none'; + btn.classList.remove('stack-tower-drop-btn--active'); + return; + } + btn.classList.remove('is-hidden'); + btn.setAttribute('aria-hidden', 'false'); + const ok = stackHumanDropGateOkPlay(); + btn.classList.toggle('stack-tower-drop-btn--active', ok); + btn.style.pointerEvents = ok ? '' : 'none'; + } + function resetQuizCarryPlayState() { quizCarryPregameActive = false; hideQuizCarryPregameOverlay(); @@ -7635,10 +7925,12 @@ : autoW; stackMini.initialWidthTiles = iw; stackMini.widthTiles = iw; + markStackNextDropVisualDirty(); } function resetStackMinigameState() { stackMini = null; + stackTowerHeartMinusFx = null; if (!mapData || !isStack()) return; const w = mapData.width || 20, h = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h); @@ -7689,6 +7981,7 @@ nextDropVisualDirty: true, pendingDropSeat: 1, pendingDropHeavy: false, + scorePopups: [], perfectStackX2Fx: null, }; if (previewFillBots) { @@ -7719,6 +8012,7 @@ const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; if (hit.miss) { stackMini.combo = 0; + stackMini.scorePopups = []; stackMini.perfectStackX2Fx = null; if (stackMini.layers && stackMini.layers.length) { for (let gi = 0; gi < stackMini.layers.length; gi++) { @@ -7734,6 +8028,7 @@ stackMini.score = Math.max(0, stackMini.score - 2); stackMini.lives = 3; stackMini.layers = []; + stackMini.scorePopups = []; stackMini.perfectStackX2Fx = null; stackMini.widthTiles = stackMini.initialWidthTiles; const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15); @@ -7743,11 +8038,14 @@ stackMini.stackTiltVel = 0; } } + if (towerLive) triggerStackTowerHeartMinusFxPlay(); } else { - const W = stackMini.initialWidthTiles; const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat(); const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy); - stackMini.layers.push({ w: W, cx: hit.placedCx, seat: seatLay, heavy: heavyLay }); + const placedW = hit.placedW != null && Number.isFinite(hit.placedW) + ? Math.max(0.85, Math.min(3.2, hit.placedW)) + : stackMini.initialWidthTiles; + stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay }); const nAfter = stackMini.layers.length; const perfectFull = towerLive && @@ -7760,6 +8058,8 @@ const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS; stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil; stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil; + } + if (towerLive && (perfectFull || hit.pts >= 20)) { ensureStackTowerPerfectFxImagesPlay(); stackMini.perfectStackX2Fx = { until: performance.now() + STACK_TOWER_PERFECT_X2_MS, @@ -7774,6 +8074,7 @@ np = Math.min(100, Math.max(0, np)); if (np > 99.999) np = 100; stackMini.progressPct = np; + markStackNextDropVisualDirty(); } if (previewFillBots && previewAttribution) { if (previewAttribution.human) { @@ -7784,8 +8085,19 @@ } } stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0; - stackMini.widthTiles = W; stackMini.topCenterX = hit.placedCx; + if (towerLive && !hit.miss && hit.pts > 0) { + const pNow = Number(stackMini.progressPct); + const prog100 = Number.isFinite(pNow) && pNow >= 99.5; + const kind20 = hit.pts >= 20 || prog100; + if (!stackMini.scorePopups) stackMini.scorePopups = []; + const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS; + stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' }); + if (kind20 && perfectFull && nAfter >= 2) { + stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' }); + } + while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift(); + } } } @@ -7797,7 +8109,8 @@ const wMap = mapData.width || 20; const hMap = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap); - const fallW = m.initialWidthTiles; + const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles; + const fallW = Math.max(0.85, Math.min(3.2, fallWRaw)); let supportCx; let supportW; if (m.layers.length === 0) { @@ -7806,7 +8119,8 @@ } else { const sup = m.layers[m.layers.length - 1]; supportCx = sup.cx; - supportW = fallW; + const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles; + supportW = Math.max(0.85, Math.min(3.2, swR)); } const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg'; const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05); @@ -7849,15 +8163,16 @@ } /** - * บล็อกทุกชั้นหน้ากว้างเท่ากัน (initialWidthTiles) · พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*) - * วาดชั้นเป็นสี่เหลี่ยมแนวนอน (ไม่หมุนต่อชั้น) เพื่อกองตรง ไม่บิดเป็นกองไม้ + * ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน) + * พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*) */ function computeStackDropResult(m) { const raw = m.swingAmp * Math.sin(m.phase); const wMap = mapData.width || 20; const hMap = mapData.height || 15; const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap); - const fallW = m.initialWidthTiles; + const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles; + const fallW = Math.max(0.85, Math.min(3.2, fallWRaw)); let supportCx; let supportW; if (m.layers.length === 0) { @@ -7866,7 +8181,8 @@ } else { const sup = m.layers[m.layers.length - 1]; supportCx = sup.cx; - supportW = fallW; + const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles; + supportW = Math.max(0.85, Math.min(3.2, swR)); } const c1 = m.topCenterX + raw; const half1 = fallW * 0.5; @@ -7981,8 +8297,7 @@ return true; } - /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */ - function tryHumanStackDrop() { + function stackHumanDropGateOkPlay() { if (!stackMini || !isStack() || stackFall || stackMini.settling) return false; if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false; if (previewFillBots && mapData && mapData.gameType === 'stack' && @@ -7990,8 +8305,14 @@ rebuildStackPreviewTurnOrder(); } if (previewFillBots && !isStackPreviewHumanTurn()) return false; - const now = Date.now(); - if (now - stackMini.lastDropAt < 480) return false; + if (Date.now() - stackMini.lastDropAt < 480) return false; + return true; + } + + /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */ + function tryHumanStackDrop() { + if (!stackHumanDropGateOkPlay()) return false; + ensureStackNextDropVisual(); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, previewFillBots ? { human: true } : null)) return false; if (previewFillBots) { @@ -8004,6 +8325,7 @@ /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */ function simulatePreviewBotStackDrop(botId, o) { if (!stackMini || stackFall || stackMini.settling) return; + ensureStackNextDropVisual(); previewBotAlignStackSwingPhasePlay(stackMini, o); const hit = computeStackDropResult(stackMini); if (!startStackFallAnimation(hit, previewFillBots ? { botId } : null)) return; @@ -8179,16 +8501,13 @@ const hue = (i * 47) % 360; const seatL = L.seat != null ? L.seat : 1; const heavyL = !!L.heavy; - drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {}); if (towerHudLive && L.perfectGlowUntil != null) { const ntGlow = performance.now(); if (ntGlow < L.perfectGlowUntil) { - const fxImG = ensureStackTowerPerfectFxImagesPlay(); - if (fxImG.light) { - drawStackTowerPerfectGlowOverlayPlay(ctx, sx, sy, drawW, drawH, fxImG.light, ntGlow, L.perfectGlowUntil); - } + drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil); } } + drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {}); } if (towerHudLive && m.perfectStackX2Fx) { const fxX2 = m.perfectStackX2Fx; @@ -8207,10 +8526,15 @@ const drawWT = lwT * zoom; const drawHT = layerWorldH * zoom; const fxImX = ensureStackTowerPerfectFxImagesPlay(); - if (fxImX.x2) drawStackTowerPerfectX2OverlayPlay(ctx, fxImX.x2, sxT, syT, drawWT, drawHT); + if (fxImX.x2) { + drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until); + } } } } + if (towerHudLive) { + drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY); + } if (m.settling) { const s = m.settling; const u = Math.min(1, (performance.now() - s.t0) / s.dur); @@ -8282,6 +8606,9 @@ ctx.lineWidth = 1; ctx.restore(); } + if (towerHudLive) { + drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay); + } } function applyGauntletTimingFromServer(payload) { @@ -8786,7 +9113,8 @@ function gauntletCrownSyncGuestReadyIfNeeded() { const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto'; - if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto) return; + const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto'; + if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto) return; if (myId == null || isMePlayHost()) return; const sid = String(myId); if (gauntletCrownLobbyReadyMap[sid]) return; @@ -12330,11 +12658,13 @@ if (previewFillBots) rebalancePreviewBots(); if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); }); socket.on('host-changed', (d) => { if (d && d.hostId != null) playHostId = d.hostId; if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); }); socket.on('quiz-carry-lobby-sync', (d) => { if (!d || typeof d.readyMap !== 'object') return; @@ -12357,6 +12687,9 @@ } else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') { gauntletCrownSyncGuestReadyIfNeeded(); updateQuizQuestionMissionHowtoHud(); + } else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') { + gauntletCrownSyncGuestReadyIfNeeded(); + updateStackTowerMissionHowtoHud(); } }); socket.on('gauntlet-crown-lobby-started', () => { @@ -12364,6 +12697,10 @@ beginQuizQuestionMissionCountdownThenRun(); return; } + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') { + beginStackTowerMissionCountdownThenRun(); + return; + } if (!usesCrownLobbyShellPlay()) return; beginGauntletCrownCountdownThenRun(); }); @@ -12471,6 +12808,7 @@ others.delete(data.id); if (previewFillBots) rebalancePreviewBots(); if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud(); + if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud(); }); socket.on('chat', (data) => { const box = document.getElementById('chat-messages'); @@ -13954,7 +14292,11 @@ ctx.fillText('TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, topY + bigAv + 96); const termW = Math.min(380, Math.max(220, cw * 0.42)); - const termH = 86; + const logLineH = 12; + const logMaxLines = 5; + const logBlockH = logMaxLines * logLineH; + /** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */ + const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10); const termY = Math.max(boardH + 24, ch - termH - 52); ctx.fillStyle = 'rgba(18, 8, 32, 0.88)'; roundRectPath(pad, termY, termW, termH, 6); @@ -13971,27 +14313,34 @@ ctx.fillText('// NODE_LOG', pad + 10, termY + 8); ctx.fillStyle = '#7dcfff'; ctx.font = monoSm; - let ly = termY + 26; + const logTop = termY + 24; + let ly = logTop; const logLines = stackPreviewHudLog.length - ? stackPreviewHudLog.slice(-5) + ? stackPreviewHudLog.slice(-logMaxLines) : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)']; logLines.forEach((ln) => { ctx.fillText(ln, pad + 10, ly); - ly += 12; + ly += logLineH; }); const decryptPct = stackTowerDecryptPctPlay(); - const barY = termY + termH - 18; - const barW = termW - 20; + const barPadX = 10; + const barH = 8; + const barW = termW - barPadX * 2; + const barY = termY + termH - barH - 10; + const labelY = barY - 14; + if (labelY >= logTop + 4) { + ctx.fillStyle = '#94e2d5'; + ctx.font = monoSm; + ctx.textBaseline = 'top'; + ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY); + } ctx.fillStyle = 'rgba(0,0,0,0.35)'; - ctx.fillRect(pad + 10, barY, barW, 8); + ctx.fillRect(pad + barPadX, barY, barW, barH); ctx.fillStyle = 'rgba(0, 255, 200, 0.65)'; - ctx.fillRect(pad + 10, barY, Math.max(4, barW * decryptPct / 100), 8); + ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH); ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)'; - ctx.strokeRect(pad + 10, barY, barW, 8); - ctx.fillStyle = '#94e2d5'; - ctx.font = monoSm; - ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + 10, barY - 14); + ctx.strokeRect(pad + barPadX, barY, barW, barH); ctx.restore(); } @@ -15366,6 +15715,7 @@ syncPlayCyberHud(); syncQuizCarryGrabButton(); syncGauntletCrownJumpButton(); + syncStackTowerDropButtonPlay(); } document.addEventListener('keydown', (e) => { @@ -15756,6 +16106,17 @@ }); })(); + (function wireStackTowerDropButton() { + const btn = document.getElementById('stack-tower-drop-btn'); + if (!btn) return; + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + if (!mapData || !isStack() || isChatFocused()) return; + if (!btn.classList.contains('stack-tower-drop-btn--active')) return; + tryHumanStackDrop(); + }); + })(); + (function wireGauntletCrownHowtoPrimary() { const btn = document.getElementById('btn-gch-ready'); if (!btn) return; @@ -15807,8 +16168,32 @@ if (isStackTowerMissionUiMapPlay()) { if (stackTowerMissionPhase !== 'howto') return; const humansSt = quizCarryPregameHumanIds(); - if (!(humansSt.length === 1 || isMePlayHost())) return; - beginStackTowerMissionCountdownThenRun(); + const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers()); + if (totPlayersSt === 1) { + if (!(humansSt.length === 1 || isMePlayHost())) return; + beginStackTowerMissionCountdownThenRun(); + return; + } + if (!isMePlayHost()) { + if (myId == null) return; + const sidSt = String(myId); + const nextSt = !gauntletCrownLobbyReadyMap[sidSt]; + gauntletCrownLobbyReadyMap[sidSt] = nextSt; + updateStackTowerMissionHowtoHud(); + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt }); + return; + } + if (myId == null) return; + const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]); + if (humansReadySt) { + socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } }); + return; + } + const sidHostSt = String(myId); + const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt]; + gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt; + updateStackTowerMissionHowtoHud(); + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt }); return; } if (!usesCrownLobbyShellPlay()) return; diff --git a/www/html/Game/public/js/version.js b/www/html/Game/public/js/version.js index e63d4e7..5cc330f 100644 --- a/www/html/Game/public/js/version.js +++ b/www/html/Game/public/js/version.js @@ -1,6 +1,6 @@ // ทุกครั้งที่มีการเพิ่มหรือเปลี่ยน ให้เพิ่ม v +0.0001 // หลังแก้ค่าแล้วต้อง copy ไป path ที่ Nginx ชี้ (หรือรัน copy-frogger-files-only.sh) ถึงจะเห็นบนเว็บ -window.APP_VERSION = '0.0253'; +window.APP_VERSION = '0.0258'; document.addEventListener('DOMContentLoaded', function () { var t = document.querySelector('.version-tag'); if (t) t.textContent = 'v ' + window.APP_VERSION; diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 2bd5686..9cbc299 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -188,6 +188,45 @@ pointer-events: none; user-select: none; } + /* Stack Tower (mnn93hpi) — ปุ่มปล่อยบล็อก มุมขวาล่าง · เทียบ Space / Enter */ + #stack-tower-drop-btn { + position: absolute; + right: max(10px, env(safe-area-inset-right, 0px)); + bottom: max(10px, env(safe-area-inset-bottom, 0px)); + z-index: 60; + width: min(14vh, 16vw, 200px); + height: min(14vh, 16vw, 200px); + padding: 0; + margin: 0; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + opacity: 0.4; + transition: transform 0.15s ease, opacity 0.2s ease, filter 0.15s ease; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + box-shadow: none; + filter: drop-shadow(0 0 14px rgba(187, 100, 255, 0.5)); + } + #stack-tower-drop-btn.is-hidden { + display: none !important; + } + #stack-tower-drop-btn.stack-tower-drop-btn--active { + opacity: 1; + cursor: pointer; + } + #stack-tower-drop-btn.stack-tower-drop-btn--active:active { + transform: scale(0.96); + } + #stack-tower-drop-btn img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; + } #quiz-game-overlay { position: fixed; left: 50%; @@ -1040,9 +1079,41 @@ backdrop-filter: blur(3px); } #gauntlet-crown-mission-overlay { z-index: 10150; } + /* Stack Tower (mnn93hpi) — แฟลชผลจบ 5 วิ ก่อน GCM */ + #stack-tower-result-flash { + position: fixed; + inset: 0; + z-index: 10160; + display: flex; + align-items: center; + justify-content: center; + padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); + box-sizing: border-box; + pointer-events: none; + } + #stack-tower-result-flash.is-hidden { + display: none !important; + } + .stack-tower-result-flash-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 8, 22, 0.58); + backdrop-filter: blur(4px); + } + #stack-tower-result-flash-img { + position: relative; + z-index: 1; + max-width: min(94vw, 980px); + max-height: min(82vh, 640px); + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 0 28px rgba(0, 0, 0, 0.55)); + } #gauntlet-crown-howto-overlay.is-hidden, #gauntlet-crown-mission-overlay.is-hidden, - #gauntlet-crown-countdown.is-hidden { display: none !important; } + #gauntlet-crown-countdown.is-hidden, + #stack-tower-result-flash.is-hidden { display: none !important; } .gcc-panel { text-align: center; padding: 0; @@ -1154,37 +1225,6 @@ flex-shrink: 0; margin: 0; } - #stack-tower-howto-rules.stack-tower-howto-rules { - pointer-events: none; - max-height: none; - overflow: visible; - width: min(98%, 440px); - margin: 0 auto 6px; - padding: 8px 10px; - text-align: left; - font-size: clamp(0.62rem, 1.45vw, 0.74rem); - line-height: 1.42; - color: #d5e6ff; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.92); - background: rgba(6, 10, 24, 0.88); - border: 1px solid rgba(0, 234, 255, 0.38); - border-radius: 8px; - box-sizing: border-box; - display: none; - visibility: hidden; - } - .stack-tower-howto-rules h4 { - margin: 8px 0 4px; - font-size: 0.92em; - color: #7af8ff; - font-weight: 800; - letter-spacing: 0.04em; - } - .stack-tower-howto-rules h4:first-child { margin-top: 0; } - .stack-tower-howto-rules ul { margin: 0; padding-left: 1.15em; } - .stack-tower-howto-rules li { margin-bottom: 3px; } - .stack-tower-howto-rules .warn { color: #f6e08a; font-weight: 700; } - .stack-tower-howto-rules.is-hidden { display: none !important; } .gch-title { margin: 0 0 4px; font-size: clamp(1rem, 3.2vw, 1.35rem); @@ -2510,6 +2550,9 @@ + + - - + +
v —
diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 25cb65c..2ea2133 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -13,6 +13,8 @@ const adminTokens = new Set(); const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; /** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ย้ายทุกคนไปฉากควิซนี้และเริ่มเกม */ const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; +/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */ +const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; /** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */ const SPACE_EMPTY_TTL_MS = 45000; // Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025) @@ -2915,8 +2917,14 @@ function isQuizQuestionMissionShellSpace(space) { return !!(md && md.gameType === 'quiz'); } +function isStackTowerMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'stack'); +} + function isCrownLobbyShellSpace(space) { - return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space); + return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space); } function newBalloonBossShellGauntletRunState() { @@ -3710,7 +3718,7 @@ io.on('connection', (socket) => { io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } startGauntletTicker(spaceId, space); - } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space)) { + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space)) { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.gauntletCrownLobbyReady[socket.id] = false; if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); @@ -3759,7 +3767,7 @@ io.on('connection', (socket) => { if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; } - if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space)) && space.gauntletRun) { + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space)) && space.gauntletRun) { joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; } if (typeof cb === 'function') cb(joinCb); @@ -4024,7 +4032,7 @@ io.on('connection', (socket) => { gauntletEliminated: !!p.gauntletEliminated, })); } - if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space))) { + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space))) { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; @@ -4039,7 +4047,7 @@ io.on('connection', (socket) => { peersSnap: peersSnap || undefined, }; if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; - if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space))) { + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space))) { const grs = space.gauntletRun; gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); }