Initial commit
This commit is contained in:
1
frontend/dist/assets/index-BeHdn1hs.css
vendored
Normal file
1
frontend/dist/assets/index-BeHdn1hs.css
vendored
Normal 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
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
13
frontend/dist/index.html
vendored
Normal 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
12
frontend/index.html
Normal 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
1152
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal 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
337
frontend/src/game/engine.js
Normal 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
302
frontend/src/game/scene.js
Normal 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
141
frontend/src/main.js
Normal 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()
|
||||
107
frontend/src/network/client.js
Normal file
107
frontend/src/network/client.js
Normal 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
180
frontend/src/style.css
Normal 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
114
frontend/src/ui/lobby.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
40
frontend/src/utils/constants.js
Normal file
40
frontend/src/utils/constants.js
Normal 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'
|
||||
}
|
||||
68
frontend/src/utils/grid.js
Normal file
68
frontend/src/utils/grid.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
94
frontend/src/utils/input.js
Normal file
94
frontend/src/utils/input.js
Normal 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
14
frontend/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user