新建项目

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

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;
}