Initial commit

This commit is contained in:
wfz
2026-07-26 14:41:36 +08:00
commit f741a0c8e8
90 changed files with 10774 additions and 0 deletions

View File

@@ -0,0 +1 @@
*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;color:#e0e0e0;font-family:Segoe UI,system-ui,-apple-system,sans-serif;overflow:hidden}canvas{display:block;width:100vw;height:100vh}.lobby{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}.lobby h1{font-size:3rem;color:#48f;margin-bottom:2rem;text-shadow:0 0 20px rgba(68,136,255,.3)}.lobby h2{font-size:2rem;color:#48f;margin-bottom:1.5rem}.lobby-form{display:flex;flex-direction:column;gap:1rem;width:100%;max-width:400px}.lobby-form input{padding:.75rem 1rem;background:#16213e;border:1px solid #334466;border-radius:6px;color:#e0e0e0;font-size:1rem;outline:none}.lobby-form input:focus{border-color:#48f}.lobby-actions{display:flex;gap:.5rem}button{padding:.6rem 1.2rem;background:#48f;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;font-weight:600;transition:background .2s}button:hover{background:#37e}button:active{background:#26d}.room-list{margin-top:1.5rem;width:100%;max-width:500px}.room-list .empty{text-align:center;color:#667;padding:2rem}.room-item{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background:#16213e;border-radius:6px;margin-bottom:.5rem}.room-name{font-weight:600;flex:1}.room-players{color:#889;margin-right:1rem}.join-btn{padding:.4rem .8rem;font-size:.8rem}.room-view{max-width:500px;width:100%}.player-list{width:100%;margin-bottom:1.5rem}.player-item{display:flex;justify-content:space-between;align-items:center;padding:.6rem 1rem;background:#16213e;border-radius:6px;margin-bottom:.4rem}.player-item.ready{border-left:3px solid #44ff88}.ready-status{font-size:.8rem;font-weight:600;color:#667}.player-item.ready .ready-status{color:#4f8}.room-actions{display:flex;gap:.5rem;justify-content:center}#startBtn{background:#4a4}#startBtn:hover{background:#393}#leaveBtn{background:#a44}#leaveBtn:hover{background:#933}

3863
frontend/dist/assets/index-BpzQrr0d.js vendored Normal file

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZP2</title>
<script type="module" crossorigin src="/assets/index-BpzQrr0d.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BeHdn1hs.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZP2</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1152
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "zp2-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.170.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

337
frontend/src/game/engine.js Normal file
View File

