From d7f7f0bc0a11c75c73ede79159e0e9602c5b98de Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Sun, 3 May 2026 05:39:40 +0000 Subject: [PATCH] feat(stack): per-seat block sprites (normal/heavy) in game-timing + admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thai: Minigame-3 ตั้งรูปบล็อก P1–P6 ปกติ+ใหญ่, เปอร์เซ็นต์สุ่มใหญ่, เกมวาดสไปรต์ + fallback สี English: stackBlockNormalImageUrls, stackBlockHeavyImageUrls, stackHeavyBlockPercent; play draws per layer/drop seat. Co-authored-by: Cursor --- www/html/Admin/admin.js | 92 +++++++++++++++++++- www/html/Admin/index.html | 17 ++++ www/html/Game/data/game-timing.json | 3 + www/html/Game/public/js/play.js | 130 ++++++++++++++++++++++++++-- www/html/Game/public/play.html | 2 +- www/html/Game/server.js | 55 ++++++++++++ 6 files changed, 288 insertions(+), 11 deletions(-) diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index 945068a..43dc87b 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -2701,6 +2701,87 @@ var pb = Number(data.stackTowerProgressBlocks); el('stack-tower-progress-blocks').value = String(Number.isFinite(pb) ? Math.floor(Math.max(1, Math.min(500, pb))) : 50); } + if (el('stack-heavy-block-percent')) { + var shp = Number(data.stackHeavyBlockPercent); + el('stack-heavy-block-percent').value = String(Number.isFinite(shp) ? Math.max(0, Math.min(100, Math.floor(shp))) : 35); + } + var normArr = Array.isArray(data.stackBlockNormalImageUrls) ? data.stackBlockNormalImageUrls : []; + var heavyArr = Array.isArray(data.stackBlockHeavyImageUrls) ? data.stackBlockHeavyImageUrls : []; + for (var si = 1; si <= 6; si++) { + var nin = el('stack-block-normal-url-' + si); + var hin = el('stack-block-heavy-url-' + si); + if (nin) nin.value = normArr[si - 1] != null ? String(normArr[si - 1]) : ''; + if (hin) hin.value = heavyArr[si - 1] != null ? String(heavyArr[si - 1]) : ''; + updateStackBlockSeatPreview(si); + } + } + + function readStackBlockNormalUrlsFromForm() { + var out = []; + for (var i = 1; i <= 6; i++) { + var inp = el('stack-block-normal-url-' + i); + out.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return out; + } + + function readStackBlockHeavyUrlsFromForm() { + var out = []; + for (var j = 1; j <= 6; j++) { + var inp = el('stack-block-heavy-url-' + j); + out.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return out; + } + + function updateStackBlockSeatPreview(seat) { + ['normal', 'heavy'].forEach(function (kind) { + var inp = el('stack-block-' + kind + '-url-' + seat); + var img = el('stack-block-' + kind + '-prev-' + seat); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = (kind === 'heavy' ? 'Heavy ' : 'Normal ') + 'P' + seat; + img.src = v; + }); + } + + function bindStackBlockVisualInputs() { + for (var s = 1; s <= 6; s++) { + (function (seat) { + var nIn = el('stack-block-normal-url-' + seat); + var hIn = el('stack-block-heavy-url-' + seat); + if (nIn) { + nIn.addEventListener('change', function () { updateStackBlockSeatPreview(seat); }); + nIn.addEventListener('blur', function () { updateStackBlockSeatPreview(seat); }); + } + if (hIn) { + hIn.addEventListener('change', function () { updateStackBlockSeatPreview(seat); }); + hIn.addEventListener('blur', function () { updateStackBlockSeatPreview(seat); }); + } + var bn = el('btn-stack-block-normal-clear-' + seat); + if (bn) { + bn.addEventListener('click', function () { + var i = el('stack-block-normal-url-' + seat); + if (i) i.value = ''; + updateStackBlockSeatPreview(seat); + }); + } + var bh = el('btn-stack-block-heavy-clear-' + seat); + if (bh) { + bh.addEventListener('click', function () { + var i = el('stack-block-heavy-url-' + seat); + if (i) i.value = ''; + updateStackBlockSeatPreview(seat); + }); + } + })(s); + } } function loadStackGamePanel() { @@ -2743,6 +2824,11 @@ setMsg('stack-game-msg', 'Progress เต็ม 100%: จำนวนชั้นต้องอยู่ระหว่าง 1–500', 'error'); return; } + var heavyPctIn = el('stack-heavy-block-percent') ? parseInt(String(el('stack-heavy-block-percent').value), 10) : 35; + if (!Number.isFinite(heavyPctIn) || heavyPctIn < 0 || heavyPctIn > 100) { + setMsg('stack-game-msg', 'โอกาสบล็อกใหญ่ (%) ต้องอยู่ระหว่าง 0–100', 'error'); + return; + } var btn = el('btn-stack-game-save'); stackGameSaveInFlight = true; if (btn) { @@ -2757,6 +2843,9 @@ data.stackTowerMissionTimeSec = secIn; data.stackTeamMissesMax = missIn; data.stackTowerProgressBlocks = progBlocksIn; + data.stackHeavyBlockPercent = heavyPctIn; + data.stackBlockNormalImageUrls = readStackBlockNormalUrlsFromForm(); + data.stackBlockHeavyImageUrls = readStackBlockHeavyUrlsFromForm(); return gameTimingFetch('PUT', data); }) .then(function () { @@ -2773,7 +2862,7 @@ var stf = fresh && fresh.stackTowerMissionTimeSec != null ? Number(fresh.stackTowerMissionTimeSec) : secIn; var msf = fresh && fresh.stackTeamMissesMax != null ? Number(fresh.stackTeamMissesMax) : missIn; var pbf = fresh && fresh.stackTowerProgressBlocks != null ? Number(fresh.stackTowerProgressBlocks) : progBlocksIn; - setMsg('stack-game-msg', 'บันทึกสำเร็จ — สวิง ' + hz + ' Hz · กว้าง ' + wLabel + ' · เวลา ' + stf + 's · พลาด ' + msf + ' ครั้ง · Progress 100% ที่ ' + pbf + ' ชั้น · โหลดยืนยันจากเซิร์ฟเวอร์แล้ว · ผู้เล่นต้องรีเฟรชหน้าเล่นหรือเข้าห้องใหม่', 'ok'); + setMsg('stack-game-msg', 'บันทึกสำเร็จ — สวิง ' + hz + ' Hz · กว้าง ' + wLabel + ' · เวลา ' + stf + 's · พลาด ' + msf + ' ครั้ง · Progress 100% ที่ ' + pbf + ' ชั้น · รูปบล็อก P1–P6 + ใหญ่ ' + (fresh && fresh.stackHeavyBlockPercent != null ? fresh.stackHeavyBlockPercent : heavyPctIn) + '% · โหลดยืนยันจากเซิร์ฟเวอร์แล้ว · ผู้เล่นต้องรีเฟรชหน้าเล่นหรือเข้าห้องใหม่', 'ok'); }) .catch(function (e) { setMsg('stack-game-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ URL /Game/api/game-timing ไม่ 404', 'error'); @@ -3485,6 +3574,7 @@ btnSpaceShooterSave.addEventListener('click', saveSpaceShooterTimingPanel); } bindSpaceShooterShipUrlInputs(); + bindStackBlockVisualInputs(); bindSpaceShooterDamageOverlayPanel(); bindSpaceShooterAsteroidSpritePanel(); var btnStackGameSave = el('btn-stack-game-save'); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 575c5f1..6ae8f47 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -742,6 +742,23 @@

ใช้เฉพาะโหมด Stack + แมปภารกิจ Tower · คะแนน 10/ชั้น (คอมโบ ×2) · Progress ต่อชั้น = 100% ÷ จำนวนชั้นด้านบน (คอมโบ ×2)

+
+ รูปบล็อกต่อผู้เล่น (ที่นั่ง 1–6) +

ช่อง P1 = ผู้เล่นคน (ตาที่ 1 ในโหมดพรีวิวบอท) · P2–P6 = บอท/ผู้เล่นอื่นในลำดับเดียวกับ HUD · แต่ละช่องมี ปกติ + ใหญ่ (PNG/WebP โปร่งพื้นหลังแนะนำ) · เกมสุ่มใช้ “ใหญ่” ตามเปอร์เซ็นต์ด้านล่าง (ถ้าไม่ใส่ URL ใหญ่ = ใช้ปกติเสมอ) · English: Six seats; two URLs each (normal / heavy). Heavy roll uses percent; empty heavy falls back to normal art.

+
+ +
+
+
P1
+
P2
+
P3
+
P4
+
P5
+
P6
+
+
diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json index 91a912e..fd5dd80 100644 --- a/www/html/Game/data/game-timing.json +++ b/www/html/Game/data/game-timing.json @@ -19,6 +19,9 @@ "stackTowerMissionTimeSec": 90, "stackTeamMissesMax": 3, "stackTowerProgressBlocks": 50, + "stackBlockNormalImageUrls": ["", "", "", "", "", ""], + "stackBlockHeavyImageUrls": ["", "", "", "", "", ""], + "stackHeavyBlockPercent": 35, "jumpSurviveJumpHeightMult": 1.5, "jumpSurviveMissionTimeSec": 0, "spaceShooterMissionTimeSec": 0, diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 16e8ccc..2eb4392 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -147,12 +147,14 @@ } stackPreviewTurnIndex = 0; stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); } function advanceStackPreviewTurn() { if (!previewFillBots || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return; stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT; stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); } function isStackPreviewHumanTurn() { @@ -166,6 +168,75 @@ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex]; return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; } + + function getStackDropActorSeat() { + if (previewFillBots && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { + return getStackPreviewCurrentSeat(); + } + return 1; + } + + function markStackNextDropVisualDirty() { + if (stackMini) stackMini.nextDropVisualDirty = true; + } + + function pickStackBlockHeavyVisual(seat) { + const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0))); + if (pct <= 0) return false; + const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1)); + const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || ''); + if (!hU) return false; + if (Math.random() * 100 >= pct) return false; + return true; + } + + function ensureStackNextDropVisual() { + if (!stackMini || !stackMini.nextDropVisualDirty) return; + stackMini.pendingDropSeat = getStackDropActorSeat(); + stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat); + stackMini.nextDropVisualDirty = false; + } + + function normalizePlayStackSixUrlsFromTiming(arr) { + const out = ['', '', '', '', '', '']; + if (!Array.isArray(arr)) return out; + for (let i = 0; i < 6; i++) { + out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : ''); + if (out[i]) ensureGauntletAssetImage(out[i]); + } + return out; + } + + function resolveStackBlockSpriteRec(seat, heavy) { + const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1)); + const tryHeavy = !!heavy; + const order = tryHeavy + ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]] + : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]]; + for (let ti = 0; ti < order.length; ti++) { + const u = normalizeGauntletAssetUrlForPlay(order[ti] || ''); + if (!u) continue; + return ensureGauntletAssetImage(u); + } + return null; + } + + function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) { + const o = opts || {}; + const rec = resolveStackBlockSpriteRec(seat, heavy); + if (rec && rec.ready && rec.img && rec.img.naturalWidth > 0) { + ctx.drawImage(rec.img, sx, sy, drawW, drawH); + if (o.missOverlay) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.42)'; + ctx.fillRect(sx, sy, drawW, drawH); + } + return; + } + const hue = ((hueFallback % 360) + 360) % 360; + ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)'); + ctx.fillRect(sx, sy, drawW, drawH); + } + if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; } const socket = io({ path: BASE + '/socket.io' }); @@ -1117,6 +1188,11 @@ let playStackTeamMissesMax = 3; /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ let playStackTowerProgressBlocks = 50; + /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */ + let playStackBlockNormalUrls = ['', '', '', '', '', '']; + let playStackBlockHeavyUrls = ['', '', '', '', '', '']; + /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */ + let playStackHeavyBlockPercent = 35; /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */ let playJumpSurviveJumpHeightMult = 1.5; /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */ @@ -6702,6 +6778,9 @@ lastDropAt: 0, stackTiltRad: 0, stackTiltVel: 0, + nextDropVisualDirty: true, + pendingDropSeat: 1, + pendingDropHeavy: false, }; if (previewFillBots) { me.stackPreviewHumanPts = 0; @@ -6726,7 +6805,7 @@ * @param {object} hit * @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม) */ - function applyStackHitFromDrop(hit, previewAttribution) { + function applyStackHitFromDrop(hit, previewAttribution, layerVisualMeta) { if (!stackMini || !hit) return; const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; if (hit.miss) { @@ -6749,7 +6828,9 @@ } } else { const W = stackMini.initialWidthTiles; - stackMini.layers.push({ w: W, cx: hit.placedCx }); + 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 }); stackMini.score += hit.pts; if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) { let np = Number(stackMini.progressPct) + Number(hit.progressDelta); @@ -6901,7 +6982,10 @@ */ function startStackFallAnimation(hit, previewActor) { if (!stackMini || !hit || stackFall || stackMini.settling) return false; + ensureStackNextDropVisual(); const m = stackMini; + const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat(); + const actorHeavy = !!m.pendingDropHeavy; const tw = m.widthTiles * tileSize; const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize; const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); @@ -6941,6 +7025,8 @@ releaseRopeAng, releaseAttachWorldX: swingXW, releaseAttachWorldY: swingAttachY, + actorSeat, + actorHeavy, }; return true; } @@ -7026,6 +7112,7 @@ } stackMini.settling = null; stackMini.lastDropAt = Date.now(); + markStackNextDropVisualDirty(); if (previewFillBots && (wasHuman || settleBotId)) advanceStackPreviewTurn(); } } else if (stackFall) { @@ -7042,6 +7129,7 @@ } stackMini.lastDropAt = Date.now(); stackFall = null; + markStackNextDropVisualDirty(); if (previewFillBots && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } else if (hit.supportRatio != null && hit.supportRatio < stableMin) { const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32); @@ -7056,6 +7144,8 @@ delta: hit.delta || 0, previewHumanDrop: wasHuman, previewBotId: fallBotId, + actorSeat: stackFall.actorSeat, + actorHeavy: stackFall.actorHeavy, }; stackFall = null; } else { @@ -7064,12 +7154,13 @@ if (wasHuman) previewAttr = { human: true }; else if (fallBotId) previewAttr = { botId: fallBotId }; } - applyStackHitFromDrop(hit, previewAttr); + applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy }); if (previewFillBots && (wasHuman || fallBotId)) { stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId }); } stackMini.lastDropAt = Date.now(); stackFall = null; + markStackNextDropVisualDirty(); if (previewFillBots && (wasHuman || fallBotId)) advanceStackPreviewTurn(); } } @@ -7103,6 +7194,7 @@ function drawStackMinigame(ctx, worldToScreen, zoom) { if (!stackMini) return; const m = stackMini; + ensureStackNextDropVisual(); const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3); const stripH = m.blockStripH || Math.max(16, tileSize * 0.32); const floorY = m.floorWorldY; @@ -7134,8 +7226,9 @@ const drawW = lw * zoom; const drawH = layerWorldH * zoom; const hue = (i * 47) % 360; - ctx.fillStyle = 'hsla(' + hue + ', 68%, 56%, 0.9)'; - ctx.fillRect(sx, sy, drawW, drawH); + const seatL = L.seat != null ? L.seat : 1; + const heavyL = !!L.heavy; + drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {}); ctx.strokeStyle = 'rgba(255,255,255,0.38)'; ctx.lineWidth = 1; ctx.strokeRect(sx, sy, drawW, drawH); @@ -7154,11 +7247,12 @@ const drawW = s.twWorld * zoom; const drawH = s.stripH * zoom; const hue = (m.layers.length * 47) % 360; + const seatS = s.actorSeat != null ? s.actorSeat : 1; + const heavyS = !!s.actorHeavy; ctx.save(); ctx.translate(cxs, cys); ctx.rotate(rotRad); - ctx.fillStyle = 'hsla(' + hue + ', 70%, 52%, 0.93)'; - ctx.fillRect(-drawW / 2, -drawH / 2, drawW, drawH); + drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {}); ctx.strokeStyle = 'rgba(255, 200, 140, 0.9)'; ctx.lineWidth = 2; ctx.strokeRect(-drawW / 2, -drawH / 2, drawW, drawH); @@ -7201,12 +7295,16 @@ : Math.atan2(bcy - tcy, bcx - tcx); const swingTiltRad = ropeAng - Math.PI / 2; const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss; - ctx.fillStyle = hitMissTint ? 'rgba(247, 118, 190, 0.88)' : 'rgba(125, 207, 255, 0.95)'; + const seatDraw = stackFall && stackFall.actorSeat != null + ? stackFall.actorSeat + : (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat()); + const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy; + const hueHint = (m.layers.length * 47) % 360; ctx.strokeStyle = hitMissTint ? 'rgba(255, 180, 210, 0.98)' : 'rgba(192, 202, 245, 0.98)'; ctx.save(); ctx.translate(bcx, bcy); ctx.rotate(fallTiltRad + swingTiltRad); - ctx.fillRect(-twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx); + drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint }); ctx.lineWidth = 2; ctx.strokeRect(-twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx); ctx.lineWidth = 1; @@ -7313,6 +7411,20 @@ playStackTowerProgressBlocks = 50; } } + if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) { + playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls); + } + if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) { + playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls); + } + if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) { + const hp = Number(payload.stackHeavyBlockPercent); + if (Number.isFinite(hp)) { + playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp))); + } else { + playStackHeavyBlockPercent = 35; + } + } if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) { const jm = Number(payload.jumpSurviveJumpHeightMult); if (Number.isFinite(jm)) { diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index a726052..a767e2a 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -2009,7 +2009,7 @@ - +
v —
diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 47e4fc6..b8f2f97 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -602,6 +602,10 @@ function defaultGameTiming() { stackTeamMissesMax: 3, /** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ stackTowerProgressBlocks: 50, + stackBlockNormalImageUrls: ['', '', '', '', '', ''], + stackBlockHeavyImageUrls: ['', '', '', '', '', ''], + /** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */ + stackHeavyBlockPercent: 35, /** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */ spaceShooterMissionTimeSec: 0, spaceShooterShipImageUrls: ['', '', '', '', '', ''], @@ -645,6 +649,31 @@ function clampStackTowerProgressBlocks(n) { return Math.max(1, Math.min(500, Math.floor(v))); } +/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */ +function normalizeStackBlockSixImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +function clampStackHeavyBlockPercent(n) { + const v = Number(n); + if (Number.isNaN(v)) return 35; + return Math.max(0, Math.min(100, Math.floor(v))); +} + function clampJumpSurviveJumpHeightMult(n) { const v = Number(n); if (Number.isNaN(v)) return 1.5; @@ -849,6 +878,11 @@ function loadGameTiming() { stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks') ? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks) : 50, + stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls), + stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls), + stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent) + : 35, jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult') ? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult) : 1.5, @@ -967,6 +1001,24 @@ function saveGameTimingToFile(d) { const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks') ? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks) : clampStackTowerProgressBlocks(prevStackProgBlocks); + const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls) + : normalizeStackBlockSixImageUrls([]); + const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls) + : normalizeStackBlockSixImageUrls([]); + const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls) + : prevStackNorm; + const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls) + : prevStackHeavy; + const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent) + : 35; + const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent) + : prevHeavyPct; const out = { gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs), gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks), @@ -977,6 +1029,9 @@ function saveGameTimingToFile(d) { stackTowerMissionTimeSec, stackTeamMissesMax, stackTowerProgressBlocks, + stackBlockNormalImageUrls, + stackBlockHeavyImageUrls, + stackHeavyBlockPercent, jumpSurviveJumpHeightMult: jumpMult, jumpSurviveMissionTimeSec: jumpMissionSec, spaceShooterMissionTimeSec: spaceShooterMissionSec,