feat: 在线双人五子棋(Socket.IO + Vue3 + Canvas)
- 服务端权威状态机:落子校验、四方向五连判胜、悔棋请求、认输、离线判负、交换黑白再来一局 - 断线重连:resumeToken 凭证 + localStorage,刷新/锁屏自动回到原座位 - 移动端 Canvas 棋盘:DPR 适配、触摸容差吸附、最后一手标记、胜利连子高亮 - Docker 多阶段构建,非 root + 只读根文件系统 - k8s 清单:单副本 Recreate(内存态房态)、Ingress WebSocket 超时与粘性会话注释
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<title>五子棋</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+646
@@ -0,0 +1,646 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import Board from './components/Board.vue'
|
||||
import {
|
||||
EV,
|
||||
OFFLINE_CLAIM_MS,
|
||||
type ActionAck,
|
||||
type CreateAck,
|
||||
type EndReason,
|
||||
type JoinAck,
|
||||
type NoticePayload,
|
||||
type Player,
|
||||
type RoomState,
|
||||
} from '../../shared/protocol'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 本地会话:房间号 + 重连凭证,用于刷新/掉线后自动回到原座位 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SESSION_KEY = 'wuziqi.session'
|
||||
|
||||
interface Session {
|
||||
roomId: string
|
||||
resumeToken: string
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
return raw ? (JSON.parse(raw) as Session) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveSession(s: Session): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
|
||||
} catch {
|
||||
/* 隐私模式下 localStorage 不可写,忽略即可 */
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession(): void {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
} catch {
|
||||
/* 同上 */
|
||||
}
|
||||
}
|
||||
|
||||
const initialRoomId = (new URLSearchParams(location.search).get('r') ?? '').trim().toUpperCase()
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
const connected = ref(false)
|
||||
const phase = ref<'connecting' | 'lobby' | 'room'>('connecting')
|
||||
const state = ref<RoomState | null>(null)
|
||||
const joinCode = ref(initialRoomId)
|
||||
const busy = ref(false)
|
||||
const resultDismissed = ref(false)
|
||||
/** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */
|
||||
const undoWaitDismissed = ref(false)
|
||||
const toast = ref<{ text: string; error: boolean } | null>(null)
|
||||
let toastTimer: number | undefined
|
||||
|
||||
/** 只有拿到服务端状态才认为真正进入了房间 */
|
||||
const view = computed<'lobby' | 'loading' | 'room'>(() => {
|
||||
if (state.value) return 'room'
|
||||
return phase.value === 'lobby' ? 'lobby' : 'loading'
|
||||
})
|
||||
|
||||
function showToast(text: string, error = false): void {
|
||||
toast.value = { text, error }
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => (toast.value = null), 2600)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 连接与房间进入 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function doJoin(payload: { roomId: string; resumeToken?: string }, notifyOnFail: boolean): void {
|
||||
const s = socket
|
||||
if (!s) return
|
||||
if (phase.value !== 'room') phase.value = 'connecting'
|
||||
|
||||
s.emit(EV.join, payload, (res: JoinAck | ActionAck) => {
|
||||
busy.value = false
|
||||
if (res.ok) {
|
||||
const ok = res as JoinAck
|
||||
saveSession({ roomId: ok.roomId, resumeToken: ok.resumeToken })
|
||||
state.value = ok.state
|
||||
joinCode.value = ok.roomId
|
||||
phase.value = 'room'
|
||||
// 进入房间后清掉 URL 上的房间码,刷新时走凭证分支而不是"新加入"
|
||||
if (initialRoomId) history.replaceState(null, '', location.pathname)
|
||||
return
|
||||
}
|
||||
// 房间已失效:清掉本地凭证回到大厅
|
||||
clearSession()
|
||||
state.value = null
|
||||
phase.value = 'lobby'
|
||||
if (notifyOnFail) showToast((res as { error: string }).error, true)
|
||||
})
|
||||
}
|
||||
|
||||
function onConnect(): void {
|
||||
connected.value = true
|
||||
const saved = loadSession()
|
||||
|
||||
// 主动点开的邀请链接优先于本地旧会话:
|
||||
// 否则手机上残留的上一局凭证会"劫持"这次进入,把人留在自己的旧房间里空等。
|
||||
if (initialRoomId && saved?.roomId !== initialRoomId) {
|
||||
doJoin({ roomId: initialRoomId }, true)
|
||||
return
|
||||
}
|
||||
if (saved) {
|
||||
// 同一房间:用凭证找回原座位
|
||||
doJoin({ roomId: saved.roomId, resumeToken: saved.resumeToken }, false)
|
||||
return
|
||||
}
|
||||
phase.value = 'lobby'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const s = io({
|
||||
// 优先 WebSocket;失败时回退长轮询(副本数为 1,不存在粘性会话问题)
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionDelay: 400,
|
||||
reconnectionDelayMax: 4000,
|
||||
})
|
||||
socket = s
|
||||
|
||||
// 首次连接与断线重连都会触发,重连时自动用凭证复位座位
|
||||
s.on('connect', onConnect)
|
||||
s.on('disconnect', () => {
|
||||
connected.value = false
|
||||
})
|
||||
s.on(EV.state, (incoming: RoomState) => {
|
||||
state.value = incoming
|
||||
phase.value = 'room'
|
||||
if (incoming.status === 'playing') resultDismissed.value = false
|
||||
// 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态
|
||||
if (incoming.undoRequestedBy === null) undoWaitDismissed.value = false
|
||||
})
|
||||
s.on(EV.notice, (p: NoticePayload) => showToast(p.message, p.level === 'error'))
|
||||
|
||||
if (s.connected) onConnect()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket?.disconnect()
|
||||
socket = null
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 动作 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function act(ev: string, payload: Record<string, unknown> = {}): void {
|
||||
const s = socket
|
||||
if (!s) return
|
||||
if (!s.connected) {
|
||||
showToast('连接已断开,正在重连…', true)
|
||||
return
|
||||
}
|
||||
s.emit(ev, payload, (res: ActionAck) => {
|
||||
if (res && !res.ok) showToast(res.error ?? '操作失败', true)
|
||||
})
|
||||
}
|
||||
|
||||
function createRoom(): void {
|
||||
const s = socket
|
||||
if (!s || !s.connected) {
|
||||
showToast('连接中,请稍候', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
// 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack
|
||||
s.emit(EV.create, {}, (res: CreateAck | ActionAck) => {
|
||||
busy.value = false
|
||||
if (!res.ok) {
|
||||
showToast((res as { error: string }).error, true)
|
||||
return
|
||||
}
|
||||
const ok = res as CreateAck
|
||||
saveSession({ roomId: ok.roomId, resumeToken: ok.resumeToken })
|
||||
joinCode.value = ok.roomId
|
||||
phase.value = 'room'
|
||||
// 随后服务端会推送 EV.state 填充棋局
|
||||
})
|
||||
}
|
||||
|
||||
function joinRoom(): void {
|
||||
const code = joinCode.value.trim().toUpperCase()
|
||||
if (code.length !== 6) {
|
||||
showToast('请输入 6 位房间码', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
doJoin({ roomId: code }, true)
|
||||
}
|
||||
|
||||
const place = (x: number, y: number): void => act(EV.move, { x, y })
|
||||
const requestUndo = (): void => act(EV.undoRequest)
|
||||
const resign = (): void => act(EV.resign)
|
||||
const claimOffline = (): void => act(EV.claimOffline)
|
||||
const restart = (swap: boolean): void => act(EV.restart, { swap })
|
||||
const respondUndo = (accept: boolean): void => act(EV.undoRespond, { accept })
|
||||
|
||||
/** 退出到大厅并断开本地凭证(房间在服务端会被自动回收) */
|
||||
function leaveRoom(): void {
|
||||
clearSession()
|
||||
state.value = null
|
||||
phase.value = 'lobby'
|
||||
joinCode.value = ''
|
||||
history.replaceState(null, '', location.pathname)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 派生状态 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const mySeat = computed<Player | null>(() => state.value?.seat ?? null)
|
||||
|
||||
const myColorText = computed(() => (mySeat.value === 1 ? '黑棋' : mySeat.value === 2 ? '白棋' : ''))
|
||||
|
||||
const opponentSeat = computed<Player | null>(() => {
|
||||
if (mySeat.value === 1) return 2
|
||||
if (mySeat.value === 2) return 1
|
||||
return null
|
||||
})
|
||||
|
||||
const opponentJoined = computed(() => {
|
||||
const s = state.value
|
||||
if (!s || opponentSeat.value === null) return false
|
||||
return opponentSeat.value === 1 ? s.black.joined : s.white.joined
|
||||
})
|
||||
|
||||
const opponentOnline = computed(() => {
|
||||
const s = state.value
|
||||
if (!s || opponentSeat.value === null) return false
|
||||
return opponentSeat.value === 1 ? s.black.online : s.white.online
|
||||
})
|
||||
|
||||
const canPlace = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && s.turn === s.seat && connected.value
|
||||
})
|
||||
|
||||
const canRequestUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && s.undoRequestedBy === null && s.moveCount > 0
|
||||
})
|
||||
|
||||
const incomingUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.undoRequestedBy !== null && s.undoRequestedBy !== s.seat
|
||||
})
|
||||
|
||||
const outgoingUndo = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.undoRequestedBy !== null && s.undoRequestedBy === s.seat
|
||||
})
|
||||
|
||||
const showOutgoingUndo = computed(() => outgoingUndo.value && !undoWaitDismissed.value)
|
||||
|
||||
const canClaimOffline = computed(() => {
|
||||
const s = state.value
|
||||
return !!s && s.status === 'playing' && opponentJoined.value && !opponentOnline.value
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const s = state.value
|
||||
if (!s) return ''
|
||||
if (!connected.value) return '网络已断开,正在重连…'
|
||||
if (s.status === 'waiting') return opponentJoined.value ? '准备开始' : '等待对手进入房间'
|
||||
if (s.status === 'over') return result.value?.title ?? '本局结束'
|
||||
if (opponentJoined.value && !opponentOnline.value) return '对手掉线了,等待重连…'
|
||||
return canPlace.value ? '轮到你落子' : '等待对手落子'
|
||||
})
|
||||
|
||||
const END_REASON_TEXT: Record<EndReason, string> = {
|
||||
five: '五子连珠',
|
||||
draw: '棋盘已满',
|
||||
resign: '对方认输',
|
||||
offline: '对手离线判负',
|
||||
}
|
||||
|
||||
const result = computed<{ title: string; sub: string } | null>(() => {
|
||||
const s = state.value
|
||||
if (!s || s.status !== 'over' || s.winner === null || s.seat === null) return null
|
||||
if (s.winner === 0) return { title: '平局', sub: END_REASON_TEXT.draw }
|
||||
const won = s.winner === s.seat
|
||||
return {
|
||||
title: won ? '你赢了' : '你输了',
|
||||
sub: s.endReason ? END_REASON_TEXT[s.endReason] : '',
|
||||
}
|
||||
})
|
||||
|
||||
const showResultDialog = computed(() => result.value !== null && !resultDismissed.value)
|
||||
|
||||
const shareLink = computed(() =>
|
||||
state.value ? `${location.origin}${location.pathname}?r=${state.value.roomId}` : '',
|
||||
)
|
||||
|
||||
async function copyShareLink(): Promise<void> {
|
||||
const link = shareLink.value
|
||||
if (!link) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
showToast('邀请链接已复制,发给朋友即可')
|
||||
return
|
||||
} catch {
|
||||
/* 降级到 execCommand */
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = link
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast('邀请链接已复制,发给朋友即可')
|
||||
} catch {
|
||||
showToast(`复制失败,请手动告知房间码 ${state.value?.roomId ?? ''}`, true)
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div v-if="toast" class="toast" :class="{ error: toast.error }">{{ toast.text }}</div>
|
||||
|
||||
<!-- 大厅 -->
|
||||
<section v-if="view === 'lobby'" class="screen lobby">
|
||||
<h1>五子棋</h1>
|
||||
<p class="muted tip">两人对弈 · 手机浏览器直接玩</p>
|
||||
|
||||
<button class="primary big" :disabled="busy || !connected" @click="createRoom">
|
||||
创建房间
|
||||
</button>
|
||||
|
||||
<div class="sep"><span>或</span></div>
|
||||
|
||||
<div class="join-row">
|
||||
<input
|
||||
v-model="joinCode"
|
||||
class="mono"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="6"
|
||||
placeholder="输入 6 位房间码"
|
||||
@keyup.enter="joinRoom"
|
||||
/>
|
||||
<button :disabled="busy || !connected" @click="joinRoom">加入</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!connected" class="muted tip">正在连接服务器…</p>
|
||||
</section>
|
||||
|
||||
<!-- 连接中 -->
|
||||
<section v-else-if="view === 'loading'" class="screen loading">
|
||||
<p class="muted">正在进入对局…</p>
|
||||
</section>
|
||||
|
||||
<!-- 对局 -->
|
||||
<section v-else class="screen room">
|
||||
<header class="topbar">
|
||||
<div class="room-id">
|
||||
<span class="muted">房间</span>
|
||||
<b class="mono">{{ state!.roomId }}</b>
|
||||
</div>
|
||||
<div class="who">
|
||||
<span class="chip">你执{{ myColorText }}</span>
|
||||
<span
|
||||
class="dot"
|
||||
:class="{ off: !opponentOnline, wait: !opponentJoined }"
|
||||
:title="opponentJoined ? (opponentOnline ? '对手在线' : '对手掉线') : '等待对手'"
|
||||
/>
|
||||
</div>
|
||||
<button class="ghost small" @click="copyShareLink">复制邀请链接</button>
|
||||
</header>
|
||||
|
||||
<p class="status" :class="{ mine: canPlace }">{{ statusText }}</p>
|
||||
|
||||
<Board
|
||||
:board="state!.board"
|
||||
:last-move="state!.lastMove"
|
||||
:win-line="state!.winLine"
|
||||
:seat="state!.seat"
|
||||
:my-turn="canPlace"
|
||||
@place="place"
|
||||
/>
|
||||
|
||||
<footer class="actions">
|
||||
<template v-if="state!.status === 'over'">
|
||||
<button class="primary" @click="restart(false)">再来一局</button>
|
||||
<button @click="restart(true)">交换黑白再来一局</button>
|
||||
</template>
|
||||
<template v-else-if="!opponentJoined">
|
||||
<button class="primary" @click="copyShareLink">邀请朋友加入</button>
|
||||
<button class="ghost" @click="leaveRoom">返回</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button :disabled="!canRequestUndo" @click="requestUndo">悔棋</button>
|
||||
<button
|
||||
v-if="canClaimOffline"
|
||||
class="danger"
|
||||
:title="`对手掉线满 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒后可判负`"
|
||||
@click="claimOffline"
|
||||
>
|
||||
判对方负
|
||||
</button>
|
||||
<button class="danger" :disabled="state!.status !== 'playing'" @click="resign">认输</button>
|
||||
</template>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- 对手请求悔棋 -->
|
||||
<div v-if="incomingUndo" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>对方想悔棋</h2>
|
||||
<p>同意后将撤回一手(或两手),并轮到对方落子。</p>
|
||||
<div class="actions">
|
||||
<button @click="respondUndo(false)">拒绝</button>
|
||||
<button class="primary" @click="respondUndo(true)">同意</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我方悔棋等待中 -->
|
||||
<div v-else-if="showOutgoingUndo" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>悔棋请求已发送</h2>
|
||||
<p>等待对方响应,60 秒内未处理将自动失效。</p>
|
||||
<div class="actions">
|
||||
<button @click="undoWaitDismissed = true">先看棋盘</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终局 -->
|
||||
<div v-else-if="showResultDialog" class="mask">
|
||||
<div class="dialog">
|
||||
<h2>{{ result!.title }}</h2>
|
||||
<p>{{ result!.sub }}</p>
|
||||
<div class="actions">
|
||||
<button @click="resultDismissed = true">看棋盘</button>
|
||||
<button class="primary" @click="restart(false)">再来一局</button>
|
||||
</div>
|
||||
<div class="actions second">
|
||||
<button class="ghost" @click="restart(true)">交换黑白再来一局</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------------- 大厅 ---------------- */
|
||||
|
||||
.lobby {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lobby h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.big {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sep::before,
|
||||
.sep::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.join-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.join-row input {
|
||||
text-transform: uppercase;
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.join-row button {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------------- 对局 ---------------- */
|
||||
|
||||
.room {
|
||||
padding: 10px 12px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.room-id b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.dot.off {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.dot.wait {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.status.mine {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dialog .actions.second {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dialog .actions.second button {
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { BOARD_SIZE, type Cell, type Player } from '../../../shared/protocol'
|
||||
|
||||
const props = defineProps<{
|
||||
board: Cell[]
|
||||
lastMove: number | null
|
||||
winLine: number[] | null
|
||||
/** 自己执子颜色,null = 未入座(不可落子) */
|
||||
seat: Player | null
|
||||
/** 轮到自己时为 true */
|
||||
myTurn: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'place', x: number, y: number): void }>()
|
||||
|
||||
const host = ref<HTMLDivElement | null>(null)
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
/** 逻辑像素边长、格宽、边距(= 半个格宽,使最外线到边缘留白对称) */
|
||||
let size = 0
|
||||
let cell = 0
|
||||
let pad = 0
|
||||
|
||||
const STAR_POINTS: Array<[number, number]> = [
|
||||
[3, 3],
|
||||
[11, 3],
|
||||
[3, 11],
|
||||
[11, 11],
|
||||
[7, 7],
|
||||
]
|
||||
|
||||
const COLORS = {
|
||||
boardTop: '#e8c088',
|
||||
boardBottom: '#d9a866',
|
||||
grid: 'rgba(60, 35, 10, 0.75)',
|
||||
border: 'rgba(50, 28, 6, 0.95)',
|
||||
}
|
||||
|
||||
function ctx2d(): CanvasRenderingContext2D | null {
|
||||
return canvas.value?.getContext('2d') ?? null
|
||||
}
|
||||
|
||||
/** 按容器尺寸与 DPR 重建画布,再整体重绘 */
|
||||
function layout(): void {
|
||||
const el = host.value
|
||||
const c = canvas.value
|
||||
if (!el || !c) return
|
||||
|
||||
// 棋盘为正方形:宽度受限,且给底部操作区留出空间
|
||||
const availW = el.clientWidth || window.innerWidth - 32
|
||||
const availH = window.innerHeight * 0.6
|
||||
size = Math.floor(Math.min(availW, availH))
|
||||
cell = size / BOARD_SIZE
|
||||
pad = cell / 2
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
c.width = Math.round(size * dpr)
|
||||
c.height = Math.round(size * dpr)
|
||||
c.style.width = `${size}px`
|
||||
c.style.height = `${size}px`
|
||||
|
||||
const g = ctx2d()
|
||||
if (!g) return
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
draw()
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
const g = ctx2d()
|
||||
if (!g || size === 0) return
|
||||
|
||||
g.clearRect(0, 0, size, size)
|
||||
|
||||
// 木纹底色
|
||||
const bg = g.createLinearGradient(0, 0, size, size)
|
||||
bg.addColorStop(0, COLORS.boardTop)
|
||||
bg.addColorStop(1, COLORS.boardBottom)
|
||||
g.fillStyle = bg
|
||||
g.fillRect(0, 0, size, size)
|
||||
|
||||
// 网格
|
||||
g.strokeStyle = COLORS.grid
|
||||
g.lineWidth = Math.max(1, cell * 0.02)
|
||||
g.beginPath()
|
||||
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||
const p = pad + i * cell
|
||||
g.moveTo(pad, p)
|
||||
g.lineTo(size - pad, p)
|
||||
g.moveTo(p, pad)
|
||||
g.lineTo(p, size - pad)
|
||||
}
|
||||
g.stroke()
|
||||
|
||||
// 外框加粗
|
||||
g.strokeStyle = COLORS.border
|
||||
g.lineWidth = Math.max(1.5, cell * 0.05)
|
||||
g.strokeRect(pad, pad, size - cell, size - cell)
|
||||
|
||||
// 星位
|
||||
g.fillStyle = COLORS.border
|
||||
for (const [x, y] of STAR_POINTS) {
|
||||
g.beginPath()
|
||||
g.arc(pad + x * cell, pad + y * cell, cell * 0.09, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
}
|
||||
|
||||
// 棋子
|
||||
const r = cell * 0.44
|
||||
for (let i = 0; i < props.board.length; i++) {
|
||||
const v = props.board[i]
|
||||
if (v === 0) continue
|
||||
const x = i % BOARD_SIZE
|
||||
const y = Math.floor(i / BOARD_SIZE)
|
||||
drawStone(g, pad + x * cell, pad + y * cell, r, v)
|
||||
}
|
||||
|
||||
// 最后一手标记(与胜利高亮不重复绘制)
|
||||
if (props.lastMove !== null && !props.winLine) {
|
||||
const x = props.lastMove % BOARD_SIZE
|
||||
const y = Math.floor(props.lastMove / BOARD_SIZE)
|
||||
const v = props.board[props.lastMove]
|
||||
g.fillStyle = v === 1 ? '#f9fafb' : '#111827'
|
||||
g.beginPath()
|
||||
g.arc(pad + x * cell, pad + y * cell, cell * 0.1, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
}
|
||||
|
||||
// 五连高亮
|
||||
if (props.winLine?.length) {
|
||||
g.strokeStyle = '#ef4444'
|
||||
g.lineWidth = Math.max(2, cell * 0.07)
|
||||
for (const i of props.winLine) {
|
||||
const x = i % BOARD_SIZE
|
||||
const y = Math.floor(i / BOARD_SIZE)
|
||||
g.beginPath()
|
||||
g.arc(pad + x * cell, pad + y * cell, r + cell * 0.12, 0, Math.PI * 2)
|
||||
g.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawStone(g: CanvasRenderingContext2D, cx: number, cy: number, r: number, v: Cell): void {
|
||||
// 阴影
|
||||
g.save()
|
||||
g.shadowColor = 'rgba(0, 0, 0, 0.35)'
|
||||
g.shadowBlur = r * 0.35
|
||||
g.shadowOffsetY = r * 0.12
|
||||
|
||||
const grad = g.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.1, cx, cy, r)
|
||||
if (v === 1) {
|
||||
grad.addColorStop(0, '#6b7280')
|
||||
grad.addColorStop(0.5, '#1f2937')
|
||||
grad.addColorStop(1, '#030712')
|
||||
} else {
|
||||
grad.addColorStop(0, '#ffffff')
|
||||
grad.addColorStop(0.65, '#f3f4f6')
|
||||
grad.addColorStop(1, '#b9bec7')
|
||||
}
|
||||
g.fillStyle = grad
|
||||
g.beginPath()
|
||||
g.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
g.restore()
|
||||
}
|
||||
|
||||
/** 触摸/点击 → 最近的交叉点,带半格容差,避免误落 */
|
||||
function onPick(ev: PointerEvent): void {
|
||||
const c = canvas.value
|
||||
if (!c || props.seat === null || !props.myTurn) return
|
||||
|
||||
const rect = c.getBoundingClientRect()
|
||||
const px = ev.clientX - rect.left
|
||||
const py = ev.clientY - rect.top
|
||||
|
||||
const x = Math.round((px - pad) / cell)
|
||||
const y = Math.round((py - pad) / cell)
|
||||
if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return
|
||||
|
||||
const dx = px - (pad + x * cell)
|
||||
const dy = py - (pad + y * cell)
|
||||
if (Math.hypot(dx, dy) > cell * 0.55) return
|
||||
if (props.board[y * BOARD_SIZE + x] !== 0) return
|
||||
|
||||
emit('place', x, y)
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
layout()
|
||||
ro = new ResizeObserver(() => layout())
|
||||
if (host.value) ro.observe(host.value)
|
||||
window.addEventListener('orientationchange', layout)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
window.removeEventListener('orientationchange', layout)
|
||||
})
|
||||
|
||||
// 棋盘数据变化即重绘(Canvas 不受 Vue 响应式影响,需手动触发)
|
||||
watch(() => [props.board, props.lastMove, props.winLine], draw, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="board-host">
|
||||
<canvas ref="canvas" class="board-canvas" @pointerdown.prevent="onPick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board-host {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.board-canvas {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
/* 交给脚本处理指针事件,禁止浏览器手势介入 */
|
||||
touch-action: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,172 @@
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--panel: #1f2937;
|
||||
--panel-2: #374151;
|
||||
--line: #4b5563;
|
||||
--text: #f9fafb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #22c55e;
|
||||
--accent-dim: #16a34a;
|
||||
--danger: #ef4444;
|
||||
--warn: #f59e0b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 禁止双击缩放与滚动回弹,避免落子时页面抖动 */
|
||||
touch-action: manipulation;
|
||||
overscroll-behavior: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
|
||||
env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #06210f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:active:not(:disabled) {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* 顶部提示条 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 12px;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(90vw, 420px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(31, 41, 55, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: var(--danger);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
/* 遮罩层:结果 / 悔棋确认 */
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog .actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog .actions button {
|
||||
flex: 1;
|
||||
}
|
||||
Reference in New Issue
Block a user