@@ -0,0 +1,337 @@
// Game engine core logic / 游戏引擎核心逻辑
import { TICK_RATE, TICK_INTERVAL, MSG_TYPE, PLAYER_CONFIG } from '../utils/constants.js'
import { Grid } from '../utils/grid.js'
import { InputManager } from '../utils/input.js'
import { GameScene } from './scene.js'
import { NetworkClient } from '../network/client.js'
/**
* Main game engine that handles game loop, player prediction, and state reconciliation / 主游戏引擎,处理游戏循环、玩家预测和状态同步
*/
export class GameEngine {
constructor(canvas) {
this.canvas = canvas
this.scene = new GameScene(canvas)
this.input = new InputManager()
this.network = new NetworkClient()
this.grid = null
this.mapData = null
// Local player identifier / 本地玩家标识
this.localPlayerId = null
// Map of all players by ID / 所有玩家的 ID 映射
this.players = new Map()
// Map of all zombies by ID / 所有僵尸的 ID 映射
this.zombies = new Map()
// Game loop state / 游戏循环状态
this.running = false
this.lastTick = 0
this.accumulator = 0
// Pending input buffer for reconciliation / 待处理的输入缓冲,用于状态同步
this.pendingInputs = []
// Current game time from server / 来自服务器的当前游戏时间
this.gameTime = 0
// External state update callback / 外部状态更新回调
this.onStateUpdate = null
}
// Connect to game server / 连接游戏服务器
async connect(url) {
await this.network.connect(url)
this._setupNetworkHandlers()
}
// Register network event handlers / 注册网络事件处理器
_setupNetworkHandlers() {
// Handle game start from server / 处理服务器发送的游戏开始信号
this.network.on(MSG_TYPE.GAME_STARTED, (data) => {
if (!data.mapData) {
console.error('Server did not provide map data / 服务器未提供地图数据')
alert('Failed to load map data from server. / 无法从服务器加载地图数据。')
return
}
this.localPlayerId = data.playerId
this.mapData = data.mapData
this.grid = new Grid(this.mapData)
this.scene.buildMap(this.mapData)
this._initPlayers(data.players)
this.start()
if (this.onStateUpdate) this.onStateUpdate('game_started', data)
})
// Handle periodic game state updates / 处理定期游戏状态更新
this.network.on(MSG_TYPE.GAME_STATE, (data) => {
this._processServerState(data)
})
// Handle new player joining / 处理新玩家加入
this.network.on(MSG_TYPE.PLAYER_JOIN, (data) => {
this._addPlayer(data)
})
// Handle player leaving / 处理玩家离开
this.network.on(MSG_TYPE.PLAYER_LEAVE, (data) => {
this._removePlayer(data)
})
// Handle server errors / 处理服务器错误
this.network.on(MSG_TYPE.ERROR, (data) => {
console.error('Server error:', data.message)
alert(data.message)
})
}
// Initialize all players from server data / 根据服务器数据初始化所有玩家
_initPlayers(playersData) {
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
for (const p of playersData) {
const isLocal = p.id === this.localPlayerId
const color = colors[p.index % colors.length]
this.players.set(p.id, {
id: p.id,
name: p.name,
x: p.x,
y: p.y,
angle: 0,
health: PLAYER_CONFIG.MAX_HEALTH,
color,
isLocal
})
this.scene.addPlayer(p.id, p.x, p.y, color, isLocal)
}
}
// Add a new player to the game / 向游戏中添加新玩家
_addPlayer(data) {
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
const isLocal = data.id === this.localPlayerId
const color = colors[data.index % colors.length]
this.players.set(data.id, {
id: data.id,
name: data.name,
x: data.x,
y: data.y,
angle: 0,
health: PLAYER_CONFIG.MAX_HEALTH,
color,
isLocal
})
this.scene.addPlayer(data.id, data.x, data.y, color, isLocal)
}
// Remove a player from the game / 从游戏中移除玩家
_removePlayer(data) {
this.players.delete(data.id)
this.scene.removePlayer(data.id)
}
// Start the game loop / 启动游戏循环
start() {
if (this.running) return
this.running = true
this.input.attach()
this.lastTick = performance.now()
this._loop()
}
// Stop the game loop / 停止游戏循环
stop() {
this.running = false
this.input.detach()
}
// Main game loop with fixed timestep / 固定时间步长的主游戏循环
_loop() {
if (!this.running) return
requestAnimationFrame(() => this._loop())
const now = performance.now()
const delta = now - this.lastTick
this.lastTick = now
this.accumulator += delta
// Run fixed ticks / 执行固定时间步长更新
while (this.accumulator >= TICK_INTERVAL) {
this._tick()
this.accumulator -= TICK_INTERVAL
}
// Update camera to follow local player / 更新相机跟随本地玩家
const localPlayer = this.players.get(this.localPlayerId)
if (localPlayer) {
this.scene.updateCamera(localPlayer.x, localPlayer.y)
}
this.scene.render()
}
// Single tick update: input, prediction, and network send / 单次tick更新输入处理、预测和网络发送
_tick() {
if (!this.localPlayerId) return
const localPlayer = this.players.get(this.localPlayerId)
if (!localPlayer || localPlayer.health <= 0) return
// Get mouse position on ground plane / 获取鼠标在地面上的位置
const mouseGroundPos = this.scene.getMouseGroundPos(this.input.mouse.x, this.input.mouse.y)
this.input.mouse.groundX = mouseGroundPos.x
this.input.mouse.groundY = mouseGroundPos.y
const inputState = this.input.buildInputState(mouseGroundPos)
// Apply client-side prediction / 应用客户端预测
this._applyLocalPrediction(inputState)
// Buffer input for reconciliation / 缓存输入用于状态同步
this.pendingInputs.push(inputState)
if (this.pendingInputs.length > 60) {
this.pendingInputs.splice(0, this.pendingInputs.length - 60)
}
this.network.sendInput(inputState)
}
// Apply local movement prediction / 应用本地移动预测
_applyLocalPrediction(inputState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
const speed = PLAYER_CONFIG.SPEED
const dt = TICK_INTERVAL / 1000
let newX = player.x + inputState.dx * speed * dt
let newY = player.y + inputState.dy * speed * dt
// Check wall collisions / 检查墙壁碰撞
if (this.grid) {
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
}
// Clamp to map bounds / 限制在地图范围内
const maxX = this.grid.width - 0.5
const maxY = this.grid.height - 0.5
newX = Math.max(0.5, Math.min(maxX, newX))
newY = Math.max(0.5, Math.min(maxY, newY))
player.x = newX
player.y = newY
// Calculate facing angle toward aim point / 计算朝向瞄准点的角度
const dx = inputState.aimX - player.x
const dy = inputState.aimY - player.y
player.angle = Math.atan2(dx, dy)
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
}
// Process server state update / 处理服务器状态更新
_processServerState(state) {
if (state.players) {
for (const ps of state.players) {
const player = this.players.get(ps.id)
if (player) {
if (ps.id === this.localPlayerId) {
this._reconcileLocalPlayer(ps)
} else {
player.x = ps.x
player.y = ps.y
player.angle = ps.angle
player.health = ps.health
this.scene.updatePlayer(ps.id, ps.x, ps.y, ps.angle, ps.health)
}
}
}
}
// Sync zombies
if (state.zombies) {
const serverZombieIds = new Set()
for (const zs of state.zombies) {
const id = zs.id
serverZombieIds.add(id)
if (!this.zombies.has(id)) {
this.zombies.set(id, { id, x: zs.x, y: zs.y })
this.scene.addZombie(id, zs.x, zs.y)
} else {
const zombie = this.zombies.get(id)
zombie.x = zs.x
zombie.y = zs.y
this.scene.updateZombie(id, zs.x, zs.y, zs.angle)
}
}
for (const [id] of this.zombies) {
if (!serverZombieIds.has(id)) {
this.zombies.delete(id)
this.scene.removeZombie(id)
}
}
}
// Sync nut walls
if (state.nutWalls) {
for (const nw of state.nutWalls) {
this.scene.updateNutWall(nw.x, nw.y, nw.health, nw.maxHealth)
}
}
if (state.gameTime !== undefined) this.gameTime = state.gameTime
if (this.onStateUpdate) this.onStateUpdate('state', state)
}
// Reconcile local player state with server authority / 用服务器权威数据同步本地玩家状态
_reconcileLocalPlayer(serverState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
let lastProcessedSeq = serverState.lastProcessedSeq || 0
// Apply server state / 应用服务器状态
player.x = serverState.x
player.y = serverState.y
player.angle = serverState.angle
player.health = serverState.health
// Remove acknowledged inputs / 移除已确认的输入
this.pendingInputs = this.pendingInputs.filter(input => input.seq > lastProcessedSeq)
// Re-apply unacknowledged inputs / 重新应用未确认的输入
if (player.health > 0) {
const speed = PLAYER_CONFIG.SPEED
const dt = TICK_INTERVAL / 1000
for (const input of this.pendingInputs) {
let newX = player.x + input.dx * speed * dt
let newY = player.y + input.dy * speed * dt
if (this.grid) {
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
}
const maxX = this.grid.width - 0.5
const maxY = this.grid.height - 0.5
newX = Math.max(0.5, Math.min(maxX, newX))
newY = Math.max(0.5, Math.min(maxY, newY))
player.x = newX
player.y = newY
const dx = input.aimX - player.x
const dy = input.aimY - player.y
player.angle = Math.atan2(dx, dy)
}
}
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
}
// Clean up resources / 清理资源
destroy() {
this.stop()
this.network.disconnect()
this.scene.destroy()
}
}

