新建项目
This commit is contained in:
116
pages/create/create.js
Normal file
116
pages/create/create.js
Normal 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
3
pages/create/create.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "创建游戏"
|
||||
}
|
||||
148
pages/create/create.wxml
Normal file
148
pages/create/create.wxml
Normal 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
129
pages/create/create.wxss
Normal 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
278
pages/game/game.js
Normal 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
4
pages/game/game.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "游戏进行中",
|
||||
"disableScroll": false
|
||||
}
|
||||
137
pages/game/game.wxml
Normal file
137
pages/game/game.wxml
Normal 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
333
pages/game/game.wxss
Normal 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
49
pages/home/home.js
Normal 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
3
pages/home/home.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "藏猫猫计划"
|
||||
}
|
||||
63
pages/home/home.wxml
Normal file
63
pages/home/home.wxml
Normal 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>建议 6~12 人 · 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
165
pages/home/home.wxss
Normal 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
82
pages/join/join.js
Normal 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
3
pages/join/join.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "加入游戏"
|
||||
}
|
||||
57
pages/join/join.wxml
Normal file
57
pages/join/join.wxml
Normal 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
91
pages/join/join.wxss
Normal 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
116
pages/lobby/lobby.js
Normal 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
4
pages/lobby/lobby.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "游戏候场",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
89
pages/lobby/lobby.wxml
Normal file
89
pages/lobby/lobby.wxml
Normal 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
221
pages/lobby/lobby.wxss
Normal 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
98
pages/result/result.js
Normal 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
3
pages/result/result.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "本局战报"
|
||||
}
|
||||
65
pages/result/result.wxml
Normal file
65
pages/result/result.wxml
Normal 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
200
pages/result/result.wxss
Normal 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
37
pages/rules/rules.js
Normal 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
3
pages/rules/rules.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "安全规则"
|
||||
}
|
||||
25
pages/rules/rules.wxml
Normal file
25
pages/rules/rules.wxml
Normal 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
74
pages/rules/rules.wxss
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user