2857 lines
118 KiB
JavaScript
2857 lines
118 KiB
JavaScript
const http = require('http');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const { Server } = require('socket.io');
|
||
|
||
const PORT = process.env.PORT || 3001;
|
||
const BASE_PATH = '/Game';
|
||
const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
|
||
const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
|
||
const adminTokens = new Set();
|
||
/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
|
||
const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
|
||
/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ย้ายทุกคนไปฉากควิซนี้และเริ่มเกม */
|
||
const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
|
||
/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */
|
||
const SPACE_EMPTY_TTL_MS = 45000;
|
||
// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025)
|
||
const AI_MODELS = [
|
||
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
|
||
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
|
||
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
|
||
'o1', 'o1-mini', 'o1-pro',
|
||
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
|
||
'gpt-3.5-turbo',
|
||
];
|
||
|
||
function loadAiSettings() {
|
||
try {
|
||
if (fs.existsSync(AI_SETTINGS_PATH)) {
|
||
const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8');
|
||
return JSON.parse(raw);
|
||
}
|
||
} catch (e) { console.error('loadAiSettings', e.message); }
|
||
return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' };
|
||
}
|
||
|
||
function saveAiSettings(settings) {
|
||
try {
|
||
const dir = path.dirname(AI_SETTINGS_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||
} catch (e) { console.error('saveAiSettings', e.message); }
|
||
}
|
||
|
||
function getAdminToken(cookieHeader) {
|
||
if (!cookieHeader) return null;
|
||
const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/);
|
||
return m && adminTokens.has(m[1]) ? m[1] : null;
|
||
}
|
||
|
||
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');
|
||
const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json');
|
||
const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json');
|
||
/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */
|
||
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
|
||
const maps = new Map();
|
||
const defaultMap = () => ({
|
||
width: 20, height: 15, tileSize: 32, gameType: 'zep',
|
||
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
|
||
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
|
||
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
|
||
quizCarryHubArea: [], quizCarryOptionArea: [],
|
||
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */
|
||
quizBattleDomeArea: [],
|
||
/** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */
|
||
quizBattlePathArea: [],
|
||
stackReleaseArea: [], stackLandArea: [],
|
||
/** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */
|
||
jumpSurvivePlatforms: [],
|
||
jumpSurvivePlatformArea: [],
|
||
jumpSurviveRisePxPerSec: 42,
|
||
jumpSurviveGravity: 3200,
|
||
/** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */
|
||
jumpSurviveJumpImpulse: 0,
|
||
jumpSurviveMovePxPerSec: 200,
|
||
});
|
||
|
||
function defaultQuizSettings() {
|
||
return {
|
||
readMs: 10000,
|
||
answerMs: 5000,
|
||
betweenMs: 3500,
|
||
questions: [],
|
||
carryQuestions: [],
|
||
battleQuizMcq: [],
|
||
};
|
||
}
|
||
|
||
/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
|
||
const QUIZ_BATTLE_CATEGORY_IDS = new Set([
|
||
'cybercrime',
|
||
'cyber_law',
|
||
'privacy',
|
||
'digital_security',
|
||
'social_media',
|
||
'ethics',
|
||
'ai_data',
|
||
]);
|
||
|
||
/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
|
||
function sanitizeBattleQuizMcq(arr) {
|
||
if (!Array.isArray(arr)) return [];
|
||
const out = [];
|
||
for (const q of arr) {
|
||
if (!q || typeof q !== 'object') continue;
|
||
let categoryId = String(q.categoryId || '').trim();
|
||
if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
|
||
categoryId = 'cybercrime';
|
||
}
|
||
const text = String(q.text || '').trim().slice(0, 500);
|
||
if (!text) continue;
|
||
const rawChoices = Array.isArray(q.choices) ? q.choices : [];
|
||
const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
|
||
if (choices.length !== 3 || !choices.every(Boolean)) continue;
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isFinite(ci)) ci = 0;
|
||
ci = Math.max(0, Math.min(2, Math.floor(ci)));
|
||
out.push({ categoryId, text, choices, correctIndex: ci });
|
||
if (out.length >= 200) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
|
||
function sanitizeCarryQuestions(arr) {
|
||
if (!Array.isArray(arr)) return [];
|
||
const out = [];
|
||
for (const q of arr) {
|
||
if (!q || typeof q !== 'object') continue;
|
||
const text = String(q.text || '').trim().slice(0, 500);
|
||
if (!text) continue;
|
||
let choices = Array.isArray(q.choices)
|
||
? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
|
||
: [];
|
||
if (choices.length < 2) continue;
|
||
if (choices.length > 16) choices = choices.slice(0, 16);
|
||
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 });
|
||
if (out.length >= 80) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampQuizMs(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.max(1000, Math.min(300000, Math.floor(v)));
|
||
}
|
||
|
||
function clampQuizBetweenMs(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.max(0, Math.min(300000, Math.floor(v)));
|
||
}
|
||
|
||
function loadQuizSettings() {
|
||
try {
|
||
if (fs.existsSync(QUIZ_SETTINGS_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8'));
|
||
const d = defaultQuizSettings();
|
||
const questions = Array.isArray(j.questions) ? j.questions : [];
|
||
const carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
|
||
const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq);
|
||
return {
|
||
readMs: clampQuizMs(j.readMs, d.readMs),
|
||
answerMs: clampQuizMs(j.answerMs, d.answerMs),
|
||
betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs),
|
||
questions,
|
||
carryQuestions,
|
||
battleQuizMcq,
|
||
};
|
||
}
|
||
} catch (e) { console.error('loadQuizSettings', e.message); }
|
||
return defaultQuizSettings();
|
||
}
|
||
|
||
const GAUNTLET_DEFAULT_TICK_MS = 220;
|
||
const GAUNTLET_DEFAULT_JUMP_TICKS = 16;
|
||
const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0;
|
||
/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */
|
||
const STACK_SWING_HZ_DEFAULT = 0.55;
|
||
|
||
function defaultGameTiming() {
|
||
return {
|
||
gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS,
|
||
gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS,
|
||
gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
|
||
...defaultGauntletVisuals(),
|
||
stackSwingHz: STACK_SWING_HZ_DEFAULT,
|
||
stackBlockWidthTiles: null,
|
||
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */
|
||
jumpSurviveJumpHeightMult: 1.5,
|
||
};
|
||
}
|
||
|
||
function clampStackSwingHz(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT;
|
||
return Math.max(0.08, Math.min(2.8, v));
|
||
}
|
||
|
||
/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */
|
||
function clampStackBlockWidthTiles(n) {
|
||
if (n === null || n === undefined || n === '') return null;
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return null;
|
||
return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100;
|
||
}
|
||
|
||
function clampJumpSurviveJumpHeightMult(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 1.5;
|
||
return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100;
|
||
}
|
||
|
||
function clampGauntletTickMs(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
|
||
return Math.max(80, Math.min(800, Math.floor(v)));
|
||
}
|
||
|
||
function clampGauntletJumpTicks(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
|
||
return Math.max(4, Math.min(40, Math.floor(v)));
|
||
}
|
||
|
||
/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */
|
||
function clampGauntletTimeLimitSec(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return 0;
|
||
return Math.max(10, Math.min(7200, Math.floor(v)));
|
||
}
|
||
|
||
function defaultGauntletVisuals() {
|
||
return {
|
||
gauntletLaneImageUrls: [],
|
||
gauntletLaserTopUrl: '',
|
||
gauntletLaserBottomUrl: '',
|
||
gauntletLaserLineUrl: '',
|
||
gauntletLaserFillColor: 'rgba(140,230,255,0.42)',
|
||
gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)',
|
||
gauntletLaserLineWidthPx: 2,
|
||
};
|
||
}
|
||
|
||
function sanitizeGauntletAssetUrl(s) {
|
||
const t = String(s || '').trim();
|
||
if (!t || t.length > 500) return '';
|
||
if (/[\s<>"'`]/.test(t)) return '';
|
||
if (t.startsWith('/')) return t;
|
||
if (/^https?:\/\//i.test(t)) return t;
|
||
return '';
|
||
}
|
||
|
||
function clampGauntletLaserColor(s, def) {
|
||
const t = String(s || '').trim().slice(0, 100);
|
||
if (!t) return def;
|
||
if (/[<>"'`]/.test(t)) return def;
|
||
return t;
|
||
}
|
||
|
||
function clampGauntletLaserLineWidthPx(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 2;
|
||
return Math.max(0, Math.min(24, Math.round(v)));
|
||
}
|
||
|
||
function normalizeGauntletVisualsFromRequest(d) {
|
||
const def = defaultGauntletVisuals();
|
||
if (!d || typeof d !== 'object') return { ...def };
|
||
let laneArr = d.gauntletLaneImageUrls;
|
||
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
|
||
if (!Array.isArray(laneArr)) laneArr = [];
|
||
const gauntletLaneImageUrls = [];
|
||
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
|
||
const u = sanitizeGauntletAssetUrl(laneArr[i]);
|
||
if (u) gauntletLaneImageUrls.push(u);
|
||
}
|
||
return {
|
||
gauntletLaneImageUrls,
|
||
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
|
||
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
|
||
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
|
||
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
|
||
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
|
||
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
|
||
};
|
||
}
|
||
|
||
function getGauntletVisualsForClient() {
|
||
const r = runtimeGameTiming;
|
||
const d = defaultGauntletVisuals();
|
||
return {
|
||
gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls,
|
||
gauntletLaserTopUrl: r.gauntletLaserTopUrl || '',
|
||
gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '',
|
||
gauntletLaserLineUrl: r.gauntletLaserLineUrl || '',
|
||
gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor,
|
||
gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor,
|
||
gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx,
|
||
};
|
||
}
|
||
|
||
function loadGameTiming() {
|
||
try {
|
||
if (fs.existsSync(GAME_TIMING_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8'));
|
||
const vis = normalizeGauntletVisualsFromRequest(j);
|
||
return {
|
||
gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs),
|
||
gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks),
|
||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
|
||
...vis,
|
||
stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz')
|
||
? clampStackSwingHz(j.stackSwingHz)
|
||
: STACK_SWING_HZ_DEFAULT,
|
||
stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles')
|
||
? clampStackBlockWidthTiles(j.stackBlockWidthTiles)
|
||
: null,
|
||
jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult')
|
||
? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult)
|
||
: 1.5,
|
||
};
|
||
}
|
||
} catch (e) { console.error('loadGameTiming', e.message); }
|
||
return defaultGameTiming();
|
||
}
|
||
|
||
let runtimeGameTiming = loadGameTiming();
|
||
|
||
function getGauntletTickMs() {
|
||
return runtimeGameTiming.gauntletTickMs;
|
||
}
|
||
|
||
function getGauntletJumpTicks() {
|
||
return runtimeGameTiming.gauntletJumpTicks;
|
||
}
|
||
|
||
function getGauntletTimeLimitSec() {
|
||
return runtimeGameTiming.gauntletTimeLimitSec || 0;
|
||
}
|
||
|
||
function saveGameTimingToFile(d) {
|
||
try {
|
||
const dir = path.dirname(GAME_TIMING_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
const prev = { ...runtimeGameTiming };
|
||
const vis = normalizeGauntletVisualsFromRequest(d);
|
||
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
|
||
? clampStackSwingHz(d.stackSwingHz)
|
||
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
|
||
const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles')
|
||
? clampStackBlockWidthTiles(d.stackBlockWidthTiles)
|
||
: (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles')
|
||
? prev.stackBlockWidthTiles
|
||
: null);
|
||
const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult')
|
||
? prev.jumpSurviveJumpHeightMult
|
||
: 1.5;
|
||
const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult')
|
||
? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult)
|
||
: clampJumpSurviveJumpHeightMult(prevMult);
|
||
const out = {
|
||
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
|
||
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
|
||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
|
||
...vis,
|
||
stackSwingHz: stackHz,
|
||
stackBlockWidthTiles: stackBlockW,
|
||
jumpSurviveJumpHeightMult: jumpMult,
|
||
};
|
||
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||
runtimeGameTiming = out;
|
||
rescheduleAllGauntletTickers();
|
||
return { ok: true, ...out };
|
||
} catch (e) {
|
||
console.error('saveGameTimingToFile', e.message);
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') {
|
||
err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
|
||
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
function ensureGauntletAssetsDir() {
|
||
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
|
||
}
|
||
|
||
function loadGauntletAssetsMeta() {
|
||
try {
|
||
if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8'));
|
||
return j && typeof j === 'object' ? j : {};
|
||
}
|
||
} catch (e) { console.error('loadGauntletAssetsMeta', e.message); }
|
||
return {};
|
||
}
|
||
|
||
function saveGauntletAssetsMeta(meta) {
|
||
const dir = path.dirname(GAUNTLET_ASSETS_META_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8');
|
||
}
|
||
|
||
function safeGauntletStoredFilename(name) {
|
||
const base = path.basename(String(name || ''));
|
||
if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
|
||
return base;
|
||
}
|
||
|
||
function gauntletAssetMimeByExt(ext) {
|
||
const e = String(ext || '').toLowerCase();
|
||
if (e === '.png') return 'image/png';
|
||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||
if (e === '.gif') return 'image/gif';
|
||
if (e === '.webp') return 'image/webp';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function listGauntletAssetsApi() {
|
||
ensureGauntletAssetsDir();
|
||
const meta = loadGauntletAssetsMeta();
|
||
let files = [];
|
||
try {
|
||
files = fs.readdirSync(GAUNTLET_ASSETS_DIR);
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
return files
|
||
.filter((f) => safeGauntletStoredFilename(f))
|
||
.map((f) => {
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, f);
|
||
let st;
|
||
try {
|
||
st = fs.statSync(fp);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
const entry = meta[f];
|
||
const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : '';
|
||
return {
|
||
filename: f,
|
||
url: BASE_PATH + '/img/gauntlet-assets/' + f,
|
||
label: label || f,
|
||
bytes: st.size,
|
||
mtime: st.mtimeMs,
|
||
};
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => b.mtime - a.mtime);
|
||
}
|
||
|
||
function saveQuizSettings(d) {
|
||
try {
|
||
const prev = loadQuizSettings();
|
||
const dir = path.dirname(QUIZ_SETTINGS_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs;
|
||
const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs;
|
||
const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs;
|
||
const questions = Array.isArray(d.questions)
|
||
? (d.questions || [])
|
||
.filter((q) => q && String(q.text || '').trim())
|
||
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }))
|
||
: prev.questions;
|
||
const carryQuestions = Array.isArray(d.carryQuestions)
|
||
? sanitizeCarryQuestions(d.carryQuestions)
|
||
: prev.carryQuestions;
|
||
const battleQuizMcq = Array.isArray(d.battleQuizMcq)
|
||
? sanitizeBattleQuizMcq(d.battleQuizMcq)
|
||
: (prev.battleQuizMcq || []);
|
||
const out = {
|
||
readMs,
|
||
answerMs,
|
||
betweenMs,
|
||
questions,
|
||
carryQuestions,
|
||
battleQuizMcq,
|
||
};
|
||
fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||
return {
|
||
ok: true,
|
||
battleQuizMcqSaved: (out.battleQuizMcq || []).length,
|
||
carryQuestionsSaved: (out.carryQuestions || []).length,
|
||
};
|
||
} catch (e) {
|
||
console.error('saveQuizSettings', e.message);
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') {
|
||
err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
|
||
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
function getQuizQuestionPool(md) {
|
||
const g = loadQuizSettings();
|
||
const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim());
|
||
if (globalQ.length) return globalQ;
|
||
return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim());
|
||
}
|
||
|
||
function quizCellOn(grid, tx, ty) {
|
||
return !!(grid && grid[ty] && grid[ty][tx] === 1);
|
||
}
|
||
|
||
function getCharacterFootprintWHForMove(md) {
|
||
let cw = Math.floor(Number(md.characterCellsW));
|
||
let ch = Math.floor(Number(md.characterCellsH));
|
||
const hasW = Number.isFinite(cw) && cw >= 1;
|
||
const hasH = Number.isFinite(ch) && ch >= 1;
|
||
if (hasW && hasH) {
|
||
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
|
||
}
|
||
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
|
||
return { cw: leg, ch: leg };
|
||
}
|
||
|
||
function quizCarryFootprintTileKeys(md, px, py) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWHForMove(md);
|
||
const minTx = Math.floor(Number(px));
|
||
const minTy = Math.floor(Number(py));
|
||
const maxTx = Math.min(w - 1, minTx + cw - 1);
|
||
const maxTy = Math.min(h - 1, minTy + ch - 1);
|
||
const out = [];
|
||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||
for (let tx = minTx; tx <= maxTx; tx++) {
|
||
if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function quizCarryFootprintOverlapsHubServer(md, px, py) {
|
||
if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false;
|
||
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Nearest walkable tile center not in true/false answer zones (BFS). */
|
||
function findNearestOutsideQuizAnswerZones(md, sx, sy) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const tGrid = md.quizTrueArea || [];
|
||
const fGrid = md.quizFalseArea || [];
|
||
const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y);
|
||
const walkable = (x, y) => {
|
||
if (x < 0 || x >= w || y < 0 || y >= h) return false;
|
||
const row = md.objects && md.objects[y];
|
||
return row && row[x] !== 1;
|
||
};
|
||
const fx = Math.floor(Number(sx));
|
||
const fy = Math.floor(Number(sy));
|
||
if (!walkable(fx, fy)) {
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
|
||
}
|
||
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
|
||
const q = [[fx, fy]];
|
||
const seen = new Set([`${fx},${fy}`]);
|
||
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||
while (q.length) {
|
||
const [cx, cy] = q.shift();
|
||
for (let di = 0; di < dirs.length; di++) {
|
||
const nx = cx + dirs[di][0];
|
||
const ny = cy + dirs[di][1];
|
||
const k = `${nx},${ny}`;
|
||
if (seen.has(k)) continue;
|
||
if (!walkable(nx, ny)) continue;
|
||
seen.add(k);
|
||
if (!inAnswer(nx, ny)) {
|
||
return { x: nx + 0.5, y: ny + 0.5 };
|
||
}
|
||
q.push([nx, ny]);
|
||
}
|
||
}
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
|
||
}
|
||
|
||
function validateQuizMap(md) {
|
||
const pool = getQuizQuestionPool(md);
|
||
if (pool.length < 1) {
|
||
return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)';
|
||
}
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const tGrid = md.quizTrueArea || [];
|
||
const fGrid = md.quizFalseArea || [];
|
||
let anyT = false;
|
||
let anyF = false;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (quizCellOn(tGrid, x, y)) anyT = true;
|
||
if (quizCellOn(fGrid, x, y)) anyF = true;
|
||
}
|
||
}
|
||
if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1';
|
||
return null;
|
||
}
|
||
|
||
function clearSpaceQuizTimers(space) {
|
||
if (!space.quizTimers || !Array.isArray(space.quizTimers)) {
|
||
space.quizTimers = [];
|
||
return;
|
||
}
|
||
space.quizTimers.forEach((t) => clearTimeout(t));
|
||
space.quizTimers = [];
|
||
}
|
||
|
||
function shuffleQuizQuestions(arr) {
|
||
const a = arr.slice();
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function endQuizGame(sid, space, message) {
|
||
clearSpaceQuizTimers(space);
|
||
if (space.quizSession) space.quizSession.active = false;
|
||
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' });
|
||
space.quizSession = null;
|
||
}
|
||
|
||
function emitQuizPlayerStates(sid, space) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.players) return;
|
||
space.peers.forEach((_, peerId) => {
|
||
const st = sess.players[peerId];
|
||
if (st) io.to(peerId).emit('quiz-player-state', { ...st });
|
||
});
|
||
}
|
||
|
||
function scheduleQuizReadPhase(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
const idx = sess.qIndex;
|
||
const qList = sess.questions;
|
||
if (idx >= qList.length) {
|
||
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
|
||
return;
|
||
}
|
||
const q = qList[idx];
|
||
const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000;
|
||
sess.phase = 'read';
|
||
sess.phaseEndsAt = Date.now() + readMs;
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'read',
|
||
questionIndex: idx + 1,
|
||
questionTotal: qList.length,
|
||
text: String(q.text || '').trim(),
|
||
endsAt: sess.phaseEndsAt,
|
||
});
|
||
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function scheduleQuizAnswerPhase(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000;
|
||
sess.phase = 'answer';
|
||
sess.phaseEndsAt = Date.now() + answerMs;
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'answer',
|
||
questionIndex: sess.qIndex + 1,
|
||
questionTotal: sess.questions.length,
|
||
endsAt: sess.phaseEndsAt,
|
||
});
|
||
emitQuizPlayerStates(sid, space);
|
||
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function resolveQuizRound(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
/* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */
|
||
const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md;
|
||
if (!mdLive) return;
|
||
const idx = sess.qIndex;
|
||
const q = sess.questions[idx];
|
||
const correctTrue = !!q.answerTrue;
|
||
const tGrid = mdLive.quizTrueArea || [];
|
||
const fGrid = mdLive.quizFalseArea || [];
|
||
|
||
let eligibleCount = 0;
|
||
let anyRight = false;
|
||
const results = [];
|
||
|
||
const ejectMoves = [];
|
||
|
||
space.peers.forEach((p, peerId) => {
|
||
const st = sess.players[peerId];
|
||
if (!st) return;
|
||
eligibleCount++;
|
||
const tx = Math.floor(Number(p.x));
|
||
const ty = Math.floor(Number(p.y));
|
||
const inT = quizCellOn(tGrid, tx, ty);
|
||
const inF = quizCellOn(fGrid, tx, ty);
|
||
let choice = null;
|
||
if (inT && !inF) choice = true;
|
||
else if (inF && !inT) choice = false;
|
||
else if (inT && inF) choice = true;
|
||
|
||
let right = false;
|
||
if (choice === null) right = false;
|
||
else right = (choice === correctTrue);
|
||
|
||
if (right) {
|
||
st.score = (typeof st.score === 'number' ? st.score : 0) + 1;
|
||
anyRight = true;
|
||
}
|
||
/* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */
|
||
if (!right) {
|
||
st.cannotTrue = true;
|
||
st.cannotFalse = true;
|
||
const pos = quizWrongAnswerRespawnPosition(mdLive);
|
||
p.x = pos.x;
|
||
p.y = pos.y;
|
||
ejectMoves.push({
|
||
id: peerId,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
characterId: p.characterId ?? null,
|
||
});
|
||
}
|
||
|
||
results.push({
|
||
id: peerId,
|
||
nickname: p.nickname || '',
|
||
right,
|
||
choice,
|
||
eliminated: !!st.eliminated,
|
||
score: typeof st.score === 'number' ? st.score : 0,
|
||
});
|
||
});
|
||
|
||
ejectMoves.forEach((ev) => {
|
||
io.to(sid).emit('user-move', ev);
|
||
try {
|
||
const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id);
|
||
if (sock) sock.emit('user-move', ev);
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
|
||
const allWrong = eligibleCount > 0 && !anyRight;
|
||
|
||
const scores = {};
|
||
space.peers.forEach((_, peerId) => {
|
||
const st = sess.players[peerId];
|
||
scores[peerId] = st && typeof st.score === 'number' ? st.score : 0;
|
||
});
|
||
|
||
io.to(sid).emit('quiz-result', {
|
||
questionIndex: idx + 1,
|
||
correctTrue,
|
||
results,
|
||
allWrong,
|
||
scores,
|
||
});
|
||
emitQuizPlayerStates(sid, space);
|
||
|
||
if (allWrong) {
|
||
endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้');
|
||
return;
|
||
}
|
||
|
||
sess.qIndex++;
|
||
if (sess.qIndex >= sess.questions.length) {
|
||
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
|
||
return;
|
||
}
|
||
|
||
const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500;
|
||
const mapRef = sess.quizMapMd || md;
|
||
const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function startQuizGame(sid, space, md) {
|
||
clearSpaceQuizTimers(space);
|
||
const settings = loadQuizSettings();
|
||
const pool = getQuizQuestionPool(md);
|
||
const shuffled = shuffleQuizQuestions(pool);
|
||
const picked = shuffled.slice(0, Math.min(10, shuffled.length));
|
||
const players = {};
|
||
space.peers.forEach((_, peerId) => {
|
||
players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
|
||
});
|
||
space.quizSession = {
|
||
active: true,
|
||
questions: picked,
|
||
qIndex: 0,
|
||
phase: 'idle',
|
||
phaseEndsAt: 0,
|
||
readMs: settings.readMs,
|
||
answerMs: settings.answerMs,
|
||
betweenMs: settings.betweenMs,
|
||
players,
|
||
quizMapMd: md,
|
||
};
|
||
scheduleQuizReadPhase(sid, space, md);
|
||
}
|
||
|
||
function safeMapId(id) {
|
||
return (id || '').replace(/[^a-z0-9_-]/gi, '') || null;
|
||
}
|
||
|
||
/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */
|
||
function normalizeQuizCarryLayersOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_carry') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const normHub = () => {
|
||
const src = m.quizCarryHubArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizCarryHubArea = rows;
|
||
};
|
||
const normOptions = () => {
|
||
const src = m.quizCarryOptionArea || [];
|
||
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];
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
m.quizCarryOptionArea = rows;
|
||
};
|
||
normHub();
|
||
normOptions();
|
||
}
|
||
|
||
/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
|
||
function normalizeQuizBattleDomeAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.quizBattleDomeArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizBattleDomeArea = rows;
|
||
}
|
||
|
||
function normalizeQuizBattlePathAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.quizBattlePathArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizBattlePathArea = rows;
|
||
}
|
||
|
||
function quizBattlePathModeActiveServer(m) {
|
||
if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
|
||
const g = m.quizBattlePathArea;
|
||
for (let y = 0; y < g.length; y++) {
|
||
const row = g[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizBattleFootprintFullyOnPathServer(m, px, py) {
|
||
if (!quizBattlePathModeActiveServer(m)) return true;
|
||
const g = m.quizBattlePathArea;
|
||
for (const k of quizCarryFootprintTileKeys(m, px, py)) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (!g[ty] || g[ty][tx] !== 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function serverFootprintClearOfWalls(md, px, py) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
|
||
const row = md.objects && md.objects[ty];
|
||
if (!row || row[tx] === 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
|
||
function snapPositionOntoQuizBattlePathServer(md, px, py) {
|
||
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
|
||
if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py };
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const g = md.quizBattlePathArea;
|
||
let bestX = null, bestY = null, bestD = Infinity;
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
const nx = tx + 0.01;
|
||
const ny = ty + 0.01;
|
||
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
|
||
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
const row = md.objects && md.objects[ty];
|
||
if (!row || row[tx] === 1) continue;
|
||
const nx = tx + 0.5;
|
||
const ny = ty + 0.5;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
return { x: px, y: py };
|
||
}
|
||
|
||
function normalizeJumpSurvivePlatformAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'jump_survive') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.jumpSurvivePlatformArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.jumpSurvivePlatformArea = rows;
|
||
}
|
||
|
||
function loadMaps() {
|
||
try {
|
||
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
|
||
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(MAPS_DIR);
|
||
for (const f of files) {
|
||
if (!f.endsWith('.json')) continue;
|
||
const id = f.slice(0, -5);
|
||
if (!safeMapId(id)) continue;
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
|
||
normalizeQuizCarryLayersOnMap(data);
|
||
normalizeJumpSurvivePlatformAreaOnMap(data);
|
||
normalizeQuizBattleDomeAreaOnMap(data);
|
||
normalizeQuizBattlePathAreaOnMap(data);
|
||
maps.set(id, data);
|
||
} catch (e) { console.error('load map', f, e.message); }
|
||
}
|
||
} catch (e) { console.error('loadMaps', e.message); }
|
||
}
|
||
|
||
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
|
||
function ensurePostCaseLobbyMapLoaded() {
|
||
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
|
||
try {
|
||
const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
|
||
if (!fs.existsSync(fp)) return false;
|
||
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
||
normalizeQuizCarryLayersOnMap(data);
|
||
maps.set(POST_CASE_LOBBY_SPACE_ID, data);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('ensurePostCaseLobbyMapLoaded', e.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function saveMap(id, m) {
|
||
const safe = safeMapId(id);
|
||
if (!safe) return;
|
||
try {
|
||
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
|
||
fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
|
||
} catch (e) { console.error('saveMap', id, e.message); }
|
||
}
|
||
|
||
function isMapNameTaken(name, excludeId) {
|
||
const n = (name || '').trim().toLowerCase();
|
||
for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
|
||
return false;
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
let url = req.url;
|
||
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
|
||
if (url.startsWith(BASE_PATH + '/api/maps')) {
|
||
const id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
|
||
if (req.method === 'GET' && id) {
|
||
const m = maps.get(id);
|
||
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
|
||
}
|
||
if (req.method === 'GET' && !id) {
|
||
const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
|
||
}
|
||
if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body);
|
||
const name = (d.name || '').trim() || 'ฉากใหม่';
|
||
if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
|
||
const id = Date.now().toString(36);
|
||
const m = { ...defaultMap(), ...d, name };
|
||
if (!m.blockPlayer) m.blockPlayer = [];
|
||
if (!m.interactive) m.interactive = [];
|
||
if (!m.startGameArea) m.startGameArea = [];
|
||
if (!m.spawnArea) m.spawnArea = [];
|
||
if (!m.quizTrueArea) m.quizTrueArea = [];
|
||
if (!m.quizFalseArea) m.quizFalseArea = [];
|
||
if (!m.quizQuestionArea) m.quizQuestionArea = [];
|
||
if (!m.quizQuestions) m.quizQuestions = [];
|
||
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
|
||
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
|
||
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
|
||
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
|
||
if (!m.stackReleaseArea) m.stackReleaseArea = [];
|
||
if (!m.stackLandArea) m.stackLandArea = [];
|
||
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
|
||
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
|
||
normalizeQuizCarryLayersOnMap(m);
|
||
normalizeJumpSurvivePlatformAreaOnMap(m);
|
||
normalizeQuizBattleDomeAreaOnMap(m);
|
||
normalizeQuizBattlePathAreaOnMap(m);
|
||
maps.set(id, m);
|
||
saveMap(id, m);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, mapId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (req.method === 'PUT' && id && maps.has(id)) {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body);
|
||
const name = (d.name || '').trim() || maps.get(id).name;
|
||
if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
|
||
const m = { ...maps.get(id), ...d, name };
|
||
if (!m.blockPlayer) m.blockPlayer = [];
|
||
if (!m.interactive) m.interactive = [];
|
||
if (!m.startGameArea) m.startGameArea = [];
|
||
if (!m.spawnArea) m.spawnArea = [];
|
||
if (!m.quizTrueArea) m.quizTrueArea = [];
|
||
if (!m.quizFalseArea) m.quizFalseArea = [];
|
||
if (!m.quizQuestionArea) m.quizQuestionArea = [];
|
||
if (!m.quizQuestions) m.quizQuestions = [];
|
||
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
|
||
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
|
||
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
|
||
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
|
||
if (!m.stackReleaseArea) m.stackReleaseArea = [];
|
||
if (!m.stackLandArea) m.stackLandArea = [];
|
||
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
|
||
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
|
||
normalizeQuizCarryLayersOnMap(m);
|
||
normalizeJumpSurvivePlatformAreaOnMap(m);
|
||
normalizeQuizBattleDomeAreaOnMap(m);
|
||
normalizeQuizBattlePathAreaOnMap(m);
|
||
maps.set(id, m);
|
||
saveMap(id, m);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, mapId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(loadQuizSettings()));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
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 saved = saveQuizSettings(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({
|
||
ok: true,
|
||
battleQuizMcqSaved: saved.battleQuizMcqSaved,
|
||
carryQuestionsSaved: saved.carryQuestionsSaved,
|
||
}));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify({ ...runtimeGameTiming }));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const saved = saveGameTimingToFile(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
const { ok: _ok, error: _err, ...timingRest } = saved;
|
||
return res.end(JSON.stringify({ ok: true, ...timingRest }));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
|
||
}
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/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' }));
|
||
}
|
||
ensureGauntletAssetsDir();
|
||
const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
|
||
const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
|
||
let fname;
|
||
if (replaceFilename) {
|
||
const s = safeGauntletStoredFilename(replaceFilename);
|
||
if (!s) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
|
||
}
|
||
const cur = path.join(GAUNTLET_ASSETS_DIR, s);
|
||
if (!fs.existsSync(cur)) {
|
||
res.writeHead(404);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
|
||
}
|
||
const norm = (e) => {
|
||
let x = String(e || '').toLowerCase();
|
||
if (x.startsWith('.')) x = x.slice(1);
|
||
return x === 'jpeg' ? 'jpg' : x;
|
||
};
|
||
if (norm(path.extname(s)) !== norm(ext)) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
|
||
}
|
||
fname = s;
|
||
} else {
|
||
fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
||
}
|
||
fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
|
||
const meta = loadGauntletAssetsMeta();
|
||
const prev = meta[fname] || {};
|
||
meta[fname] = {
|
||
...prev,
|
||
label: labelIn || prev.label || fname,
|
||
updatedAt: Date.now(),
|
||
};
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({
|
||
ok: true,
|
||
filename: fname,
|
||
url: BASE_PATH + '/img/gauntlet-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; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const fname = safeGauntletStoredFilename(d.filename);
|
||
if (!fname) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
|
||
}
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (!fs.existsSync(fp)) {
|
||
res.writeHead(404);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
|
||
}
|
||
const meta = loadGauntletAssetsMeta();
|
||
const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
|
||
meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
|
||
} 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 === 'DELETE') {
|
||
try {
|
||
const q = url.includes('?') ? url.split('?')[1] : '';
|
||
const sp = new URLSearchParams(q);
|
||
const rawFn = sp.get('file');
|
||
const fname = safeGauntletStoredFilename(rawFn);
|
||
if (!fname) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
|
||
}
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||
const meta = loadGauntletAssetsMeta();
|
||
delete meta[fname];
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
|
||
}
|
||
}
|
||
if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
|
||
try {
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||
const ids = new Set();
|
||
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
|
||
files.forEach(f => {
|
||
let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
});
|
||
const idArr = [...ids];
|
||
const oldestMsById = {};
|
||
idArr.forEach((id) => {
|
||
let oldest = Infinity;
|
||
const prefix = id + '_';
|
||
files.forEach((f) => {
|
||
if (!f.startsWith(prefix) || !ext.test(f)) return;
|
||
try {
|
||
const st = fs.statSync(path.join(CHARACTERS_DIR, f));
|
||
const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
|
||
if (t < oldest) oldest = t;
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
oldestMsById[id] = oldest === Infinity ? 0 : oldest;
|
||
});
|
||
idArr.sort((a, b) => {
|
||
const ta = oldestMsById[a] || 0;
|
||
const tb = oldestMsById[b] || 0;
|
||
if (ta !== tb) return ta - tb;
|
||
return String(a).localeCompare(String(b));
|
||
});
|
||
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 };
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify(list));
|
||
} catch (e) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify([]));
|
||
}
|
||
}
|
||
const urlPath = url.split('?')[0];
|
||
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
|
||
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
|
||
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
|
||
if (!id) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
|
||
}
|
||
try {
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
let removed = 0;
|
||
files.forEach(f => {
|
||
if (re.test(f) || layerRe.test(f)) {
|
||
try {
|
||
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
|
||
removed++;
|
||
} catch (e) {
|
||
// ignore single file errors
|
||
}
|
||
}
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, removed }));
|
||
} catch (e) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
return;
|
||
}
|
||
if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
if (!body || body.length < 2) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
|
||
}
|
||
const d = JSON.parse(body);
|
||
const dirs = ['up', 'down', 'left', 'right'];
|
||
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
|
||
let written = 0;
|
||
for (const dir of dirs) {
|
||
let data = d[dir];
|
||
if (!data) continue;
|
||
const list = Array.isArray(data) ? data : [data];
|
||
let frameIndex = 0;
|
||
for (const dataUrl of list) {
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
// เขียนไฟล์เฟรม (สำหรับ animation)
|
||
const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
|
||
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <id>_<dir>.png เพื่อใช้เป็น preview / fallback
|
||
if (list.length > 1 && frameIndex === 0) {
|
||
const baseName = id + '_' + dir + '.png';
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
|
||
}
|
||
written++;
|
||
frameIndex++;
|
||
}
|
||
}
|
||
// ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
|
||
const lf = d.layerFrames;
|
||
if (lf && typeof lf === 'object') {
|
||
for (const dir of dirs) {
|
||
const arr = lf[dir];
|
||
if (!Array.isArray(arr)) continue;
|
||
const multi = arr.length > 1;
|
||
arr.forEach((frameObj, frameIndex) => {
|
||
if (!frameObj || typeof frameObj !== 'object') return;
|
||
for (const layerName of Object.keys(frameObj)) {
|
||
if (!ALLOWED_LAYER[layerName]) continue;
|
||
const dataUrl = frameObj[layerName];
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
const fnameLayer = multi
|
||
? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
|
||
: (id + '_' + dir + '_layer_' + layerName + '.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (written === 0) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (เลือกรูป png/jpg)' }));
|
||
}
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, characterId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/api/spaces')) {
|
||
const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean);
|
||
const spaceId = parts[0];
|
||
if (req.method === 'GET' && !spaceId) {
|
||
const now = Date.now();
|
||
for (const [id, s] of [...spaces.entries()]) {
|
||
if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
|
||
const n = s.peers ? s.peers.size : 0;
|
||
if (n !== 0) continue;
|
||
const created = s.createdAt;
|
||
const stale = created == null || now - created > SPACE_EMPTY_TTL_MS;
|
||
if (stale) spaces.delete(id);
|
||
}
|
||
const list = [...spaces]
|
||
.filter(([, s]) => !s.isPrivate)
|
||
.filter(([, s]) => (s.peers && s.peers.size > 0))
|
||
.map(([id, s]) => ({
|
||
spaceId: id,
|
||
spaceName: s.spaceName || id,
|
||
mapName: (s.mapData && s.mapData.name) || '—',
|
||
peerCount: s.peers ? s.peers.size : 0,
|
||
maxPlayers: s.maxPlayers || 10
|
||
}));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
|
||
}
|
||
if (req.method === 'GET' && spaceId) {
|
||
const s = spaces.get(spaceId);
|
||
if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name }));
|
||
}
|
||
if (req.method === 'POST' && !spaceId) {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const mapData = d.mapId ? maps.get(d.mapId) : null;
|
||
if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' }));
|
||
const isPrivate = !!d.isPrivate;
|
||
let sid;
|
||
if (isPrivate) {
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
let code;
|
||
for (let i = 0; i < 20; i++) {
|
||
code = '';
|
||
for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)];
|
||
if (!spaces.has(code)) { sid = code; break; }
|
||
}
|
||
if (!sid) sid = 'priv-' + Date.now();
|
||
} else {
|
||
const base = (d.name || '').trim() || 'space';
|
||
sid = base + '-' + Date.now();
|
||
}
|
||
const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid);
|
||
let maxPlayers = parseInt(d.maxPlayers, 10);
|
||
if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10;
|
||
maxPlayers = Math.min(10, Math.max(1, maxPlayers));
|
||
if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6);
|
||
const mapId = d.mapId ? String(d.mapId).trim() : null;
|
||
const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview';
|
||
spaces.set(sid, {
|
||
mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(),
|
||
previewEditorTest,
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') {
|
||
function sendIceServers(iceServers) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ iceServers: iceServers || [] }));
|
||
}
|
||
function fallback() {
|
||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
|
||
const turnUser = process.env.TURN_USER;
|
||
const turnCred = process.env.TURN_CRED;
|
||
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
|
||
sendIceServers(iceServers);
|
||
}
|
||
const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY;
|
||
const meteredAppId = process.env.METERED_TURN_APP_ID;
|
||
let meteredBase = process.env.METERED_TURN_URL;
|
||
if (!meteredBase) {
|
||
let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret';
|
||
if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live';
|
||
meteredBase = 'https://' + meteredHost;
|
||
}
|
||
meteredBase = meteredBase.replace(/\/$/, '');
|
||
if (meteredKey) {
|
||
function tryNext(credUrl) {
|
||
return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) {
|
||
if (data && (data.error || data.message)) throw new Error(data.error || data.message);
|
||
const list = Array.isArray(data) ? data : (data && data.iceServers);
|
||
if (list && list.length) return sendIceServers(list);
|
||
if (data && data.data && Array.isArray(data.data) && data.data.length) {
|
||
const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca';
|
||
const built = data.data.map(function (c) {
|
||
return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password };
|
||
});
|
||
return sendIceServers(built);
|
||
}
|
||
throw new Error('No iceServers');
|
||
});
|
||
}
|
||
const urlsToTry = [
|
||
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey),
|
||
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey),
|
||
meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey)
|
||
];
|
||
let p = tryNext(urlsToTry[0]);
|
||
for (let i = 1; i < urlsToTry.length; i++) {
|
||
p = p.catch(function () { return tryNext(urlsToTry[i]); });
|
||
}
|
||
p.catch(fallback);
|
||
return;
|
||
}
|
||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
|
||
const turnUser = process.env.TURN_USER;
|
||
const turnCred = process.env.TURN_CRED;
|
||
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
|
||
sendIceServers(iceServers);
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const password = (d.password || '').trim();
|
||
if (password !== ADMIN_PASSWORD) {
|
||
return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' }));
|
||
}
|
||
const token = require('crypto').randomBytes(24).toString('hex');
|
||
adminTokens.add(token);
|
||
res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax');
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') {
|
||
const token = getAdminToken(req.headers.cookie);
|
||
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
|
||
const s = loadAiSettings();
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({
|
||
model: s.model || 'gpt-4o-mini',
|
||
models: AI_MODELS,
|
||
hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0),
|
||
intent: s.intent != null ? s.intent : '',
|
||
rag_enabled: !!s.rag_enabled,
|
||
rag_context: s.rag_context != null ? s.rag_context : '',
|
||
}));
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') {
|
||
const token = getAdminToken(req.headers.cookie);
|
||
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const s = loadAiSettings();
|
||
if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim();
|
||
if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini';
|
||
if (d.intent !== undefined) s.intent = String(d.intent);
|
||
if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled;
|
||
if (d.rag_context !== undefined) s.rag_context = String(d.rag_context);
|
||
saveAiSettings(s);
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-chat' && 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 message = (d.message || d.text || '').trim();
|
||
if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
|
||
const s = loadAiSettings();
|
||
if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
|
||
const model = s.model || 'gpt-4o-mini';
|
||
const messages = [];
|
||
const systemParts = [];
|
||
if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
|
||
if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
|
||
systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
|
||
}
|
||
if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
|
||
messages.push({ role: 'user', content: message });
|
||
const payload = JSON.stringify({
|
||
model: model,
|
||
messages: messages,
|
||
max_tokens: 500,
|
||
});
|
||
const urlObj = new URL('https://api.openai.com/v1/chat/completions');
|
||
const opts = {
|
||
hostname: urlObj.hostname,
|
||
path: urlObj.pathname,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + s.openai_api_key,
|
||
'Content-Length': Buffer.byteLength(payload, 'utf8'),
|
||
},
|
||
};
|
||
const reqOut = require('https').request(opts, (resOut) => {
|
||
let data = '';
|
||
resOut.on('data', chunk => data += chunk);
|
||
resOut.on('end', () => {
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
|
||
if (text != null) {
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ response: String(text).trim() }));
|
||
} else {
|
||
res.writeHead(502);
|
||
res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
|
||
}
|
||
} catch (e) {
|
||
res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
|
||
}
|
||
});
|
||
});
|
||
reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
|
||
reqOut.write(payload);
|
||
reqOut.end();
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') {
|
||
const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
|
||
const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
|
||
if (fname && fname.length > 0) {
|
||
const fp = path.join(CHARACTERS_DIR, fname);
|
||
if (fs.existsSync(fp)) {
|
||
return fs.readFile(fp, (err, data) => {
|
||
if (err) return res.writeHead(500), res.end();
|
||
res.writeHead(200, { 'Content-Type': 'image/png' });
|
||
res.end(data);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
|
||
const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
|
||
const fname = safeGauntletStoredFilename(sub);
|
||
if (!fname) {
|
||
res.writeHead(400);
|
||
return res.end();
|
||
}
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (!fs.existsSync(fp)) {
|
||
res.writeHead(404);
|
||
return res.end();
|
||
}
|
||
const ext = path.extname(fname);
|
||
return fs.readFile(fp, (err, data) => {
|
||
if (err) return res.writeHead(500), res.end();
|
||
res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
|
||
res.end(data);
|
||
});
|
||
}
|
||
if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
|
||
const filePath = path.join(__dirname, 'public', url);
|
||
const ext = path.extname(filePath);
|
||
const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
|
||
fs.readFile(filePath, (err, data) => {
|
||
if (err) return res.writeHead(404), res.end('Not Found');
|
||
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
|
||
res.end(data);
|
||
});
|
||
});
|
||
|
||
const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
|
||
const spaces = new Map();
|
||
|
||
const GAUNTLET_MAX_PLAYERS = 6;
|
||
/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */
|
||
/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */
|
||
const GAUNTLET_LASER_SPAWN_CHANCE = 0.14;
|
||
/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */
|
||
const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34;
|
||
|
||
function gauntletSpawnYs(md, playerCount) {
|
||
const h = md.height || 15;
|
||
const lo = 1;
|
||
const hi = Math.max(lo, h - 2);
|
||
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
|
||
if (hi <= lo) return Array.from({ length: n }, () => lo);
|
||
const ys = [];
|
||
for (let i = 0; i < n; i++) {
|
||
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
|
||
}
|
||
return ys;
|
||
}
|
||
|
||
/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */
|
||
function collectGauntletSpawnSlotsFromSpawnArea(md) {
|
||
const grid = md.spawnArea;
|
||
if (!grid || !Array.isArray(grid)) return [];
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const slots = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = grid[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y });
|
||
}
|
||
}
|
||
slots.sort((a, b) => a.y - b.y || a.x - b.x);
|
||
return slots;
|
||
}
|
||
|
||
/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */
|
||
function collectGauntletSpawnSlotsFromMap(md) {
|
||
const explicit = md.gauntletPlayerSpawns;
|
||
if (Array.isArray(explicit) && explicit.length > 0) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const slots = [];
|
||
for (const raw of explicit) {
|
||
if (slots.length >= GAUNTLET_MAX_PLAYERS) break;
|
||
const x = Math.floor(Number(raw && raw.x));
|
||
const y = Math.floor(Number(raw && raw.y));
|
||
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
|
||
if (!isMapTileWalkableForSpawn(md, x, y)) continue;
|
||
slots.push({ x, y });
|
||
}
|
||
if (slots.length > 0) return slots;
|
||
}
|
||
return collectGauntletSpawnSlotsFromSpawnArea(md);
|
||
}
|
||
|
||
function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) {
|
||
const w = md.width || 20;
|
||
if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX;
|
||
if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback;
|
||
for (let d = 0; d < w; d++) {
|
||
if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d;
|
||
if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d;
|
||
}
|
||
return Math.max(0, Math.min(w - 1, spawnColX));
|
||
}
|
||
|
||
/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */
|
||
function gauntletSpawnPositions(md, playerCount) {
|
||
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
|
||
const slots = collectGauntletSpawnSlotsFromMap(md);
|
||
const fallbackYs = gauntletSpawnYs(md, n);
|
||
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const y = i < slots.length
|
||
? slots[i].y
|
||
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
|
||
const slotX = i < slots.length ? slots[i].x : null;
|
||
const x = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX);
|
||
out.push({ x, y });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stopGauntletTicker(space) {
|
||
if (space.gauntletRun && space.gauntletRun.timerId) {
|
||
clearInterval(space.gauntletRun.timerId);
|
||
space.gauntletRun.timerId = null;
|
||
}
|
||
}
|
||
|
||
/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */
|
||
function ensureGauntletEndsAtIfNeeded(space) {
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return;
|
||
const lim = getGauntletTimeLimitSec();
|
||
if (lim > 0 && gr.endsAt == null) {
|
||
gr.endsAt = Date.now() + lim * 1000;
|
||
}
|
||
}
|
||
|
||
function startGauntletTicker(sid, space) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.gauntletRun && space.gauntletRun.timerId) return;
|
||
if (!space.gauntletRun) {
|
||
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
|
||
}
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
space.gauntletRun.timerId = setInterval(() => {
|
||
runGauntletTick(sid, space);
|
||
}, getGauntletTickMs());
|
||
}
|
||
|
||
function initGauntletPeersAll(space, md) {
|
||
const list = [...space.peers.values()];
|
||
const pos = gauntletSpawnPositions(md, list.length);
|
||
list.forEach((p, i) => {
|
||
const pt = pos[i] || pos[pos.length - 1];
|
||
p.x = pt.x;
|
||
p.y = pt.y;
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = 0;
|
||
p.gauntletJumpPending = false;
|
||
});
|
||
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
|
||
space.gauntletPreviewRowsBySocket = new Map();
|
||
}
|
||
|
||
function findFallbackLobbyMapForGauntletEnd() {
|
||
for (const [id, m] of maps) {
|
||
if (m && m.gameType === 'lobby') return { id, m };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */
|
||
function endGauntletGame(sid, space, reason) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
stopGauntletTicker(space);
|
||
const rankings = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
|
||
score: Math.max(0, p.gauntletScore | 0),
|
||
})).sort((a, b) => b.score - a.score);
|
||
|
||
let retId = space.gauntletReturnMapId;
|
||
let retMap = retId && maps.has(retId) ? maps.get(retId) : null;
|
||
if (!retMap || retMap.gameType === 'gauntlet') {
|
||
const fb = findFallbackLobbyMapForGauntletEnd();
|
||
if (fb) {
|
||
retId = fb.id;
|
||
retMap = fb.m;
|
||
}
|
||
}
|
||
if (retMap && retMap.gameType !== 'gauntlet') {
|
||
space.mapId = retId;
|
||
space.mapData = retMap;
|
||
space.peers.forEach((p) => {
|
||
const sp = pickRandomSpawnFromMap(retMap);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = 0;
|
||
p.gauntletJumpPending = false;
|
||
});
|
||
} else {
|
||
console.warn('endGauntletGame: no lobby map to return to', sid);
|
||
}
|
||
space.gauntletReturnMapId = null;
|
||
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear();
|
||
space.gauntletRun = null;
|
||
const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว';
|
||
io.to(sid).emit('gauntlet-ended', {
|
||
reason: reason || 'time',
|
||
message: msg,
|
||
rankings,
|
||
});
|
||
}
|
||
|
||
function runGauntletTick(sid, space) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
return;
|
||
}
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return;
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
|
||
endGauntletGame(sid, space, 'time');
|
||
return;
|
||
}
|
||
|
||
/* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */
|
||
space.peers.forEach((p) => {
|
||
if (p.gauntletJumpPending) {
|
||
if ((p.gauntletJumpTicks || 0) === 0) {
|
||
p.gauntletJumpTicks = getGauntletJumpTicks();
|
||
}
|
||
p.gauntletJumpPending = false;
|
||
}
|
||
});
|
||
|
||
for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1;
|
||
gr.obstacles = gr.obstacles.filter((o) => o.x >= 0);
|
||
|
||
const occupiedY = new Set();
|
||
space.peers.forEach((peer) => {
|
||
const fy = Math.floor(Number(peer.y));
|
||
if (Number.isFinite(fy) && fy >= 0 && fy < h) occupiedY.add(fy);
|
||
});
|
||
/* แถวที่ client แจ้ง (บอท preview อยู่บน client ไม่ใช่ peer) — ให้ spawn lane บนแถวบอทด้วย */
|
||
const previewRows = space.gauntletPreviewRowsBySocket;
|
||
if (previewRows && previewRows.size) {
|
||
previewRows.forEach((set) => {
|
||
set.forEach((y) => {
|
||
const fy = Math.floor(Number(y));
|
||
if (Number.isFinite(fy) && fy >= 0 && fy < h) occupiedY.add(fy);
|
||
});
|
||
});
|
||
}
|
||
/* lane obstacle เฉพาะแถวที่มีคน — ลบชิ้นที่ลอยในแถวว่าง */
|
||
gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || occupiedY.has(o.y));
|
||
|
||
gr.spawnAcc = (gr.spawnAcc || 0) + 1;
|
||
if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) {
|
||
gr.spawnAcc = 0;
|
||
gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6);
|
||
/* ประเภท 2: เลเซอร์ — ทุกแถว */
|
||
if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) {
|
||
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1 });
|
||
}
|
||
/* ประเภท 1: แยกเลน — เฉพาะแถวที่มีผู้เล่นยืนอยู่ (สุ่มแยกแถว) */
|
||
occupiedY.forEach((ly) => {
|
||
if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) {
|
||
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly });
|
||
}
|
||
});
|
||
}
|
||
|
||
/* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */
|
||
space.peers.forEach((p) => {
|
||
let px = Math.floor(Number(p.x)) || 0;
|
||
let py = Math.floor(Number(p.y)) || 0;
|
||
px = Math.max(0, Math.min(w - 1, px));
|
||
py = Math.max(0, Math.min(h - 1, py));
|
||
const air = (p.gauntletJumpTicks || 0) > 0;
|
||
let advanceX = false;
|
||
let hitBack = false;
|
||
for (const o of gr.obstacles) {
|
||
if (o.kind === 'lane' && o.x === px && o.y === py) {
|
||
if (air) advanceX = true;
|
||
else hitBack = true;
|
||
}
|
||
if (o.kind === 'laser' && o.x === px) {
|
||
if (air) advanceX = true;
|
||
else hitBack = true;
|
||
}
|
||
}
|
||
if (advanceX) {
|
||
px = Math.min(w - 2, px + 1);
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = (p.gauntletScore || 0) + 1;
|
||
} else if (hitBack) {
|
||
px = Math.max(0, px - 1);
|
||
}
|
||
if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--;
|
||
p.x = px;
|
||
p.y = py;
|
||
});
|
||
|
||
const obsOut = gr.obstacles.map((o) => ({ id: o.id, kind: o.kind, x: o.x, y: o.kind === 'laser' ? null : o.y }));
|
||
const playersOut = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
characterId: p.characterId ?? null,
|
||
gauntletJumpTicks: p.gauntletJumpTicks || 0,
|
||
gauntletScore: p.gauntletScore || 0,
|
||
}));
|
||
io.to(sid).emit('gauntlet-sync', {
|
||
obstacles: obsOut,
|
||
players: playersOut,
|
||
gauntletTickMs: getGauntletTickMs(),
|
||
gauntletJumpTicks: getGauntletJumpTicks(),
|
||
gauntletTimeLimitSec: getGauntletTimeLimitSec(),
|
||
gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null,
|
||
...getGauntletVisualsForClient(),
|
||
});
|
||
}
|
||
|
||
function rescheduleAllGauntletTickers() {
|
||
for (const [sid, space] of spaces) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') continue;
|
||
if (space.gauntletRun && space.gauntletRun.timerId) {
|
||
clearInterval(space.gauntletRun.timerId);
|
||
space.gauntletRun.timerId = null;
|
||
}
|
||
if (space.peers.size === 0) continue;
|
||
if (!space.gauntletRun) {
|
||
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
|
||
}
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
space.gauntletRun.timerId = setInterval(() => {
|
||
runGauntletTick(sid, space);
|
||
}, getGauntletTickMs());
|
||
}
|
||
}
|
||
|
||
function getLobbyLayoutMapForSpace(space) {
|
||
return (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
}
|
||
|
||
/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */
|
||
function serverMapIsPostCaseLobbyB(space) {
|
||
if (!space) return false;
|
||
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
|
||
const md = getLobbyLayoutMapForSpace(space);
|
||
if (!md || md.gameType !== 'lobby') return false;
|
||
const nm = String(md.name || '').trim().toLowerCase();
|
||
return nm === 'lobbyb';
|
||
}
|
||
|
||
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
|
||
function normalizeLobbyNickname(nickname) {
|
||
const raw = String(nickname || '').trim();
|
||
if (!raw) return 'ผู้เล่น';
|
||
try {
|
||
return raw.normalize('NFKC').toLowerCase();
|
||
} catch (e) {
|
||
return raw.toLowerCase();
|
||
}
|
||
}
|
||
|
||
/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */
|
||
function lobbySpaceAlreadyLobbyB(space, md) {
|
||
if (!md || md.gameType !== 'lobby') return false;
|
||
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
|
||
if (String(md.name || '').trim() === 'LobbyB') return true;
|
||
return false;
|
||
}
|
||
|
||
function lobbyMapHasStartGameArea(md) {
|
||
if (!md) return false;
|
||
const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya';
|
||
if (md.gameType !== 'lobby' && !namedLobbyA) return false;
|
||
const area = md.startGameArea;
|
||
if (!area || !Array.isArray(area)) return false;
|
||
for (let y = 0; y < area.length; y++) {
|
||
const row = area[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */
|
||
function lobbyHostStandingInStartArea(space, hostId) {
|
||
const md = getLobbyLayoutMapForSpace(space);
|
||
if (!lobbyMapHasStartGameArea(md)) return true;
|
||
const p = space.peers.get(hostId);
|
||
if (!p || p.x == null || p.y == null) return false;
|
||
const tx = Math.floor(p.x);
|
||
const ty = Math.floor(p.y);
|
||
const row = md.startGameArea[ty];
|
||
return !!(row && row[tx] === 1);
|
||
}
|
||
|
||
function isMapTileWalkableForSpawn(md, x, y) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
if (x < 0 || x >= w || y < 0 || y >= h) return false;
|
||
const row = md.objects && md.objects[y];
|
||
if (row && row[x] === 1) return false;
|
||
return true;
|
||
}
|
||
|
||
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
|
||
function pickRandomSpawnFromMap(md) {
|
||
const fallback = md.spawn || { x: 1, y: 1 };
|
||
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
|
||
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
|
||
const grid = md.spawnArea;
|
||
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const pool = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = grid[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y });
|
||
}
|
||
}
|
||
if (!pool.length) return { x: fx, y: fy };
|
||
const idx = typeof crypto.randomInt === 'function'
|
||
? crypto.randomInt(0, pool.length)
|
||
: Math.floor(Math.random() * pool.length);
|
||
const pick = pool[idx];
|
||
return { x: pick.x, y: pick.y };
|
||
}
|
||
|
||
/** ตอบผิดในเกมคำถาม — กลับจุดเกิด (spawnArea / spawn) แล้วดีดออกนอกโซนถ้าจุดเกิดทับโซนตอบ */
|
||
function quizWrongAnswerRespawnPosition(md) {
|
||
const sp = pickRandomSpawnFromMap(md);
|
||
return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5);
|
||
}
|
||
|
||
function initTroublesomeState(space) {
|
||
if (!space.troublesomeEligible) space.troublesomeEligible = new Set();
|
||
if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false;
|
||
if (space.suspectPickIndex == null) space.suspectPickIndex = 0;
|
||
if (space.suspectPhaseActive == null) space.suspectPhaseActive = false;
|
||
}
|
||
|
||
function attemptTroublesomeRoll(sid, force) {
|
||
const space = spaces.get(sid);
|
||
if (!space || space.troublesomeOfferSent) return;
|
||
const peerIds = [...space.peers.keys()];
|
||
if (peerIds.length === 0) return;
|
||
if (!force && space.troublesomeEligible.size < peerIds.length) return;
|
||
space.troublesomeOfferSent = true;
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
|
||
space.troublesomeDebTimer = null;
|
||
space.troublesomeMaxTimer = null;
|
||
let pool = peerIds.filter((id) => space.troublesomeEligible.has(id));
|
||
if (pool.length === 0) pool = peerIds;
|
||
const chosen = pool[Math.floor(Math.random() * pool.length)];
|
||
space.troublesomeTargetId = chosen;
|
||
space.troublesomeResponseReceived = false;
|
||
io.to(chosen).emit('troublesome-offer', { seconds: 15 });
|
||
}
|
||
|
||
function scheduleTroublesomeRoll(sid) {
|
||
const space = spaces.get(sid);
|
||
if (!space || space.troublesomeOfferSent) return;
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450);
|
||
if (!space.troublesomeMaxTimer) {
|
||
space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000);
|
||
}
|
||
}
|
||
|
||
io.on('connection', (socket) => {
|
||
socket.on('join-space', ({ spaceId, nickname, characterId }, cb) => {
|
||
const space = spaces.get(spaceId);
|
||
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
|
||
const maxPlayers = space.maxPlayers || 10;
|
||
const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' });
|
||
const joinNickNorm = normalizeLobbyNickname(nickname);
|
||
if (space.joinLocked) {
|
||
const wl = space.caseParticipantNicknames;
|
||
if (wl && wl.size > 0) {
|
||
if (!wl.has(joinNickNorm)) {
|
||
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
|
||
}
|
||
} else {
|
||
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
|
||
}
|
||
}
|
||
socket.join(spaceId);
|
||
socket.data.spaceId = spaceId;
|
||
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 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,
|
||
gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, spaceShooterScore: 0,
|
||
balloonBossScore: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false,
|
||
};
|
||
if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) {
|
||
const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5);
|
||
peer.x = sn.x;
|
||
peer.y = sn.y;
|
||
}
|
||
space.peers.set(socket.id, peer);
|
||
if (mdJoin.gameType === 'gauntlet') {
|
||
const ord = [...space.peers.keys()].indexOf(socket.id);
|
||
const pos = gauntletSpawnPositions(mdJoin, space.peers.size);
|
||
const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1];
|
||
peer.x = pt.x;
|
||
peer.y = pt.y;
|
||
peer.gauntletJumpTicks = 0;
|
||
startGauntletTicker(spaceId, space);
|
||
}
|
||
const peersList = [...space.peers.values()];
|
||
const mapData = mdJoin;
|
||
const joinCb = {
|
||
ok: true,
|
||
mapData,
|
||
mapId: space.mapId || null,
|
||
peers: peersList,
|
||
hostId: space.hostId,
|
||
spaceName: space.spaceName || spaceId,
|
||
isPrivate: !!space.isPrivate,
|
||
roomCode: space.isPrivate ? spaceId : null,
|
||
maxPlayers: space.maxPlayers || 10,
|
||
suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space),
|
||
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
|
||
};
|
||
if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) {
|
||
joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
|
||
}
|
||
if (typeof cb === 'function') cb(joinCb);
|
||
socket.to(spaceId).emit('user-joined', peer);
|
||
if (space.voiceInits) {
|
||
for (const [peerId, initData] of Object.entries(space.voiceInits)) {
|
||
if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData });
|
||
}
|
||
}
|
||
});
|
||
|
||
/** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */
|
||
socket.on('preview-claim-host', (cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
const isPreviewEditorRoom = !!(space.previewEditorTest
|
||
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
|
||
if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' });
|
||
space.hostId = socket.id;
|
||
io.to(sid).emit('host-changed', { hostId: socket.id });
|
||
return reply({ ok: true });
|
||
});
|
||
|
||
socket.on('troublesome-eligible', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return;
|
||
initTroublesomeState(space);
|
||
space.troublesomeEligible.add(socket.id);
|
||
scheduleTroublesomeRoll(sid);
|
||
});
|
||
|
||
socket.on('troublesome-response', (data) => {
|
||
const accept = !!(data && data.accept);
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space)) return;
|
||
if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return;
|
||
if (space.troublesomeResponseReceived) return;
|
||
space.troublesomeResponseReceived = true;
|
||
space.troublesomeAccept = accept;
|
||
let idx = Math.floor(Number(space.suspectPickIndex));
|
||
if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0;
|
||
space.suspectPickIndex = idx;
|
||
space.suspectPhaseActive = true;
|
||
io.to(sid).emit('suspect-phase-open', {
|
||
hostId: space.hostId,
|
||
selectedIndex: space.suspectPickIndex,
|
||
disruptorAccepted: accept
|
||
});
|
||
socket.emit('troublesome-ack', { ok: true, accept });
|
||
});
|
||
|
||
socket.on('suspect-pick-select', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return;
|
||
if (space.hostId !== socket.id) return;
|
||
let idx = Math.floor(Number(data && data.index));
|
||
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
|
||
space.suspectPickIndex = idx;
|
||
io.to(sid).emit('suspect-pick-update', { selectedIndex: idx });
|
||
});
|
||
|
||
socket.on('suspect-pick-start', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space)) {
|
||
return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' });
|
||
}
|
||
if (!space.suspectPhaseActive) {
|
||
return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' });
|
||
}
|
||
if (space.hostId !== socket.id) {
|
||
return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' });
|
||
}
|
||
const md = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID);
|
||
if (!md) {
|
||
return reply({ ok: false, error: 'ไม่พบฉากเกม (mng8a80o) บนเซิร์ฟเวอร์' });
|
||
}
|
||
const qErr = validateQuizMap(md);
|
||
if (qErr) {
|
||
return reply({ ok: false, error: qErr });
|
||
}
|
||
space.suspectPhaseActive = false;
|
||
clearSpaceQuizTimers(space);
|
||
space.mapId = SUSPECT_INVESTIGATION_QUIZ_MAP_ID;
|
||
space.mapData = md;
|
||
space.peers.forEach((p) => {
|
||
const sp = pickRandomSpawnFromMap(md);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.direction = 'down';
|
||
});
|
||
const peersSnap = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null
|
||
}));
|
||
const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0;
|
||
io.to(sid).emit('suspect-investigation-start', { selectedIndex });
|
||
io.to(sid).emit('game-start', {
|
||
quizMode: true,
|
||
stayInRoomLobby: true,
|
||
mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID,
|
||
peersSnap
|
||
});
|
||
setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || !spNow.peers.has(socket.id)) return;
|
||
const mdLive = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID);
|
||
if (!mdLive) return;
|
||
startQuizGame(sid, spNow, mdLive);
|
||
}, 900);
|
||
reply({ ok: true });
|
||
});
|
||
|
||
/* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */
|
||
socket.on('set-ready', ({ ready }) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
if (p) p.ready = !!ready;
|
||
io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('start-game', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id) && space.hostId === socket.id) {
|
||
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
|
||
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
|
||
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
|
||
if (caseId && !/^[1-3]$/.test(caseId)) caseId = '';
|
||
ensurePostCaseLobbyMapLoaded();
|
||
const mdBefore = getLobbyLayoutMapForSpace(space);
|
||
if (mdBefore && mdBefore.gameType === 'quiz') {
|
||
const qErr = validateQuizMap(mdBefore);
|
||
if (qErr) return reply({ ok: false, error: qErr });
|
||
clearSpaceQuizTimers(space);
|
||
startQuizGame(sid, space, mdBefore);
|
||
io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true });
|
||
return reply({ ok: true });
|
||
}
|
||
const detectiveB = !!(data && data.detectiveLobbyBStart);
|
||
if (detectiveB && (!lobbyLevel || !caseId)) {
|
||
return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' });
|
||
}
|
||
const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : '';
|
||
const isNamedLobbyA = mapNameNorm === 'lobbya';
|
||
const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet');
|
||
/* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */
|
||
const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore)
|
||
&& lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID)
|
||
&& (
|
||
mdBefore.gameType === 'lobby'
|
||
|| isNamedLobbyA
|
||
|| (detectiveB && notZepOrFrogger)
|
||
));
|
||
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
|
||
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
|
||
}
|
||
if (!lobbyHostStandingInStartArea(space, socket.id)) {
|
||
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
|
||
}
|
||
if (isLobbyADetectiveStart) {
|
||
space.mapId = POST_CASE_LOBBY_SPACE_ID;
|
||
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
|
||
const caseNickWl = new Set();
|
||
space.peers.forEach((p) => {
|
||
caseNickWl.add(normalizeLobbyNickname(p.nickname));
|
||
});
|
||
space.caseParticipantNicknames = caseNickWl;
|
||
space.joinLocked = true;
|
||
space.troublesomeOfferSent = false;
|
||
space.troublesomeTargetId = null;
|
||
space.troublesomeResponseReceived = false;
|
||
space.suspectPhaseActive = false;
|
||
space.suspectPickIndex = 0;
|
||
if (space.troublesomeEligible) space.troublesomeEligible.clear();
|
||
else space.troublesomeEligible = new Set();
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
|
||
space.troublesomeDebTimer = null;
|
||
space.troublesomeMaxTimer = null;
|
||
initTroublesomeState(space);
|
||
space.peers.forEach((p) => {
|
||
const sp = pickRandomSpawnFromMap(space.mapData);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.direction = 'down';
|
||
});
|
||
const peersSnap = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null
|
||
}));
|
||
io.to(sid).emit('game-start', {
|
||
stayInRoomLobby: true,
|
||
mapId: POST_CASE_LOBBY_SPACE_ID,
|
||
lobbyLevel,
|
||
caseId,
|
||
peersSnap
|
||
});
|
||
return reply({ ok: true });
|
||
}
|
||
const mapId = (data && data.mapId) ? String(data.mapId).trim() : null;
|
||
let peersSnap = null;
|
||
let gauntletEndsAtEmit = undefined;
|
||
if (mapId && maps.has(mapId)) {
|
||
const prevMapIdForReturn = space.mapId;
|
||
space.mapId = mapId;
|
||
space.mapData = maps.get(mapId);
|
||
const md = space.mapData;
|
||
if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
|
||
space.peers.forEach((p) => {
|
||
const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y));
|
||
p.x = sn.x;
|
||
p.y = sn.y;
|
||
});
|
||
}
|
||
if (!md || md.gameType !== 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
space.gauntletReturnMapId = null;
|
||
space.gauntletRun = null;
|
||
}
|
||
if (md && md.gameType === 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
space.gauntletReturnMapId = prevMapIdForReturn;
|
||
initGauntletPeersAll(space, md);
|
||
const lim = getGauntletTimeLimitSec();
|
||
if (lim > 0 && space.gauntletRun) {
|
||
space.gauntletRun.endsAt = Date.now() + lim * 1000;
|
||
} else if (space.gauntletRun) {
|
||
space.gauntletRun.endsAt = null;
|
||
}
|
||
startGauntletTicker(sid, space);
|
||
gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null
|
||
? space.gauntletRun.endsAt
|
||
: null;
|
||
peersSnap = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null,
|
||
gauntletJumpTicks: p.gauntletJumpTicks || 0,
|
||
gauntletScore: p.gauntletScore || 0,
|
||
}));
|
||
}
|
||
}
|
||
const gameStartPayload = {
|
||
mapId: mapId || undefined,
|
||
lobbyLevel: lobbyLevel || undefined,
|
||
caseId: caseId || undefined,
|
||
peersSnap: peersSnap || undefined,
|
||
};
|
||
if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null;
|
||
io.to(sid).emit('game-start', gameStartPayload);
|
||
return reply({ ok: true });
|
||
}
|
||
}
|
||
reply({ ok: false, error: 'ไม่ใช่ host' });
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
let sid = socket.data.spaceId;
|
||
let space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) {
|
||
for (const [s, sp] of spaces) {
|
||
if (sp.peers.has(socket.id)) { sid = s; space = sp; break; }
|
||
}
|
||
}
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const wasHost = space.hostId === socket.id;
|
||
if (space.voiceInits) delete space.voiceInits[socket.id];
|
||
if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id);
|
||
if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id];
|
||
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id);
|
||
space.peers.delete(socket.id);
|
||
if (space.peers.size === 0) {
|
||
stopGauntletTicker(space);
|
||
clearSpaceQuizTimers(space);
|
||
space.quizSession = null;
|
||
spaces.delete(sid);
|
||
} else {
|
||
if (wasHost) {
|
||
const firstPeer = space.peers.values().next().value;
|
||
if (firstPeer) {
|
||
space.hostId = firstPeer.id;
|
||
io.to(sid).emit('host-changed', { hostId: firstPeer.id });
|
||
}
|
||
}
|
||
io.to(sid).emit('user-left', { id: socket.id });
|
||
}
|
||
});
|
||
|
||
socket.on('gauntlet-jump', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if ((p.gauntletJumpTicks || 0) > 0) return;
|
||
p.gauntletJumpPending = true;
|
||
});
|
||
|
||
/** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */
|
||
socket.on('gauntlet-preview-rows', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
const hh = md.height || 15;
|
||
if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map();
|
||
const raw = data && Array.isArray(data.ys) ? data.ys : [];
|
||
const set = new Set();
|
||
const cap = Math.min(raw.length, 48);
|
||
for (let i = 0; i < cap; i++) {
|
||
const fy = Math.floor(Number(raw[i]));
|
||
if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy);
|
||
}
|
||
space.gauntletPreviewRowsBySocket.set(socket.id, set);
|
||
});
|
||
|
||
socket.on('move', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (md && md.gameType === 'gauntlet') {
|
||
const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId };
|
||
socket.emit('user-move', out);
|
||
break;
|
||
}
|
||
let nx = data.x;
|
||
let ny = data.y;
|
||
const sess = space.quizSession;
|
||
const mdQuiz = (sess && sess.quizMapMd) || md;
|
||
if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') {
|
||
const st = sess.players && sess.players[socket.id];
|
||
if (st && !st.eliminated) {
|
||
const tx = Math.floor(Number(nx));
|
||
const ty = Math.floor(Number(ny));
|
||
const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty);
|
||
const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty);
|
||
if (blockTrue || blockFalse) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) {
|
||
const txN = Number(nx);
|
||
const tyN = Number(ny);
|
||
if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
|
||
const txN = Number(nx);
|
||
const tyN = Number(ny);
|
||
if (!Number.isFinite(txN) || !Number.isFinite(tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
} else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny));
|
||
nx = sn.x;
|
||
ny = sn.y;
|
||
}
|
||
if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) {
|
||
const ns = Math.floor(Number(data.spaceShooterScore));
|
||
if (Number.isFinite(ns) && ns >= 0) {
|
||
const prev = Math.max(0, p.spaceShooterScore | 0);
|
||
if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns);
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'balloon_boss' && data) {
|
||
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
|
||
if (data.balloonBossScore != null) {
|
||
const ns = Math.floor(Number(data.balloonBossScore));
|
||
if (Number.isFinite(ns) && ns >= 0) {
|
||
const prev = Math.max(0, p.balloonBossScore | 0);
|
||
if (ns <= prev + 25) p.balloonBossScore = Math.max(prev, ns);
|
||
}
|
||
}
|
||
if (data.balloonBossBalloons != null) {
|
||
const nb = Math.floor(Number(data.balloonBossBalloons));
|
||
if (Number.isFinite(nb) && nb >= 0) {
|
||
const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
|
||
? Math.floor(p.balloonBossBalloons)
|
||
: bbDefaultBalloons;
|
||
if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb;
|
||
}
|
||
}
|
||
if (data.balloonBossEliminated) p.balloonBossEliminated = true;
|
||
}
|
||
if (p) {
|
||
p.x = nx;
|
||
p.y = ny;
|
||
p.direction = data.direction || p.direction;
|
||
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
|
||
if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
|
||
if (md && md.gameType === 'balloon_boss') {
|
||
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
|
||
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
|
||
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
|
||
? Math.max(0, Math.floor(p.balloonBossBalloons))
|
||
: bbDef;
|
||
out.balloonBossEliminated = !!p.balloonBossEliminated;
|
||
}
|
||
if (nx !== data.x || ny !== data.y) socket.emit('user-move', out);
|
||
socket.to(sid).emit('user-move', out);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('lobby-interact', (data, ack) => {
|
||
const reply = typeof ack === 'function' ? ack : () => {};
|
||
const tx = Math.floor(Number(data && data.x));
|
||
const ty = Math.floor(Number(data && data.y));
|
||
for (const [sid, space] of spaces) {
|
||
if (!space.peers.has(socket.id)) continue;
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' });
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) {
|
||
return reply({ ok: false, error: 'จุดไม่ถูกต้อง' });
|
||
}
|
||
if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) {
|
||
return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' });
|
||
}
|
||
const px = Math.floor(Number(p.x));
|
||
const py = Math.floor(Number(p.y));
|
||
const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]];
|
||
let near = false;
|
||
for (const [dx, dy] of neighbors) {
|
||
if (px + dx === tx && py + dy === ty) { near = true; break; }
|
||
}
|
||
if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' });
|
||
const nick = (p.nickname || '').trim() || 'ผู้เล่น';
|
||
io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty });
|
||
return reply({ ok: true });
|
||
}
|
||
reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' });
|
||
});
|
||
|
||
socket.on('chat', (text) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
const nick = space.peers.get(socket.id)?.nickname || '';
|
||
io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-state', ({ micOn }) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
if (p) p.voiceMicOn = micOn !== false;
|
||
if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id];
|
||
io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-activity', ({ level }) => {
|
||
const sid = socket.data.spaceId;
|
||
if (!sid || !spaces.get(sid)) return;
|
||
const until = Date.now() + 400;
|
||
io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until });
|
||
});
|
||
|
||
socket.on('voice-init', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
if (!space.voiceInits) space.voiceInits = {};
|
||
space.voiceInits[socket.id] = data;
|
||
io.to(sid).emit('voice-init', { from: socket.id, data });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-chunk', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
socket.to(sid).emit('voice-chunk', { from: socket.id, data });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('webrtc-signal', (data) => {
|
||
const { to, type, sdp, candidate } = data;
|
||
if (!to) return;
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid) && space.peers.has(to)) {
|
||
io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
loadMaps();
|
||
server.listen(PORT, () => console.log('Game', PORT)); |