新建项目

This commit is contained in:
2026-07-29 10:50:33 +08:00
parent bfd3841cbd
commit 4f69b859a8
49 changed files with 5565 additions and 0 deletions

116
pages/create/create.js Normal file
View File

@@ -0,0 +1,116 @@
const service = require("../../services/game-service");
const { GAME_MODES, DEFAULT_SETTINGS } = require("../../utils/constants");
Page({
data: {
name: "周末藏猫猫",
mode: DEFAULT_SETTINGS.mode,
modes: Object.values(GAME_MODES),
durationOptions: [15, 20, 30, 45, 60],
durationIndex: 2,
prepareOptions: [1, 3, 5, 8],
prepareIndex: 1,
maxPlayerOptions: [4, 6, 8, 10, 12, 20],
maxPlayerIndex: 2,
seekerOptions: [1, 2, 3, 4],
seekerIndex: 1,
clueOptions: [3, 5, 8, 10],
clueIndex: 1,
radiusOptions: [200, 300, 500, 800, 1000],
radiusIndex: 2,
skillsEnabled: true,
safetyAccepted: false,
submitting: false
},
updateName(event) {
this.setData({ name: event.detail.value });
},
chooseMode(event) {
this.setData({ mode: event.currentTarget.dataset.mode });
},
onDurationChange(event) {
this.setData({ durationIndex: Number(event.detail.value) });
},
onPrepareChange(event) {
this.setData({ prepareIndex: Number(event.detail.value) });
},
onMaxPlayerChange(event) {
const maxPlayerIndex = Number(event.detail.value);
const maxPlayers = this.data.maxPlayerOptions[maxPlayerIndex];
let seekerIndex = this.data.seekerIndex;
while (
seekerIndex > 0 &&
this.data.seekerOptions[seekerIndex] >= maxPlayers
) {
seekerIndex -= 1;
}
this.setData({ maxPlayerIndex, seekerIndex });
},
onSeekerChange(event) {
const seekerIndex = Number(event.detail.value);
const seekers = this.data.seekerOptions[seekerIndex];
const maxPlayers = this.data.maxPlayerOptions[this.data.maxPlayerIndex];
if (seekers >= maxPlayers) {
wx.showToast({ title: "寻找者必须少于人数上限", icon: "none" });
return;
}
this.setData({ seekerIndex });
},
onClueChange(event) {
this.setData({ clueIndex: Number(event.detail.value) });
},
onRadiusChange(event) {
this.setData({ radiusIndex: Number(event.detail.value) });
},
onSkillsChange(event) {
this.setData({ skillsEnabled: event.detail.value });
},
onSafetyChange(event) {
this.setData({ safetyAccepted: event.detail.value.length > 0 });
},
openRules() {
wx.navigateTo({ url: "/pages/rules/rules" });
},
submit() {
if (!this.data.name.trim()) {
wx.showToast({ title: "请填写房间名称", icon: "none" });
return;
}
if (!this.data.safetyAccepted) {
wx.showToast({ title: "请先确认安全规则", icon: "none" });
return;
}
this.setData({ submitting: true });
const data = this.data;
try {
const room = service.createRoom({
name: data.name.trim(),
mode: data.mode,
durationMinutes: data.durationOptions[data.durationIndex],
prepareMinutes: data.prepareOptions[data.prepareIndex],
maxPlayers: data.maxPlayerOptions[data.maxPlayerIndex],
seekerCount: data.seekerOptions[data.seekerIndex],
clueIntervalMinutes: data.clueOptions[data.clueIndex],
radiusMeters: data.radiusOptions[data.radiusIndex],
skillsEnabled: data.skillsEnabled
});
wx.redirectTo({ url: `/pages/lobby/lobby?code=${room.code}` });
} catch (error) {
this.setData({ submitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
}
});

3
pages/create/create.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "创建游戏"
}

148
pages/create/create.wxml Normal file
View File

@@ -0,0 +1,148 @@
<view class="page">
<view class="eyebrow">CREATE A GAME</view>
<view class="title">设置这场追逃</view>
<view class="subtitle">先用推荐规则开局,之后可以在候场页继续调整。</view>
<view class="section-label">房间名称</view>
<view class="input-card">
<input
maxlength="16"
value="{{name}}"
placeholder="给这场游戏起个名字"
bindinput="updateName"
/>
<text>{{name.length}}/16</text>
</view>
<view class="section-label">游戏模式</view>
<view
wx:for="{{modes}}"
wx:key="id"
class="mode-card {{mode === item.id ? 'selected' : ''}}"
data-mode="{{item.id}}"
bindtap="chooseMode"
>
<view class="mode-icon">{{item.icon}}</view>
<view class="mode-main">
<view class="mode-name">{{item.name}}</view>
<view class="mode-description">{{item.description}}</view>
</view>
<view class="radio">{{mode === item.id ? '✓' : ''}}</view>
</view>
<view class="section-label">基础规则</view>
<view class="settings-card">
<picker
mode="selector"
range="{{durationOptions}}"
value="{{durationIndex}}"
bindchange="onDurationChange"
>
<view class="setting-row">
<view>
<view class="setting-name">游戏时长</view>
<view class="setting-hint">正式追逃阶段</view>
</view>
<view class="setting-value">{{durationOptions[durationIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{prepareOptions}}"
value="{{prepareIndex}}"
bindchange="onPrepareChange"
>
<view class="setting-row">
<view>
<view class="setting-name">躲藏准备</view>
<view class="setting-hint">寻找者原地等待</view>
</view>
<view class="setting-value">{{prepareOptions[prepareIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{maxPlayerOptions}}"
value="{{maxPlayerIndex}}"
bindchange="onMaxPlayerChange"
>
<view class="setting-row">
<view>
<view class="setting-name">人数上限</view>
<view class="setting-hint">至少 4 人开局</view>
</view>
<view class="setting-value">{{maxPlayerOptions[maxPlayerIndex]}} 人 </view>
</view>
</picker>
<picker
mode="selector"
range="{{seekerOptions}}"
value="{{seekerIndex}}"
bindchange="onSeekerChange"
>
<view class="setting-row">
<view>
<view class="setting-name">初始寻找者</view>
<view class="setting-hint">建议约占总人数 1/4</view>
</view>
<view class="setting-value">{{seekerOptions[seekerIndex]}} 人 </view>
</view>
</picker>
<picker
mode="selector"
range="{{clueOptions}}"
value="{{clueIndex}}"
bindchange="onClueChange"
>
<view class="setting-row">
<view>
<view class="setting-name">位置线索</view>
<view class="setting-hint">只广播模糊区域</view>
</view>
<view class="setting-value">每 {{clueOptions[clueIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{radiusOptions}}"
value="{{radiusIndex}}"
bindchange="onRadiusChange"
>
<view class="setting-row">
<view>
<view class="setting-name">活动半径</view>
<view class="setting-hint">原型暂以文字展示</view>
</view>
<view class="setting-value">{{radiusOptions[radiusIndex]}} 米 </view>
</view>
</picker>
<view class="setting-row no-border">
<view>
<view class="setting-name">启用角色技能</view>
<view class="setting-hint">雷达、静默等轻量能力</view>
</view>
<switch
color="#1d6b4f"
checked="{{skillsEnabled}}"
bindchange="onSkillsChange"
/>
</view>
</view>
<checkbox-group class="safety-check" bindchange="onSafetyChange">
<label>
<checkbox value="accepted" color="#1d6b4f" />
<text>我已确认场地安全,并同意</text>
<text class="link" catchtap="openRules">《户外游戏安全规则》</text>
</label>
</checkbox-group>
<button
class="primary-button"
loading="{{submitting}}"
disabled="{{submitting}}"
bindtap="submit"
>
创建房间
</button>
</view>

129
pages/create/create.wxss Normal file
View File

@@ -0,0 +1,129 @@
.input-card {
display: flex;
align-items: center;
padding: 10rpx 26rpx;
background: #fffdf7;
border: 2rpx solid rgba(23, 35, 29, 0.1);
border-radius: 24rpx;
}
.input-card input {
flex: 1;
height: 86rpx;
font-size: 30rpx;
}
.input-card text {
color: #8b938e;
font-size: 22rpx;
}
.mode-card {
display: flex;
align-items: center;
gap: 22rpx;
margin-bottom: 18rpx;
padding: 26rpx;
background: #fffdf7;
border: 3rpx solid transparent;
border-radius: 26rpx;
}
.mode-card.selected {
border-color: #1d6b4f;
background: #f2f8ef;
}
.mode-icon {
display: flex;
align-items: center;
justify-content: center;
width: 74rpx;
height: 74rpx;
background: #d9ef72;
border-radius: 22rpx;
font-size: 36rpx;
font-weight: 900;
}
.mode-main {
flex: 1;
}
.mode-name {
font-size: 29rpx;
font-weight: 800;
}
.mode-description {
margin-top: 8rpx;
color: #68726c;
font-size: 23rpx;
line-height: 1.5;
}
.radio {
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
border: 2rpx solid #9ba39e;
border-radius: 50%;
color: #fff;
}
.selected .radio {
background: #1d6b4f;
border-color: #1d6b4f;
}
.settings-card {
padding: 0 26rpx;
background: #fffdf7;
border-radius: 28rpx;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 120rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.setting-row.no-border {
border-bottom: none;
}
.setting-name {
font-size: 27rpx;
font-weight: 750;
}
.setting-hint {
margin-top: 7rpx;
color: #8b938e;
font-size: 22rpx;
}
.setting-value {
color: #1d6b4f;
font-size: 26rpx;
font-weight: 700;
}
.safety-check {
margin: 32rpx 4rpx;
color: #68726c;
font-size: 24rpx;
line-height: 1.7;
}
.safety-check checkbox {
transform: scale(0.8);
}
.link {
color: #1d6b4f;
}

278
pages/game/game.js Normal file
View File

@@ -0,0 +1,278 @@
const service = require("../../services/game-service");
const { formatCountdown, formatTime } = require("../../utils/format");
Page({
data: {
code: "",
room: null,
me: null,
readyToRender: false,
stageLabel: "",
countdown: "00:00",
survivors: 0,
seekers: 0,
captureCode: "",
captureTargets: [],
selectedTargetId: "",
submittedCode: "",
skillUsed: false,
canManage: false,
showDemoCode: false,
captureSubmitting: false,
errorMessage: "",
eventViews: []
},
onLoad(options) {
this.setData({ code: options.code || "" });
this.tick();
},
onShow() {
if (!this.data.code) return;
this.startTimer();
this.tick();
},
onHide() {
this.stopTimer();
},
onUnload() {
this.stopTimer();
},
startTimer() {
this.stopTimer();
this.timer = setInterval(() => this.tick(), 1000);
},
stopTimer() {
if (!this.timer) return;
clearInterval(this.timer);
this.timer = null;
},
tick() {
let room = service.getRoom(this.data.code);
if (!room) {
this.stopTimer();
this.setData({ readyToRender: false, errorMessage: "房间不存在或游戏数据已被清理" });
return;
}
if (room.status === "preparing" && Date.now() >= room.phaseEndsAt) {
room = service.beginPlaying(room.code);
wx.vibrateLong();
}
if (room.status === "playing") {
if (Date.now() >= room.phaseEndsAt) {
service.finishGame(room.code, "hiders", null, "timeout");
this.goResult();
return;
}
if (room.nextClueAt && Date.now() >= room.nextClueAt) {
service.revealClue(room.code);
wx.vibrateShort({ type: "medium" });
}
}
if (room.status === "finished") {
this.goResult();
return;
}
this.refresh();
},
refresh() {
const room = service.getRoom(this.data.code);
if (!room) {
this.stopTimer();
this.setData({ readyToRender: false, errorMessage: "房间不存在或游戏数据已被清理" });
return;
}
const me =
room.players.find((player) => player.id === service.USER_ID) ||
room.players[0];
const seconds = room.status === "paused"
? Math.ceil(room.pausedRemainingMs / 1000)
: Math.ceil((room.phaseEndsAt - Date.now()) / 1000);
const survivors = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
).length;
const seekers = room.players.filter(
(player) => player.role === "seeker"
).length;
const captureTargets = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
);
const selectedTargetId = captureTargets.some(
(player) => player.id === this.data.selectedTargetId
)
? this.data.selectedTargetId
: captureTargets.length ? captureTargets[0].id : "";
const currentSkill = me.role === "seeker" ? "radar" : "silent";
const captureCode = me.role === "seeker"
? service.getCaptureCode(room.code, selectedTargetId)
: service.getCaptureCode(room.code, me.id);
this.setData({
room,
errorMessage: "",
me,
readyToRender: true,
stageLabel: room.status === "preparing"
? "躲藏准备"
: room.status === "paused" ? "游戏已暂停" : "追逃进行中",
countdown: formatCountdown(seconds),
survivors,
seekers,
captureTargets,
selectedTargetId,
captureCode,
skillUsed: Boolean(me.skillsUsed && me.skillsUsed[currentSkill] > 0),
canManage: room.hostId === service.USER_ID || room.code === "731204",
showDemoCode: Boolean(room.runtime && room.runtime.demoAccelerated),
eventViews: room.events.slice(0, 6).map((event) => ({
...event,
time: formatTime(event.createdAt)
}))
});
},
useSkill() {
if (this.data.skillUsed) {
wx.showToast({ title: "本局体验技能已使用", icon: "none" });
return;
}
const skill = this.data.me.role === "seeker" ? "radar" : "silent";
try {
service.useSkill(this.data.code, skill);
wx.vibrateShort({ type: "heavy" });
this.refresh();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
chooseCaptureTarget(event) {
this.setData({
selectedTargetId: event.currentTarget.dataset.id,
submittedCode: ""
});
this.refresh();
},
inputCaptureCode(event) {
this.setData({
submittedCode: event.detail.value.replace(/\D/g, "").slice(0, 4)
});
},
fillDemoCode() {
this.setData({ submittedCode: this.data.captureCode });
},
submitCapture() {
if (this.data.captureSubmitting) return;
this.setData({ captureSubmitting: true });
try {
const room = service.capture(
this.data.code,
this.data.selectedTargetId,
this.data.submittedCode
);
wx.vibrateLong();
this.setData({ submittedCode: "" });
if (room.status === "finished") {
this.goResult();
} else {
this.setData({ captureSubmitting: false });
wx.showToast({ title: "抓捕成功", icon: "success" });
this.refresh();
}
} catch (error) {
this.setData({ captureSubmitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
showSafety() {
wx.showActionSheet({
itemList: ["我已安全,继续游戏", "联系房主", "退出并前往集合点"],
success: (result) => {
if (result.tapIndex === 1) {
wx.showModal({
title: "联系房主",
content: "当前原型暂不提供即时通讯,请通过约定的电话或微信联系房主。",
showCancel: false
});
}
if (result.tapIndex === 2) {
wx.showModal({
title: "确认退出?",
content: "请先确保自己处于安全位置,并前往约定集合点。",
confirmText: "安全退出",
confirmColor: "#d9554d",
success: (modalResult) => {
if (!modalResult.confirm) return;
try {
const room = service.quitGame(this.data.code);
if (room.status === "finished") {
this.goResult();
} else {
this.stopTimer();
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
}
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
}
});
}
}
});
},
togglePause() {
try {
if (this.data.room.status === "paused") {
service.resumeGame(this.data.code);
} else {
service.pauseGame(this.data.code);
}
this.refresh();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
finishEarly() {
wx.showModal({
title: "提前结束游戏?",
content: "系统将根据当前存活情况立即生成本局战报。",
confirmText: "结束游戏",
confirmColor: "#d9554d",
success: (result) => {
if (!result.confirm) return;
try {
service.finishGame(this.data.code, "", service.USER_ID, "host_ended");
this.goResult();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
}
});
},
goResult() {
this.stopTimer();
wx.redirectTo({
url: `/pages/result/result?code=${this.data.code}`
});
},
backHome() {
this.stopTimer();
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
}
});

4
pages/game/game.json Normal file
View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "游戏进行中",
"disableScroll": false
}

137
pages/game/game.wxml Normal file
View File

@@ -0,0 +1,137 @@
<view class="game-page" wx:if="{{readyToRender}}">
<view class="game-head {{me.role === 'seeker' ? 'seeker-head' : 'hider-head'}}">
<view class="row between">
<view class="live-pill"><view class="live-dot"></view>{{stageLabel}}</view>
<view class="safe-state">● 边界内</view>
</view>
<view class="role-label">你的身份</view>
<view class="role-name">{{me.role === 'seeker' ? '寻找者' : '躲藏者'}}</view>
<view class="role-task">
{{me.role === 'seeker' ? '根据线索找到目标,用动态口令完成抓捕。' : '保持隐蔽,在倒计时结束前不要被发现。'}}
</view>
<view class="countdown">{{countdown}}</view>
</view>
<view class="page game-content">
<view class="stat-grid">
<view class="stat-card">
<view class="stat-value">{{survivors}}</view>
<view class="stat-label">仍在躲藏</view>
</view>
<view class="stat-card">
<view class="stat-value">{{seekers}}</view>
<view class="stat-label">正在寻找</view>
</view>
<view class="stat-card">
<view class="stat-value">{{room.settings.radiusMeters}}m</view>
<view class="stat-label">安全半径</view>
</view>
</view>
<view wx:if="{{room.status === 'preparing'}}" class="preparing-card">
<view class="preparing-icon">◌</view>
<view class="card-title">准备阶段</view>
<view class="preparing-copy">
{{me.role === 'seeker' ? '请留在起点,不要观察躲藏者的方向。倒计时结束后会震动提醒。' : '现在出发寻找安全藏身处,不要进入封闭、危险或私人区域。'}}
</view>
</view>
<view wx:elif="{{room.status === 'paused'}}" class="preparing-card">
<view class="preparing-icon">Ⅱ</view>
<view class="card-title">游戏暂停中</view>
<view class="preparing-copy">请留在安全位置并等待房主继续游戏,暂停期间倒计时和线索计时均不会推进。</view>
</view>
<block wx:else>
<view wx:if="{{room.settings.skillsEnabled && (me.status === 'seeking' || me.status === 'hiding')}}">
<view class="section-label">角色能力</view>
<view class="skill-card">
<view class="skill-icon">{{me.role === 'seeker' ? '⌁' : '◐'}}</view>
<view class="skill-main">
<view class="card-title">{{me.role === 'seeker' ? '区域雷达' : '静默信号'}}</view>
<view class="skill-copy">
{{me.role === 'seeker' ? '检测附近是否可能存在躲藏者,不显示精确方向。' : '跳过下一次周期性位置广播。'}}
</view>
</view>
<button
class="skill-button {{skillUsed ? 'used' : ''}}"
disabled="{{skillUsed}}"
bindtap="useSkill"
>{{skillUsed ? '已使用' : '使用'}}</button>
</view>
</view>
<view wx:if="{{me.role === 'seeker' && me.status === 'seeking'}}">
<view class="section-label">抓捕确认</view>
<view class="capture-card">
<view class="capture-title">选择你在线下找到的目标</view>
<view class="target-list">
<view
wx:for="{{captureTargets}}"
wx:key="id"
class="target-chip {{selectedTargetId === item.id ? 'selected' : ''}}"
data-id="{{item.id}}"
bindtap="chooseCaptureTarget"
>{{item.name}}</view>
</view>
<view class="capture-copy">请在安全距离内完成确认,禁止追逐中发生身体接触。</view>
<input
type="number"
maxlength="4"
value="{{submittedCode}}"
placeholder="0000"
bindinput="inputCaptureCode"
/>
<view wx:if="{{showDemoCode}}" class="demo-code" bindtap="fillDemoCode">
原型演示:点击填入口令 {{captureCode}}
</view>
<button
class="secondary-button capture-button"
loading="{{captureSubmitting}}"
disabled="{{captureSubmitting || !selectedTargetId || submittedCode.length !== 4}}"
bindtap="submitCapture"
>确认抓捕</button>
</view>
</view>
<view wx:elif="{{me.role === 'hider' && me.status === 'hiding'}}">
<view class="section-label">你的动态口令</view>
<view class="token-card">
<view class="token-label">被找到后,将口令展示给寻找者</view>
<view class="token-code">{{captureCode}}</view>
<view class="token-note">每 30 秒刷新 · 不要远程发送口令</view>
</view>
</view>
<view wx:else class="preparing-card player-finished-card">
<view class="card-title">你已结束躲藏</view>
<view class="preparing-copy">经典模式下可以留在安全区域观战,请勿向其他玩家透露信息。</view>
</view>
<view class="section-label">实时事件</view>
<view class="event-list">
<view wx:if="{{eventViews.length === 0}}" class="empty-inline">还没有游戏事件</view>
<view wx:for="{{eventViews}}" wx:key="id" class="event-row">
<view class="event-mark {{item.type}}"></view>
<view class="event-message">{{item.message}}</view>
<view class="event-time">{{item.time}}</view>
</view>
</view>
</block>
<view class="safety-actions">
<button class="danger-button" bindtap="showSafety">安全与退出</button>
<button wx:if="{{canManage}}" class="ghost-button" bindtap="togglePause">
{{room.status === 'paused' ? '继续游戏' : '暂停游戏'}}
</button>
<button wx:if="{{canManage}}" class="ghost-button" bindtap="finishEarly">提前结束</button>
</view>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">无法恢复游戏</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

333
pages/game/game.wxss Normal file
View File

@@ -0,0 +1,333 @@
.game-page {
min-height: 100vh;
background: #f7f4ec;
}
.game-head {
padding: 34rpx 34rpx 46rpx;
color: #fff;
}
.seeker-head {
background: #183f31;
}
.hider-head {
background: #914a22;
}
.live-pill,
.safe-state {
display: flex;
align-items: center;
gap: 10rpx;
color: rgba(255, 255, 255, 0.75);
font-size: 22rpx;
}
.live-pill {
padding: 10rpx 16rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 99rpx;
}
.live-dot {
width: 12rpx;
height: 12rpx;
background: #d9ef72;
border-radius: 50%;
}
.role-label {
margin-top: 38rpx;
color: rgba(255, 255, 255, 0.6);
font-size: 22rpx;
font-weight: 700;
}
.role-name {
margin-top: 7rpx;
font-size: 58rpx;
font-weight: 900;
}
.role-task {
width: 75%;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.55;
}
.countdown {
margin-top: 34rpx;
color: #d9ef72;
font-size: 90rpx;
font-weight: 900;
letter-spacing: 4rpx;
}
.game-content {
padding-top: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: -24rpx;
}
.stat-card {
padding: 24rpx 12rpx;
background: #fffdf7;
border-radius: 22rpx;
box-shadow: 0 10rpx 30rpx rgba(35, 46, 40, 0.07);
text-align: center;
}
.stat-value {
font-size: 33rpx;
font-weight: 900;
}
.stat-label {
margin-top: 7rpx;
color: #7c8580;
font-size: 19rpx;
}
.preparing-card {
margin-top: 32rpx;
padding: 48rpx 32rpx;
background: #fffdf7;
border-radius: 30rpx;
text-align: center;
}
.preparing-icon {
color: #ef8f4a;
font-size: 90rpx;
}
.preparing-copy {
margin-top: 16rpx;
color: #68726c;
font-size: 25rpx;
line-height: 1.65;
}
.skill-card {
display: flex;
align-items: center;
gap: 18rpx;
padding: 24rpx;
background: #fffdf7;
border-radius: 26rpx;
}
.skill-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
background: #d9ef72;
border-radius: 22rpx;
font-size: 36rpx;
}
.skill-main {
flex: 1;
}
.skill-copy {
margin-top: 7rpx;
color: #68726c;
font-size: 21rpx;
line-height: 1.45;
}
.skill-button {
width: 120rpx;
height: 64rpx;
margin: 0;
background: #1d6b4f;
color: #fff;
font-size: 23rpx;
}
.skill-button.used {
background: #dfe4e0;
color: #7c8580;
}
.capture-card,
.token-card {
padding: 28rpx;
background: #183f31;
border-radius: 28rpx;
color: #fff;
}
.capture-title {
font-size: 29rpx;
font-weight: 800;
}
.capture-copy,
.token-note {
margin-top: 9rpx;
color: rgba(255, 255, 255, 0.62);
font-size: 21rpx;
line-height: 1.5;
}
.target-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.target-chip {
padding: 12rpx 20rpx;
border: 2rpx solid rgba(255, 255, 255, 0.24);
border-radius: 99rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 22rpx;
}
.target-chip.selected {
background: #d9ef72;
border-color: #d9ef72;
color: #17231d;
font-weight: 800;
}
.player-finished-card {
margin-top: 32rpx;
}
.capture-card input {
height: 120rpx;
margin-top: 24rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
color: #d9ef72;
font-size: 60rpx;
font-weight: 900;
letter-spacing: 24rpx;
text-align: center;
}
.demo-code {
padding: 18rpx 0;
color: #d9ef72;
font-size: 22rpx;
text-align: center;
}
.capture-button {
height: 80rpx;
font-size: 26rpx;
}
.token-card {
text-align: center;
}
.token-label {
color: rgba(255, 255, 255, 0.7);
font-size: 23rpx;
}
.token-code {
margin: 20rpx 0;
color: #d9ef72;
font-size: 74rpx;
font-weight: 900;
letter-spacing: 18rpx;
}
.event-list {
overflow: hidden;
background: #fffdf7;
border-radius: 26rpx;
}
.event-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 24rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.event-row:last-child {
border-bottom: none;
}
.event-mark {
width: 14rpx;
height: 14rpx;
background: #7c8580;
border-radius: 50%;
}
.event-mark.clue {
background: #ef8f4a;
}
.event-mark.skill {
background: #1d6b4f;
}
.event-mark.capture {
background: #d9554d;
}
.event-message {
flex: 1;
font-size: 23rpx;
}
.event-time {
color: #939b96;
font-size: 20rpx;
}
.safety-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 32rpx;
}
.safety-actions button {
width: 100%;
padding: 0;
font-size: 23rpx;
}
.safety-actions button:nth-child(3) {
grid-column: 1 / -1;
}
@media (max-width: 340px) {
.role-task {
width: 100%;
}
.countdown {
font-size: 76rpx;
}
.stat-grid {
gap: 8rpx;
}
}
button[disabled] {
opacity: 0.5;
}

49
pages/home/home.js Normal file
View File

@@ -0,0 +1,49 @@
const service = require("../../services/game-service");
const { GAME_MODES } = require("../../utils/constants");
Page({
data: {
currentRoom: null,
modeName: ""
},
onShow() {
let currentRoom = service.getCurrentRoom();
if (currentRoom && currentRoom.status === "cancelled") {
service.clearCurrentRoom();
currentRoom = null;
}
this.setData({
currentRoom,
modeName: currentRoom
? GAME_MODES[currentRoom.settings.mode].name
: ""
});
},
createGame() {
wx.navigateTo({ url: "/pages/create/create" });
},
joinGame() {
wx.navigateTo({ url: "/pages/join/join" });
},
openRules() {
wx.navigateTo({ url: "/pages/rules/rules" });
},
continueGame() {
const room = this.data.currentRoom;
if (!room) return;
const page =
room.status === "finished"
? "result"
: room.status === "waiting"
? "lobby"
: "game";
wx.navigateTo({
url: `/pages/${page}/${page}?code=${room.code}`
});
}
});

3
pages/home/home.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "藏猫猫计划"
}

63
pages/home/home.wxml Normal file
View File

@@ -0,0 +1,63 @@
<view class="page home-page">
<view class="hero">
<view class="hero-orbit orbit-one"></view>
<view class="hero-orbit orbit-two"></view>
<view class="eyebrow">OUTDOOR SOCIAL GAME</view>
<view class="hero-title">把城市一角,<br />变成游戏地图。</view>
<view class="hero-copy">
邀请好友、自动分队、接收线索,在真实世界里完成一场紧张又安全的追逃。
</view>
<view class="hero-players">
<view class="avatar avatar-a">藏</view>
<view class="avatar avatar-b">找</view>
<view class="avatar avatar-c">跑</view>
<text>建议 612 人 · 30 分钟</text>
</view>
</view>
<view class="button-stack actions">
<button class="primary-button" bindtap="createGame">创建一场游戏</button>
<button class="secondary-button" bindtap="joinGame">输入房间码加入</button>
</view>
<view wx:if="{{currentRoom}}" class="card continue-card" bindtap="continueGame">
<view class="row between">
<view>
<view class="continue-label">继续上一场</view>
<view class="card-title">{{currentRoom.name}}</view>
</view>
<view class="arrow">→</view>
</view>
<view class="room-meta">{{modeName}} · 房间 {{currentRoom.code}}</view>
</view>
<view class="section-label">怎么玩</view>
<view class="steps">
<view class="step">
<view class="step-number">01</view>
<view>
<view class="step-title">圈定安全场地</view>
<view class="step-copy">选择公园、校园或营地,设置活动范围。</view>
</view>
</view>
<view class="step">
<view class="step-number">02</view>
<view>
<view class="step-title">分享给好友</view>
<view class="step-copy">系统自动分配寻找者和躲藏者身份。</view>
</view>
</view>
<view class="step">
<view class="step-number">03</view>
<view>
<view class="step-title">开始真实追逃</view>
<view class="step-copy">利用模糊线索和技能,在倒计时内完成挑战。</view>
</view>
</view>
</view>
<view class="safety-link" bindtap="openRules">
<text>户外游戏安全规则</text>
<text>查看 →</text>
</view>
</view>

165
pages/home/home.wxss Normal file
View File

@@ -0,0 +1,165 @@
.home-page {
overflow: hidden;
}
.hero {
position: relative;
overflow: hidden;
padding: 52rpx 38rpx 42rpx;
background: #183f31;
border-radius: 40rpx;
color: #fff;
}
.hero .eyebrow {
position: relative;
color: #d9ef72;
}
.hero-title {
position: relative;
margin-top: 22rpx;
font-size: 62rpx;
font-weight: 900;
line-height: 1.18;
letter-spacing: -2rpx;
}
.hero-copy {
position: relative;
width: 88%;
margin-top: 26rpx;
color: rgba(255, 255, 255, 0.76);
font-size: 27rpx;
line-height: 1.7;
}
.hero-players {
position: relative;
display: flex;
align-items: center;
margin-top: 38rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 23rpx;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 54rpx;
height: 54rpx;
margin-right: -12rpx;
border: 4rpx solid #183f31;
border-radius: 50%;
color: #17231d;
font-size: 20rpx;
font-weight: 800;
}
.avatar-c {
margin-right: 24rpx;
}
.avatar-a {
background: #d9ef72;
}
.avatar-b {
background: #ef8f4a;
}
.avatar-c {
background: #f9e6c4;
}
.hero-orbit {
position: absolute;
border: 2rpx solid rgba(217, 239, 114, 0.23);
border-radius: 50%;
}
.orbit-one {
top: -120rpx;
right: -100rpx;
width: 420rpx;
height: 420rpx;
}
.orbit-two {
top: -32rpx;
right: -20rpx;
width: 220rpx;
height: 220rpx;
}
.actions {
margin-top: 28rpx;
}
.continue-card {
border-left: 10rpx solid #ef8f4a;
}
.continue-label {
margin-bottom: 10rpx;
color: #9c531e;
font-size: 22rpx;
font-weight: 800;
}
.arrow {
font-size: 44rpx;
}
.room-meta {
margin-top: 14rpx;
color: #68726c;
font-size: 24rpx;
}
.steps {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.step {
display: flex;
gap: 24rpx;
padding: 26rpx 28rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.step:last-child {
border-bottom: none;
}
.step-number {
color: #1d6b4f;
font-size: 22rpx;
font-weight: 900;
letter-spacing: 2rpx;
}
.step-title {
font-size: 28rpx;
font-weight: 800;
}
.step-copy {
margin-top: 8rpx;
color: #68726c;
font-size: 24rpx;
line-height: 1.5;
}
.safety-link {
display: flex;
justify-content: space-between;
margin-top: 34rpx;
padding: 24rpx 4rpx;
color: #1d6b4f;
font-size: 25rpx;
font-weight: 700;
}

82
pages/join/join.js Normal file
View File

@@ -0,0 +1,82 @@
const service = require("../../services/game-service");
Page({
data: {
code: "",
name: "我",
avatar: "我",
preview: null,
previewHint: "",
canJoin: false,
submitting: false
},
onLoad(options) {
if (options.code) {
this.setData({ code: options.code });
this.previewRoom(options.code);
}
},
onCodeInput(event) {
const code = event.detail.value.replace(/\D/g, "").slice(0, 6);
this.setData({ code, preview: null, previewHint: "", canJoin: false });
if (code.length === 6) this.previewRoom(code);
},
onNameInput(event) {
const name = event.detail.value;
this.setData({ name, avatar: name.slice(0, 1) || "?" });
},
previewRoom(code) {
const preview = service.getRoom(code);
let previewHint = "";
let canJoin = false;
if (!preview) {
previewHint = "没有找到这个房间,请检查房间码";
} else if (preview.status === "finished") {
previewHint = "这场游戏已经结束";
} else if (preview.status === "cancelled") {
previewHint = "房间已被房主取消";
} else if (preview.status !== "waiting") {
previewHint = "游戏已经开始,暂时无法加入";
} else {
const alreadyJoined = preview.players.some(
(player) => player.id === service.USER_ID
);
canJoin = alreadyJoined || preview.players.length < preview.settings.maxPlayers;
if (!canJoin) previewHint = "房间人数已满";
}
this.setData({ preview, previewHint, canJoin });
},
useDemoCode() {
this.setData({ code: "731204" });
this.previewRoom("731204");
},
join() {
if (this.data.submitting) return;
if (this.data.code.length !== 6) {
wx.showToast({ title: "请输入六位房间码", icon: "none" });
return;
}
if (!this.data.name.trim()) {
wx.showToast({ title: "请输入游戏昵称", icon: "none" });
return;
}
if (!this.data.canJoin) {
wx.showToast({ title: this.data.previewHint || "当前不能加入房间", icon: "none" });
return;
}
this.setData({ submitting: true });
try {
const room = service.joinRoom(this.data.code, this.data.name.trim());
wx.redirectTo({ url: `/pages/lobby/lobby?code=${room.code}` });
} catch (error) {
this.setData({ submitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
}
});

3
pages/join/join.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "加入游戏"
}

57
pages/join/join.wxml Normal file
View File

@@ -0,0 +1,57 @@
<view class="page join-page">
<view class="eyebrow">JOIN THE GAME</view>
<view class="title">找到你的队伍</view>
<view class="subtitle">向房主索取六位房间码,或直接打开好友分享的小程序卡片。</view>
<view class="code-card">
<view class="code-label">房间码</view>
<input
type="number"
maxlength="6"
value="{{code}}"
placeholder="000000"
focus
bindinput="onCodeInput"
/>
<view class="demo-link" bindtap="useDemoCode">没有房间?使用演示房间 731204</view>
</view>
<view wx:if="{{preview}}" class="card room-preview">
<view class="row between">
<view>
<view class="preview-label">找到房间</view>
<view class="card-title">{{preview.name}}</view>
</view>
<view class="pill">{{preview.players.length}} / {{preview.settings.maxPlayers}} 人</view>
</view>
<view class="divider"></view>
<view class="preview-meta">
<text>{{preview.settings.mode === 'infection' ? '感染模式' : '经典模式'}}</text>
<text>{{preview.settings.durationMinutes}} 分钟</text>
<text>{{preview.settings.radiusMeters}} 米范围</text>
</view>
</view>
<view wx:if="{{previewHint}}" class="preview-error">{{previewHint}}</view>
<view class="section-label">你的游戏昵称</view>
<view class="name-input">
<view class="name-avatar">{{avatar}}</view>
<input
maxlength="10"
value="{{name}}"
placeholder="输入昵称"
bindinput="onNameInput"
/>
</view>
<button
class="primary-button join-button"
loading="{{submitting}}"
disabled="{{submitting || !canJoin}}"
bindtap="join"
>
加入房间
</button>
<view class="privacy-tip">加入即表示你同意本局安全规则。精确位置不会直接展示给其他玩家。</view>
</view>

91
pages/join/join.wxss Normal file
View File

@@ -0,0 +1,91 @@
.code-card {
margin-top: 44rpx;
padding: 34rpx 30rpx;
background: #183f31;
border-radius: 32rpx;
color: #fff;
text-align: center;
}
.code-label {
color: rgba(255, 255, 255, 0.66);
font-size: 23rpx;
font-weight: 700;
}
.code-card input {
height: 130rpx;
color: #d9ef72;
font-size: 70rpx;
font-weight: 900;
letter-spacing: 16rpx;
}
.demo-link {
color: rgba(255, 255, 255, 0.7);
font-size: 23rpx;
text-decoration: underline;
}
.preview-label {
margin-bottom: 8rpx;
color: #1d6b4f;
font-size: 22rpx;
font-weight: 800;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
color: #68726c;
font-size: 23rpx;
}
.preview-error {
margin-top: 18rpx;
padding: 20rpx 24rpx;
background: #fff0ee;
border-radius: 20rpx;
color: #a63b35;
font-size: 23rpx;
line-height: 1.5;
}
.name-input {
display: flex;
align-items: center;
gap: 20rpx;
padding: 18rpx 24rpx;
background: #fffdf7;
border-radius: 24rpx;
}
.name-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
background: #d9ef72;
border-radius: 50%;
font-weight: 900;
}
.name-input input {
flex: 1;
height: 70rpx;
font-size: 29rpx;
}
.join-button {
margin-top: 40rpx;
}
.privacy-tip {
padding: 22rpx;
color: #7c8580;
font-size: 22rpx;
line-height: 1.6;
text-align: center;
}

116
pages/lobby/lobby.js Normal file
View File

@@ -0,0 +1,116 @@
const service = require("../../services/game-service");
const { GAME_MODES } = require("../../utils/constants");
Page({
data: {
code: "",
room: null,
modeName: "",
isHost: false,
showStartButton: false,
meReady: false,
canStart: false,
startHint: "",
errorMessage: "",
starting: false
},
onLoad(options) {
this.setData({ code: options.code || "" });
this.loadRoom(true);
},
onShow() {
if (this.data.code) this.loadRoom(false);
},
onPullDownRefresh() {
this.loadRoom(false);
wx.stopPullDownRefresh();
},
loadRoom(addBots) {
let room = service.getRoom(this.data.code);
if (!room) {
this.setData({ room: null, errorMessage: "房间不存在或本地数据已被清理" });
return;
}
if (addBots && room.players.length < Math.min(6, room.settings.maxPlayers)) {
room = service.addDemoPlayers(
room.code,
Math.min(6, room.settings.maxPlayers)
);
}
if (room.status !== "waiting") {
const target = room.status === "finished" ? "result" : "game";
wx.redirectTo({
url: `/pages/${target}/${target}?code=${room.code}`
});
return;
}
const me = room.players.find((player) => player.id === service.USER_ID);
const isHost = room.hostId === service.USER_ID;
const canOperate = isHost || room.code === "731204";
const enoughPlayers = room.players.length >= 4;
const seekersValid = room.settings.seekerCount < room.players.length;
const everyoneReady = room.players.every((player) => player.ready);
let startHint = "分配身份并开始";
if (!enoughPlayers) startHint = "至少需要 4 人";
else if (!seekersValid) startHint = "寻找者人数过多";
else if (!everyoneReady) startHint = "等待所有玩家准备";
this.setData({
room,
errorMessage: "",
modeName: GAME_MODES[room.settings.mode].name,
isHost,
showStartButton: canOperate,
meReady: me ? me.ready : false,
canStart: canOperate && enoughPlayers && seekersValid && everyoneReady,
startHint
});
},
toggleReady() {
try {
service.toggleReady(this.data.code);
wx.vibrateShort({ type: "light" });
this.loadRoom(false);
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
startGame() {
if (this.data.starting) return;
this.setData({ starting: true });
try {
service.startGame(this.data.code);
wx.redirectTo({
url: `/pages/game/game?code=${this.data.code}`
});
} catch (error) {
this.setData({ starting: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
copyCode() {
wx.setClipboardData({
data: this.data.code,
success: () => wx.showToast({ title: "房间码已复制", icon: "success" })
});
},
backHome() {
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
},
onShareAppMessage() {
return {
title: `来加入「${this.data.room.name}`,
path: `/pages/join/join?code=${this.data.code}`
};
}
});

4
pages/lobby/lobby.json Normal file
View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "游戏候场",
"enablePullDownRefresh": true
}

89
pages/lobby/lobby.wxml Normal file
View File

@@ -0,0 +1,89 @@
<view class="page lobby-page" wx:if="{{room}}">
<view class="room-head">
<view class="row between">
<view class="pill">{{modeName}}</view>
<view class="room-status"><view class="pulse"></view>等待加入</view>
</view>
<view class="room-name">{{room.name}}</view>
<view class="code-wrap" bindtap="copyCode">
<view>
<view class="code-label">房间码 · 点击复制</view>
<view class="room-code">{{room.code}}</view>
</view>
<button class="share-mini" open-type="share">邀请好友</button>
</view>
</view>
<view class="rule-strip">
<view>
<text class="rule-number">{{room.settings.durationMinutes}}</text>
<text class="rule-unit">分钟</text>
<view class="rule-name">游戏时间</view>
</view>
<view>
<text class="rule-number">{{room.settings.radiusMeters}}</text>
<text class="rule-unit">米</text>
<view class="rule-name">活动半径</view>
</view>
<view>
<text class="rule-number">{{room.settings.seekerCount}}</text>
<text class="rule-unit">人</text>
<view class="rule-name">寻找者</view>
</view>
</view>
<view class="row between player-heading">
<view class="section-label">玩家 {{room.players.length}} / {{room.settings.maxPlayers}}</view>
<view class="muted demo-note">已加入模拟队友</view>
</view>
<view class="player-list">
<view wx:for="{{room.players}}" wx:key="id" class="player-row">
<view class="player-avatar {{item.isHost ? 'host' : ''}}">{{item.avatar}}</view>
<view class="player-main">
<view class="player-name">
{{item.name}}
<text wx:if="{{item.isHost}}" class="host-tag">房主</text>
<text wx:if="{{item.isBot}}" class="bot-tag">模拟</text>
</view>
<view class="player-state">{{item.ready ? '已准备好' : '正在确认装备'}}</view>
</view>
<view class="ready-dot {{item.ready ? 'ready' : ''}}">
{{item.ready ? '✓' : '…'}}
</view>
</view>
</view>
<view class="tip-card">
<view class="tip-icon">!</view>
<view>
<view class="tip-title">开局前安全确认</view>
<view class="tip-copy">确保所有人定位可用、电量充足,并约定紧急集合点。抓捕只用口令确认,禁止身体接触。</view>
</view>
</view>
<view class="button-stack">
<button
wx:if="{{showStartButton}}"
class="primary-button"
loading="{{starting}}"
disabled="{{starting || !canStart}}"
bindtap="startGame"
>
{{startHint}}
</button>
<button
wx:else
class="{{meReady ? 'ghost-button' : 'secondary-button'}}"
bindtap="toggleReady"
>
{{meReady ? '取消准备' : '我准备好了'}}
</button>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">无法进入候场</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

221
pages/lobby/lobby.wxss Normal file
View File

@@ -0,0 +1,221 @@
.room-head {
padding: 30rpx;
background: #183f31;
border-radius: 34rpx;
color: #fff;
}
.room-status {
display: flex;
align-items: center;
gap: 10rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 23rpx;
}
.pulse {
width: 14rpx;
height: 14rpx;
background: #d9ef72;
border-radius: 50%;
box-shadow: 0 0 0 8rpx rgba(217, 239, 114, 0.12);
}
.room-name {
margin-top: 28rpx;
font-size: 43rpx;
font-weight: 900;
overflow-wrap: anywhere;
}
.code-wrap {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: 34rpx;
padding-top: 26rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.12);
}
.code-label {
color: rgba(255, 255, 255, 0.6);
font-size: 21rpx;
}
.room-code {
margin-top: 5rpx;
color: #d9ef72;
font-size: 50rpx;
font-weight: 900;
letter-spacing: 7rpx;
}
.share-mini {
width: 180rpx;
height: 70rpx;
margin: 0;
background: #d9ef72;
color: #17231d;
font-size: 24rpx;
}
.rule-strip {
display: flex;
justify-content: space-around;
margin-top: 22rpx;
padding: 26rpx 10rpx;
background: #fffdf7;
border-radius: 26rpx;
text-align: center;
}
.rule-number {
font-size: 36rpx;
font-weight: 900;
}
.rule-unit {
margin-left: 4rpx;
color: #68726c;
font-size: 20rpx;
}
.rule-name {
margin-top: 6rpx;
color: #7c8580;
font-size: 21rpx;
}
.player-heading .section-label {
margin-bottom: 12rpx;
}
.demo-note {
margin-top: 26rpx;
font-size: 20rpx;
}
.player-list {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.player-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 26rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.player-row:last-child {
border-bottom: none;
}
.player-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 66rpx;
height: 66rpx;
background: #e7eee8;
border-radius: 22rpx;
font-size: 25rpx;
font-weight: 800;
}
.player-avatar.host {
background: #d9ef72;
}
.player-main {
flex: 1;
min-width: 0;
}
.player-name {
overflow: hidden;
font-size: 27rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-tag,
.bot-tag {
margin-left: 10rpx;
padding: 3rpx 10rpx;
background: #e7eee8;
border-radius: 99rpx;
color: #1d6b4f;
font-size: 18rpx;
}
.bot-tag {
background: #fff0df;
color: #9c531e;
}
.player-state {
margin-top: 6rpx;
color: #89918c;
font-size: 21rpx;
}
.ready-dot {
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
background: #eef0ed;
border-radius: 50%;
color: #8b938e;
font-size: 22rpx;
}
.ready-dot.ready {
background: #1d6b4f;
color: #fff;
}
.tip-card {
display: flex;
gap: 20rpx;
margin-top: 26rpx;
padding: 24rpx;
background: #fff0df;
border-radius: 24rpx;
}
.tip-icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 46rpx;
height: 46rpx;
background: #ef8f4a;
border-radius: 50%;
color: #fff;
font-weight: 900;
}
.tip-title {
color: #813f16;
font-size: 25rpx;
font-weight: 800;
}
.tip-copy {
margin-top: 8rpx;
color: #9c531e;
font-size: 22rpx;
line-height: 1.55;
}
button[disabled] {
background: #c8ceca;
color: #fff;
}

98
pages/result/result.js Normal file
View File

@@ -0,0 +1,98 @@
const service = require("../../services/game-service");
const { formatCountdown } = require("../../utils/format");
Page({
data: {
code: "",
room: null,
winnerName: "",
winnerCopy: "",
ranking: [],
elapsedText: "00:00",
eventCount: 0,
errorMessage: "",
replaying: false
},
onLoad(options) {
const code = options.code || "";
const room = service.getRoom(code);
if (!room) {
this.setData({ errorMessage: "战报不存在或本地数据已被清理" });
return;
}
const hidersWin = room.winner === "hiders";
const snapshot = room.resultSnapshot;
const sourcePlayers = snapshot
? snapshot.players
: room.players.map((player) => ({
...player,
initialRole: player.initialRole || player.role,
finalRole: player.role,
survivedSeconds: 0
}));
const ranking = [...sourcePlayers]
.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);
})
.map((player, index) => ({
...player,
rank: index + 1,
roleText: player.initialRole === "hider" && player.finalRole === "seeker"
? "躲藏者→寻找者"
: player.initialRole === "seeker" ? "寻找者" : "躲藏者",
resultText:
player.captures > 0
? `完成 ${player.captures} 次抓捕`
: player.caughtAt
? "勇敢参与追逃"
: player.initialRole === "hider" ? "坚持到游戏结束" : "持续追踪目标"
}));
this.setData({
code,
room,
errorMessage: "",
winnerName: hidersWin ? "躲藏者胜利" : "寻找者胜利",
winnerCopy: hidersWin
? "有人成功坚持到了倒计时结束。"
: "所有躲藏者都已被找到。",
ranking,
elapsedText: formatCountdown(snapshot ? snapshot.elapsedSeconds : 0),
eventCount: snapshot ? snapshot.eventCount : room.events.length
});
},
replay() {
if (this.data.replaying) return;
this.setData({ replaying: true });
try {
service.resetRoom(this.data.code);
wx.redirectTo({
url: `/pages/lobby/lobby?code=${this.data.code}`
});
} catch (error) {
this.setData({ replaying: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
backHome() {
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
},
onShareAppMessage() {
if (!this.data.room) {
return { title: "藏猫猫计划", path: "/pages/home/home" };
}
return {
title: `${this.data.room.name}${this.data.winnerName}`,
path: "/pages/home/home"
};
}
});

3
pages/result/result.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "本局战报"
}

65
pages/result/result.wxml Normal file
View File

@@ -0,0 +1,65 @@
<view class="page result-page" wx:if="{{room}}">
<view class="result-hero">
<view class="confetti confetti-a"></view>
<view class="confetti confetti-b"></view>
<view class="trophy">★</view>
<view class="result-eyebrow">GAME COMPLETE</view>
<view class="winner">{{winnerName}}</view>
<view class="winner-copy">{{winnerCopy}}</view>
<view class="result-meta">{{room.name}} · 房间 {{room.code}}</view>
</view>
<view class="highlight-grid">
<view class="highlight">
<view class="highlight-value">{{room.players.length}}</view>
<view class="highlight-label">参与玩家</view>
</view>
<view class="highlight">
<view class="highlight-value">{{elapsedText}}</view>
<view class="highlight-label">实际用时</view>
</view>
<view class="highlight">
<view class="highlight-value">{{eventCount}}</view>
<view class="highlight-label">关键事件</view>
</view>
</view>
<view class="section-label">本局表现</view>
<view class="ranking-list">
<view wx:for="{{ranking}}" wx:key="id" class="ranking-row">
<view class="rank {{item.rank <= 3 ? 'top' : ''}}">{{item.rank}}</view>
<view class="rank-avatar">{{item.avatar}}</view>
<view class="rank-main">
<view class="rank-name">{{item.name}}</view>
<view class="rank-copy">{{item.resultText}}</view>
</view>
<view class="rank-role">{{item.roleText}}</view>
</view>
</view>
<view class="badge-card">
<view class="badge-icon">↗</view>
<view>
<view class="badge-title">本局称号 · 城市追踪者</view>
<view class="badge-copy">完成了第一场真实世界追逃。下一局试试更大的场地吧。</view>
</view>
</view>
<view class="button-stack">
<button class="secondary-button" open-type="share">分享本局战报</button>
<button
class="primary-button"
loading="{{replaying}}"
disabled="{{replaying}}"
bindtap="replay"
>按原规则再来一局</button>
<button class="ghost-button" bindtap="backHome">返回首页</button>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">战报已不可用</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

200
pages/result/result.wxss Normal file
View File

@@ -0,0 +1,200 @@
.result-hero {
position: relative;
overflow: hidden;
padding: 50rpx 30rpx;
background: #183f31;
border-radius: 36rpx;
color: #fff;
text-align: center;
}
.trophy {
display: flex;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
margin: 0 auto 24rpx;
background: #d9ef72;
border-radius: 32rpx;
color: #17231d;
font-size: 54rpx;
}
.result-eyebrow {
color: rgba(255, 255, 255, 0.58);
font-size: 20rpx;
font-weight: 800;
letter-spacing: 4rpx;
}
.winner {
margin-top: 12rpx;
font-size: 52rpx;
font-weight: 900;
}
.winner-copy {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 25rpx;
}
.result-meta {
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.12);
color: #d9ef72;
font-size: 22rpx;
}
.confetti {
position: absolute;
width: 20rpx;
height: 70rpx;
background: #ef8f4a;
transform: rotate(28deg);
}
.confetti-a {
top: 70rpx;
left: 45rpx;
}
.confetti-b {
top: 120rpx;
right: 50rpx;
height: 48rpx;
background: #d9ef72;
transform: rotate(-35deg);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 20rpx;
}
.highlight {
padding: 24rpx 10rpx;
background: #fffdf7;
border-radius: 22rpx;
text-align: center;
}
.highlight-value {
font-size: 38rpx;
font-weight: 900;
}
.highlight-label {
margin-top: 5rpx;
color: #7c8580;
font-size: 20rpx;
}
.ranking-list {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.ranking-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 20rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.ranking-row:last-child {
border-bottom: none;
}
.rank {
width: 32rpx;
color: #8b938e;
font-size: 23rpx;
font-weight: 900;
text-align: center;
}
.rank.top {
color: #ef8f4a;
}
.rank-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
background: #e7eee8;
border-radius: 20rpx;
font-size: 23rpx;
font-weight: 800;
}
.rank-main {
flex: 1;
min-width: 0;
}
.rank-name {
overflow: hidden;
font-size: 26rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-copy {
margin-top: 5rpx;
color: #7c8580;
font-size: 20rpx;
}
.rank-role {
flex: 0 0 auto;
max-width: 150rpx;
color: #1d6b4f;
font-size: 21rpx;
font-weight: 700;
text-align: right;
}
.badge-card {
display: flex;
gap: 20rpx;
margin-top: 26rpx;
padding: 26rpx;
background: #fff0df;
border-radius: 26rpx;
}
.badge-icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
background: #ef8f4a;
border-radius: 22rpx;
color: #fff;
font-size: 36rpx;
}
.badge-title {
color: #813f16;
font-size: 26rpx;
font-weight: 800;
}
.badge-copy {
margin-top: 8rpx;
color: #9c531e;
font-size: 21rpx;
line-height: 1.5;
}

37
pages/rules/rules.js Normal file
View File

@@ -0,0 +1,37 @@
Page({
data: {
rules: [
{
number: "01",
title: "选择安全场地",
copy: "仅在公园、校园、营地等获准且低车流区域游戏。远离机动车道、水域、施工区和陡坡。"
},
{
number: "02",
title: "抓捕不发生身体接触",
copy: "发现目标后保持安全距离,通过动态口令或扫码确认。禁止拉扯、冲撞、围堵和危险追逐。"
},
{
number: "03",
title: "尊重公共与私人空间",
copy: "不得进入住宅、厕所、商铺后场、封闭建筑或未经许可的区域躲藏。不给路人造成困扰。"
},
{
number: "04",
title: "随时可以安全退出",
copy: "感到不适、迷路、天气变化或设备异常时立即停止游戏,告知房主并前往约定集合点。"
},
{
number: "05",
title: "保护位置隐私",
copy: "不要截图或转发他人的位置和口令。游戏结束后不应保留或公开他人的精确行动轨迹。"
}
]
},
accept() {
wx.navigateBack({
fail: () => wx.reLaunch({ url: "/pages/home/home" })
});
}
});

3
pages/rules/rules.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "安全规则"
}

25
pages/rules/rules.wxml Normal file
View File

@@ -0,0 +1,25 @@
<view class="page">
<view class="rules-hero">
<view class="rules-icon">+</view>
<view class="eyebrow">SAFETY FIRST</view>
<view class="title">安全永远高于胜负</view>
<view class="subtitle">开始前请由房主带领所有玩家确认场地、集合点和以下规则。</view>
</view>
<view class="rule-list">
<view wx:for="{{rules}}" wx:key="number" class="rule-card">
<view class="rule-number">{{item.number}}</view>
<view>
<view class="rule-title">{{item.title}}</view>
<view class="rule-copy">{{item.copy}}</view>
</view>
</view>
</view>
<view class="emergency-card">
<view class="emergency-title">遇到紧急情况</view>
<view class="emergency-copy">立即停止游戏并移动到安全位置,联系同行人员或当地紧急服务。小程序不能替代专业救援。</view>
</view>
<button class="primary-button" bindtap="accept">我已了解</button>
</view>

74
pages/rules/rules.wxss Normal file
View File

@@ -0,0 +1,74 @@
.rules-hero {
position: relative;
overflow: hidden;
padding: 38rpx 30rpx;
background: #fffdf7;
border-radius: 32rpx;
}
.rules-icon {
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
margin-bottom: 24rpx;
background: #d9ef72;
border-radius: 24rpx;
color: #183f31;
font-size: 45rpx;
font-weight: 900;
}
.rule-list {
margin: 32rpx 0;
}
.rule-card {
display: flex;
gap: 22rpx;
margin-bottom: 16rpx;
padding: 26rpx;
background: #fffdf7;
border-radius: 24rpx;
}
.rule-number {
flex: 0 0 auto;
color: #1d6b4f;
font-size: 22rpx;
font-weight: 900;
letter-spacing: 2rpx;
}
.rule-title {
font-size: 28rpx;
font-weight: 800;
}
.rule-copy {
margin-top: 10rpx;
color: #68726c;
font-size: 23rpx;
line-height: 1.65;
}
.emergency-card {
margin-bottom: 28rpx;
padding: 26rpx;
background: #ffe8e5;
border-radius: 24rpx;
}
.emergency-title {
color: #a63b35;
font-size: 27rpx;
font-weight: 800;
}
.emergency-copy {
margin-top: 10rpx;
color: #9b504a;
font-size: 23rpx;
line-height: 1.6;
}