302
frontend/src/game/scene.js Normal file
View File

@@ -0,0 +1,302 @@
// 3D scene rendering with Three.js / 使用 Three.js 进行3D场景渲染
import * as THREE from 'three'
import { PLAYER_SIZE } from '../utils/constants.js'
/**
* Manages the 3D scene, camera, lighting, player models, zombie models, and wall meshes
*/
export class GameScene {
constructor(canvas) {
this.canvas = canvas
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x1a1a2e)
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200)
this.cameraOffset = new THREE.Vector3(0, 25, 18)
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
this.renderer.setSize(window.innerWidth, window.innerHeight)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
this.players = new Map()
this.zombies = new Map()
this.wallMeshes = []
this.nutWallMeshes = new Map()
this._setupLighting()
this._setupResize()
}
_setupLighting() {
const ambient = new THREE.AmbientLight(0x404060, 0.6)
this.scene.add(ambient)
const dirLight = new THREE.DirectionalLight(0xffeedd, 0.8)
dirLight.position.set(16, 30, 16)
dirLight.castShadow = true
dirLight.shadow.mapSize.width = 2048
dirLight.shadow.mapSize.height = 2048
dirLight.shadow.camera.near = 0.5
dirLight.shadow.camera.far = 80
dirLight.shadow.camera.left = -20
dirLight.shadow.camera.right = 20
dirLight.shadow.camera.top = 20
dirLight.shadow.camera.bottom = -20
this.scene.add(dirLight)
const pointLight = new THREE.PointLight(0xff4400, 0.3, 50)
pointLight.position.set(16, 10, 16)
this.scene.add(pointLight)
}
_setupResize() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.updateProjectionMatrix()
this.renderer.setSize(window.innerWidth, window.innerHeight)
})
}
// Build map: floor, static walls (1), player spawns (2), nut walls (4)
buildMap(mapData) {
for (const mesh of this.wallMeshes) {
this.scene.remove(mesh)
mesh.geometry.dispose()
mesh.material.dispose()
}
this.wallMeshes = []
for (const [, entry] of this.nutWallMeshes) {
this.scene.remove(entry.mesh)
entry.mesh.geometry.dispose()
entry.mesh.material.dispose()
}
this.nutWallMeshes.clear()
const mapHeight = mapData.length
const mapWidth = mapData[0]?.length || 0
const floorGeo = new THREE.PlaneGeometry(mapWidth, mapHeight)
const floorMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a })
const floor = new THREE.Mesh(floorGeo, floorMat)
floor.rotation.x = -Math.PI / 2
floor.position.set(mapWidth / 2, 0, mapHeight / 2)
floor.receiveShadow = true
this.scene.add(floor)
this.wallMeshes.push(floor)
const wallGeo = new THREE.BoxGeometry(1, 1.5, 1)
const wallMat = new THREE.MeshLambertMaterial({ color: 0x555577 })
const nutWallGeo = new THREE.BoxGeometry(1, 1.2, 1)
const nutWallMat = new THREE.MeshLambertMaterial({ color: 0x8B6914 })
const spawnGeo = new THREE.BoxGeometry(1, 0.1, 1)
const spawnMat = new THREE.MeshLambertMaterial({ color: 0x00ff88, transparent: true, opacity: 0.5 })
const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.1, 1)
const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4444, transparent: true, opacity: 0.4 })
for (let y = 0; y < mapHeight; y++) {
for (let x = 0; x < mapWidth; x++) {
const cell = mapData[y] && mapData[y][x]
if (cell === 1) {
const wall = new THREE.Mesh(wallGeo, wallMat)
wall.position.set(x + 0.5, 0.75, y + 0.5)
wall.castShadow = true
wall.receiveShadow = true
this.scene.add(wall)
this.wallMeshes.push(wall)
} else if (cell === 2) {
const spawn = new THREE.Mesh(spawnGeo, spawnMat)
spawn.position.set(x + 0.5, 0.05, y + 0.5)
this.scene.add(spawn)
this.wallMeshes.push(spawn)
} else if (cell === 3) {
const zsp = new THREE.Mesh(zombieSpawnGeo, zombieSpawnMat)
zsp.position.set(x + 0.5, 0.05, y + 0.5)
this.scene.add(zsp)
this.wallMeshes.push(zsp)
} else if (cell === 4) {
const nutWall = new THREE.Mesh(nutWallGeo, nutWallMat.clone())
nutWall.position.set(x + 0.5, 0.6, y + 0.5)
nutWall.castShadow = true
nutWall.receiveShadow = true
this.scene.add(nutWall)
this.wallMeshes.push(nutWall)
const key = x + ',' + y
this.nutWallMeshes.set(key, { mesh: nutWall, x, y, maxHealth: 500 })
}
}
}
}
// Update nut wall appearance based on health
updateNutWall(x, y, health, maxHealth) {
const key = x + ',' + y
const entry = this.nutWallMeshes.get(key)
if (!entry) return
const ratio = health / maxHealth
const r = 0.55 + (1 - ratio) * 0.45
const g = 0.41 * ratio
const b = 0.08 * ratio
entry.mesh.material.color.setRGB(r, g, b)
if (health <= 0) {
this.scene.remove(entry.mesh)
entry.mesh.geometry.dispose()
entry.mesh.material.dispose()
this.nutWallMeshes.delete(key)
}
}
createPlayerModel(color = 0x4488ff) {
const group = new THREE.Group()
const bodyGeo = new THREE.CylinderGeometry(PLAYER_SIZE / 2, PLAYER_SIZE / 2, 0.8, 8)
const bodyMat = new THREE.MeshLambertMaterial({ color })
const body = new THREE.Mesh(bodyGeo, bodyMat)
body.position.y = 0.4
body.castShadow = true
group.add(body)
const headGeo = new THREE.SphereGeometry(0.2, 8, 8)
const headMat = new THREE.MeshLambertMaterial({ color: 0xffcc99 })
const head = new THREE.Mesh(headGeo, headMat)
head.position.y = 1.0
head.castShadow = true
group.add(head)
const gunGeo = new THREE.BoxGeometry(0.08, 0.08, 0.5)
const gunMat = new THREE.MeshLambertMaterial({ color: 0x333333 })
const gun = new THREE.Mesh(gunGeo, gunMat)
gun.position.set(0, 0.5, 0.4)
group.add(gun)
return group
}
addPlayer(id, x, y, color, isLocal = false) {
const model = this.createPlayerModel(color)
model.position.set(x, 0, y)
this.scene.add(model)
this.players.set(id, { model, isLocal })
}
removePlayer(id) {
const player = this.players.get(id)
if (player) {
this.scene.remove(player.model)
this.players.delete(id)
}
}
updatePlayer(id, x, y, angle, health) {
const player = this.players.get(id)
if (player) {
player.model.position.x = x
player.model.position.z = y
player.model.rotation.y = angle
}
}
// --- Zombie rendering ---
createZombieModel() {
const group = new THREE.Group()
// Body - dark green
const bodyGeo = new THREE.CylinderGeometry(0.35, 0.4, 0.9, 8)
const bodyMat = new THREE.MeshLambertMaterial({ color: 0x3a5a2a })
const body = new THREE.Mesh(bodyGeo, bodyMat)
body.position.y = 0.45
body.castShadow = true
group.add(body)
// Head - slightly green
const headGeo = new THREE.SphereGeometry(0.22, 8, 8)
const headMat = new THREE.MeshLambertMaterial({ color: 0x6b8e4e })
const head = new THREE.Mesh(headGeo, headMat)
head.position.y = 1.1
head.castShadow = true
group.add(head)
// Arms - thin cylinders pointing forward
const armGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.6, 6)
const armMat = new THREE.MeshLambertMaterial({ color: 0x3a5a2a })
const leftArm = new THREE.Mesh(armGeo, armMat)
leftArm.position.set(-0.3, 0.7, 0.3)
leftArm.rotation.x = -Math.PI / 3
group.add(leftArm)
const rightArm = new THREE.Mesh(armGeo, armMat)
rightArm.position.set(0.3, 0.7, 0.3)
rightArm.rotation.x = -Math.PI / 3
group.add(rightArm)
return group
}
addZombie(id, x, y) {
const model = this.createZombieModel()
model.position.set(x, 0, y)
this.scene.add(model)
this.zombies.set(id, { model })
}
removeZombie(id) {
const zombie = this.zombies.get(id)
if (zombie) {
this.scene.remove(zombie.model)
this.zombies.delete(id)
}
}
updateZombie(id, x, y, angle) {
const zombie = this.zombies.get(id)
if (zombie) {
zombie.model.position.x = x
zombie.model.position.z = y
zombie.model.rotation.y = angle
}
}
// Camera follows target
updateCamera(targetX, targetY) {
const target = new THREE.Vector3(targetX, 0, targetY)
const desiredPos = target.clone().add(this.cameraOffset)
this.camera.position.lerp(desiredPos, 0.1)
this.camera.lookAt(target)
}
getMouseGroundPos(mouseX, mouseY) {
const mouse = new THREE.Vector2(
(mouseX / window.innerWidth) * 2 - 1,
-(mouseY / window.innerHeight) * 2 + 1
)
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, this.camera)
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
const intersection = new THREE.Vector3()
raycaster.ray.intersectPlane(groundPlane, intersection)
if (intersection) {
return { x: intersection.x, y: intersection.z }
}
return { x: 0, y: 0 }
}
render() {
this.renderer.render(this.scene, this.camera)
}
destroy() {
this.renderer.dispose()
this.scene.traverse(child => {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose())
} else {
child.material.dispose()
}
}
})
}
}

