minigame 4 add more

This commit is contained in:
2026-04-28 15:14:05 +00:00
parent 08ac1f47a7
commit 5df6d16c6b
7 changed files with 724 additions and 86 deletions
File diff suppressed because one or more lines are too long
+117
View File
@@ -283,6 +283,24 @@ button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; }
width: 3.2rem;
margin-left: 0.25rem;
}
.editor-grid-image-carry-bind-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
align-items: center;
font-size: 0.72rem;
color: #a9b1d6;
}
.editor-grid-image-carry-bind-row select {
margin-left: 0.35rem;
max-width: 12rem;
}
.editor-hint-inline {
font-size: 0.68rem;
color: #787c99;
line-height: 1.35;
flex: 1 1 140px;
}
.editor-grid-image-gallery-wrap {
display: flex;
flex-direction: column;
@@ -293,6 +311,13 @@ button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; }
color: #a9b1d6;
font-weight: 600;
}
.editor-grid-image-gallery-note {
margin: 0.1rem 0 0.35rem;
font-size: 0.65rem;
line-height: 1.35;
color: #787c99;
max-width: 36rem;
}
.grid-cell-image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
@@ -330,6 +355,70 @@ button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; }
height: 56px;
object-fit: cover;
}
.grid-cell-image-gallery-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: stretch;
}
.grid-cell-image-gallery-same-hint {
font-size: 0.62rem;
line-height: 1.35;
color: #f7768e;
padding: 0.2rem 0.35rem;
border-radius: 4px;
background: rgba(247, 118, 142, 0.12);
border: 1px solid rgba(247, 118, 142, 0.35);
}
.grid-cell-image-gallery-held-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.1rem 0.25rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.22);
}
.grid-cell-image-gallery-held-label {
font-size: 0.58rem;
font-weight: 700;
color: #7dcfff;
letter-spacing: 0.04em;
}
.grid-cell-image-gallery-held-thumb {
display: block;
width: 100%;
max-height: 44px;
object-fit: cover;
border-radius: 4px;
background: #1a1b26;
}
.grid-cell-image-gallery-held-btn {
font-size: 0.58rem;
padding: 0.15rem 0.35rem;
border-radius: 4px;
border: 1px solid rgba(125, 207, 255, 0.45);
background: rgba(26, 27, 38, 0.95);
color: #c0caf5;
cursor: pointer;
}
.grid-cell-image-gallery-held-btn:hover {
border-color: rgba(158, 206, 106, 0.65);
color: #9ece6a;
}
.grid-cell-image-gallery-clear-held {
font-size: 0.7rem;
line-height: 1;
padding: 0.05rem 0.25rem;
border: none;
border-radius: 4px;
background: rgba(247, 118, 142, 0.25);
color: #f7768e;
cursor: pointer;
}
.grid-cell-image-gallery-clear-held:hover {
background: rgba(247, 118, 142, 0.45);
}
.grid-cell-image-gallery-cap {
display: block;
font-size: 0.65rem;
@@ -1721,6 +1810,34 @@ body.room-lobby--quiz-active .lobby-b-extra-row {
pointer-events: none;
}
/* play — quiz carry pregame (สอดคล้อง play.html) */
.quiz-carry-pregame-card {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: stretch;
max-width: min(94vw, 900px);
width: 100%;
max-height: min(94vh, 900px);
overflow: hidden;
background: rgba(10, 12, 26, 0.55);
border: none;
border-radius: 4px;
box-shadow: none;
pointer-events: auto;
}
.quiz-carry-pregame-footer {
flex: 0 0 auto;
padding: 0.85rem 1rem 1rem;
background: linear-gradient(180deg, rgba(8, 10, 22, 0.2), rgba(8, 10, 22, 0.96));
border-top: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.65rem;
}
/* Editor — เกมตอบคำถาม */
.quiz-editor-wrap {
margin: 1rem 0;
+27 -1
View File
@@ -145,12 +145,38 @@
<label>กว้าง (ช่อง) <input type="number" id="grid-image-brush-w" value="1" min="1" max="24" title="จำนวนช่องกริดแนวนอน"></label>
<label>สูง (ช่อง) <input type="number" id="grid-image-brush-h" value="1" min="1" max="24" title="จำนวนช่องกริดแนวตั้ง"></label>
</div>
<div id="grid-sprite-carry-bind-wrap" class="editor-grid-image-carry-bind-row" style="display:none;" title="quiz carry: สไปรต์ที่ไม่ทับช่องโซนตัวเลือก — ต้องผูกข้อเองถึงจะสลับรูปหยิบแล้ว">
<label>ผูกสไปรต์กับข้อ (หยิบแล้ว)
<select id="grid-sprite-carry-bind">
<option value="auto">อัตโนมัติ (ช่องที่ทับโซนตัวเลือก)</option>
<option value="1">ข้อ 1</option>
<option value="2">ข้อ 2</option>
<option value="3">ข้อ 3</option>
<option value="4">ข้อ 4</option>
<option value="5">ข้อ 5</option>
<option value="6">ข้อ 6</option>
<option value="7">ข้อ 7</option>
<option value="8">ข้อ 8</option>
<option value="9">ข้อ 9</option>
<option value="10">ข้อ 10</option>
<option value="11">ข้อ 11</option>
<option value="12">ข้อ 12</option>
<option value="13">ข้อ 13</option>
<option value="14">ข้อ 14</option>
<option value="15">ข้อ 15</option>
<option value="16">ข้อ 16</option>
</select>
</label>
<span class="editor-hint-inline">Alt+คลิกซ้ายบนสไปรต์ = ใส่/เปลี่ยนผูก · รายการ &quot;อัตโนมัติ&quot; = ล้างผูก</span>
</div>
<div class="editor-grid-image-gallery-wrap">
<span class="editor-grid-image-gallery-label">คลังรูป (คลิกเลือก)</span>
<p class="editor-grid-image-gallery-note">สองช่องคือรูปที่บันทึกในแมป (ก่อนหยิบ / หยิบแล้ว) — ไม่อัปเดตตามการหยิบในห้อง · ทดสอบ idle/held ให้เปิดหน้าเล่น หลังบันทึกแมป</p>
<div id="grid-cell-image-gallery" class="grid-cell-image-gallery" role="listbox" aria-label="คลังรูปบนกริด"></div>
</div>
<div class="editor-grid-image-upload-row">
<label>อัปโหลดเข้าคลัง <input type="file" id="grid-cell-image-upload" accept="image/*"></label>
<input type="file" id="grid-cell-image-held-upload" accept="image/*" hidden tabindex="-1" aria-hidden="true" title="อัปโหลดรูปตอนถือ (quiz carry)">
<button type="button" id="btn-grid-image-remove-from-lib" title="ลบรูปที่เลือกในคลัง (สไปรต์ที่ใช้รูปนี้จะหาย)">ลบจากคลัง</button>
</div>
</div>
@@ -245,7 +271,7 @@
</div>
</div>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260427-lobby-spawn-mode"></script>
<script src="js/editor.js?v=20260428-quiz-carry-toast-silent"></script>
<div class="version-tag">v —</div>
</body>
</html>
+209 -17
View File
@@ -94,8 +94,39 @@
let gridImageLibrary = [];
let gridImageSprites = [];
let gridImageBrushIndex = 0;
let pendingHeldLibIndex = -1;
const editorGridImageElByIndex = {};
const editorGridImageHeldElByIndex = {};
const GRID_IMG_MAX_WH = 24;
function gridLibIdleUrl(entry) {
if (entry == null) return '';
if (typeof entry === 'string') return entry;
if (typeof entry === 'object' && entry.idle) return String(entry.idle);
return '';
}
function gridLibHeldUrl(entry) {
if (entry == null || typeof entry === 'string') return '';
if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
return '';
}
function normalizeGridLibEntry(raw) {
if (raw == null) return null;
if (typeof raw === 'string' && raw.length > 0) return { idle: raw, held: null };
if (typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0) {
let held = (typeof raw.held === 'string' && raw.held.length > 0) ? raw.held : null;
if (held && held === raw.idle) held = null;
return { idle: raw.idle, held };
}
return null;
}
function serializeGridLibEntry(entry) {
const idle = gridLibIdleUrl(entry);
if (!idle) return null;
const held = gridLibHeldUrl(entry);
if (held && held !== idle) return { idle, held };
return idle;
}
let isSpawnMode = false;
let editorFroggerAnimationId = null;
@@ -464,7 +495,10 @@
const y = Math.floor(Number(raw.y));
const w = Math.max(1, Math.floor(Number(raw.w)) || 1);
const h = Math.max(1, Math.floor(Number(raw.h)) || 1);
return { i, x, y, w, h };
const b = parseSpriteBindCarryOption(raw.bindCarryOption);
const o = { i, x, y, w, h };
if (b != null) o.bindCarryOption = b;
return o;
}).filter((s) => Number.isFinite(s.x) && Number.isFinite(s.y));
}
const libLen = (Array.isArray(m.gridImageLibrary) && m.gridImageLibrary.length) || 0;
@@ -486,15 +520,33 @@
return out;
}
function parseSpriteBindCarryOption(raw) {
if (raw == null || raw === '') return null;
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_EDITOR_OPT_COUNT) return null;
return n;
}
function readSpriteCarryBindFromSelect() {
const el = document.getElementById('grid-sprite-carry-bind');
if (!el) return null;
const v = String(el.value || '').trim();
if (v === '' || v === 'auto') return null;
return parseSpriteBindCarryOption(v);
}
function clampSpriteToMap(s) {
let { i, x, y, w, h } = s;
let { i, x, y, w, h, bindCarryOption } = s;
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x + w > width) w = width - x;
if (y + h > height) h = height - y;
w = Math.max(1, w);
h = Math.max(1, h);
return { i, x, y, w, h };
const out = { i, x, y, w, h };
const b = parseSpriteBindCarryOption(bindCarryOption);
if (b != null) out.bindCarryOption = b;
return out;
}
function sanitizeSpritesInPlace() {
@@ -529,7 +581,10 @@
if (objects[yy][xx] === 1) return null;
}
}
return { i: libIdx, x: ax, y: ay, w, h };
const out = { i: libIdx, x: ax, y: ay, w, h };
const b = readSpriteCarryBindFromSelect();
if (b != null) out.bindCarryOption = b;
return out;
}
function rebuildGridImageGallery() {
@@ -541,15 +596,17 @@
return;
}
if (gridImageBrushIndex >= gridImageLibrary.length) gridImageBrushIndex = gridImageLibrary.length - 1;
gridImageLibrary.forEach((url, idx) => {
gridImageLibrary.forEach((entry, idx) => {
const row = document.createElement('div');
row.className = 'grid-cell-image-gallery-row';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'grid-cell-image-gallery-item' + (idx === gridImageBrushIndex ? ' is-selected' : '');
btn.title = 'รูป #' + (idx + 1) + ' — คลิกเลือก';
btn.title = 'รูป #' + (idx + 1) + ' (ก่อนหยิบ) — คลิกเลือกแปรง';
btn.dataset.libIndex = String(idx);
const thumb = document.createElement('img');
thumb.alt = '';
thumb.src = url;
thumb.src = gridLibIdleUrl(entry);
thumb.loading = 'lazy';
btn.appendChild(thumb);
const cap = document.createElement('span');
@@ -562,20 +619,92 @@
el.classList.toggle('is-selected', el.dataset.libIndex === String(idx));
});
});
gal.appendChild(btn);
const heldSide = document.createElement('div');
heldSide.className = 'grid-cell-image-gallery-held-side';
const heldLabel = document.createElement('span');
heldLabel.className = 'grid-cell-image-gallery-held-label';
heldLabel.textContent = 'หยิบแล้ว';
heldSide.appendChild(heldLabel);
const heldUrl = gridLibHeldUrl(entry);
if (heldUrl) {
const him = document.createElement('img');
him.className = 'grid-cell-image-gallery-held-thumb';
him.alt = '';
him.src = heldUrl;
him.loading = 'lazy';
heldSide.appendChild(him);
}
const btnHeld = document.createElement('button');
btnHeld.type = 'button';
btnHeld.className = 'grid-cell-image-gallery-held-btn';
btnHeld.textContent = heldUrl ? 'เปลี่ยนรูปถือ' : 'ใส่รูปถือ';
btnHeld.title = 'รูปบนแมปเมื่อมีคนหยิบตัวเลือกในช่องนี้ (quiz carry) จนกว่าจะปล่อย';
btnHeld.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
pendingHeldLibIndex = idx;
const inp = document.getElementById('grid-cell-image-held-upload');
if (inp) inp.click();
});
heldSide.appendChild(btnHeld);
if (heldUrl) {
const clr = document.createElement('button');
clr.type = 'button';
clr.className = 'grid-cell-image-gallery-clear-held';
clr.title = 'ลบรูปตอนถือ';
clr.textContent = '×';
clr.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
gridImageLibrary[idx] = { idle: gridLibIdleUrl(gridImageLibrary[idx]), held: null };
delete editorGridImageHeldElByIndex[idx];
rebuildGridImageGallery();
draw();
if (statusEl) statusEl.textContent = 'ลบรูปตอนถือของ #' + (idx + 1) + ' แล้ว';
});
heldSide.appendChild(clr);
}
row.appendChild(btn);
row.appendChild(heldSide);
if (heldUrl) {
const idleStr = gridLibIdleUrl(entry);
if (idleStr && heldUrl === idleStr) {
const sameHint = document.createElement('div');
sameHint.className = 'grid-cell-image-gallery-same-hint';
sameHint.textContent = 'รูปก่อนหยิบกับหยิบแล้วเป็นข้อมูลเดียวกัน — อัปโหลดภาพคนละไฟล์สำหรับ «หยิบแล้ว» จึงจะเห็นต่างในเกม';
row.appendChild(sameHint);
}
}
gal.appendChild(row);
primeEditorGridImage(idx);
primeEditorGridImageHeld(idx);
});
}
function primeEditorGridImage(idx) {
if (editorGridImageElByIndex[idx] || !gridImageLibrary[idx]) return;
const idle = gridLibIdleUrl(gridImageLibrary[idx]);
if (editorGridImageElByIndex[idx] || !idle) return;
const im = new Image();
im.onload = () => { draw(); };
im.onerror = () => { delete editorGridImageElByIndex[idx]; };
im.src = gridImageLibrary[idx];
im.src = idle;
editorGridImageElByIndex[idx] = im;
}
function primeEditorGridImageHeld(idx) {
const held = gridLibHeldUrl(gridImageLibrary[idx]);
if (!held) {
delete editorGridImageHeldElByIndex[idx];
return;
}
if (editorGridImageHeldElByIndex[idx]) return;
const im = new Image();
im.onload = () => { draw(); };
im.onerror = () => { delete editorGridImageHeldElByIndex[idx]; };
im.src = held;
editorGridImageHeldElByIndex[idx] = im;
}
function removeGridImageFromLibraryAt(index) {
const i = parseInt(index, 10);
if (!Number.isFinite(i) || i < 0 || i >= gridImageLibrary.length) return;
@@ -589,6 +718,7 @@
.filter(Boolean);
if (gridImageBrushIndex >= gridImageLibrary.length) gridImageBrushIndex = Math.max(0, gridImageLibrary.length - 1);
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
sanitizeSpritesInPlace();
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
@@ -959,6 +1089,7 @@
gridImageSprites = [];
gridImageBrushIndex = 0;
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
for (let i = 0; i < width; i++) { objects[0][i] = 1; objects[height - 1][i] = 1; }
@@ -1469,7 +1600,7 @@
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function setCell(x, y, left) {
function setCell(x, y, left, mouseEv) {
if (drawModeEl.value === 'gauntletPlayerSpawn') {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
if (gt !== 'gauntlet') return;
@@ -1531,6 +1662,24 @@
if (statusEl) statusEl.textContent = 'ช่องกำแพง — วางรูปบนกริดไม่ได้';
return;
}
const gtCell = gameTypeEl ? gameTypeEl.value : gameType;
if (left && mouseEv && mouseEv.altKey && gtCell === 'quiz_carry') {
const hit = findSpriteIndexAtCell(x, y);
if (hit >= 0) {
const bind = readSpriteCarryBindFromSelect();
if (bind == null) delete gridImageSprites[hit].bindCarryOption;
else gridImageSprites[hit].bindCarryOption = bind;
sanitizeSpritesInPlace();
if (statusEl) {
statusEl.textContent = bind == null
? 'สไปรต์: ผูกแบบอัตโนมัติ (นับจากช่องโซนตัวเลือกที่ทับสไปรต์)'
: ('ผูกสไปรต์กับตัวเลือกข้อ ' + bind + ' แล้ว (Alt+คลิกซ้ายอีกครั้งเปลี่ยนค่าในรายการ)');
}
return;
}
if (statusEl) statusEl.textContent = 'ไม่มีสไปรต์ในช่องนี้ — Alt+คลิกบนพื้นที่ที่มีรูป';
return;
}
const wEl = document.getElementById('grid-image-brush-w');
const hEl = document.getElementById('grid-image-brush-h');
const bw = Math.max(1, parseInt(wEl && wEl.value, 10) || 1);
@@ -1727,14 +1876,14 @@
document.getElementById('btn-spawn').textContent = 'ตั้งจุดเกิด';
return;
}
setCell(x, y, e.button === 0);
setCell(x, y, e.button === 0, e);
draw();
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('mousemove', (e) => {
if (e.buttons !== 1 && e.buttons !== 2) return;
const { x, y } = getCell(e);
setCell(x, y, e.buttons === 1);
setCell(x, y, e.buttons === 1, e);
draw();
});
@@ -1802,6 +1951,7 @@
if (drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn' && !supportsLobbySpawnPaint(gameType)) drawModeEl.value = 'wall';
toggleFroggerUI();
syncGridImageBrushInputCaps();
syncDrawModeAuxUi();
});
const btnClearGauntletSpawns = document.getElementById('btn-clear-gauntlet-spawns');
@@ -1972,8 +2122,12 @@
balloonBossBossSpawn: gameType === 'balloon_boss' && balloonBossBossSpawn && Number.isFinite(balloonBossBossSpawn.x)
? { x: Math.floor(balloonBossBossSpawn.x), y: Math.floor(balloonBossBossSpawn.y) }
: null,
gridImageLibrary: gridImageLibrary.slice(),
gridImageSprites: gridImageSprites.map((s) => ({ i: s.i, x: s.x, y: s.y, w: s.w, h: s.h })),
gridImageLibrary: gridImageLibrary.map(serializeGridLibEntry).filter((x) => x != null),
gridImageSprites: gridImageSprites.map((s) => {
const o = { i: s.i, x: s.x, y: s.y, w: s.w, h: s.h };
if (s.bindCarryOption != null) o.bindCarryOption = s.bindCarryOption;
return o;
}),
gridImageCells: deriveGridImageCellsFromSprites(),
};
if (backgroundImage) body.backgroundImage = backgroundImage;
@@ -2004,6 +2158,11 @@
if (!drawModeEl) return;
if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none';
if (gridImageToolsWrap) gridImageToolsWrap.style.display = drawModeEl.value === 'cellImage' ? 'flex' : 'none';
const bindWrap = document.getElementById('grid-sprite-carry-bind-wrap');
if (bindWrap) {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
bindWrap.style.display = (drawModeEl.value === 'cellImage' && gt === 'quiz_carry') ? 'flex' : 'none';
}
}
if (drawModeEl) drawModeEl.addEventListener('change', () => { syncDrawModeAuxUi(); syncLobbySpawnAuxUi(); });
syncDrawModeAuxUi();
@@ -2025,7 +2184,7 @@
e.target.value = '';
return;
}
gridImageLibrary.push(dataUrl);
gridImageLibrary.push({ idle: dataUrl, held: null });
gridImageBrushIndex = gridImageLibrary.length - 1;
delete editorGridImageElByIndex[gridImageBrushIndex];
primeEditorGridImage(gridImageBrushIndex);
@@ -2038,6 +2197,38 @@
e.target.value = '';
});
}
const gridHeldUpload = document.getElementById('grid-cell-image-held-upload');
if (gridHeldUpload) {
gridHeldUpload.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
const idx = pendingHeldLibIndex;
pendingHeldLibIndex = -1;
e.target.value = '';
if (!file || idx < 0 || idx >= gridImageLibrary.length) return;
if (file.size > 6 * 1024 * 1024) {
if (statusEl) statusEl.textContent = 'ไฟล์ใหญ่เกิน 6MB — ลดขนาดก่อนอัปโหลด';
return;
}
const r = new FileReader();
r.onload = () => {
const dataUrl = r.result;
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) return;
const idle = gridLibIdleUrl(gridImageLibrary[idx]);
if (dataUrl === idle) {
if (statusEl) statusEl.textContent = 'ไฟล์นี้เหมือนรูปก่อนหยิบทุกไบต์ — เลือกภาพอื่นสำหรับ «หยิบแล้ว» ถึงจะเห็นต่างในเกม';
rebuildGridImageGallery();
draw();
return;
}
gridImageLibrary[idx] = { idle, held: dataUrl };
delete editorGridImageHeldElByIndex[idx];
rebuildGridImageGallery();
draw();
if (statusEl) statusEl.textContent = 'ตั้งรูปตอนถือสำหรับ #' + (idx + 1) + ' แล้ว (quiz carry — แสดงบนแมปจนกว่าจะปล่อย)';
};
r.readAsDataURL(file);
});
}
const btnRmGridLib = document.getElementById('btn-grid-image-remove-from-lib');
if (btnRmGridLib) {
btnRmGridLib.addEventListener('click', () => {
@@ -2107,12 +2298,13 @@
stackLandArea = m.stackLandArea && m.stackLandArea.length ? m.stackLandArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
cellColors = (m.cellColors && m.cellColors.length === height) ? m.cellColors.map(r => r && r.slice ? r.slice() : Array(width).fill(null)) : Array(height).fill(0).map(() => Array(width).fill(null));
gridImageLibrary = Array.isArray(m.gridImageLibrary)
? m.gridImageLibrary.filter((s) => typeof s === 'string' && s.length > 0 && s.length < 25000000)
? m.gridImageLibrary.map(normalizeGridLibEntry).filter((e) => e && gridLibIdleUrl(e) && gridLibIdleUrl(e).length < 25000000)
: [];
gridImageSprites = migrateSpritesFromMapJson(m, width, height);
sanitizeSpritesInPlace();
gridImageBrushIndex = Math.min(gridImageBrushIndex, Math.max(0, gridImageLibrary.length - 1));
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
showMapInGame = m.showMapInGame !== false;
+315 -65
View File
@@ -85,6 +85,7 @@
let mapBackgroundImg = null;
/** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
let mapGridImageImgs = [];
let mapGridImageHeldImgs = [];
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null, quizCarryHeld: null };
const others = new Map();
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
@@ -161,6 +162,8 @@
const playLayerAllDirsQueued = {};
/** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก <img> อย่างเดียว) */
let playCharLayerApi = { status: 'idle', map: null };
/** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */
const playCharDiskLayersById = Object.create(null);
function ensurePlayCharLayerListFetch() {
if (playCharLayerApi.status !== 'idle') return;
@@ -171,7 +174,18 @@
const map = Object.create(null);
if (Array.isArray(list)) {
list.forEach((c) => {
if (c && c.id) map[c.id] = !!c.hasLayerFiles;
if (!c || !c.id) return;
map[c.id] = !!c.hasLayerFiles;
if (Array.isArray(c.layers) && c.layers.length) {
const st = new Set();
c.layers.forEach((n) => {
if (typeof n === 'string' && n.length) st.add(n);
});
if (st.size) playCharDiskLayersById[c.id] = st;
else delete playCharDiskLayersById[c.id];
} else {
delete playCharDiskLayersById[c.id];
}
});
}
playCharLayerApi = { status: 'done', map };
@@ -190,6 +204,13 @@
return null;
}
/** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */
function playCharDiskLayersSet(characterId) {
if (!characterId || playCharLayerApi.status !== 'done') return null;
const s = playCharDiskLayersById[characterId];
return s && s.size ? s : null;
}
const playLayerImageCache = new Map();
const playLayerCompositeCache = new Map();
function pickRandomPlayTint() {
@@ -369,8 +390,10 @@
const cctx = c.getContext('2d');
let anyTintColorLayer = false;
let skippedShadowWhilePending = false;
const diskLayers = playCharDiskLayersSet(id);
for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
const layerName = PLAY_LAYER_ORDER[li];
if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
const urls = layerName === 'shadow'
? shadowUrlCandidates(id, dir, frameIndex)
: layerUrlCandidates(id, dir, layerName, frameIndex);
@@ -400,7 +423,8 @@
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
const apiFlag = playCharLayerFromApi(characterId);
const charHasLayerFiles = apiFlag === true || playCharAnyDirectionLayered(characterId);
/* API บอกชัดว่าไม่มี *_layer_* → ห้ามประกอบเลเยอร์ (กันยิง URL เช่น layer_face แล้ว 404) */
const charHasLayerFiles = apiFlag === true || (apiFlag == null && playCharAnyDirectionLayered(characterId));
const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body].join('|');
const hit = playLayerCompositeCache.get(cacheKey);
if (hit) return hit;
@@ -1937,6 +1961,15 @@
quizCarryChoicePlaqueThemes = null;
resetQuizCarryPlayState();
resetQuizBattlePlayState();
const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn');
if (grabBtnTeardown) {
grabBtnTeardown.classList.add('is-hidden');
grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active');
grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place');
grabBtnTeardown.style.pointerEvents = '';
const im = grabBtnTeardown.querySelector('img');
if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
}
}
function setupPlayQuizUi() {
@@ -2141,7 +2174,10 @@
const textCol = th.textColor || '#f8f9ff';
const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
const img = imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
const elImg = plaqueExtra && plaqueExtra.imageElement;
const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
const simplePreviewPlaque = previewMode && editorEmbedReturn;
function pathBoard() {
@@ -2162,7 +2198,7 @@
ctx.fillStyle = fillCol;
pathBoard();
ctx.fill();
if (img && img.complete && img.naturalWidth > 0) {
if (drawPlaqueImg) {
ctx.save();
pathBoard();
ctx.clip();
@@ -2172,7 +2208,9 @@
const iw = Math.max(0, w - padImg * 2);
const ih = Math.max(0, h - padImg * 2);
if (iw > 0 && ih > 0) {
const ir = img.naturalWidth / img.naturalHeight;
const nw = drawPlaqueImg.naturalWidth;
const nh = drawPlaqueImg.naturalHeight;
const ir = nw / nh;
let dw;
let dh;
if (iw / ih > ir) {
@@ -2184,7 +2222,7 @@
}
const dx = ix + (iw - dw) / 2;
const dy = iy + (ih - dh) / 2;
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, dw, dh);
ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
}
ctx.restore();
}
@@ -2329,12 +2367,15 @@
if (!b) continue;
const choiceText = String(choices[i] || '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
if (!choiceText && !imgUrl) continue;
const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
: (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
const tileSpanX = (b.maxX - b.minX + 1);
const tileSpanY = (b.maxY - b.minY + 1);
const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
if (!choiceText && imgUrl) {
if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
const lineH = fontPx * 1.2;
const padY = 8;
@@ -2343,7 +2384,7 @@
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, { imageUrl: imgUrl });
drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
continue;
}
const line = choiceText;
@@ -2373,7 +2414,7 @@
const padY = 8;
let w = Math.ceil(maxLineW + padX * 2);
let h = Math.ceil(textLines.length * lh2 + padY * 2);
if (imgUrl) {
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
@@ -2382,7 +2423,7 @@
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, { imageUrl: imgUrl });
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
continue;
}
for (let ti = 0; ti < textLines.length; ti++) {
@@ -2400,7 +2441,7 @@
const padY = 8;
let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
let h = Math.ceil(textLines.length * lineH + padY * 2);
if (imgUrl) {
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
@@ -2409,7 +2450,7 @@
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, { imageUrl: imgUrl });
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
}
ctx.restore();
}
@@ -2531,8 +2572,8 @@
const phaseEl = document.getElementById('quiz-game-phase-label');
if (!phaseEl || !mapData) return;
phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
? 'กด F หยิบบนโซนตัวเลือก · กด F ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
: 'กด F หยิบบนโซนตัวเลือก · กด F ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
: 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
}
function isQuizCarryEmbedCountdownBlockingMovement() {
@@ -2965,12 +3006,16 @@
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + 1;
const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
showQuizCarryToast(willEnd ? 'ถูกต้อง +1 แต้ม — ครบชุดคำถาม' : 'ถูกต้อง +1 แต้ม — สุ่มคำถามใหม่', true);
if (!opts.silent) {
showQuizCarryToast(willEnd ? 'ถูกต้อง +1 แต้ม — ครบชุดคำถาม' : 'ถูกต้อง +1 แต้ม — สุ่มคำถามใหม่', true);
}
me.quizCarryHeld = null;
others.forEach((o) => { if (o) o.quizCarryHeld = null; });
quizCarryAfterRoundResolved();
} else {
showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
if (!opts.silent) {
showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
}
}
renderPlayQuizScoreboard(playLiveQuizScores);
return true;
@@ -2985,7 +3030,9 @@
}
ent.quizCarryHeld = pickupIdx;
const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
showQuizCarryToast('หยิบ: ' + label, true);
if (!opts.silent) {
showQuizCarryToast('หยิบ: ' + label, true);
}
return true;
}
if (opts.fromKey && actorId === myId && !opts.silent) {
@@ -3006,6 +3053,77 @@
return false;
}
function quizCarryGrabCoreGatesOk() {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
if (quizCarryPregameActive || quizCarrySessionEnded) return false;
if (myId == null) return false;
if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
if (!quizCarryOptionsPickableNow()) return false;
return true;
}
/** ยืนบนช่องตัวเลือกและหยิบได้ */
function quizCarryGrabPickupAvailable() {
if (!quizCarryGrabCoreGatesOk()) return false;
if (me.quizCarryHeld != null) return false;
const md = mapData;
const footprint = quizTilesFootprintPlay(me.x, me.y);
let pickupIdx = null;
for (const k of footprint) {
const p = k.split(',');
const tx = +p[0];
const ty = +p[1];
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
}
return pickupIdx != null && !quizCarryOptionHeldByAnyone(pickupIdx);
}
/** ถือป้ายแล้วยืนโซนส่งได้ */
function quizCarryGrabSubmitAvailable() {
if (!quizCarryGrabCoreGatesOk()) return false;
if (me.quizCarryHeld == null) return false;
return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
}
/** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
function quizCarryGrabInteractionAvailable() {
return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
}
function syncQuizCarryGrabButton() {
const btn = document.getElementById('quiz-carry-grab-btn');
if (!btn) return;
if (!isQuizCarry() || !mapData) {
btn.classList.add('is-hidden');
btn.classList.remove('quiz-carry-grab-btn--active');
btn.classList.remove('quiz-carry-grab-btn--place');
btn.setAttribute('aria-disabled', 'true');
return;
}
const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
const img = btn.querySelector('img');
btn.classList.remove('is-hidden');
const active = quizCarryGrabInteractionAvailable();
btn.classList.toggle('quiz-carry-grab-btn--active', active);
btn.setAttribute('aria-disabled', active ? 'false' : 'true');
btn.style.pointerEvents = active ? '' : 'none';
if (img) {
if (active && quizCarryGrabSubmitAvailable()) {
img.src = placeSrc;
btn.classList.add('quiz-carry-grab-btn--place');
btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
} else {
img.src = grabSrc;
btn.classList.remove('quiz-carry-grab-btn--place');
btn.title = 'หยิบตัวเลือก — เหมือนกด F';
btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
}
}
}
function resetQuizCarryPlayState() {
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
@@ -3027,42 +3145,38 @@
});
}
/** รวม carry จากดิสก์ผ่าน Node GET /Game/api/quiz-carry-from-disk (อยู่ใต้ /Game/api/ เพื่อให้ nginx proxy ตรงกับเซิร์ฟอื่น) — ทาง .php ใต้ /Game/ มักโดน fastcgi SCRIPT_FILENAME ผิดจึง 404 */
/** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
async function mergeQuizCarrySettingsFromDisk(s) {
const out = s && typeof s === 'object' ? s : {};
const urls = [
BASE + '/api/quiz-carry-from-disk?_=' + Date.now(),
BASE + '/api-quiz-carry-from-disk.php?_=' + (Date.now() + 1),
];
for (let ui = 0; ui < urls.length; ui++) {
try {
const rd = await fetch(urls[ui], { cache: 'no-store' });
if (!rd.ok) continue;
try {
const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
if (rd.ok) {
const disk = await rd.json();
if (!disk || typeof disk !== 'object') continue;
if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
out.carryMapPanelTheme = disk.carryMapPanelTheme;
if (disk && typeof disk === 'object') {
if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
out.carryMapPanelTheme = disk.carryMapPanelTheme;
}
if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
}
if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
} else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
}
if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
if (Number.isFinite(diskPlaqueScale)) {
out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
}
if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
out.carryQuestions = disk.carryQuestions;
}
}
if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
}
if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
} else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
}
if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
if (Number.isFinite(diskPlaqueScale)) {
out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
}
if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
out.carryQuestions = disk.carryQuestions;
}
} catch (e) { /* next url */ }
}
}
} catch (e) { /* ignore */ }
return out;
}
@@ -3186,8 +3300,8 @@
const leg = document.getElementById('quiz-play-legend');
if (leg) {
leg.textContent = quizCarryMapHasAnswerInteractive(mapData)
? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง'
: 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F ส่งเมื่อถือป้าย';
? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
: 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย';
}
resetQuizCarryPlayState();
if (previewMode) {
@@ -3197,6 +3311,7 @@
loadQuizCarryPoolAndStart();
}
renderPlayQuizScoreboard(playLiveQuizScores);
syncQuizCarryGrabButton();
}
function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
@@ -6077,11 +6192,33 @@
}
}
function gridImageLibIdleUrlPlay(entry) {
if (entry == null) return '';
if (typeof entry === 'string') return entry;
if (typeof entry === 'object' && entry.idle) return String(entry.idle);
return '';
}
function gridImageLibHeldUrlPlay(entry) {
if (entry == null || typeof entry === 'string') return '';
if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
return '';
}
function normalizeGridImageCellsOnMap(md) {
if (!md) return;
const w = md.width || 20, h = md.height || 15;
if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
md.gridImageLibrary = md.gridImageLibrary.filter((s) => typeof s === 'string' && s.length > 0 && s.length < 30000000);
md.gridImageLibrary = md.gridImageLibrary
.map((raw) => {
if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null };
if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) {
let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null;
if (held && held === raw.idle) held = null;
return { idle: raw.idle, held };
}
return null;
})
.filter(Boolean);
const libLen = md.gridImageLibrary.length;
let placements = [];
if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
@@ -6091,7 +6228,11 @@
const y = Math.floor(Number(raw.y));
const ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
return { i, x, y, w: ww, h: hh };
const rawB = raw.bindCarryOption;
const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
const base = { i, x, y, w: ww, h: hh };
if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn;
return base;
}).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
@@ -6101,7 +6242,9 @@
if (y < 0) { hh += y; y = 0; }
if (x + ww > w) ww = w - x;
if (y + hh > h) hh = h - y;
return { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption;
return out;
}).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
} else {
const cells = md.gridImageCells;
@@ -6132,18 +6275,102 @@
function loadMapGridImages() {
mapGridImageImgs = [];
mapGridImageHeldImgs = [];
if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
mapData.gridImageLibrary.forEach((src, i) => {
mapData.gridImageLibrary.forEach((entry, i) => {
mapGridImageImgs[i] = null;
if (!src || typeof src !== 'string') return;
mapGridImageHeldImgs[i] = null;
const idle = gridImageLibIdleUrlPlay(entry);
if (!idle) return;
const im = new Image();
im.onload = () => { try { draw(); } catch (e) {} };
im.onerror = () => {};
im.src = src;
im.src = idle;
mapGridImageImgs[i] = im;
const held = gridImageLibHeldUrlPlay(entry);
if (held && held !== idle) {
const hm = new Image();
hm.onload = () => { try { draw(); } catch (e) {} };
hm.onerror = () => {};
hm.src = held;
mapGridImageHeldImgs[i] = hm;
}
});
}
/** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held */
function dominantQuizCarryOptionInSpriteRect(md, sp) {
if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null;
const g = md.quizCarryOptionArea;
const mw = md.width || 20, mh = md.height || 15;
const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
const counts = new Map();
for (let yy = y0; yy < y0 + hh; yy++) {
if (yy < 0 || yy >= mh) continue;
const row = g[yy];
if (!row) continue;
for (let xx = x0; xx < x0 + ww; xx++) {
if (xx < 0 || xx >= mw) continue;
const v = row[xx];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue;
const idx = n - 1;
counts.set(idx, (counts.get(idx) || 0) + 1);
}
}
if (!counts.size) return null;
let best = null;
let bestN = -1;
counts.forEach((n, k) => {
if (best == null || n > bestN || (n === bestN && k < best)) {
bestN = n;
best = k;
}
});
return best;
}
/** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */
function quizCarryOptionIndexForGridSprite(md, sp) {
if (!sp) return null;
const rawB = sp.bindCarryOption;
if (rawB != null && rawB !== '') {
const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1;
}
return dominantQuizCarryOptionInSpriteRect(md, sp);
}
/** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */
function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) {
if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
const pl = mapData.gridImagePlacements;
if (!Array.isArray(pl) || !pl.length) return null;
for (let gi = 0; gi < pl.length; gi++) {
const sp = pl[gi];
const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
if (opt !== choiceIdx) continue;
const gh = mapGridImageHeldImgs[sp.i];
if (gh && gh.complete && gh.naturalWidth) return gh;
}
return null;
}
/** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */
function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) {
if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
const pl = mapData.gridImagePlacements;
if (!Array.isArray(pl) || !pl.length) return null;
for (let gi = 0; gi < pl.length; gi++) {
const sp = pl[gi];
const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
if (opt !== choiceIdx) continue;
const gid = mapGridImageImgs[sp.i];
if (gid && gid.complete && gid.naturalWidth) return gid;
}
return null;
}
function applyMapAndStart(gameMapData, res) {
const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
@@ -8063,7 +8290,14 @@
const ww = sp.w * tileSize;
const wh = sp.h * tileSize;
if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
const gim = mapGridImageImgs[sp.i];
let gim = mapGridImageImgs[sp.i];
if (isQuizCarry() && quizCarryCurrent) {
const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) {
const gh = mapGridImageHeldImgs[sp.i];
if (gh && gh.complete && gh.naturalWidth) gim = gh;
}
}
if (!gim || !gim.complete || !gim.naturalWidth) continue;
const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
const sy0 = (wy0 - camY) * zDraw + canvas.height / 2;
@@ -8386,10 +8620,11 @@
}
/** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */
function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl) {
function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) {
const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl);
const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null;
const raw = String(signText || '').trim();
if (!raw && !imgUrlSan) return;
if (!raw && !imgUrlSan && !gridHeldDraw) return;
ctx.save();
const heldPs = (() => {
const sc = Number(quizCarryPlaqueMapScale);
@@ -8430,7 +8665,7 @@
const lineH = fontPx * 1.22;
let w;
let h;
if (!lines.length && imgUrlSan) {
if (!lines.length && (imgUrlSan || gridHeldDraw)) {
w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2)));
h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2));
} else {
@@ -8448,7 +8683,7 @@
if (signY < 6) signY = 6;
if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan });
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw });
ctx.restore();
}
@@ -8513,6 +8748,7 @@
sx, sy, halfSpriteW, spriteTopY,
quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
quizCarrySignPayload.imageUrl,
quizCarrySignPayload.imageElement,
);
}
}
@@ -8523,8 +8759,9 @@
if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
const t = String(ch[idx] != null ? ch[idx] : '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
if (!t && !imgUrl) return null;
return { text: t, choiceIndex: idx, imageUrl: imgUrl };
const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
if (!t && !imgUrl && !gridHeldEl) return null;
return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
}
const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
@@ -8654,6 +8891,7 @@
syncQuizCarryEmbedCountdownLayout();
}
syncPlayCyberHud();
syncQuizCarryGrabButton();
}
document.addEventListener('keydown', (e) => {
@@ -8950,6 +9188,18 @@
}
});
})();
(function wireQuizCarryGrabButton() {
const btn = document.getElementById('quiz-carry-grab-btn');
if (!btn) return;
btn.addEventListener('click', (ev) => {
if (!isQuizCarry() || !mapData || isChatFocused()) return;
if (!btn.classList.contains('quiz-carry-grab-btn--active')) return;
ev.preventDefault();
if (myId == null) return;
tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
});
})();
(function wireQuizCarryPregameOverlay() {
const primary = document.getElementById('quiz-carry-pregame-primary');
if (!primary) return;
+45 -1
View File
@@ -27,6 +27,47 @@
display: block;
touch-action: none;
}
/* Quiz carry — ปุ่ม GRAB มุมขวาล่าง (เทียบ F) · mockup ≈ 10–12% ความสูงจอ */
#quiz-carry-grab-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(12vh, 13vw, 168px);
height: min(12vh, 13vw, 168px);
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: default;
opacity: 0.38;
transition: opacity 0.2s ease, transform 0.15s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
box-shadow: none;
}
#quiz-carry-grab-btn.is-hidden {
display: none !important;
}
#quiz-carry-grab-btn.quiz-carry-grab-btn--active {
opacity: 1;
cursor: pointer;
}
#quiz-carry-grab-btn.quiz-carry-grab-btn--active:active {
transform: scale(0.96);
}
#quiz-carry-grab-btn.quiz-carry-grab-btn--place.quiz-carry-grab-btn--active {
filter: drop-shadow(0 0 10px rgba(94, 234, 255, 0.45));
}
#quiz-carry-grab-btn img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
user-select: none;
}
#quiz-game-overlay {
position: fixed;
left: 50%;
@@ -840,6 +881,9 @@
</div>
<div class="play-canvas-stack" id="play-canvas-stack">
<canvas id="game-canvas"></canvas>
<button type="button" id="quiz-carry-grab-btn" class="is-hidden" aria-label="หยิบหรือส่งคำตอบ (Grab)" title="หยิบ / ส่งคำตอบ — เหมือนกด F">
<img src="/Game/img/quiz-carry/btn-grab.png" alt="" width="256" height="256" decoding="async" />
</button>
<div id="quiz-map-question-panel" class="is-hidden" aria-hidden="true">
<p id="quiz-map-question-text"></p>
</div>
@@ -936,7 +980,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0166"></script>
<script src="js/play.js?v=0.123"></script>
<script src="js/play.js?v=0.133"></script>
<div class="version-tag">v —</div>
</body>
</html>
+10 -1
View File
@@ -1757,7 +1757,16 @@ const server = http.createServer((req, res) => {
const list = idArr.map((id) => {
const prefix = id + '_';
const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
return { id, name: id, hasLayerFiles };
const layerNameSet = new Set();
if (hasLayerFiles) {
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
const m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
if (m) layerNameSet.add(m[1]);
});
}
const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
return { id, name: id, hasLayerFiles, layers };
});
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(list));