813 lines
26 KiB
JavaScript
813 lines
26 KiB
JavaScript
const {
|
||
DEFAULT_SETTINGS,
|
||
BOT_NAMES,
|
||
AREA_CLUES
|
||
} = require("../utils/constants");
|
||
const { randomCode, clone } = require("../utils/format");
|
||
|
||
const ROOMS_KEY = "zmc_rooms";
|
||
const CURRENT_ROOM_KEY = "zmc_current_room";
|
||
const USER_ID = "player-me";
|
||
const ROOM_SCHEMA_VERSION = 2;
|
||
const ROOM_STATUSES = ["waiting", "preparing", "playing", "paused", "finished", "cancelled"];
|
||
const PLAYER_ROLES = ["", "seeker", "hider"];
|
||
const PLAYER_STATUSES = ["idle", "ready", "hiding", "seeking", "caught", "spectating", "offline", "quit"];
|
||
const DEFAULT_RUNTIME = {
|
||
demoAccelerated: true,
|
||
prepareSecondsPerMinute: 1,
|
||
maxGameSeconds: 180,
|
||
demoClueIntervalSeconds: 15
|
||
};
|
||
|
||
function isRecord(value) {
|
||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function asNumber(value, fallback) {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : fallback;
|
||
}
|
||
|
||
function assertIntegerInRange(value, min, max, message) {
|
||
if (!Number.isInteger(value) || value < min || value > max) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
function validateRoomPayload(payload) {
|
||
const source = isRecord(payload) ? payload : {};
|
||
const name = String(source.name || "").trim();
|
||
if (!name) throw new Error("请填写房间名称");
|
||
|
||
const settings = normalizeSettings(source);
|
||
assertIntegerInRange(settings.durationMinutes, 15, 90, "游戏时长应为 15~90 分钟");
|
||
assertIntegerInRange(settings.prepareMinutes, 1, 10, "准备时长应为 1~10 分钟");
|
||
assertIntegerInRange(settings.maxPlayers, 4, 30, "房间人数应为 4~30 人");
|
||
assertIntegerInRange(settings.seekerCount, 1, 29, "初始寻找者人数应为 1~29 人");
|
||
assertIntegerInRange(settings.clueIntervalMinutes, 3, 10, "线索间隔应为 3~10 分钟");
|
||
assertIntegerInRange(settings.radiusMeters, 200, 1500, "活动半径应为 200~1500 米");
|
||
if (settings.seekerCount >= settings.maxPlayers) {
|
||
throw new Error("初始寻找者必须少于房间人数上限");
|
||
}
|
||
|
||
return {
|
||
...settings,
|
||
name: name.slice(0, 16)
|
||
};
|
||
}
|
||
|
||
function normalizeSettings(settings) {
|
||
const source = isRecord(settings) ? settings : {};
|
||
const merged = { ...DEFAULT_SETTINGS, ...source };
|
||
return {
|
||
mode: merged.mode === "classic" ? "classic" : "infection",
|
||
durationMinutes: asNumber(merged.durationMinutes, DEFAULT_SETTINGS.durationMinutes),
|
||
prepareMinutes: asNumber(merged.prepareMinutes, DEFAULT_SETTINGS.prepareMinutes),
|
||
maxPlayers: asNumber(merged.maxPlayers, DEFAULT_SETTINGS.maxPlayers),
|
||
seekerCount: asNumber(merged.seekerCount, DEFAULT_SETTINGS.seekerCount),
|
||
clueIntervalMinutes: asNumber(merged.clueIntervalMinutes, DEFAULT_SETTINGS.clueIntervalMinutes),
|
||
radiusMeters: asNumber(merged.radiusMeters, DEFAULT_SETTINGS.radiusMeters),
|
||
skillsEnabled: merged.skillsEnabled !== false
|
||
};
|
||
}
|
||
|
||
function normalizeRuntime(runtime) {
|
||
const source = isRecord(runtime) ? runtime : {};
|
||
return {
|
||
demoAccelerated: source.demoAccelerated !== false,
|
||
prepareSecondsPerMinute: Math.max(1, asNumber(
|
||
source.prepareSecondsPerMinute,
|
||
DEFAULT_RUNTIME.prepareSecondsPerMinute
|
||
)),
|
||
maxGameSeconds: Math.max(1, asNumber(
|
||
source.maxGameSeconds,
|
||
DEFAULT_RUNTIME.maxGameSeconds
|
||
)),
|
||
demoClueIntervalSeconds: Math.max(1, asNumber(
|
||
source.demoClueIntervalSeconds,
|
||
DEFAULT_RUNTIME.demoClueIntervalSeconds
|
||
))
|
||
};
|
||
}
|
||
|
||
function getPrepareDurationSeconds(room) {
|
||
if (room.runtime.demoAccelerated) {
|
||
return room.settings.prepareMinutes * room.runtime.prepareSecondsPerMinute;
|
||
}
|
||
return room.settings.prepareMinutes * 60;
|
||
}
|
||
|
||
function getGameDurationSeconds(room) {
|
||
const configured = room.settings.durationMinutes * 60;
|
||
return room.runtime.demoAccelerated
|
||
? Math.min(configured, room.runtime.maxGameSeconds)
|
||
: configured;
|
||
}
|
||
|
||
function getClueIntervalSeconds(room) {
|
||
return room.runtime.demoAccelerated
|
||
? room.runtime.demoClueIntervalSeconds
|
||
: room.settings.clueIntervalMinutes * 60;
|
||
}
|
||
|
||
function normalizePlayer(player, index, roomCreatedAt) {
|
||
const source = isRecord(player) ? player : {};
|
||
const fallbackName = `玩家${index + 1}`;
|
||
const name = String(source.name || fallbackName).slice(0, 10);
|
||
const ready = Boolean(source.ready);
|
||
return {
|
||
...source,
|
||
id: String(source.id || `legacy-player-${index}`),
|
||
name,
|
||
avatar: String(source.avatar || name.slice(0, 1) || "?"),
|
||
isHost: Boolean(source.isHost),
|
||
isBot: Boolean(source.isBot),
|
||
ready,
|
||
role: PLAYER_ROLES.includes(source.role) ? source.role : "",
|
||
status: PLAYER_STATUSES.includes(source.status)
|
||
? source.status
|
||
: ready ? "ready" : "idle",
|
||
caughtAt: source.caughtAt || null,
|
||
caughtBy: source.caughtBy || null,
|
||
captures: Math.max(0, asNumber(source.captures, 0)),
|
||
initialRole: PLAYER_ROLES.includes(source.initialRole) ? source.initialRole : source.role || "",
|
||
skillsUsed: {
|
||
radar: Math.max(0, asNumber(source.skillsUsed && source.skillsUsed.radar, 0)),
|
||
silent: Math.max(0, asNumber(source.skillsUsed && source.skillsUsed.silent, 0))
|
||
},
|
||
silentPending: Boolean(source.silentPending),
|
||
joinedAt: asNumber(source.joinedAt, roomCreatedAt)
|
||
};
|
||
}
|
||
|
||
function normalizeEvent(event, index, roomCreatedAt) {
|
||
const source = isRecord(event) ? event : {};
|
||
return {
|
||
...source,
|
||
id: String(source.id || `legacy-event-${roomCreatedAt}-${index}`),
|
||
type: String(source.type || "system"),
|
||
message: String(source.message || "游戏状态已更新"),
|
||
createdAt: asNumber(source.createdAt, roomCreatedAt)
|
||
};
|
||
}
|
||
|
||
function normalizeRoom(room, codeHint = "") {
|
||
if (!isRecord(room)) return null;
|
||
const code = String(room.code || codeHint || "");
|
||
if (!/^\d{6}$/.test(code)) return null;
|
||
|
||
const createdAt = asNumber(room.createdAt, Date.now());
|
||
const players = Array.isArray(room.players)
|
||
? room.players.map((player, index) => normalizePlayer(player, index, createdAt))
|
||
: [];
|
||
const events = Array.isArray(room.events)
|
||
? room.events.slice(0, 20).map((event, index) => normalizeEvent(event, index, createdAt))
|
||
: [];
|
||
|
||
return {
|
||
...room,
|
||
schemaVersion: ROOM_SCHEMA_VERSION,
|
||
id: String(room.id || `room-${code}`),
|
||
code,
|
||
name: String(room.name || "未命名游戏").slice(0, 16),
|
||
hostId: String(room.hostId || (players[0] && players[0].id) || ""),
|
||
status: ROOM_STATUSES.includes(room.status) ? room.status : "waiting",
|
||
settings: normalizeSettings(room.settings),
|
||
runtime: normalizeRuntime(room.runtime),
|
||
players,
|
||
events,
|
||
captureTokensUsed: Array.isArray(room.captureTokensUsed)
|
||
? room.captureTokensUsed.slice(-50).map(String)
|
||
: [],
|
||
winner: room.winner === "hiders" || room.winner === "seekers" ? room.winner : "",
|
||
phaseStartedAt: room.phaseStartedAt || null,
|
||
phaseEndsAt: room.phaseEndsAt || null,
|
||
nextClueAt: room.nextClueAt || null,
|
||
finishedAt: room.finishedAt || null,
|
||
pausedAt: room.pausedAt || null,
|
||
pausedRemainingMs: Math.max(0, asNumber(room.pausedRemainingMs, 0)),
|
||
pausedClueRemainingMs: Math.max(0, asNumber(room.pausedClueRemainingMs, 0)),
|
||
finishReason: String(room.finishReason || ""),
|
||
resultSnapshot: isRecord(room.resultSnapshot) ? room.resultSnapshot : null,
|
||
createdAt,
|
||
updatedAt: asNumber(room.updatedAt, createdAt)
|
||
};
|
||
}
|
||
|
||
function readRooms() {
|
||
try {
|
||
const rooms = wx.getStorageSync(ROOMS_KEY);
|
||
return isRecord(rooms) ? rooms : {};
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeRooms(rooms) {
|
||
wx.setStorageSync(ROOMS_KEY, rooms);
|
||
}
|
||
|
||
function saveRoom(room) {
|
||
const rooms = readRooms();
|
||
const normalized = normalizeRoom(room);
|
||
if (!normalized) throw new Error("房间数据无效");
|
||
normalized.updatedAt = Date.now();
|
||
rooms[normalized.code] = normalized;
|
||
writeRooms(rooms);
|
||
return clone(normalized);
|
||
}
|
||
|
||
function makePlayer(id, name, isHost = false, isBot = false) {
|
||
return {
|
||
id,
|
||
name,
|
||
avatar: name.slice(0, 1),
|
||
isHost,
|
||
isBot,
|
||
ready: isHost,
|
||
role: "",
|
||
initialRole: "",
|
||
status: "idle",
|
||
caughtAt: null,
|
||
caughtBy: null,
|
||
captures: 0,
|
||
skillsUsed: { radar: 0, silent: 0 },
|
||
silentPending: false,
|
||
joinedAt: Date.now()
|
||
};
|
||
}
|
||
|
||
function makeDemoRoom() {
|
||
const room = {
|
||
schemaVersion: ROOM_SCHEMA_VERSION,
|
||
id: "room-demo",
|
||
code: "731204",
|
||
name: "周末公园追逃局",
|
||
hostId: "demo-host",
|
||
status: "waiting",
|
||
settings: {
|
||
...DEFAULT_SETTINGS,
|
||
durationMinutes: 20,
|
||
prepareMinutes: 1
|
||
},
|
||
runtime: { ...DEFAULT_RUNTIME },
|
||
players: [
|
||
makePlayer("demo-host", "阿树", true, true),
|
||
makePlayer("demo-2", "小满", false, true),
|
||
makePlayer("demo-3", "木木", false, true)
|
||
],
|
||
events: [],
|
||
createdAt: Date.now()
|
||
};
|
||
room.players.forEach((player) => {
|
||
player.ready = true;
|
||
});
|
||
return room;
|
||
}
|
||
|
||
function ensureSeedData() {
|
||
const rooms = readRooms();
|
||
let changed = false;
|
||
Object.keys(rooms).forEach((code) => {
|
||
const normalized = normalizeRoom(rooms[code], code);
|
||
if (normalized) {
|
||
rooms[code] = normalized;
|
||
} else {
|
||
delete rooms[code];
|
||
}
|
||
changed = true;
|
||
});
|
||
if (!rooms["731204"]) {
|
||
rooms["731204"] = makeDemoRoom();
|
||
changed = true;
|
||
}
|
||
if (changed) {
|
||
writeRooms(rooms);
|
||
}
|
||
}
|
||
|
||
function createRoom(payload) {
|
||
const validated = validateRoomPayload(payload);
|
||
const rooms = readRooms();
|
||
let code = randomCode();
|
||
while (rooms[code]) code = randomCode();
|
||
const room = {
|
||
schemaVersion: ROOM_SCHEMA_VERSION,
|
||
id: `room-${Date.now()}`,
|
||
code,
|
||
name: validated.name,
|
||
hostId: USER_ID,
|
||
status: "waiting",
|
||
settings: {
|
||
...DEFAULT_SETTINGS,
|
||
...validated
|
||
},
|
||
runtime: { ...DEFAULT_RUNTIME },
|
||
players: [makePlayer(USER_ID, "我", true, false)],
|
||
events: [
|
||
{
|
||
id: `event-${Date.now()}`,
|
||
type: "system",
|
||
message: "房间已创建,邀请好友加入吧",
|
||
createdAt: Date.now()
|
||
}
|
||
],
|
||
createdAt: Date.now()
|
||
};
|
||
|
||
saveRoom(room);
|
||
wx.setStorageSync(CURRENT_ROOM_KEY, code);
|
||
return clone(room);
|
||
}
|
||
|
||
function getRoom(code) {
|
||
const room = normalizeRoom(readRooms()[code], code);
|
||
return room ? clone(room) : null;
|
||
}
|
||
|
||
function getCurrentRoom() {
|
||
let code = "";
|
||
try {
|
||
code = wx.getStorageSync(CURRENT_ROOM_KEY);
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
return code ? getRoom(code) : null;
|
||
}
|
||
|
||
function setCurrentRoom(code) {
|
||
wx.setStorageSync(CURRENT_ROOM_KEY, code);
|
||
}
|
||
|
||
function joinRoom(code, displayName = "我") {
|
||
const normalizedCode = String(code || "").trim();
|
||
if (!/^\d{6}$/.test(normalizedCode)) {
|
||
throw new Error("请输入正确的六位房间码");
|
||
}
|
||
const normalizedName = String(displayName || "").trim().slice(0, 10);
|
||
if (!normalizedName) throw new Error("请输入游戏昵称");
|
||
|
||
const rooms = readRooms();
|
||
const room = normalizeRoom(rooms[normalizedCode], normalizedCode);
|
||
if (!room) {
|
||
throw new Error("没有找到这个房间,请检查房间码");
|
||
}
|
||
if (room.status === "finished") throw new Error("这场游戏已经结束");
|
||
if (room.status === "cancelled") throw new Error("房间已被房主取消");
|
||
if (room.status !== "waiting") throw new Error("游戏已经开始,暂时无法加入");
|
||
|
||
const existing = room.players.find((player) => player.id === USER_ID);
|
||
if (!existing) {
|
||
if (room.players.length >= room.settings.maxPlayers) {
|
||
throw new Error("房间人数已满");
|
||
}
|
||
const player = makePlayer(USER_ID, normalizedName, false, false);
|
||
if (room.code === "731204") {
|
||
player.ready = true;
|
||
player.status = "ready";
|
||
}
|
||
room.players.push(player);
|
||
} else {
|
||
existing.name = normalizedName;
|
||
existing.avatar = normalizedName.slice(0, 1);
|
||
}
|
||
saveRoom(room);
|
||
setCurrentRoom(normalizedCode);
|
||
return clone(room);
|
||
}
|
||
|
||
function addDemoPlayers(code, targetCount = 6) {
|
||
const room = getRoom(code);
|
||
if (!room || room.status !== "waiting") return room;
|
||
|
||
const usedNames = room.players.map((player) => player.name);
|
||
const available = BOT_NAMES.filter((name) => !usedNames.includes(name));
|
||
while (
|
||
room.players.length < targetCount &&
|
||
room.players.length < room.settings.maxPlayers &&
|
||
available.length
|
||
) {
|
||
const name = available.shift();
|
||
const player = makePlayer(`bot-${Date.now()}-${room.players.length}`, name, false, true);
|
||
player.ready = true;
|
||
room.players.push(player);
|
||
}
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function toggleReady(code, playerId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "waiting") throw new Error("游戏已开始,不能修改准备状态");
|
||
const player = room && room.players.find((item) => item.id === playerId);
|
||
if (!player) throw new Error("你不在当前房间中");
|
||
player.ready = !player.ready;
|
||
player.status = player.ready ? "ready" : "idle";
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function startGame(code, operatorId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "waiting") throw new Error("当前房间不能重复开始");
|
||
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
|
||
if (room.hostId !== operatorId && !canControlDemo) {
|
||
throw new Error("只有房主可以开始游戏");
|
||
}
|
||
if (room.players.length < 4) throw new Error("至少需要 4 名玩家");
|
||
if (room.settings.seekerCount >= room.players.length) {
|
||
throw new Error("寻找者人数必须少于当前玩家人数");
|
||
}
|
||
if (room.players.some((player) => !player.ready)) {
|
||
throw new Error("还有玩家尚未准备好");
|
||
}
|
||
|
||
const seekerCount = room.settings.seekerCount;
|
||
const sorted = [...room.players].sort((a, b) => {
|
||
if (a.id === USER_ID) return -1;
|
||
if (b.id === USER_ID) return 1;
|
||
return a.joinedAt - b.joinedAt;
|
||
});
|
||
|
||
sorted.forEach((player, index) => {
|
||
player.role = index < seekerCount ? "seeker" : "hider";
|
||
player.initialRole = player.role;
|
||
player.status = player.role === "seeker" ? "seeking" : "hiding";
|
||
player.ready = true;
|
||
player.skillsUsed = { radar: 0, silent: 0 };
|
||
player.silentPending = false;
|
||
});
|
||
|
||
room.players = sorted;
|
||
room.status = "preparing";
|
||
room.phaseStartedAt = Date.now();
|
||
room.phaseEndsAt = room.phaseStartedAt + getPrepareDurationSeconds(room) * 1000;
|
||
room.gameDurationSeconds = getGameDurationSeconds(room);
|
||
room.captureTokensUsed = [];
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}`,
|
||
type: "system",
|
||
message: "身份已分配,躲藏者请立即出发",
|
||
createdAt: Date.now()
|
||
});
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function beginPlaying(code) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "preparing") throw new Error("当前房间不能进入追逃阶段");
|
||
const scheduledStartAt = room.phaseEndsAt || Date.now();
|
||
room.status = "playing";
|
||
room.phaseStartedAt = scheduledStartAt;
|
||
room.gameDurationSeconds = room.gameDurationSeconds || getGameDurationSeconds(room);
|
||
room.phaseEndsAt = scheduledStartAt + room.gameDurationSeconds * 1000;
|
||
room.nextClueAt = scheduledStartAt + getClueIntervalSeconds(room) * 1000;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}`,
|
||
type: "system",
|
||
message: "追逃开始!请留意安全边界",
|
||
createdAt: Date.now()
|
||
});
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function addEvent(code, message, type = "system") {
|
||
const room = getRoom(code);
|
||
if (!room) return null;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}-${Math.random()}`,
|
||
type,
|
||
message,
|
||
createdAt: Date.now()
|
||
});
|
||
room.events = room.events.slice(0, 20);
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function revealClue(code) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "playing") throw new Error("当前阶段不能广播线索");
|
||
const silentPlayers = room.players.filter(
|
||
(player) => player.role === "hider" && player.status === "hiding" && player.silentPending
|
||
);
|
||
silentPlayers.forEach((player) => {
|
||
player.silentPending = false;
|
||
});
|
||
const area = AREA_CLUES[Math.floor(Math.random() * AREA_CLUES.length)];
|
||
const message = silentPlayers.length
|
||
? `${silentPlayers.length} 名目标的静默已生效;${area}`
|
||
: area;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}-${Math.random()}`,
|
||
type: "clue",
|
||
message,
|
||
createdAt: Date.now()
|
||
});
|
||
room.events = room.events.slice(0, 20);
|
||
room.nextClueAt = Date.now() + getClueIntervalSeconds(room) * 1000;
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function useSkill(code, skill, playerId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "playing") throw new Error("当前阶段不能使用技能");
|
||
if (!room.settings.skillsEnabled) throw new Error("本局未启用角色技能");
|
||
const player = room.players.find((item) => item.id === playerId);
|
||
if (!player) throw new Error("你不在当前房间中");
|
||
const expectedSkill = player.role === "seeker" ? "radar" : "silent";
|
||
if (skill !== expectedSkill) throw new Error("当前身份不能使用这个技能");
|
||
if (player.status !== (player.role === "seeker" ? "seeking" : "hiding")) {
|
||
throw new Error("当前玩家状态不能使用技能");
|
||
}
|
||
if (player.skillsUsed[skill] >= 1) throw new Error("本局技能已经使用过");
|
||
|
||
player.skillsUsed[skill] += 1;
|
||
if (skill === "radar") {
|
||
const nearby = Math.random() > 0.45;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}-${Math.random()}`,
|
||
type: "skill",
|
||
message: nearby ? "雷达回应:附近 150 米内可能有人" : "雷达回应:附近暂未检测到目标",
|
||
createdAt: Date.now()
|
||
});
|
||
} else {
|
||
player.silentPending = true;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}-${Math.random()}`,
|
||
type: "skill",
|
||
message: "有目标已开启静默信号",
|
||
createdAt: Date.now()
|
||
});
|
||
}
|
||
room.events = room.events.slice(0, 20);
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function captureTokenKey(targetId, timeWindow) {
|
||
return `${targetId}:${timeWindow}`;
|
||
}
|
||
|
||
function hashCaptureToken(value) {
|
||
let hash = 2166136261;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
hash ^= value.charCodeAt(index);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return String(Math.abs(hash) % 10000).padStart(4, "0");
|
||
}
|
||
|
||
function getCaptureCode(code, targetId = USER_ID, now = Date.now()) {
|
||
const room = getRoom(code);
|
||
if (!room) return "";
|
||
const target = room.players.find(
|
||
(player) => player.id === targetId && player.role === "hider" && player.status === "hiding"
|
||
);
|
||
if (!target) return "";
|
||
const timeWindow = Math.floor(now / 30000);
|
||
return hashCaptureToken(`${room.code}:${target.id}:${timeWindow}`);
|
||
}
|
||
|
||
function capture(code, targetId, submittedCode, seekerId = USER_ID, now = Date.now()) {
|
||
const room = getRoom(code);
|
||
if (!room || room.status !== "playing") {
|
||
throw new Error("当前不能提交抓捕");
|
||
}
|
||
const seeker = room.players.find((player) => player.id === seekerId);
|
||
if (!seeker || seeker.role !== "seeker" || seeker.status !== "seeking") {
|
||
throw new Error("只有正在游戏的寻找者可以提交抓捕");
|
||
}
|
||
const target = room.players.find((player) => player.id === targetId);
|
||
if (!target) throw new Error("没有找到抓捕目标");
|
||
|
||
const timeWindow = Math.floor(now / 30000);
|
||
const tokenKey = captureTokenKey(target.id, timeWindow);
|
||
if (room.captureTokensUsed.includes(tokenKey)) throw new Error("口令已经使用过");
|
||
if (target.role !== "hider" || target.status !== "hiding") {
|
||
throw new Error("目标已不在躲藏状态");
|
||
}
|
||
if (String(submittedCode) !== getCaptureCode(code, target.id, now)) {
|
||
throw new Error("口令不正确或已经失效");
|
||
}
|
||
|
||
room.captureTokensUsed.push(tokenKey);
|
||
room.captureTokensUsed = room.captureTokensUsed.slice(-50);
|
||
target.caughtAt = now;
|
||
target.caughtBy = seeker.id;
|
||
seeker.captures += 1;
|
||
if (room.settings.mode === "infection") {
|
||
target.role = "seeker";
|
||
target.status = "seeking";
|
||
} else {
|
||
target.status = "caught";
|
||
}
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}`,
|
||
type: "capture",
|
||
message: `${seeker.name} 抓到了 ${target.name}`,
|
||
createdAt: Date.now()
|
||
});
|
||
|
||
const survivors = room.players.filter(
|
||
(player) => player.role === "hider" && player.status === "hiding"
|
||
);
|
||
if (!survivors.length) {
|
||
finishGameObject(room, "seekers");
|
||
}
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function makeResultSnapshot(room, winner, finishedAt, reason) {
|
||
const gameStartedAt = room.phaseStartedAt || room.createdAt;
|
||
const elapsedSeconds = Math.max(0, Math.floor((finishedAt - gameStartedAt) / 1000));
|
||
const players = room.players.map((player) => {
|
||
const survivedUntil = player.caughtAt || finishedAt;
|
||
return {
|
||
id: player.id,
|
||
name: player.name,
|
||
avatar: player.avatar,
|
||
initialRole: player.initialRole || player.role,
|
||
finalRole: player.role,
|
||
finalStatus: player.status,
|
||
captures: player.captures,
|
||
caughtAt: player.caughtAt,
|
||
survivedSeconds: player.initialRole === "hider"
|
||
? Math.max(0, Math.floor((survivedUntil - gameStartedAt) / 1000))
|
||
: 0,
|
||
joinedAt: player.joinedAt
|
||
};
|
||
});
|
||
players.sort((a, b) => {
|
||
if (b.captures !== a.captures) return b.captures - a.captures;
|
||
if (b.survivedSeconds !== a.survivedSeconds) return b.survivedSeconds - a.survivedSeconds;
|
||
if (a.joinedAt !== b.joinedAt) return a.joinedAt - b.joinedAt;
|
||
return a.id.localeCompare(b.id);
|
||
});
|
||
return {
|
||
winner,
|
||
reason,
|
||
finishedAt,
|
||
elapsedSeconds,
|
||
eventCount: room.events.length,
|
||
players
|
||
};
|
||
}
|
||
|
||
function finishGameObject(room, winner, reason = "completed") {
|
||
const finishedAt = Date.now();
|
||
room.status = "finished";
|
||
room.finishedAt = finishedAt;
|
||
room.winner = winner;
|
||
room.finishReason = reason;
|
||
room.phaseEndsAt = finishedAt;
|
||
room.resultSnapshot = makeResultSnapshot(room, winner, finishedAt, reason);
|
||
}
|
||
|
||
function finishGame(code, winner, operatorId = USER_ID, reason = "manual") {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (!["preparing", "playing", "paused"].includes(room.status)) {
|
||
throw new Error("当前房间不能结束游戏");
|
||
}
|
||
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
|
||
if (operatorId && room.hostId !== operatorId && !canControlDemo) {
|
||
throw new Error("只有房主可以提前结束游戏");
|
||
}
|
||
const survivors = room.players.filter(
|
||
(player) => player.role === "hider" && player.status === "hiding"
|
||
);
|
||
finishGameObject(room, winner || (survivors.length ? "hiders" : "seekers"), reason);
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function pauseGame(code, operatorId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "playing") throw new Error("只有追逃阶段可以暂停");
|
||
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
|
||
if (room.hostId !== operatorId && !canControlDemo) throw new Error("只有房主可以暂停游戏");
|
||
const now = Date.now();
|
||
room.status = "paused";
|
||
room.pausedAt = now;
|
||
room.pausedRemainingMs = Math.max(0, room.phaseEndsAt - now);
|
||
room.pausedClueRemainingMs = room.nextClueAt
|
||
? Math.max(0, room.nextClueAt - now)
|
||
: 0;
|
||
room.events.unshift({
|
||
id: `event-${now}`,
|
||
type: "system",
|
||
message: "房主暂停了游戏,请所有玩家留在安全位置",
|
||
createdAt: now
|
||
});
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function resumeGame(code, operatorId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (room.status !== "paused") throw new Error("当前游戏没有暂停");
|
||
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
|
||
if (room.hostId !== operatorId && !canControlDemo) throw new Error("只有房主可以继续游戏");
|
||
const now = Date.now();
|
||
room.status = "playing";
|
||
room.phaseEndsAt = now + room.pausedRemainingMs;
|
||
room.nextClueAt = room.pausedClueRemainingMs
|
||
? now + room.pausedClueRemainingMs
|
||
: now + getClueIntervalSeconds(room) * 1000;
|
||
room.pausedAt = null;
|
||
room.pausedRemainingMs = 0;
|
||
room.pausedClueRemainingMs = 0;
|
||
room.events.unshift({
|
||
id: `event-${now}`,
|
||
type: "system",
|
||
message: "房主继续了游戏",
|
||
createdAt: now
|
||
});
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function quitGame(code, playerId = USER_ID) {
|
||
const room = getRoom(code);
|
||
if (!room) throw new Error("房间不存在");
|
||
if (!["preparing", "playing", "paused"].includes(room.status)) {
|
||
throw new Error("当前不能退出游戏");
|
||
}
|
||
const player = room.players.find((item) => item.id === playerId);
|
||
if (!player) throw new Error("你不在当前房间中");
|
||
if (player.status === "quit") throw new Error("玩家已经退出游戏");
|
||
player.status = "quit";
|
||
player.ready = false;
|
||
room.events.unshift({
|
||
id: `event-${Date.now()}`,
|
||
type: "safety",
|
||
message: `${player.name} 已安全退出并前往集合点`,
|
||
createdAt: Date.now()
|
||
});
|
||
const survivors = room.players.filter(
|
||
(item) => item.role === "hider" && item.status === "hiding"
|
||
);
|
||
if (!survivors.length) finishGameObject(room, "seekers", "all_hiders_left");
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function resetRoom(code) {
|
||
const room = getRoom(code);
|
||
if (!room) return null;
|
||
room.status = "waiting";
|
||
room.winner = "";
|
||
room.finishReason = "";
|
||
room.phaseEndsAt = null;
|
||
room.phaseStartedAt = null;
|
||
room.nextClueAt = null;
|
||
room.gameDurationSeconds = null;
|
||
room.finishedAt = null;
|
||
room.pausedAt = null;
|
||
room.pausedRemainingMs = 0;
|
||
room.pausedClueRemainingMs = 0;
|
||
room.resultSnapshot = null;
|
||
room.events = [];
|
||
room.players.forEach((player) => {
|
||
player.role = "";
|
||
player.initialRole = "";
|
||
player.status = "ready";
|
||
player.ready = true;
|
||
player.caughtAt = null;
|
||
player.caughtBy = null;
|
||
player.captures = 0;
|
||
player.skillsUsed = { radar: 0, silent: 0 };
|
||
player.silentPending = false;
|
||
});
|
||
room.captureTokensUsed = [];
|
||
return saveRoom(room);
|
||
}
|
||
|
||
function clearCurrentRoom() {
|
||
wx.removeStorageSync(CURRENT_ROOM_KEY);
|
||
}
|
||
|
||
module.exports = {
|
||
USER_ID,
|
||
ensureSeedData,
|
||
createRoom,
|
||
getRoom,
|
||
getCurrentRoom,
|
||
setCurrentRoom,
|
||
joinRoom,
|
||
addDemoPlayers,
|
||
toggleReady,
|
||
startGame,
|
||
beginPlaying,
|
||
revealClue,
|
||
useSkill,
|
||
getCaptureCode,
|
||
capture,
|
||
finishGame,
|
||
pauseGame,
|
||
resumeGame,
|
||
quitGame,
|
||
resetRoom,
|
||
clearCurrentRoom
|
||
};
|