141
frontend/src/main.js Normal file
View File

@@ -0,0 +1,141 @@
// Application entry point / 应用入口
import { MSG_TYPE } from './utils/constants.js'
import { GameEngine } from './game/engine.js'
import { LobbyUI } from './ui/lobby.js'
import './style.css'
// WebSocket server URL / WebSocket 服务器地址
const WS_URL = `ws://${window.location.hostname}:8080/ws`
/**
* Main application class that manages lobby and game transitions / 主应用类,管理大厅和游戏之间的切换
*/
class App {
constructor() {
// Root DOM element / 根 DOM 元素
this.appEl = document.getElementById('app')
// Lobby container element / 大厅容器元素
this.lobbyEl = document.createElement('div')
// Game canvas element / 游戏画布元素
this.gameCanvasEl = document.createElement('canvas')
this.appEl.appendChild(this.lobbyEl)
this.appEl.appendChild(this.gameCanvasEl)
// Hide game canvas initially / 初始时隐藏游戏画布
this.gameCanvasEl.style.display = 'none'
// Game engine instance / 游戏引擎实例
this.engine = null
// Lobby UI instance / 大厅 UI 实例
this.lobby = new LobbyUI(this.lobbyEl)
// Player session info / 玩家会话信息
this.playerId = null
this.roomId = null
this.isHost = false
this.playerName = ''
this._setupLobby()
}
// Set up lobby event callbacks / 设置大厅事件回调
_setupLobby() {
this.lobby.onCreateRoom = (name) => {
this.playerName = name
this._ensureConnection().then(() => {
this.engine.network.createRoom(name)
})
}
this.lobby.onJoinRoom = (roomId, name) => {
this.playerName = name
this._ensureConnection().then(() => {
this.engine.network.joinRoom(roomId, name)
})
}
this.lobby.onRefreshRooms = () => {
this._ensureConnection().then(() => {
this.engine.network.requestRoomList()
})
}
this.lobby.onReady = () => {
if (this.engine) this.engine.network.ready()
}
this.lobby.onStartGame = () => {
if (this.engine) this.engine.network.startGame()
}
this.lobby.onLeaveRoom = () => {
if (this.engine) {
this.engine.network.leaveRoom()
this.roomId = null
this.isHost = false
this.lobby.render()
}
}
}
// Ensure WebSocket connection is established / 确保 WebSocket 连接已建立
async _ensureConnection() {
if (!this.engine) {
this.engine = new GameEngine(this.gameCanvasEl)
try {
await this.engine.connect(WS_URL)
this._setupNetworkHandlers()
} catch (e) {
console.error('Failed to connect:', e)
alert('Failed to connect to server. Make sure the server is running on port 8080.')
}
} else if (!this.engine.network.connected) {
try {
await this.engine.connect(WS_URL)
this._setupNetworkHandlers()
} catch (e) {
console.error('Failed to reconnect:', e)
}
}
}
// Register network message handlers / 注册网络消息处理器
_setupNetworkHandlers() {
const net = this.engine.network
// Handle room list updates / 处理房间列表更新
net.on(MSG_TYPE.ROOM_LIST, (data) => {
this.lobby.updateRoomList(data.rooms)
})
// Handle room state updates / 处理房间状态更新
net.on(MSG_TYPE.ROOM_STATE, (data) => {
if (data.playerId) {
this.playerId = data.playerId
} else if (!this.playerId) {
console.warn('ROOM_STATE missing playerId / ROOM_STATE 缺少 playerId')
}
this.roomId = data.roomId
this.isHost = data.isHost || false
this.lobby.showRoom(data, this.isHost, this.playerId)
})
// Handle game start event / 处理游戏开始事件
net.on(MSG_TYPE.GAME_STARTED, (data) => {
this.playerId = data.playerId
this.lobbyEl.style.display = 'none'
this.gameCanvasEl.style.display = 'block'
})
// Handle error messages / 处理错误消息
net.on(MSG_TYPE.ERROR, (data) => {
console.error('Error:', data.message)
alert(data.message)
})
}
}
// Initialize the application / 初始化应用
const app = new App()

