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)
+
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 —