minigame 6 players position

This commit is contained in:
2026-04-28 08:32:48 +00:00
parent 0804411851
commit eb29e5636e
34 changed files with 2767 additions and 109 deletions
+3 -1
View File
@@ -26,12 +26,14 @@ sudo /root/repos/justice/scripts/deploy-nginx.sh
## เว็บ (`/var/www/html`)
หลังแก้ใน repo แล้วไป production:
หลังแก้ใน repo แล้วไป production (**ทุกครั้ง** ที่ต้องการให้ `/var/www/html` ตรงกับ `www/html/` ใน repo — path คนละตัว ไม่ sync เอง):
```bash
sudo /root/repos/justice/scripts/deploy-www.sh
```
กฎ Cursor: `.cursor/rules/sync-var-www-html.mdc` (และที่ workspace `/root`: `~/.cursor/rules/justice-sync-var-www-html.mdc`) ให้ช่วยเตือน/รันหลังแก้ `www/html`.
ดึงจากเซิร์ฟเวอร์เข้า repo + commit + push:
```bash
+12
View File
@@ -6,11 +6,22 @@ server {
server_name srv1361159.hstgr.cloud;
root /var/www/html;
index index.php index.html index.htm;
# กันค่าเริ่มต้น 1M ตัด POST (รูป base64 ผ่าน Admin API) — คู่กับบล็อก /Admin/api/ และ /Game/api/ ด้านล่าง
client_max_body_size 20M;
location / {
try_files $uri $uri/ =404;
}
# Admin API — JSON body ใหญ่ (รูป base64 อัปโหลดป้าย quiz_carry ฯลฯ) · ค่าเริ่มต้น nginx 1M ทำให้ POST ถูกตัดเงียบ ๆ
location ~ ^/Admin/api/.+\.php$ {
client_max_body_size 20M;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
@@ -44,6 +55,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# รูปป้าย quiz_carry อัปโหลดจาก Admin — เก็บใต้ Game/public/img/quiz-carry-plaque-assets/ (เสิร์ฟโดย location /Game/ ด้านล่าง)
# Game (ZEP-like) — รูปตัวละครอยู่ที่ Node (data/characters)
location /Game/img/characters/ {
proxy_pass http://127.0.0.1:3001;
+1
View File
@@ -6,3 +6,4 @@ install -o root -g root -m 644 "$SRC" "$DST"
nginx -t
systemctl reload nginx
echo "OK: deployed $SRC -> $DST and nginx reloaded."
echo "Tip: ถ้า POST อัปโหลดรูปผ่าน Admin API ยังว่าง body — ตั้ง PHP-FPM post_max_size ≥20M (เช่น pool www.conf) · If uploads still fail, raise PHP post_max_size to match nginx 20M."
+7
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# Deploy tracked web files from repo -> /var/www/html (ไม่ลบ node_modules บนเซิร์ฟเวอร์)
# ใช้ทุกครั้งหลังแก้ www/html ใน repo แล้วต้องการให้เว็บจริงตรงกับ repo
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO/www/html/"
@@ -9,7 +10,13 @@ if [[ "$(id -u)" -ne 0 ]]; then
exit 1
fi
rsync -a \
--checksum \
--exclude 'node_modules' \
"$SRC" "$DST"
chown -R www-data:www-data "$DST"
# รูปป้าย quiz_carry — ให้เขียน/ลบได้ชัดเจน (FTP/Node); setgid ให้ไฟล์ใหม่ได้กลุ่ม www-data
PLAQUE="$DST/Game/public/img/quiz-carry-plaque-assets"
mkdir -p "$PLAQUE"
chown www-data:www-data "$PLAQUE"
chmod 2775 "$PLAQUE"
echo "OK: deployed $SRC -> $DST (node_modules on server untouched)"
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# ตั้งค่าโฟลเดอร์อัปโหลดรูปป้าย quiz_carry ให้ Node (www-data) อ่าน/เขียน/ลบได้
# และ (ถ้าตั้งค่า) ให้บัญชี FTP เขียนได้ด้วย ACL
#
# Usage:
# sudo bash scripts/fix-quiz-carry-plaque-assets-permissions.sh
# sudo WEBROOT=/var/www/html OWNER=www-data GROUP=www-data bash scripts/fix-quiz-carry-plaque-assets-permissions.sh
# sudo FTP_USER=yourftpuser bash scripts/fix-quiz-carry-plaque-assets-permissions.sh # ต้องมี package acl
#
# English: Ensures Game/public/img/quiz-carry-plaque-assets is writable by the game (www-data).
# Optional FTP_USER: setfacl so that Linux user can STOR/delete via FTP without 553.
set -euo pipefail
WEBROOT="${WEBROOT:-/var/www/html}"
OWNER="${OWNER:-www-data}"
GROUP="${GROUP:-www-data}"
DIR="$WEBROOT/Game/public/img/quiz-carry-plaque-assets"
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
mkdir -p "$DIR"
chown "$OWNER:$GROUP" "$DIR"
# rwxrwxr-x + setgid: ไฟล์ใหม่ได้กลุ่มเดียวกับโฟลเดอร์ (เหมาะถ้า FTP user อยู่กลุ่ม www-data)
chmod 2775 "$DIR"
if [[ -n "${FTP_USER:-}" ]]; then
if command -v setfacl >/dev/null 2>&1; then
setfacl -m "u:${FTP_USER}:rwx" "$DIR"
setfacl -d -m "u:${FTP_USER}:rwx" "$DIR"
echo "ACL: user ${FTP_USER} has rwx on $DIR (default ACL for new files)."
else
echo "WARN: FTP_USER set but setfacl not installed. Install acl package or run: usermod -aG ${GROUP} ${FTP_USER}" >&2
fi
fi
echo "OK: $DIR"
ls -ldn "$DIR"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# ทดสอบ POST /Game/api/quiz-carry-plaque-upload ที่ Node (ไม่ผ่าน PHP) — หลัง deploy รันบนเซิร์ฟ: bash scripts/test-plaque-upload-node.sh
set -euo pipefail
PORT="${GAME_NODE_PORT:-3001}"
PNG_B64='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
BODY=$(printf '%s' "{\"imageDataUrl\":\"data:image/png;base64,${PNG_B64}\"}")
code=$(curl -sS -o /tmp/plaque-up.json -w '%{http_code}' -X POST "http://127.0.0.1:${PORT}/Game/api/quiz-carry-plaque-upload" \
-H 'Content-Type: application/json' --data-binary "$BODY" || true)
echo "HTTP $code"
cat /tmp/plaque-up.json
echo
if [[ "$code" != "200" ]]; then
echo "FAIL: expected 200" >&2
exit 1
fi
if ! grep -q '"ok":true' /tmp/plaque-up.json; then
echo "FAIL: expected ok:true in JSON" >&2
exit 1
fi
echo "OK: Node plaque upload accepts minimal PNG"
+4
View File
@@ -25,3 +25,7 @@ TH / EN
สิทธิ์โฟลเดอร์ (ถ้าเขียนไม่ได้):
sudo chown -R www-data:www-data /var/www/html/Admin/private
sudo chmod 750 /var/www/html/Admin/private
รูปป้าย quiz_carry (FTP 553 / Node อัปโหลดไม่ได้):
sudo bash /path/to/repo/scripts/fix-quiz-carry-plaque-assets-permissions.sh
# FTP user เขียนได้: sudo FTP_USER=ftpusername bash .../fix-quiz-carry-plaque-assets-permissions.sh
+86
View File
@@ -825,6 +825,19 @@ code {
min-width: 0;
}
.quiz-carry-slot-img {
font: inherit;
font-size: 0.78rem;
padding: 0.32rem 0.45rem;
margin-top: 0.25rem;
border-radius: 8px;
border: 1px dashed var(--border);
background: rgba(0, 0, 0, 0.15);
color: var(--text);
width: 100%;
min-width: 0;
}
.quiz-carry-correct-wrap {
display: flex;
flex-wrap: wrap;
@@ -2185,3 +2198,76 @@ code {
background: rgba(0, 35, 50, 0.5);
box-shadow: 0 0 12px rgba(0, 242, 255, 0.12);
}
/* quiz_carry — ป้ายต่อช่อง: โหมด neon ไม่ใช้สีขอบ fixed */
.quiz-carry-plaque-slots-root .quiz-carry-theme-grid.quiz-carry-plaque-grid--neon [data-quiz-carry-plaque-color-row='fixed'] {
opacity: 0.48;
}
.quiz-carry-plaque-slots-root .quiz-carry-plaque-neon-hint {
color: var(--muted);
}
.quiz-carry-plaque-slots-root .quiz-carry-plaque-image-row {
grid-template-columns: 1fr;
max-width: none;
}
.quiz-carry-plaque-slots-root .quiz-carry-plaque-image-row .admin-inp-text {
width: 100%;
max-width: none;
min-width: 0;
font-family: ui-monospace, monospace;
font-size: 0.78rem;
}
.quiz-carry-plaque-slots-root .quiz-carry-plaque-preview-err {
color: var(--danger);
}
.quiz-carry-plaque-slots-root .quiz-carry-plaque-preview-img {
vertical-align: top;
box-sizing: border-box;
}
/* quiz_carry — ปรับขนาดป้ายบนแมป (ทั่วทั้งแมป) */
.quiz-carry-plaque-map-scale-panel {
margin: 0 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(122, 200, 255, 0.45);
border-radius: var(--radius);
background: rgba(12, 22, 40, 0.55);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
}
.quiz-carry-plaque-map-scale-panel__title {
font-size: 0.95rem;
font-weight: 700;
color: #c8e6ff;
letter-spacing: 0.02em;
}
.quiz-carry-plaque-map-scale-panel__row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1.25rem;
}
.quiz-carry-plaque-map-scale-range-wrap {
flex: 1 1 220px;
min-width: 180px;
}
.quiz-carry-plaque-map-scale-range-wrap input[type='range'] {
width: 100%;
margin-top: 0.35rem;
}
.quiz-carry-plaque-map-scale-label {
display: block;
font-size: 0.8rem;
color: var(--muted);
}
.quiz-carry-plaque-map-scale-out {
font-size: 1.05rem;
font-weight: 700;
color: #9fe8a8;
font-variant-numeric: tabular-nums;
padding-bottom: 0.2rem;
}
+884 -22
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Proxy อัปโหลดรูปป้าย quiz_carry → Node POST /Game/api/quiz-carry-plaque-upload
* ใช้เมื่อแอดมินอยู่คนละ path / nginx ไม่ส่ง POST ไป Node — คล้าย game-quiz-settings.php
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
}
$raw = file_get_contents('php://input');
if ($raw === false) {
$raw = '';
}
$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
if ($raw === '' && $contentLen > 0) {
json_response([
'ok' => false,
'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
], 413);
}
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
$ch = curl_init($target);
if ($ch === false) {
json_response(['ok' => false, 'error' => 'curl init failed'], 500);
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
json_response([
'ok' => false,
'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
], 502);
}
http_response_code($code > 0 ? $code : 500);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo $out;
+15 -2
View File
@@ -52,7 +52,7 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool
if ($hadFileButInvalidJson) {
return false;
}
$mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
$mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'carryEmbedCountdownTheme', 'carryChoicePlaqueTheme', 'carryChoicePlaqueThemes', 'carryChoicePlaqueMapScale', 'questions', 'carryQuestions', 'battleQuizMcq'];
foreach ($mergeKeys as $k) {
if (array_key_exists($k, $patch)) {
$base[$k] = $patch[$k];
@@ -99,7 +99,11 @@ function overlay_quiz_settings_from_disk(string $nodeBody): string
if (array_key_exists('carryMapPanelTheme', $disk) && is_array($disk['carryMapPanelTheme'])) {
$j['carryMapPanelTheme'] = $disk['carryMapPanelTheme'];
}
foreach (['carryReadMs', 'carryAnswerMs', 'carrySessionLength'] as $ck) {
if (array_key_exists('carryEmbedCountdownTheme', $disk) && is_array($disk['carryEmbedCountdownTheme'])) {
$j['carryEmbedCountdownTheme'] = $disk['carryEmbedCountdownTheme'];
}
/* ไม่ทับ carryChoicePlaqueThemes / carryChoicePlaqueTheme จากดิสก์ — ใช้ค่าจาก Node (อ่านไฟล์เดียวกันกับ disk merge) เพื่อกันทับด้วย JSON เก่าที่ไม่มี plaqueImageUrl ทำให้ช่อง URL ใน Admin ว่างหลังรีเฟรช */
foreach (['carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryChoicePlaqueMapScale'] as $ck) {
if (array_key_exists($ck, $disk)) {
$j[$ck] = $disk[$ck];
}
@@ -169,6 +173,15 @@ function proxy_quiz_curl(string $method, ?string $body = null): void
if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) {
$payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme'];
}
if (isset($dj['carryEmbedCountdownTheme']) && is_array($dj['carryEmbedCountdownTheme'])) {
$payload['carryEmbedCountdownTheme'] = $dj['carryEmbedCountdownTheme'];
}
if (isset($dj['carryChoicePlaqueThemes']) && is_array($dj['carryChoicePlaqueThemes'])) {
$payload['carryChoicePlaqueThemes'] = $dj['carryChoicePlaqueThemes'];
}
if (isset($dj['carryChoicePlaqueTheme']) && is_array($dj['carryChoicePlaqueTheme'])) {
$payload['carryChoicePlaqueTheme'] = $dj['carryChoicePlaqueTheme'];
}
if (array_key_exists('carryReadMs', $dj)) {
$payload['carryReadMs'] = $dj['carryReadMs'];
}
+4 -1
View File
@@ -46,7 +46,7 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool
}
}
}
$mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
$mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'carryEmbedCountdownTheme', 'questions', 'carryQuestions', 'battleQuizMcq'];
foreach ($mergeKeys as $k) {
if (array_key_exists($k, $patch)) {
$base[$k] = $patch[$k];
@@ -149,6 +149,9 @@ function proxy_quiz_curl(string $method, ?string $body = null): void
if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) {
$payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme'];
}
if (isset($dj['carryEmbedCountdownTheme']) && is_array($dj['carryEmbedCountdownTheme'])) {
$payload['carryEmbedCountdownTheme'] = $dj['carryEmbedCountdownTheme'];
}
if (array_key_exists('carryReadMs', $dj)) {
$payload['carryReadMs'] = $dj['carryReadMs'];
}
+95 -2
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="admin.css?v=15">
<link rel="stylesheet" href="admin.css?v=18">
</head>
<body>
<a class="skip-link" href="#admin-main">ข้ามไปเนื้อหา</a>
@@ -183,6 +183,7 @@
<fieldset class="quiz-carry-theme-fieldset" style="margin:0.75rem 0 1rem;padding:0.75rem 1rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-elevated)">
<legend class="quiz-carry-theme-legend" style="font-size:0.92rem;font-weight:600;padding:0 0.35rem">แผงข้อความบนแผนที่ (คำถาม / ตัวเลือกที่ถือ)</legend>
<p class="muted" style="margin:0 0 0.65rem;font-size:0.82rem;line-height:1.45">ใช้กับ <code>#quiz-map-question-panel</code> ในโหมดหยิบมาวาง (โซนทองหรือโซนกลาง) · เก็บที่ <code>carryMapPanelTheme</code> ใน <code>quiz-settings.json</code> · <em>English:</em> Color pickers + alpha (0100%) — saved as <code>rgba()</code>.</p>
<p class="muted" style="margin:-0.35rem 0 0.65rem;padding:0.5rem 0.65rem;border-radius:8px;border:1px solid rgba(255,180,80,0.35);background:rgba(255,200,120,0.08);font-size:0.8rem;line-height:1.5"><strong>ทำไมไม่เห็นเปลี่ยน?</strong> แผงนี้แสดงเฉพาะในหน้า <strong>เล่นเกม</strong> (<code>/Game/play.html</code>) ขณะแมปเป็น <strong>quiz_carry</strong> และมีข้อความคำถาม — ไม่แสดงในหน้า Admin นี้ · ถ้า <strong>โปร่ง A = 0%</strong> กับพื้นหลังหรือขอบ = โปร่งใสทั้งหมด จะไม่เห็นสีนั้น · ความหนาขอบ <strong>0 px</strong> = ไม่มีเส้นขอบ · ลองโปร่ง 70–90% และขอบ 2 px แล้วเปิดพรีวิว/เล่น · <em>English:</em> This panel is only on <strong>play</strong> (not Admin). <strong>0% alpha</strong> = fully transparent color; <strong>0 px</strong> border = no stroke.</p>
<div class="quiz-carry-theme-grid">
<div class="quiz-carry-theme-row" data-quiz-carry-theme="bg">
<span class="quiz-carry-theme-label">พื้นหลังแผง</span>
@@ -227,7 +228,99 @@
</div>
</div>
</fieldset>
<fieldset class="quiz-carry-theme-fieldset" style="margin:0.75rem 0 1rem;padding:0.75rem 1rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-elevated)">
<legend class="quiz-carry-theme-legend" style="font-size:0.92rem;font-weight:600;padding:0 0.35rem">ป้ายตัวเลือกบนแผนที่ (กล่องหยิบมาวาง) — ช่อง 1–16</legend>
<p class="muted" style="margin:0 0 0.65rem;font-size:0.82rem;line-height:1.45">แต่ละช่องตรงกับ <strong>โซนตัวเลือก 116</strong> บนแมป · เก็บที่ <code>carryChoicePlaqueThemes</code> · <strong>Neon</strong> = ขอบตามเลขช่อง · <strong>สีขอบคงที่</strong> = ใช้ picker ด้านล่าง · รูปช่องใช้เมื่อคำถามนั้นไม่มีรูปแยก · รูปคำถามทับช่อง · <strong>ขนาดป้ายบนแมป</strong> ปรับที่กล่องสีเขียวถัดลงนี้ (ไม่ใช่ในแต่ละช่อง) · <em>English:</em> Per-slot = colors/URL; map size = green panel below; Save quiz-carry.</p>
<div class="quiz-carry-plaque-map-scale-panel" role="region" aria-labelledby="quiz-carry-plaque-map-scale-title">
<div class="quiz-carry-plaque-map-scale-panel__title" id="quiz-carry-plaque-map-scale-title">ปรับขนาดป้าย + รูปบนแมป</div>
<p class="muted" style="margin:0.35rem 0 0.65rem;font-size:0.82rem;line-height:1.45">เลื่อนแถบหรือพิมพ์ตัวเลข (×) — ขยายทั้งกล่องป้ายและตัวอักษรบนแมป · จบด้วยปุ่ม <strong>บันทึกชุดหลายตัวเลือก</strong> ด้านล่าง · <em>English:</em> Slider or number, then Save.</p>
<div class="quiz-carry-plaque-map-scale-panel__row">
<label class="admin-field quiz-carry-plaque-map-scale-range-wrap"><span class="quiz-carry-plaque-map-scale-label">เลื่อนขยาย</span>
<input type="range" id="quiz-carry-plaque-map-scale-range" min="85" max="250" step="1" value="125" aria-label="ขยายป้ายบนแมป 0.85 ถึง 2.5 เท่า" />
</label>
<label class="admin-field"><span>ค่า ×</span>
<input type="number" id="quiz-carry-plaque-map-scale" class="admin-inp-num" min="0.85" max="2.5" step="0.05" value="1.25" title="คูณขนาดกล่องป้าย + ฟอนต์บนแผนที่" aria-describedby="quiz-carry-plaque-map-scale-hint" />
</label>
<span id="quiz-carry-plaque-map-scale-out" class="quiz-carry-plaque-map-scale-out" aria-live="polite">× 1.25</span>
</div>
<span id="quiz-carry-plaque-map-scale-hint" class="muted" style="font-size:0.76rem;display:block;margin-top:0.45rem"><code>carryChoicePlaqueMapScale</code> · 1 = ปกติ · 1.5–2.0 ถ้าต้องการใหญ่ชัด</span>
</div>
<div id="quiz-carry-plaque-slots-root" class="quiz-carry-plaque-slots-root" aria-label="ธีมป้ายตัวเลือกทีละช่อง"></div>
</fieldset>
<fieldset class="quiz-carry-theme-fieldset" style="margin:0.75rem 0 1rem;padding:0.75rem 1rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-elevated)">
<legend class="quiz-carry-theme-legend" style="font-size:0.92rem;font-weight:600;padding:0 0.35rem">นับถอยหลัง 3-2-1 (พรีวิว embed)</legend>
<p class="muted" style="margin:0 0 0.65rem;font-size:0.82rem;line-height:1.45">ใช้กับ <code>#quiz-carry-embed-countdown</code> ตอนพรีวิวจากเอดิเตอร์ · เก็บที่ <code>carryEmbedCountdownTheme</code> ใน <code>quiz-settings.json</code> · <em>English:</em> Overlay, inner card, digit color, and size (map = grid box / screen = center).</p>
<div class="quiz-carry-theme-grid">
<div class="quiz-carry-theme-row" data-quiz-carry-ecd="overlay">
<span class="quiz-carry-theme-label">ม่านมืดเต็มจอ</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-ecd-overlay-swatch" value="#080a14" title="สีม่าน" aria-label="สีม่านพื้นหลัง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-ecd-overlay-alpha" min="0" max="100" value="42" step="1" aria-label="ความทึบม่าน" />
<output id="quiz-carry-ecd-overlay-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-ecd-overlay-alpha">42%</output>
</label>
<code id="quiz-carry-ecd-overlay-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-ecd-overlay-val" value="" />
</div>
<div class="quiz-carry-theme-row" data-quiz-carry-ecd="innerBg">
<span class="quiz-carry-theme-label">พื้นหลังกล่องตัวเลข</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-ecd-inner-bg-swatch" value="#0c0e1c" title="พื้นหลังกล่อง" aria-label="พื้นหลังกล่องนับถอยหลัง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-ecd-inner-bg-alpha" min="0" max="100" value="82" step="1" aria-label="โปร่งพื้นหลังกล่อง" />
<output id="quiz-carry-ecd-inner-bg-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-ecd-inner-bg-alpha">82%</output>
</label>
<code id="quiz-carry-ecd-inner-bg-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-ecd-inner-bg-val" value="" />
</div>
<div class="quiz-carry-theme-row" data-quiz-carry-ecd="innerBorder">
<span class="quiz-carry-theme-label">สีขอบกล่อง</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-ecd-inner-border-swatch" value="#7aa2f7" title="สีขอบ" aria-label="สีขอบกล่อง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-ecd-inner-border-alpha" min="0" max="100" value="45" step="1" aria-label="โปร่งขอบ" />
<output id="quiz-carry-ecd-inner-border-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-ecd-inner-border-alpha">45%</output>
</label>
<code id="quiz-carry-ecd-inner-border-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-ecd-inner-border-val" value="" />
</div>
<div class="quiz-carry-theme-row quiz-carry-theme-row--narrow" style="flex-wrap:wrap;gap:0.75rem 1.25rem">
<label class="admin-field quiz-carry-theme-label">ความหนาขอบกล่อง (px)
<input type="number" id="quiz-carry-ecd-inner-border-w" class="admin-inp-num" min="0" max="12" step="1" value="1" />
</label>
<label class="admin-field quiz-carry-theme-label">มุมโค้งกล่อง (px)
<input type="number" id="quiz-carry-ecd-inner-radius" class="admin-inp-num" min="0" max="32" step="1" value="12" />
</label>
<label class="admin-field quiz-carry-theme-label">สีตัวเลข
<input type="color" id="quiz-carry-ecd-digit-swatch" value="#ffe066" title="สีตัวเลข 3-2-1" aria-label="สีตัวเลขนับถอยหลัง" />
</label>
</div>
<div class="quiz-carry-theme-row quiz-carry-theme-row--narrow" style="flex-wrap:wrap;gap:0.75rem 1.25rem">
<label class="admin-field quiz-carry-theme-label">ขนาดบนแมป — cqmin (% ของช่อง)
<input type="number" id="quiz-carry-ecd-map-cqmin" class="admin-inp-num" min="35" max="100" step="1" value="78" title="ยิ่งมากตัวเลขใหญ่ขึ้นในกรอบช่อง" />
</label>
<label class="admin-field quiz-carry-theme-label">cqh
<input type="number" id="quiz-carry-ecd-map-cqh" class="admin-inp-num" min="35" max="100" step="1" value="82" />
</label>
<label class="admin-field quiz-carry-theme-label">สูงสุด (px)
<input type="number" id="quiz-carry-ecd-map-max-px" class="admin-inp-num" min="48" max="400" step="1" value="200" />
</label>
</div>
<div class="quiz-carry-theme-row quiz-carry-theme-row--narrow" style="flex-wrap:wrap;gap:0.75rem 1.25rem">
<label class="admin-field quiz-carry-theme-label">กลางจอ — vw
<input type="number" id="quiz-carry-ecd-screen-vw" class="admin-inp-num" min="6" max="44" step="1" value="28" title="ความกว้างตัวเลขเทียบ viewport" />
</label>
<label class="admin-field quiz-carry-theme-label">สูงสุด (px)
<input type="number" id="quiz-carry-ecd-screen-max-px" class="admin-inp-num" min="48" max="220" step="1" value="132" />
</label>
</div>
</div>
</fieldset>
<h3 class="admin-subheading">รายการคำถาม</h3>
<p class="muted" style="margin:0.35rem 0 0.65rem;font-size:0.8rem;line-height:1.45"><strong>รูปป้ายแต่ละช่อง:</strong> เลือกไฟล์แล้วกด «บันทึก» ได้เลย — ระบบจะ<strong>รอให้อัปโหลดจบ</strong>แล้วค่อยบันทึก URL ลงเซิร์ฟ (หรือรอเห็น «อัปโหลดแล้ว» ก่อนก็ได้) · ปุ่มบันทึก<strong>ไม่</strong>ส่งไฟล์แทนการอัปโหลด · <em>English:</em> Pick plaque file then Save — we wait for upload to finish before writing URLs (Save does not replace the upload step).</p>
<div id="quiz-carry-admin-list" class="quiz-carry-admin-list"></div>
<div class="quiz-admin-actions">
<button type="button" class="btn btn-ghost" id="btn-quiz-carry-add">+ เพิ่มคำถาม</button>
@@ -568,6 +661,6 @@
</div>
</main>
</div>
<script src="admin.js?v=31"></script>
<script src="admin.js?v=47"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+161 -9
View File
@@ -2,6 +2,166 @@
"readMs": 30000,
"answerMs": 5000,
"betweenMs": 3000,
"carryReadMs": 3000,
"carryAnswerMs": 5000,
"carrySessionLength": 10,
"carryMapPanelTheme": {
"panelBg": "rgba(255, 0, 0, 0)",
"panelBorder": "rgba(255, 255, 255, 0)",
"textColor": "rgba(241, 245, 249, 1)",
"borderWidthPx": 0
},
"carryEmbedCountdownTheme": {
"overlayBackdrop": "rgba(8, 10, 20, 0.42)",
"innerBg": "rgba(12, 14, 28, 0.82)",
"innerBorder": "rgba(122, 162, 247, 0.45)",
"innerBorderWpx": 1,
"innerRadiusPx": 12,
"digitColor": "#ffe066",
"mapDigitCqmin": 78,
"mapDigitCqh": 82,
"mapDigitMaxPx": 200,
"screenDigitVw": 28,
"screenDigitMaxPx": 132
},
"carryChoicePlaqueThemes": [
{
"borderMode": "fixed",
"fixedBorder": "rgba(122, 200, 255, 0)",
"fillBg": "rgba(12, 10, 20, 0)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": "https:\/\/srv1361159.hstgr.cloud\/Game\/img\/quiz-carry\/board-1.png"
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
},
{
"borderMode": "neon",
"fixedBorder": "rgba(122, 200, 255, 0.9)",
"fillBg": "rgba(12, 10, 20, 0.88)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": ""
}
],
"carryChoicePlaqueTheme": {
"borderMode": "fixed",
"fixedBorder": "rgba(122, 200, 255, 0)",
"fillBg": "rgba(12, 10, 20, 0)",
"textColor": "rgba(248, 249, 255, 1)",
"borderWidthPx": 2.5,
"plaqueImageUrl": "https:\/\/srv1361159.hstgr.cloud\/Game\/img\/quiz-carry\/board-1.png"
},
"questions": [
{
"text": "test1",
@@ -118,13 +278,5 @@
"correctIndex": 0
}
],
"carryReadMs": 3000,
"carryAnswerMs": 5000,
"carrySessionLength": 10,
"carryMapPanelTheme": {
"panelBg": "rgba(255, 0, 0, 0)",
"panelBorder": "rgba(255, 255, 255, 0)",
"textColor": "rgba(241, 245, 249, 1)",
"borderWidthPx": 0
}
"carryChoicePlaqueMapScale": 1.25
}
@@ -25,12 +25,18 @@ if (!is_array($data)) {
}
$out = [];
$keys = ['carryMapPanelTheme', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryQuestions'];
$keys = ['carryMapPanelTheme', 'carryEmbedCountdownTheme', 'carryChoicePlaqueThemes', 'carryChoicePlaqueTheme', 'carryChoicePlaqueMapScale', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryQuestions'];
foreach ($keys as $k) {
if (!array_key_exists($k, $data)) {
continue;
}
if ($k === 'carryMapPanelTheme') {
if ($k === 'carryMapPanelTheme' || $k === 'carryEmbedCountdownTheme' || $k === 'carryChoicePlaqueTheme') {
if (is_array($data[$k])) {
$out[$k] = $data[$k];
}
continue;
}
if ($k === 'carryChoicePlaqueThemes') {
if (is_array($data[$k])) {
$out[$k] = $data[$k];
}
+40 -2
View File
@@ -33,6 +33,22 @@
</label>
</div>
<div class="game-type-hint editor-card__hint" id="game-type-hint" role="note" aria-labelledby="editor-card-game-label"></div>
<div id="editor-quiz-carry-countdown-wrap" class="editor-card__row editor-card__row--wrap" style="display:none;gap:0.65rem 1rem;align-items:center;margin-top:0.45rem;" aria-label="หยิบมาวาง พรีวิวนับถอยหลัง">
<label class="editor-field-nogrow" style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;">
<input type="checkbox" id="carry-embed-countdown-highlight" checked>
<span>พรีวิว: ไฮไลต์โซนตัวเลือกตอนนับ 3-2-1</span>
</label>
<label class="editor-field-nogrow">สีขอบไฮไลต์
<input type="color" id="carry-embed-countdown-color" value="#ffb44a" title="สีขอบเรืองรอบช่องตัวเลือก (ตรงกับโซนที่วาดเป็น ตัวเลือก 1–16 บนกริด)">
</label>
<label class="editor-field-grow">ตำแหน่งเลข 3-2-1
<select id="carry-embed-countdown-anchor" title="ตามกริด = วาดช่องในโหมด «ตำแหน่งเลข 3-2-1» · อัตโนมัติ = โซนทองหรือโซนกลาง">
<option value="grid">ตามกริดที่วาด (โหมดวาดด้านล่าง)</option>
<option value="map" selected>บนแมป — โซนทอง (คำถาม) หรือโซนกลาง</option>
<option value="screen">กลางจอ (ม่านทึบ)</option>
</select>
</label>
</div>
</section>
<section class="editor-card" aria-labelledby="editor-card-map-label">
@@ -68,6 +84,7 @@
<option value="cellImage">รูปบนกริด (อัปโหลด / เลือกจากคลัง)</option>
<option value="interactive">โซน interactive (จุดกด/ใช้ได้)</option>
<option value="spawnArea" id="draw-mode-option-spawn-area">พื้นที่สุ่มจุดเกิด (ฟ้า — ผู้เล่นใหม่สุ่มในช่องนี้)</option>
<option value="lobbyPlayerSpawn" id="draw-mode-option-lobby-spawn" hidden>จุดเกิดผู้เล่น 1–6 (ลำดับเข้า P1…P6)</option>
<option value="startGame" id="draw-mode-option-start-game">พื้นที่เริ่มเกม (host ยืนแล้วกดเริ่มได้)</option>
<option value="color">สีช่อง (ทาสีบนกริด)</option>
</optgroup>
@@ -115,6 +132,7 @@
<option value="quizCarryOpt14" class="quiz-carry-mode-opt" hidden>ตัวเลือก 14</option>
<option value="quizCarryOpt15" class="quiz-carry-mode-opt" hidden>ตัวเลือก 15</option>
<option value="quizCarryOpt16" class="quiz-carry-mode-opt" hidden>ตัวเลือก 16</option>
<option value="carryEmbedCountdown" class="quiz-carry-mode-opt" hidden>ตำแหน่งเลข 3-2-1 (กริด cyan · พรีวิว embed)</option>
</optgroup>
</select>
</label>
@@ -160,6 +178,26 @@
</label>
<button type="button" id="btn-clear-shooter-spawns" hidden title="ล้างจุดเกิดยานทั้งหมด">ล้างจุดเกิดยาน</button>
<button type="button" id="btn-clear-balloon-boss-spawns" hidden title="ล้างจุดเกิดผู้เล่น + บอส">ล้างจุดเกิดบอสโหมด</button>
<div id="lobby-spawn-wrap" class="editor-card__row" style="display:none;flex-wrap:wrap;gap:0.35rem 0.75rem;align-items:center;width:100%;">
<label class="editor-field-grow" title="ใช้เมื่อเข้าห้อง/ทดลองเล่น (ไม่รวมพรมแดง·ยิงยาน·บอส·กบที่มีจุดเกิดแยก)">จุดเกิดผู้เล่น
<select id="lobby-spawn-mode">
<option value="random">สุ่มในพื้นที่ฟ้า — ไม่มีฟ้าใช้ปุ่มตั้งจุดเกิด</option>
<option value="fixed">คงที่ — จุดเดียว (ปุ่มตั้งจุดเกิด)</option>
<option value="slots6">6 จุดตามลำดับเข้า (P1…P6)</option>
</select>
</label>
</div>
<label id="lobby-slot-paint-wrap" class="editor-field-grow" style="display:none;" title="คนแรกที่เข้าห้อง = P1 ตามลำดับ join">ช่องเกิด P
<select id="lobby-slot-paint">
<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>
</select>
</label>
<button type="button" id="btn-clear-lobby-spawns" hidden title="ล้างจุด P1–P6 บนแผนที่">ล้างจุด P1P6</button>
<button type="button" id="btn-spawn">ตั้งจุดเกิด</button>
</div>
<div class="editor-card__row editor-card__row--actions">
@@ -191,7 +229,7 @@
<li><strong>พื้นที่สุ่มจุดเกิด</strong> (ฟ้าอ่อน) — ไม่วาดช่องนี้ได้ ใช้ปุ่ม «ตั้งจุดเกิด»</li>
<li><strong>พื้นที่เริ่มเกม</strong> (ห้องโถง — ส้ม) — host ยืนแล้วกดเริ่ม</li>
<li><strong>ถามตอบ</strong>: โซนถูก/ผิด + พื้นที่คำถาม (ทอง)</li>
<li><strong>หยิบมาวาง</strong>: ม่วง = โซนกลาง (กำแพง) · <strong>ทอง</strong> = พื้นที่โชว์ข้อความคำถามบนแผนที่ (ถ้าไม่วาดทอง ข้อความไปที่โซนม่วง) · <strong>interactive เขียว</strong> = ยืนแล้วกด F ส่งคำตอบ · สี = ตัวเลือก 1–16 · <code>quizQuestions</code> ใส่ <code>choices</code> + <code>correctIndex</code> หรือ <code>answerTrue</code></li>
<li><strong>หยิบมาวาง</strong>: ม่วง = โซนกลาง (กำแพง) · <strong>ทอง</strong> = พื้นที่โชว์ข้อความคำถามบนแผนที่ (ถ้าไม่วาดทอง ข้อความไปที่โซนม่วง) · <strong>interactive เขียว</strong> = ยืนแล้วกด F ส่งคำตอบ · โหมดวาด <strong>ตัวเลือก 116</strong> = ช่องบนกริดตรงกับลำดับ <code>choices</code> · พรีวิวนับ <strong>3-2-1</strong> ไฮไลต์โซนข้อถูก (หรือใส่ <code>countdownHighlightSlot</code> 116 ต่อข้อใน <code>quizQuestions</code>) · <code>correctIndex</code> / <code>answerTrue</code></li>
<li><strong>Stack</strong>: โหมดวาดจุดปล่อย (ฟ้า) · จุดซ้อนตึก (ชมพู)</li>
<li><strong>กระโดดให้รอด</strong>: โหมด <strong>แพลตฟอร์ม</strong> (ฟ้าอมเขียว) · กำแพง = ขอบซ้ายขวา/บน · Space / W = กระโดด</li>
<li><strong>ลูกโป้งยิงบอส</strong>: จุดเกิดผู้เล่น P1–P6 + <strong>จุดเกิดบอส</strong> 1 ช่อง · ในเกมเดินช้า ยิงมีดีเลย์ · ลูกโป้งหมด = ตกรอบ</li>
@@ -207,7 +245,7 @@
</div>
</div>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260428-play-cache-bust"></script>
<script src="js/editor.js?v=20260427-lobby-spawn-mode"></script>
<div class="version-tag">v —</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+244 -4
View File
@@ -41,7 +41,7 @@
}
function quizCarryEditorCarryModes() {
const m = ['quizCarryHub'];
const m = ['quizCarryHub', 'carryEmbedCountdown'];
for (let n = 1; n <= QUIZ_CARRY_EDITOR_OPT_COUNT; n++) m.push('quizCarryOpt' + n);
return m;
}
@@ -69,6 +69,8 @@
let ground = [], objects = [], blockPlayer = [], interactive = [], startGameArea = [], spawnArea = [], spawn = { x: 1, y: 1 };
let quizTrueArea = [], quizFalseArea = [], quizQuestionArea = [];
let quizCarryHubArea = [], quizCarryOptionArea = [];
/** quiz_carry embed: ช่อง = 1 วางกล่องนับ 3-2-1 บนแมปเมื่อเลือก anchor = grid */
let carryEmbedCountdownArea = [];
let quizBattleDomeArea = [];
/** quiz_battle: กริด 0/1 — ถ้ามีอย่างน้อย 1 ช่อง = ในเกมเดินได้เฉพาะบนเส้นทาง */
let quizBattlePathArea = [];
@@ -78,6 +80,8 @@
let jumpSurvivePlatformArea = [];
let cellColors = [];
let gauntletPlayerSpawns = [];
/** ล็อบบี้ / ZEP / quiz_carry / ฯลฯ — P1..P6 ตามลำดับ join เมื่อเลือกโหมด slots6 */
let lobbyPlayerSpawns = [null, null, null, null, null, null];
/** space_shooter: กริด 0 หรือ 1–6 = ลำดับผู้เล่น P1–P6 */
let shooterSpawnSlots = [];
/** balloon_boss: กริด 0 หรือ 1–6 = จุดเกิดผู้เล่น · บอส = tile เดียว */
@@ -221,6 +225,18 @@
if (!quizQuestionArea[y] || quizQuestionArea[y].length !== width) quizQuestionArea[y] = Array(width).fill(0);
}
}
if (!carryEmbedCountdownArea.length || carryEmbedCountdownArea.length !== height) {
const existingCd = carryEmbedCountdownArea.slice().map(r => r && r.slice());
carryEmbedCountdownArea = [];
for (let y = 0; y < height; y++) {
const row = existingCd[y] && existingCd[y].length === width ? existingCd[y].slice() : Array(width).fill(0);
carryEmbedCountdownArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!carryEmbedCountdownArea[y] || carryEmbedCountdownArea[y].length !== width) carryEmbedCountdownArea[y] = Array(width).fill(0);
}
}
}
function ensureQuizBattleDomeArea() {
@@ -349,6 +365,44 @@
.slice(0, 6);
}
function supportsLobbySpawnPaint(gt) {
return gt === 'zep' || gt === 'lobby' || gt === 'quiz' || gt === 'quiz_carry' || gt === 'stack' || gt === 'jump_survive' || gt === 'quiz_battle';
}
function ensureLobbyPlayerSpawnsLength() {
while (lobbyPlayerSpawns.length < 6) lobbyPlayerSpawns.push(null);
lobbyPlayerSpawns.length = 6;
}
function sanitizeLobbyPlayerSpawnsInEditor() {
ensureLobbyPlayerSpawnsLength();
for (let i = 0; i < 6; i++) {
const s = lobbyPlayerSpawns[i];
if (!s) continue;
const x = Math.floor(Number(s.x));
const y = Math.floor(Number(s.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= width || y < 0 || y >= height
|| (objects[y] && objects[y][x] === 1)) {
lobbyPlayerSpawns[i] = null;
} else {
lobbyPlayerSpawns[i] = { x, y };
}
}
}
function syncLobbySpawnAuxUi() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
const wrap = document.getElementById('lobby-spawn-wrap');
const slotWrap = document.getElementById('lobby-slot-paint-wrap');
const btnClr = document.getElementById('btn-clear-lobby-spawns');
const modeEl = document.getElementById('lobby-spawn-mode');
const use = supportsLobbySpawnPaint(gt);
if (wrap) wrap.style.display = use ? 'flex' : 'none';
const slotsMode = !!(use && modeEl && modeEl.value === 'slots6');
if (slotWrap) slotWrap.style.display = slotsMode ? '' : 'none';
if (btnClr) btnClr.hidden = !slotsMode;
}
function ensureSpawnArea() {
if (!spawnArea.length || spawnArea.length !== height) {
const existing = spawnArea.slice().map(r => r && r.slice());
@@ -651,6 +705,8 @@
if (dqStackL) dqStackL.hidden = gt !== 'stack';
const showCarry = gt === 'quiz_carry';
dqCarryOpts.forEach(function (node) { node.hidden = !showCarry; });
const carryCdWrap = document.getElementById('editor-quiz-carry-countdown-wrap');
if (carryCdWrap) carryCdWrap.style.display = showCarry ? 'flex' : 'none';
if (dqBattleDome) {
dqBattleDome.hidden = gt !== 'quiz_battle';
if (gt !== 'quiz_battle' && drawModeEl && drawModeEl.value === 'quizBattleDome') drawModeEl.value = 'wall';
@@ -727,7 +783,8 @@
'<strong>โซนกลาง (ม่วง)</strong> — กำแพง · ถ้าไม่วาดโซนทองด้านล่าง ข้อความคำถามจะโชว์บนโซนม่วงในเกม',
'<strong>พื้นที่โชว์ข้อความคำถาม (ทอง)</strong> — วาดโซนทองแยกได้ · ในเกมข้อความจะอยู่ตรงโซนที่วาด',
'<strong>โซน interactive (เขียว)</strong> — ยืนบนหรือชิดขอบแล้วกด <kbd>F</kbd> ส่งคำตอบ (วาดอย่างน้อย 1 ช่อง — ถ้าไม่วาดจะใช้ชิดโซนกลางแทน)',
'<strong>ตัวเลือก 116</strong> — ตรงกับลำดับ <code>choices</code> ใน Admin หรือแมป',
'<strong>ตัวเลือก 116</strong> — วาดบนกริดให้ตรงลำดับ <code>choices</code> (Admin / แมป)',
'พรีวิวเอดิเตอร์: ช่วงนับ <strong>3-2-1</strong> — เลือกตำแหน่ง (กลางจอ / บนแมปอัตโนมัติที่โซนทองหรือโซนกลาง / <strong>ตามกริด</strong> โหมดวาด «ตำแหน่งเลข 3-2-1») · ไฮไลต์ข้อถูกด้านบน · ต่อข้อ <code>countdownHighlightSlot</code> (116)',
'เล่นพร้อมกันได้ · คำถามจากแมปหรือ Admin',
]);
}
@@ -864,6 +921,13 @@
if (gt !== 'jump_survive' && drawModeEl && drawModeEl.value === 'jumpSurvivePlatform') drawModeEl.value = 'wall';
}
if (drawModeEl && drawModeEl.value === 'shooterSpawnPaint' && gt !== 'space_shooter') drawModeEl.value = 'wall';
const lobbySpawnOpt = document.getElementById('draw-mode-option-lobby-spawn');
if (lobbySpawnOpt) {
const useLobby = supportsLobbySpawnPaint(gt);
lobbySpawnOpt.hidden = !useLobby;
if (!useLobby && drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn') drawModeEl.value = 'wall';
}
syncLobbySpawnAuxUi();
}
function initGrid() {
@@ -881,6 +945,7 @@
quizQuestionArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryHubArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryOptionArea = Array(height).fill(0).map(() => Array(width).fill(0));
carryEmbedCountdownArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizBattleDomeArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizBattlePathArea = Array(height).fill(0).map(() => Array(width).fill(0));
stackReleaseArea = Array(height).fill(0).map(() => Array(width).fill(0));
@@ -903,6 +968,9 @@
if (gameType === 'gauntlet' || gameType === 'stack' || gameType === 'quiz_carry' || gameType === 'quiz_battle' || gameType === 'jump_survive' || gameType === 'space_shooter' || gameType === 'balloon_boss') lanes = [];
jumpSurvivePlatforms = [];
gauntletPlayerSpawns = [];
lobbyPlayerSpawns = [null, null, null, null, null, null];
const lsmInit = document.getElementById('lobby-spawn-mode');
if (lsmInit) lsmInit.value = 'random';
const fpNew = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fpNew.cw, fpNew.ch);
mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize;
@@ -914,6 +982,7 @@
canvas.width = width * tileSize;
canvas.height = height * tileSize;
sanitizeGauntletPlayerSpawns();
sanitizeLobbyPlayerSpawnsInEditor();
ensureShooterSpawnSlots();
ensureBalloonBossPlayerSlots();
sanitizeSpritesInPlace();
@@ -1190,6 +1259,19 @@
ctx.fillText(lab, tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (carryEmbedCountdownArea[y] && Number(carryEmbedCountdownArea[y][x]) === 1) {
ctx.fillStyle = 'rgba(100, 230, 255, 0.32)';
ctx.fillRect(tx + 4, ty + 4, tileSize - 8, tileSize - 8);
ctx.strokeStyle = 'rgba(80, 220, 255, 0.88)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 4, ty + 4, tileSize - 8, tileSize - 8);
ctx.lineWidth = 1;
ctx.fillStyle = '#1a3a44';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('321', tx + tileSize / 2, ty + tileSize / 2 + 3);
ctx.textAlign = 'left';
}
}
if (gtDraw === 'quiz_battle' && quizBattlePathArea[y] && quizBattlePathArea[y][x] === 1) {
ctx.fillStyle = 'rgba(186, 130, 255, 0.4)';
@@ -1333,6 +1415,34 @@
ctx.textAlign = 'left';
ctx.fillText('ลูกโป้งยิงบอส — P1–P6 + โหมด BOSS 1 ช่อง · ปิดโชว์กริดในเกมให้เหมือนรีเฟอเรนซ์', 8, 20);
}
if (supportsLobbySpawnPaint(gtDraw)) {
ensureLobbyPlayerSpawnsLength();
let anyLobbyP = false;
for (let ii = 0; ii < 6; ii++) {
if (lobbyPlayerSpawns[ii]) { anyLobbyP = true; break; }
}
if (anyLobbyP) {
const drawLobbyPNum = (s, num) => {
if (!s || s.x < 0 || s.x >= width || s.y < 0 || s.y >= height) return;
if (objects[s.y][s.x] === 1) return;
const cx = s.x * tileSize + tileSize / 2;
const cy = s.y * tileSize + tileSize / 2 + 4;
ctx.fillStyle = 'rgba(0,80,120,0.55)';
ctx.beginPath();
ctx.arc(cx, cy - 3, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#b4f9f8';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('P' + String(num), cx, cy + 2);
ctx.textAlign = 'left';
};
for (let i = 0; i < 6; i++) {
const s = lobbyPlayerSpawns[i];
if (s) drawLobbyPNum(s, i + 1);
}
}
}
const fpHud = readCharacterFootprintInputs();
ctx.fillStyle = '#c0caf5';
ctx.font = '12px sans-serif';
@@ -1384,6 +1494,38 @@
}
return;
}
if (drawModeEl.value === 'lobbyPlayerSpawn') {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
if (!supportsLobbySpawnPaint(gt)) return;
const slotEl = document.getElementById('lobby-slot-paint');
const slotOne = parseInt(slotEl && slotEl.value, 10) || 1;
const slot = Math.max(0, Math.min(5, slotOne - 1));
ensureLobbyPlayerSpawnsLength();
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดไม่ได้';
return;
}
if (left) {
for (let k = 0; k < 6; k++) {
if (k === slot) continue;
const o = lobbyPlayerSpawns[k];
if (o && o.x === x && o.y === y) lobbyPlayerSpawns[k] = null;
}
lobbyPlayerSpawns[slot] = { x, y };
if (statusEl) statusEl.textContent = 'จุดเกิด P' + (slot + 1) + ' = (' + x + ',' + y + ')';
} else {
let cleared = false;
for (let k = 0; k < 6; k++) {
const o = lobbyPlayerSpawns[k];
if (o && o.x === x && o.y === y) {
lobbyPlayerSpawns[k] = null;
cleared = true;
}
}
if (statusEl) statusEl.textContent = cleared ? 'ถอดจุด P ที่ช่องนี้แล้ว' : 'ไม่มีจุด P ที่ช่องนี้';
}
return;
}
if (drawModeEl.value === 'cellImage') {
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — วางรูปบนกริดไม่ได้';
@@ -1455,6 +1597,7 @@
} else if (gtq === 'quiz_carry') {
ensureQuizCarryAreas();
quizQuestionArea[y][x] = left ? 1 : 0;
if (left) carryEmbedCountdownArea[y][x] = 0;
} else return;
} else if (drawModeEl.value === 'stackRelease') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'stack') return;
@@ -1534,11 +1677,24 @@
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_battle') return;
ensureQuizBattleDomeArea();
quizBattleDomeArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'carryEmbedCountdown') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_carry') return;
ensureQuizCarryAreas();
if (left) {
carryEmbedCountdownArea[y][x] = 1;
quizCarryHubArea[y][x] = 0;
quizCarryOptionArea[y][x] = 0;
} else {
carryEmbedCountdownArea[y][x] = 0;
}
} else if (drawModeEl.value === 'quizCarryHub') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_carry') return;
ensureQuizCarryAreas();
quizCarryHubArea[y][x] = left ? 1 : 0;
if (left) quizCarryOptionArea[y][x] = 0;
if (left) {
quizCarryOptionArea[y][x] = 0;
carryEmbedCountdownArea[y][x] = 0;
}
} else {
const carryOptM = /^quizCarryOpt(\d+)$/.exec(drawModeEl.value);
if (carryOptM && (gameTypeEl ? gameTypeEl.value : gameType) === 'quiz_carry') {
@@ -1548,6 +1704,7 @@
if (left) {
quizCarryOptionArea[y][x] = val;
quizCarryHubArea[y][x] = 0;
carryEmbedCountdownArea[y][x] = 0;
} else {
quizCarryOptionArea[y][x] = 0;
}
@@ -1642,6 +1799,7 @@
if (drawModeEl && drawModeEl.value === 'balloonBossPlayerPaint' && gameType !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'balloonBossBossPaint' && gameType !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && (drawModeEl.value === 'quizBattleDome' || drawModeEl.value === 'quizBattlePath') && gameType !== 'quiz_battle') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn' && !supportsLobbySpawnPaint(gameType)) drawModeEl.value = 'wall';
toggleFroggerUI();
syncGridImageBrushInputCaps();
});
@@ -1655,6 +1813,27 @@
});
}
const lobbySpawnModeEl = document.getElementById('lobby-spawn-mode');
if (lobbySpawnModeEl) {
lobbySpawnModeEl.addEventListener('change', () => {
if (lobbySpawnModeEl.value !== 'slots6' && drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn') drawModeEl.value = 'wall';
syncLobbySpawnAuxUi();
draw();
});
}
const btnClearLobbySpawns = document.getElementById('btn-clear-lobby-spawns');
if (btnClearLobbySpawns) {
btnClearLobbySpawns.addEventListener('click', () => {
lobbyPlayerSpawns = [null, null, null, null, null, null];
if (statusEl) statusEl.textContent = 'ล้างจุดเกิด P1–P6 แล้ว';
draw();
});
}
const lobbySlotPaintEl = document.getElementById('lobby-slot-paint');
if (lobbySlotPaintEl) {
lobbySlotPaintEl.addEventListener('change', () => { draw(); });
}
const btnClearShooterSpawns = document.getElementById('btn-clear-shooter-spawns');
if (btnClearShooterSpawns) {
btnClearShooterSpawns.addEventListener('click', () => {
@@ -1749,9 +1928,39 @@
characterCellsW: fpSave.cw,
characterCellsH: fpSave.ch,
ground, objects, blockPlayer, interactive, startGameArea, spawnArea, spawn, cellColors, showMapInGame,
lobbySpawnMode: (() => {
const el = document.getElementById('lobby-spawn-mode');
const v = el && el.value;
return (v === 'fixed' || v === 'slots6') ? v : 'random';
})(),
lobbyPlayerSpawns: (() => {
ensureLobbyPlayerSpawnsLength();
sanitizeLobbyPlayerSpawnsInEditor();
return lobbyPlayerSpawns.map((s) => (s && Number.isFinite(s.x) && Number.isFinite(s.y)
? { x: Math.floor(s.x), y: Math.floor(s.y) }
: null));
})(),
quizTrueArea, quizFalseArea, quizQuestionArea,
quizCarryHubArea: gameType === 'quiz_carry' ? quizCarryHubArea.map(r => r.slice()) : [],
quizCarryOptionArea: gameType === 'quiz_carry' ? quizCarryOptionArea.map(r => r.slice()) : [],
carryEmbedCountdownArea: gameType === 'quiz_carry' ? carryEmbedCountdownArea.map(r => r.slice()) : [],
carryEmbedCountdownHighlight: (() => {
if (gameType !== 'quiz_carry') return false;
const h = document.getElementById('carry-embed-countdown-highlight');
return !h || !!h.checked;
})(),
carryEmbedCountdownHighlightColor: (() => {
if (gameType !== 'quiz_carry') return undefined;
const el = document.getElementById('carry-embed-countdown-color');
return (el && el.value) ? el.value : '#ffb44a';
})(),
carryEmbedCountdownAnchor: (() => {
if (gameType !== 'quiz_carry') return undefined;
const el = document.getElementById('carry-embed-countdown-anchor');
const v = el && el.value;
if (v === 'grid' || v === 'map' || v === 'screen') return v;
return 'map';
})(),
quizBattleDomeArea: gameType === 'quiz_battle' ? quizBattleDomeArea.map(r => r.slice()) : [],
quizBattlePathArea: gameType === 'quiz_battle' ? quizBattlePathArea.map(r => r.slice()) : [],
stackReleaseArea: gameType === 'stack' ? stackReleaseArea.map(r => r.slice()) : [],
@@ -1796,8 +2005,9 @@
if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none';
if (gridImageToolsWrap) gridImageToolsWrap.style.display = drawModeEl.value === 'cellImage' ? 'flex' : 'none';
}
if (drawModeEl) drawModeEl.addEventListener('change', () => { syncDrawModeAuxUi(); });
if (drawModeEl) drawModeEl.addEventListener('change', () => { syncDrawModeAuxUi(); syncLobbySpawnAuxUi(); });
syncDrawModeAuxUi();
syncLobbySpawnAuxUi();
const gridCellUpload = document.getElementById('grid-cell-image-upload');
if (gridCellUpload) {
gridCellUpload.addEventListener('change', (e) => {
@@ -1872,6 +2082,9 @@
quizQuestionArea = m.quizQuestionArea && m.quizQuestionArea.length ? m.quizQuestionArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryHubArea = m.quizCarryHubArea && m.quizCarryHubArea.length ? m.quizCarryHubArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryOptionArea = m.quizCarryOptionArea && m.quizCarryOptionArea.length ? m.quizCarryOptionArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
carryEmbedCountdownArea = m.carryEmbedCountdownArea && m.carryEmbedCountdownArea.length
? m.carryEmbedCountdownArea.map(r => r && r.slice())
: Array(height).fill(0).map(() => Array(width).fill(0));
quizBattleDomeArea = m.quizBattleDomeArea && m.quizBattleDomeArea.length ? m.quizBattleDomeArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
quizBattlePathArea = m.quizBattlePathArea && m.quizBattlePathArea.length ? m.quizBattlePathArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurvivePlatforms = Array.isArray(m.jumpSurvivePlatforms) ? m.jumpSurvivePlatforms.map((p) => (p && typeof p === 'object' ? { ...p } : null)).filter(Boolean) : [];
@@ -1905,6 +2118,18 @@
showMapInGame = m.showMapInGame !== false;
const showMapEl = document.getElementById('show-map-in-game');
if (showMapEl) showMapEl.checked = showMapInGame;
const cdH = document.getElementById('carry-embed-countdown-highlight');
if (cdH) cdH.checked = m.carryEmbedCountdownHighlight !== false;
const cdC = document.getElementById('carry-embed-countdown-color');
if (cdC && typeof m.carryEmbedCountdownHighlightColor === 'string') {
const hx = m.carryEmbedCountdownHighlightColor.trim();
if (/^#[0-9a-fA-F]{6}$/.test(hx)) cdC.value = hx;
}
const cdA = document.getElementById('carry-embed-countdown-anchor');
if (cdA) {
const a = m.carryEmbedCountdownAnchor;
cdA.value = (a === 'grid' || a === 'screen' || a === 'map') ? a : 'map';
}
ensureInteractive();
ensureStartGameArea();
ensureSpawnArea();
@@ -1919,6 +2144,21 @@
? m.gauntletPlayerSpawns.map((s) => ({ x: Math.floor(Number(s.x)), y: Math.floor(Number(s.y)) }))
.filter((s) => Number.isFinite(s.x) && Number.isFinite(s.y))
: [];
lobbyPlayerSpawns = [null, null, null, null, null, null];
if (Array.isArray(m.lobbyPlayerSpawns)) {
for (let li = 0; li < 6 && li < m.lobbyPlayerSpawns.length; li++) {
const c = m.lobbyPlayerSpawns[li];
if (c && c.x != null && c.y != null) {
lobbyPlayerSpawns[li] = { x: Math.floor(Number(c.x)), y: Math.floor(Number(c.y)) };
}
}
}
sanitizeLobbyPlayerSpawnsInEditor();
const lsmLoad = document.getElementById('lobby-spawn-mode');
if (lsmLoad) {
const mv = m.lobbySpawnMode;
lsmLoad.value = (mv === 'fixed' || mv === 'slots6') ? mv : 'random';
}
gameType = m.gameType || 'zep';
if (gameTypeEl) gameTypeEl.value = gameType;
lanes = (m.lanes && m.lanes.length) ? m.lanes.map(l => ({ ...l })) : [];
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -116,7 +116,12 @@
align-items: center;
justify-content: center;
pointer-events: none;
background: rgba(8, 10, 20, 0.42);
background: var(--carry-ecd-overlay, rgba(8, 10, 20, 0.42));
}
#quiz-carry-embed-countdown.quiz-carry-embed-countdown--map-anchor {
align-items: flex-start;
justify-content: flex-start;
background: var(--carry-ecd-overlay, rgba(8, 10, 20, 0.32));
}
#quiz-carry-embed-countdown.is-hidden { display: none !important; }
#quiz-carry-embed-countdown-inner {
@@ -129,6 +134,15 @@
padding: 0 14px;
text-align: center;
}
#quiz-carry-embed-countdown-inner.quiz-carry-embed-countdown-inner--map-rect {
max-width: none;
padding: clamp(6px, 1.2vmin, 12px);
box-sizing: border-box;
border-radius: var(--carry-ecd-inner-radius, 12px);
background: var(--carry-ecd-inner-bg, rgba(12, 14, 28, 0.82));
border: var(--carry-ecd-inner-border-w, 1px) solid var(--carry-ecd-inner-border, rgba(122, 162, 247, 0.45));
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
#quiz-carry-embed-countdown-kicker {
display: none;
margin: 0;
@@ -148,11 +162,24 @@
overflow: auto;
}
#quiz-carry-embed-countdown-num {
font: 800 min(28vw, 132px) / 1.05 system-ui, "Kanit", sans-serif;
color: #ffe066;
font: 800 min(var(--carry-ecd-screen-vw, 28vw), var(--carry-ecd-screen-max, 132px)) / 1.05 system-ui, "Kanit", sans-serif;
color: var(--carry-ecd-digit-color, #ffe066);
text-shadow: 0 0 48px rgba(255, 224, 102, 0.55), 0 4px 24px rgba(0, 0, 0, 0.65);
letter-spacing: -0.04em;
}
/* ยึดกล่องบนแมป (ทอง / กลาง / กริด 321): ตัวเลขตามขนาดกล่องช่อง — ไม่ใช้ vw เต็มจอ */
#quiz-carry-embed-countdown.quiz-carry-embed-countdown--map-anchor #quiz-carry-embed-countdown-inner.quiz-carry-embed-countdown-inner--map-rect {
container-type: size;
}
#quiz-carry-embed-countdown.quiz-carry-embed-countdown--map-anchor #quiz-carry-embed-countdown-inner.quiz-carry-embed-countdown-inner--map-rect #quiz-carry-embed-countdown-num {
font-weight: 800;
font-size: var(--carry-ecd-map-digit-fs, clamp(14px, min(78cqmin, 82cqh), 200px));
line-height: 1;
font-family: system-ui, "Kanit", sans-serif;
color: var(--carry-ecd-digit-color, #ffe066);
letter-spacing: -0.02em;
text-shadow: 0 0 clamp(6px, 3cqmin, 18px) rgba(255, 224, 102, 0.5), 0 2px clamp(4px, 1.5cqh, 12px) rgba(0, 0, 0, 0.6);
}
/* ด้านบน: ไม่ชน HUD ล่างบนแคนวาส + เห็นชัดกว่าแถบล่าง */
#quiz-carry-embed-q-strip {
position: fixed;
@@ -909,7 +936,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.114"></script>
<script src="js/play.js?v=0.123"></script>
<div class="version-tag">v —</div>
</body>
</html>
+325 -5
View File
@@ -53,6 +53,8 @@ const MAPS_DIR = path.join(__dirname, 'data', 'maps');
const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters');
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets');
const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json');
/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */
const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets');
const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json');
/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */
const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH
@@ -71,6 +73,8 @@ const defaultMap = () => ({
gridImageCells: [],
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
quizCarryHubArea: [], quizCarryOptionArea: [],
/** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */
carryEmbedCountdownArea: [],
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */
quizBattleDomeArea: [],
/** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */
@@ -99,6 +103,14 @@ function defaultQuizSettings() {
carrySessionLength: 0,
/** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */
carryMapPanelTheme: defaultCarryMapPanelTheme(),
/** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */
carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(),
/** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 116 */
carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
/** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
/** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
carryChoicePlaqueMapScale: 1.25,
questions: [],
carryQuestions: [],
battleQuizMcq: [],
@@ -141,6 +153,118 @@ function sanitizeCarryMapPanelTheme(raw) {
};
}
/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
function defaultCarryChoicePlaqueTheme() {
return {
borderMode: 'neon',
fixedBorder: 'rgba(122, 200, 255, 0.9)',
fillBg: 'rgba(12, 10, 20, 0.88)',
textColor: 'rgba(248, 249, 255, 1)',
borderWidthPx: 2.5,
};
}
function sanitizeCarryChoicePlaqueTheme(raw) {
const d = defaultCarryChoicePlaqueTheme();
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
let bw = Number(raw.borderWidthPx);
if (!Number.isFinite(bw)) bw = d.borderWidthPx;
bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
return {
borderMode: mode,
fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
borderWidthPx: bw,
plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
};
}
const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
function defaultCarryChoicePlaqueThemes() {
const out = [];
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
out.push(sanitizeCarryChoicePlaqueTheme({}));
}
return out;
}
/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
function sanitizeCarryChoicePlaqueThemes(raw) {
if (Array.isArray(raw) && raw.length > 0) {
const out = [];
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
}
return out;
}
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
const one = sanitizeCarryChoicePlaqueTheme(raw);
return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
}
return defaultCarryChoicePlaqueThemes();
}
function sanitizeCarryChoiceImageUrl(input) {
const s = String(input == null ? '' : input).trim().slice(0, 512);
if (!s) return '';
if (/[\s<>'"`]/.test(s)) return '';
if (s.startsWith('/')) {
if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
return s;
}
const low = s.toLowerCase();
if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
try {
const u = new URL(s);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
return s;
} catch (e) {
return '';
}
}
function defaultCarryEmbedCountdownTheme() {
return {
overlayBackdrop: 'rgba(8, 10, 20, 0.42)',
innerBg: 'rgba(12, 14, 28, 0.82)',
innerBorder: 'rgba(122, 162, 247, 0.45)',
innerBorderWpx: 1,
innerRadiusPx: 12,
digitColor: '#ffe066',
mapDigitCqmin: 78,
mapDigitCqh: 82,
mapDigitMaxPx: 200,
screenDigitVw: 28,
screenDigitMaxPx: 132,
};
}
function sanitizeCarryEmbedCountdownTheme(raw) {
const d = defaultCarryEmbedCountdownTheme();
if (!raw || typeof raw !== 'object') return d;
const clampN = (v, lo, hi, def) => {
const n = Number(v);
if (!Number.isFinite(n)) return def;
return Math.max(lo, Math.min(hi, Math.round(n)));
};
return {
overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
};
}
/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
const QUIZ_BATTLE_CATEGORY_IDS = new Set([
'cybercrime',
@@ -192,7 +316,12 @@ function sanitizeCarryQuestions(arr) {
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
out.push({ text, choices, correctIndex: ci });
const row = { text, choices, correctIndex: ci };
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
if (urls.some((u) => u)) row.choiceImageUrls = urls;
}
out.push(row);
if (out.length >= 80) break;
}
return out;
@@ -216,6 +345,12 @@ function clampCarrySessionLength(n, def) {
return Math.max(0, Math.min(500, Math.floor(v)));
}
function clampCarryChoicePlaqueMapScale(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
}
function mergeQuizCarryFromMirrorIfSet(base) {
if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
try {
@@ -235,6 +370,16 @@ function mergeQuizCarryFromMirrorIfSet(base) {
if (j.carrySessionLength != null) {
base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
}
if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
}
if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
} else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
}
if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
}
@@ -254,6 +399,12 @@ function loadQuizSettings() {
const carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq);
const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length
? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes)
: sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale);
const out = {
readMs: clampQuizMs(j.readMs, d.readMs),
answerMs: clampQuizMs(j.answerMs, d.answerMs),
@@ -262,6 +413,10 @@ function loadQuizSettings() {
carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs),
carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength),
carryMapPanelTheme,
carryEmbedCountdownTheme,
carryChoicePlaqueThemes,
carryChoicePlaqueTheme,
carryChoicePlaqueMapScale,
questions,
carryQuestions,
battleQuizMcq,
@@ -508,6 +663,16 @@ function safeGauntletStoredFilename(name) {
return base;
}
function safeQuizCarryPlaqueStoredFilename(name) {
const base = path.basename(String(name || ''));
if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
return base;
}
function ensureQuizCarryPlaqueAssetsDir() {
if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true });
}
function gauntletAssetMimeByExt(ext) {
const e = String(ext || '').toLowerCase();
if (e === '.png') return 'image/png';
@@ -577,6 +742,25 @@ function saveQuizSettings(d) {
const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object'
? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme)
: prev.carryMapPanelTheme;
const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object'
? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme)
: prev.carryEmbedCountdownTheme;
let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes;
if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes);
} else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme);
}
if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme);
}
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
const prevScale = prev.carryChoicePlaqueMapScale != null
? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25)
: 1.25;
const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null
? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale)
: prevScale;
const out = {
readMs,
answerMs,
@@ -585,6 +769,10 @@ function saveQuizSettings(d) {
carryAnswerMs,
carrySessionLength,
carryMapPanelTheme,
carryEmbedCountdownTheme,
carryChoicePlaqueThemes,
carryChoicePlaqueTheme,
carryChoicePlaqueMapScale,
questions,
carryQuestions,
battleQuizMcq,
@@ -595,6 +783,10 @@ function saveQuizSettings(d) {
battleQuizMcqSaved: (out.battleQuizMcq || []).length,
carryQuestionsSaved: (out.carryQuestions || []).length,
carryMapPanelTheme: out.carryMapPanelTheme,
carryEmbedCountdownTheme: out.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: out.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: out.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale,
};
} catch (e) {
console.error('saveQuizSettings', e.message);
@@ -958,8 +1150,23 @@ function normalizeQuizCarryLayersOnMap(m) {
}
m.quizCarryOptionArea = rows;
};
const normCountdown = () => {
const src = m.carryEmbedCountdownArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
row.push(Number(v) === 1 ? 1 : 0);
}
rows.push(row);
}
m.carryEmbedCountdownArea = rows;
};
normHub();
normOptions();
normCountdown();
}
/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
@@ -1158,6 +1365,7 @@ const server = http.createServer((req, res) => {
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
@@ -1197,6 +1405,7 @@ const server = http.createServer((req, res) => {
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
@@ -1228,6 +1437,10 @@ const server = http.createServer((req, res) => {
const g = loadQuizSettings();
const slice = {
carryMapPanelTheme: g.carryMapPanelTheme,
carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
carryReadMs: g.carryReadMs,
carryAnswerMs: g.carryAnswerMs,
carrySessionLength: g.carrySessionLength,
@@ -1271,6 +1484,10 @@ const server = http.createServer((req, res) => {
battleQuizMcqSaved: saved.battleQuizMcqSaved,
carryQuestionsSaved: saved.carryQuestionsSaved,
carryMapPanelTheme: saved.carryMapPanelTheme,
carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
}));
} catch (e) {
res.writeHead(400);
@@ -1404,6 +1621,54 @@ const server = http.createServer((req, res) => {
});
return;
}
const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureQuizCarryPlaqueAssetsDir();
const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
let body = '';
req.on('data', (c) => { body += c; });
@@ -2089,8 +2354,9 @@ function endGauntletGame(sid, space, reason) {
if (retMap && retMap.gameType !== 'gauntlet') {
space.mapId = retId;
space.mapData = retMap;
let gi = 0;
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(retMap);
const sp = pickSpawnForJoin(retMap, gi++);
p.x = sp.x;
p.y = sp.y;
p.gauntletJumpTicks = 0;
@@ -2313,6 +2579,54 @@ function isMapTileWalkableForSpawn(md, x, y) {
return true;
}
/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */
function parseLobbyPlayerSpawnsFromMap(md) {
const w = md.width || 20;
const h = md.height || 15;
const out = [null, null, null, null, null, null];
const raw = md && md.lobbyPlayerSpawns;
if (!Array.isArray(raw)) return out;
for (let i = 0; i < 6 && i < raw.length; i++) {
const cell = raw[i];
if (!cell || typeof cell !== 'object') continue;
const x = Math.floor(Number(cell.x));
const y = Math.floor(Number(cell.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
if (x < 0 || x >= w || y < 0 || y >= h) continue;
out[i] = { x, y };
}
return out;
}
/**
* จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก)
* โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม
*/
function pickSpawnForJoin(md, joinOrderIndex) {
if (!md) return { x: 1, y: 1 };
const mode = md.lobbySpawnMode;
const ord = joinOrderIndex | 0;
if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md);
const j = Math.min(Math.max(0, ord), 5);
if (mode === 'fixed' && md.spawn) {
const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
const w = md.width || 20;
const h = md.height || 15;
const x = Math.max(0, Math.min(w - 1, fx));
const y = Math.max(0, Math.min(h - 1, fy));
if (isMapTileWalkableForSpawn(md, x, y)) return { x, y };
return pickRandomSpawnFromMap(md);
}
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMap(md);
const pick = slots[j];
if (pick && isMapTileWalkableForSpawn(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
return pickRandomSpawnFromMap(md);
}
return pickRandomSpawnFromMap(md);
}
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
function pickRandomSpawnFromMap(md) {
const fallback = md.spawn || { x: 1, y: 1 };
@@ -2428,7 +2742,7 @@ io.on('connection', (socket) => {
if (!space.hostId) space.hostId = socket.id;
if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space);
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnPt = pickRandomSpawnFromMap(mdJoin);
const spawnPt = pickSpawnForJoin(mdJoin, space.peers.size);
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 5));
const peer = {
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
@@ -2475,6 +2789,10 @@ io.on('connection', (socket) => {
const qs = loadQuizSettings();
joinCb.quizCarrySettingsSnap = {
carryMapPanelTheme: qs.carryMapPanelTheme,
carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale,
carryReadMs: qs.carryReadMs,
carryAnswerMs: qs.carryAnswerMs,
carrySessionLength: qs.carrySessionLength,
@@ -2573,8 +2891,9 @@ io.on('connection', (socket) => {
clearSpaceQuizTimers(space);
space.mapId = SUSPECT_INVESTIGATION_QUIZ_MAP_ID;
space.mapData = md;
let si = 0;
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(md);
const sp = pickSpawnForJoin(md, si++);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
@@ -2678,8 +2997,9 @@ io.on('connection', (socket) => {
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
let li = 0;
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(space.mapData);
const sp = pickSpawnForJoin(space.mapData, li++);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';