View File

@@ -0,0 +1,107 @@
// Network client for WebSocket communication / WebSocket 通信网络客户端
import { MSG_TYPE } from '../utils/constants.js'
/**
* WebSocket client that handles connection and message routing / WebSocket 客户端,处理连接和消息路由
*/
export class NetworkClient {
constructor() {
this.ws = null
this.connected = false
// Message handlers by type / 按类型存储的消息处理器
this.handlers = new Map()
}
// Connect to WebSocket server / 连接 WebSocket 服务器
async connect(url) {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.connected = true
resolve()
}
this.ws.onerror = (e) => {
reject(e)
}
this.ws.onclose = () => {
this.connected = false
}
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
const handlers = this.handlers.get(msg.type)
if (handlers) {
for (const h of handlers) {
h(msg.data || {})
}
}
} catch (err) {
console.error('Failed to parse message:', err)
}
}
})
}
// Disconnect from server / 断开服务器连接
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
this.connected = false
}
}
// Register a message handler for a specific type / 为特定类型注册消息处理器
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, [])
}
this.handlers.get(type).push(handler)
}
// Send a message to the server / 向服务器发送消息
send(type, data = {}) {
if (this.ws && this.connected) {
this.ws.send(JSON.stringify({ type, data }))
}
}
// Create a new room / 创建新房间
createRoom(playerName) {
this.send(MSG_TYPE.CREATE_ROOM, { playerName })
}
// Join an existing room / 加入已有房间
joinRoom(roomId, playerName) {
this.send(MSG_TYPE.JOIN_ROOM, { roomId, playerName })
}
// Leave current room / 离开当前房间
leaveRoom() {
this.send(MSG_TYPE.LEAVE_ROOM)
}
// Request room list / 请求房间列表
requestRoomList() {
this.send(MSG_TYPE.ROOM_LIST)
}
// Signal ready state / 发送准备状态
ready() {
this.send(MSG_TYPE.READY)
}
// Start the game (host only) / 开始游戏(仅房主)
startGame() {
this.send(MSG_TYPE.START_GAME)
}
// Send player input to server / 向服务器发送玩家输入
sendInput(inputState) {
this.send(MSG_TYPE.PLAYER_INPUT, inputState)
}
}

180
frontend/src/style.css Normal file
View File

@@ -0,0 +1,180 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a2e;
color: #e0e0e0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.lobby {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.lobby h1 {
font-size: 3rem;
color: #4488ff;
margin-bottom: 2rem;
text-shadow: 0 0 20px rgba(68, 136, 255, 0.3);
}
.lobby h2 {
font-size: 2rem;
color: #4488ff;
margin-bottom: 1.5rem;
}
.lobby-form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 400px;
}
.lobby-form input {
padding: 0.75rem 1rem;
background: #16213e;
border: 1px solid #334466;
border-radius: 6px;
color: #e0e0e0;
font-size: 1rem;
outline: none;
}
.lobby-form input:focus {
border-color: #4488ff;
}
.lobby-actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.6rem 1.2rem;
background: #4488ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background 0.2s;
}
button:hover {
background: #3377ee;
}
button:active {
background: #2266dd;
}
.room-list {
margin-top: 1.5rem;
width: 100%;
max-width: 500px;
}
.room-list .empty {
text-align: center;
color: #667;
padding: 2rem;
}
.room-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #16213e;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.room-name {
font-weight: 600;
flex: 1;
}
.room-players {
color: #889;
margin-right: 1rem;
}
.join-btn {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.room-view {
max-width: 500px;
width: 100%;
}
.player-list {
width: 100%;
margin-bottom: 1.5rem;
}
.player-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 1rem;
background: #16213e;
border-radius: 6px;
margin-bottom: 0.4rem;
}
.player-item.ready {
border-left: 3px solid #44ff88;
}
.ready-status {
font-size: 0.8rem;
font-weight: 600;
color: #667;
}
.player-item.ready .ready-status {
color: #44ff88;
}
.room-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
#startBtn {
background: #44aa44;
}
#startBtn:hover {
background: #339933;
}
#leaveBtn {
background: #aa4444;
}
#leaveBtn:hover {
background: #993333;
}

114
frontend/src/ui/lobby.js Normal file
View File

@@ -0,0 +1,114 @@
// Lobby UI for room management / 房间管理大厅 UI
/**
* Renders and manages the lobby interface for room creation and joining / 渲染和管理用于创建和加入房间的大厅界面
*/
export class LobbyUI {
constructor(container) {
this.container = container
// Event callbacks / 事件回调
this.onCreateRoom = null
this.onJoinRoom = null
this.onRefreshRooms = null
this.onReady = null
this.onStartGame = null
this.onLeaveRoom = null
this.render()
}
// Render the initial lobby view / 渲染初始大厅视图
render() {
this.container.innerHTML = `
<div class="lobby">
<h1>ZP2</h1>
<div class="lobby-form">
<input type="text" id="playerName" placeholder="Your name" maxlength="16" />
<div class="lobby-actions">
<button id="createRoom">Create Room</button>
<button id="refreshRooms">Refresh Rooms</button>
</div>
</div>
<div id="roomList" class="room-list"></div>
</div>
`
// Bind create room button / 绑定创建房间按钮
this.container.querySelector('#createRoom').onclick = () => {
const name = this.container.querySelector('#playerName').value.trim() || 'Player'
if (this.onCreateRoom) this.onCreateRoom(name)
}
// Bind refresh rooms button / 绑定刷新房间按钮
this.container.querySelector('#refreshRooms').onclick = () => {
if (this.onRefreshRooms) this.onRefreshRooms()
}
this.container.style.display = 'block'
}
// Update the room list display / 更新房间列表显示
updateRoomList(rooms) {
const listEl = this.container.querySelector('#roomList')
if (!rooms || rooms.length === 0) {
listEl.innerHTML = '<p class="empty">No rooms available</p>'
return
}
listEl.innerHTML = rooms.map(r => `
<div class="room-item" data-id="${r.id}">
<span class="room-name">${r.hostName || r.id}'s Room</span>
<span class="room-players">${r.playerCount}/4</span>
<button class="join-btn" data-id="${r.id}">Join</button>
</div>
`).join('')
// Bind join buttons / 绑定加入按钮
listEl.querySelectorAll('.join-btn').forEach(btn => {
btn.onclick = () => {
const name = this.container.querySelector('#playerName').value.trim() || 'Player'
if (this.onJoinRoom) this.onJoinRoom(btn.dataset.id, name)
}
})
}
// Show the room view with player list / 显示带有玩家列表的房间视图
showRoom(data, isHost, playerId) {
const players = data.players || []
this.container.innerHTML = `
<div class="lobby room-view">
<h2>${data.roomName || data.roomId}</h2>
<div class="player-list">
${players.map(p => `
<div class="player-item ${p.ready ? 'ready' : ''}">
<span>${p.name}${p.id === playerId ? ' (you)' : ''}</span>
<span class="ready-status">${p.ready ? 'READY' : 'NOT READY'}</span>
</div>
`).join('')}
</div>
<div class="room-actions">
<button id="readyBtn">Ready</button>
${isHost ? '<button id="startBtn">Start Game</button>' : ''}
<button id="leaveBtn">Leave</button>
</div>
</div>
`
// Bind ready button / 绑定准备按钮
this.container.querySelector('#readyBtn').onclick = () => {
if (this.onReady) this.onReady()
}
// Bind start game button (host only) / 绑定开始游戏按钮(仅房主)
const startBtn = this.container.querySelector('#startBtn')
if (startBtn) {
startBtn.onclick = () => {
if (this.onStartGame) this.onStartGame()
}
}
// Bind leave button / 绑定离开按钮
this.container.querySelector('#leaveBtn').onclick = () => {
if (this.onLeaveRoom) this.onLeaveRoom()
}
}
}

View File

@@ -0,0 +1,40 @@
// Game configuration constants / 游戏配置常量
// Size of each cell in world units / 每个格子的世界单位大小
export const CELL_SIZE = 1
// Player model size / 玩家模型大小
export const PLAYER_SIZE = 0.8
// Network tick rate / 网络tick速率
export const TICK_RATE = 30
// Time between ticks in ms / 每次tick之间的时间毫秒
export const TICK_INTERVAL = 1000 / TICK_RATE
// Player configuration / 玩家配置
export const PLAYER_CONFIG = {
MAX_HEALTH: 100, // Maximum player health / 玩家最大生命值
SPEED: 5 // Player movement speed / 玩家移动速度
}
// Zombie configuration / 僵尸配置
export const ZOMBIE_CONFIG = {
SIZE: 0.8, // Zombie collision size / 僵尸碰撞体积
SPEED: 2 // Zombie movement speed / 僵尸移动速度
}
// Message types for network protocol / 网络协议的消息类型
export const MSG_TYPE = {
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
LEAVE_ROOM: 'leave_room',
ROOM_LIST: 'room_list',
ROOM_STATE: 'room_state',
READY: 'ready',
START_GAME: 'start_game',
GAME_STARTED: 'game_started',
PLAYER_INPUT: 'player_input',
GAME_STATE: 'game_state',
PLAYER_JOIN: 'player_join',
PLAYER_LEAVE: 'player_leave',
ERROR: 'error'
}

View File

@@ -0,0 +1,68 @@
// Grid system for collision detection / 用于碰撞检测的网格系统
import { CELL_SIZE } from './constants.js'
/**
* Grid class for map parsing and collision queries / 网格类,用于地图解析和碰撞查询
*/
export class Grid {
constructor(mapData) {
this.cells = []
this.width = 0
this.height = 0
this.parseMap(mapData)
}
// Parse map data into internal grid / 将地图数据解析为内部网格
parseMap(mapData) {
this.height = mapData.length
this.width = mapData[0]?.length || 0
this.cells = []
for (let y = 0; y < this.height; y++) {
this.cells[y] = []
for (let x = 0; x < this.width; x++) {
this.cells[y][x] = mapData[y][x] || 0
}
}
}
// Check if a grid cell is a wall (static=1 or nut=4) / 检查网格单元格是否为墙
isWall(gridX, gridY) {
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true
const cell = this.cells[gridY][gridX]
return cell === 1 || cell === 4
}
// Convert world coordinates to grid coordinates / 将世界坐标转换为网格坐标
worldToGrid(wx, wy) {
return {
x: Math.floor(wx / CELL_SIZE),
y: Math.floor(wy / CELL_SIZE)
}
}
// Convert grid coordinates to world coordinates / 将网格坐标转换为世界坐标
gridToWorld(gx, gy) {
return {
x: gx * CELL_SIZE + CELL_SIZE / 2,
y: gy * CELL_SIZE + CELL_SIZE / 2
}
}
// Check if a world position is walkable (no wall collisions) / 检查世界位置是否可行走(无墙壁碰撞)
isWalkable(wx, wy, size) {
const half = size / 2
const corners = [
{ x: wx - half, y: wy - half },
{ x: wx + half, y: wy - half },
{ x: wx - half, y: wy + half },
{ x: wx + half, y: wy + half }
]
for (const corner of corners) {
const g = this.worldToGrid(corner.x, corner.y)
if (this.isWall(g.x, g.y)) return false
}
return true
}
}

View File

@@ -0,0 +1,94 @@
// Input manager for keyboard and mouse / 键盘和鼠标输入管理器
/**
* Tracks keyboard and mouse state for player input / 追踪键盘和鼠标状态用于玩家输入
*/
export class InputManager {
constructor() {
// Currently pressed keys / 当前按下的按键
this.keys = {}
// Mouse state / 鼠标状态
this.mouse = { x: 0, y: 0, left: false, right: false, groundX: 0, groundY: 0 }
// Key bindings for movement / 移动按键绑定
this.keyBindings = {
moveUp: 'KeyW',
moveDown: 'KeyS',
moveLeft: 'KeyA',
moveRight: 'KeyD'
}
// Input sequence number for reconciliation / 用于同步的输入序列号
this.sequenceNumber = 0
// Event handler bound references (for attach/detach) / 事件处理器绑定引用(用于挂载/卸载)
this._onKeyDown = (e) => {
this.keys[e.code] = true
}
this._onKeyUp = (e) => {
this.keys[e.code] = false
}
this._onMouseMove = (e) => {
this.mouse.x = e.clientX
this.mouse.y = e.clientY
}
this._onMouseDown = (e) => {
if (e.button === 0) this.mouse.left = true
if (e.button === 2) this.mouse.right = true
}
this._onMouseUp = (e) => {
if (e.button === 0) this.mouse.left = false
if (e.button === 2) this.mouse.right = false
}
// Prevent context menu on right click / 阻止右键菜单
this._onContextMenu = (e) => e.preventDefault()
}
// Attach event listeners / 挂载事件监听器
attach() {
window.addEventListener('keydown', this._onKeyDown)
window.addEventListener('keyup', this._onKeyUp)
window.addEventListener('mousemove', this._onMouseMove)
window.addEventListener('mousedown', this._onMouseDown)
window.addEventListener('mouseup', this._onMouseUp)
window.addEventListener('contextmenu', this._onContextMenu)
}
// Remove event listeners / 移除事件监听器
detach() {
window.removeEventListener('keydown', this._onKeyDown)
window.removeEventListener('keyup', this._onKeyUp)
window.removeEventListener('mousemove', this._onMouseMove)
window.removeEventListener('mousedown', this._onMouseDown)
window.removeEventListener('mouseup', this._onMouseUp)
window.removeEventListener('contextmenu', this._onContextMenu)
}
// Get normalized movement direction / 获取归一化的移动方向
getMovement() {
let dx = 0, dy = 0
if (this.keys[this.keyBindings.moveUp] || this.keys['ArrowUp']) dy -= 1
if (this.keys[this.keyBindings.moveDown] || this.keys['ArrowDown']) dy += 1
if (this.keys[this.keyBindings.moveLeft] || this.keys['ArrowLeft']) dx -= 1
if (this.keys[this.keyBindings.moveRight] || this.keys['ArrowRight']) dx += 1
// Normalize diagonal movement / 对角线移动归一化
if (dx !== 0 && dy !== 0) {
dx *= 0.7071
dy *= 0.7071
}
return { dx, dy }
}
// Build complete input state for sending to server / 构建完整的输入状态用于发送给服务器
buildInputState(mouseGroundPos) {
const movement = this.getMovement()
this.sequenceNumber++
return {
seq: this.sequenceNumber,
dx: movement.dx,
dy: movement.dy,
aimX: mouseGroundPos.x,
aimY: mouseGroundPos.y
}
}
}

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/ws': {
target: 'ws://localhost:8080',
ws: true
}
}
}
})