This commit is contained in:
wfz
2026-04-25 22:09:27 +08:00
parent 9be6b17593
commit 7bffe41d41
33 changed files with 3461 additions and 581 deletions

View File

@@ -4,55 +4,89 @@ import { InputManager } from '../utils/input.js'
import { GameScene } from './scene.js'
import { NetworkClient } from '../network/client.js'
/**
* 游戏引擎核心类
* 负责游戏逻辑、网络通信、客户端预测和渲染协调
*/
export class GameEngine {
constructor(canvas) {
this.canvas = canvas
// 游戏场景负责3D渲染
this.scene = new GameScene(canvas)
// 输入管理器,处理键盘鼠标输入
this.input = new InputManager()
// 网络客户端,负责与服务器通信
this.network = new NetworkClient()
// 地图网格,用于碰撞检测
this.grid = null
// 地图数据
this.mapData = null
// 本地玩家ID
this.localPlayerId = null
// 所有玩家列表 Map<playerId, playerData>
this.players = new Map()
// 僵尸列表
this.zombies = new Map()
// 玩家子弹列表
this.bullets = new Map()
// 僵尸子弹列表
this.zombieBullets = new Map()
// 掉落物列表
this.loots = new Map()
// 游戏运行状态
this.running = false
// 上次tick时间戳
this.lastTick = 0
// 时间累加器,用于固定时间步长
this.accumulator = 0
// 待处理的输入序列(用于客户端预测)
this.pendingInputs = []
// 服务器状态历史
this.serverStates = []
// 当前武器索引
this.currentWeaponIndex = 0
// 武器弹药数量
this.weaponAmmo = {
[WEAPONS.PISTOL]: Infinity,
[WEAPONS.PISTOL]: Infinity, // 手枪无限弹药
[WEAPONS.MACHINE_GUN]: 100,
[WEAPONS.SHOTGUN]: 20,
[WEAPONS.GRENADE]: 10
}
this.grenadeChargeStart = 0
this.isChargingGrenade = false
this.grenadeChargePercent = 0
this.grenadeReleased = false
// 手雷蓄力相关
this.grenadeChargeStart = 0 // 蓄力开始时间
this.isChargingGrenade = false // 是否正在蓄力
this.grenadeChargePercent = 0 // 蓄力百分比 0-1
this.grenadeReleased = false // 是否已释放
this.gameTime = 0
this.waveNumber = 0
this.score = 0
// 游戏状态
this.gameTime = 0 // 游戏已进行时间
this.waveNumber = 0 // 当前波次
this.score = 0 // 分数
// 状态更新回调
this.onStateUpdate = null
}
/**
* 连接到游戏服务器
* @param {string} url 服务器地址
*/
async connect(url) {
await this.network.connect(url)
this._setupNetworkHandlers()
}
/**
* 设置网络消息处理器
* 监听服务器发送的各种游戏消息
*/
_setupNetworkHandlers() {
// 游戏开始消息
this.network.on(MSG_TYPE.GAME_STARTED, (data) => {
this.localPlayerId = data.playerId
this.mapData = data.mapData || generateDefaultMap()
@@ -63,24 +97,33 @@ export class GameEngine {
if (this.onStateUpdate) this.onStateUpdate('game_started', data)
})
// 游戏状态同步消息(服务器定期发送)
this.network.on(MSG_TYPE.GAME_STATE, (data) => {
this._processServerState(data)
})
// 玩家加入消息
this.network.on(MSG_TYPE.PLAYER_JOIN, (data) => {
this._addPlayer(data)
})
// 玩家离开消息
this.network.on(MSG_TYPE.PLAYER_LEAVE, (data) => {
this._removePlayer(data)
})
// 服务器错误消息
this.network.on(MSG_TYPE.ERROR, (data) => {
console.error('Server error:', data.message)
})
}
/**
* 初始化所有玩家
* @param {Array} playersData 服务器提供的玩家数据
*/
_initPlayers(playersData) {
// 玩家颜色列表,用于区分不同玩家
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
for (const p of playersData) {
const isLocal = p.id === this.localPlayerId
@@ -100,6 +143,10 @@ export class GameEngine {
}
}
/**
* 添加新玩家
* @param {Object} data 玩家数据
*/
_addPlayer(data) {
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
const isLocal = data.id === this.localPlayerId
@@ -118,11 +165,19 @@ export class GameEngine {
this.scene.addPlayer(data.id, data.x, data.y, color, isLocal)
}
/**
* 移除离开的玩家
* @param {Object} data 包含玩家ID的数据
*/
_removePlayer(data) {
this.players.delete(data.id)
this.scene.removePlayer(data.id)
}
/**
* 启动游戏引擎
* 开始游戏循环
*/
start() {
if (this.running) return
this.running = true
@@ -131,11 +186,18 @@ export class GameEngine {
this._loop()
}
/**
* 停止游戏引擎
*/
stop() {
this.running = false
this.input.detach()
}
/**
* 游戏主循环
* 使用requestAnimationFrame实现固定时间步长进行逻辑更新
*/
_loop() {
if (!this.running) return
requestAnimationFrame(() => this._loop())
@@ -144,73 +206,101 @@ export class GameEngine {
const delta = now - this.lastTick
this.lastTick = now
// 累加时间,使用固定时间步长更新游戏逻辑
this.accumulator += delta
while (this.accumulator >= TICK_INTERVAL) {
this._tick()
this.accumulator -= TICK_INTERVAL
}
// 更新摄像机跟随本地玩家
const localPlayer = this.players.get(this.localPlayerId)
if (localPlayer) {
this.scene.updateCamera(localPlayer.x, localPlayer.y)
// 手雷瞄准指示器
if (this.isChargingGrenade && this.currentWeaponIndex === 3) {
this.scene.showGrenadeTarget(localPlayer.x, localPlayer.y,
this.scene.showGrenadeTarget(localPlayer.x, localPlayer.y,
this.input.mouse.groundX || 0, this.input.mouse.groundY || 0,
this.grenadeChargePercent)
} else {
this.scene.hideGrenadeTarget()
}
}
// 渲染画面
this.scene.render()
}
/**
* 游戏逻辑Tick
* 每个固定时间步长调用一次,处理输入和网络同步
*/
_tick() {
if (!this.localPlayerId) return
const localPlayer = this.players.get(this.localPlayerId)
if (!localPlayer || localPlayer.health <= 0) return
// 获取鼠标在游戏世界中的地面位置
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)
// 武器切换
const weaponIdx = inputState.weaponIndex
if (weaponIdx >= 0 && weaponIdx !== this.currentWeaponIndex) {
this.currentWeaponIndex = weaponIdx
}
// 处理手雷蓄力
this._handleGrenadeCharge(inputState)
// 应用本地预测(客户端预测)
this._applyLocalPrediction(inputState)
// 添加手雷相关数据到输入状态
inputState.grenadeCharge = this.grenadeChargePercent
inputState.grenadeReleased = this.grenadeReleased
// 手雷释放前不发火
inputState.firing = this.currentWeaponIndex === 3 ? false : inputState.firing
// 保存输入以便进行客户端预测校正
this.pendingInputs.push(inputState)
if (this.pendingInputs.length > 60) {
this.pendingInputs.splice(0, this.pendingInputs.length - 60)
}
// 发送输入到服务器
this.network.sendInput(inputState)
// 重置手雷状态
if (this.grenadeReleased) {
this.grenadeReleased = false
this.grenadeChargePercent = 0
}
}
/**
* 处理手雷蓄力逻辑
* 手雷需要长按鼠标蓄力,松开释放
*/
_handleGrenadeCharge(inputState) {
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
const currentWeapon = weaponList[this.currentWeaponIndex]
if (currentWeapon === WEAPONS.GRENADE && WEAPON_CONFIG[WEAPONS.GRENADE].chargeable) {
// 开始蓄力
if (inputState.firing && !this.isChargingGrenade) {
this.isChargingGrenade = true
this.grenadeChargeStart = Date.now()
} else if (inputState.firing && this.isChargingGrenade) {
// 蓄力中,计算蓄力百分比
const elapsed = Date.now() - this.grenadeChargeStart
this.grenadeChargePercent = Math.min(1, elapsed / WEAPON_CONFIG[WEAPONS.GRENADE].maxCharge)
} else if (!inputState.firing && this.isChargingGrenade) {
// 释放手雷
this.grenadeReleased = true
this.isChargingGrenade = false
const elapsed = Date.now() - this.grenadeChargeStart
@@ -222,6 +312,10 @@ export class GameEngine {
}
}
/**
* 应用本地预测
* 在收到服务器确认前,先本地计算玩家位置和角度
*/
_applyLocalPrediction(inputState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
@@ -231,17 +325,20 @@ export class GameEngine {
let newX = player.x + inputState.dx * speed * dt
let newY = player.y + inputState.dy * speed * dt
// 碰撞检测使用0.8半径检测
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
}
// 边界限制
newX = Math.max(0.5, Math.min(31.5, newX))
newY = Math.max(0.5, Math.min(31.5, newY))
player.x = newX
player.y = newY
// 计算玩家朝向角度(朝向鼠标位置)
const dx = inputState.aimX - player.x
const dy = inputState.aimY - player.y
player.angle = Math.atan2(dx, dy)
@@ -249,30 +346,41 @@ export class GameEngine {
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
}
/**
* 处理服务器状态同步
* 根据服务器状态更新游戏世界
*/
_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
player.weaponIndex = ps.weaponIndex || 0
player.waitingForRespawn = ps.waitingForRespawn || false
player.respawnTimer = ps.respawnTimer || 0
this.scene.updatePlayer(ps.id, ps.x, ps.y, ps.angle, ps.health)
}
}
}
}
// 更新僵尸状态
if (state.zombies) {
const serverZombieIds = new Set()
for (const zs of state.zombies) {
serverZombieIds.add(zs.id)
if (this.zombies.has(zs.id)) {
// 更新现有僵尸
const zombie = this.zombies.get(zs.id)
const prevHealth = zombie.health
zombie.x = zs.x
@@ -280,15 +388,19 @@ export class GameEngine {
zombie.health = zs.health
const angle = zs.angle || 0
this.scene.updateZombie(zs.id, zs.x, zs.y, angle, zs.health)
// 受伤特效
if (prevHealth > zs.health && zs.health > 0) {
this.scene.addHitEffect(zs.x, zs.y)
}
} else {
// 新增僵尸
const isElite = zs.isElite || false
this.zombies.set(zs.id, { id: zs.id, x: zs.x, y: zs.y, health: zs.health, angle: zs.angle || 0, isElite })
this.scene.addZombie(zs.id, zs.x, zs.y, isElite)
const isSplitter = zs.isSplitter || false
this.zombies.set(zs.id, { id: zs.id, x: zs.x, y: zs.y, health: zs.health, angle: zs.angle || 0, isElite, isSplitter })
this.scene.addZombie(zs.id, zs.x, zs.y, isElite, isSplitter)
}
}
// 移除服务器不再发送的僵尸(死亡或消失)
for (const [id, zombie] of this.zombies) {
if (!serverZombieIds.has(id)) {
this.scene.addHitEffect(zombie.x, zombie.y)
@@ -298,16 +410,20 @@ export class GameEngine {
}
}
// 更新玩家子弹
if (state.bullets) {
for (const bs of state.bullets) {
if (!this.bullets.has(bs.id)) {
// 新增子弹
this.bullets.set(bs.id, { ...bs })
this.scene.addBullet(bs)
// 枪口火焰特效(手雷除外)
const player = this.players.get(bs.ownerId)
if (player && bs.weapon !== WEAPONS.GRENADE) {
this.scene.addMuzzleFlash(player.x, player.y, bs.angle || 0)
}
} else {
// 更新子弹位置
const bullet = this.bullets.get(bs.id)
bullet.x = bs.x
bullet.y = bs.y
@@ -315,6 +431,7 @@ export class GameEngine {
this.scene.updateBullet(bs.id, bs.x, bs.y, bs.z)
}
}
// 移除消失的子弹并检测命中
const serverBulletIds = new Set(state.bullets.map(b => b.id))
for (const [id, bullet] of this.bullets) {
if (!serverBulletIds.has(id)) {
@@ -325,6 +442,7 @@ export class GameEngine {
}
}
// 移除子弹(服务器明确要求移除)
if (state.removedBullets) {
for (const id of state.removedBullets) {
if (this.bullets.has(id)) {
@@ -336,6 +454,7 @@ export class GameEngine {
}
}
// 爆炸效果
if (state.explosions) {
console.log('Explosions received:', state.explosions)
for (const exp of state.explosions) {
@@ -344,12 +463,14 @@ export class GameEngine {
}
}
// 命中效果
if (state.hits) {
for (const hit of state.hits) {
this.scene.addHitEffect(hit.x, hit.y)
}
}
// 更新僵尸子弹
if (state.zombieBullets) {
for (const bs of state.zombieBullets) {
if (!this.zombieBullets.has(bs.id)) {
@@ -371,6 +492,7 @@ export class GameEngine {
}
}
// 移除僵尸子弹
if (state.removedZombieBullets) {
for (const id of state.removedZombieBullets) {
this.zombieBullets.delete(id)
@@ -378,6 +500,7 @@ export class GameEngine {
}
}
// 更新掉落物
if (state.loots) {
for (const ls of state.loots) {
if (!this.loots.has(ls.id)) {
@@ -394,12 +517,14 @@ export class GameEngine {
}
}
// 更新弹药
if (state.ammo) {
for (const [weapon, ammo] of Object.entries(state.ammo)) {
this.weaponAmmo[weapon] = ammo
}
}
// 更新游戏状态
if (state.gameTime !== undefined) this.gameTime = state.gameTime
if (state.waveNumber !== undefined) this.waveNumber = state.waveNumber
if (state.score !== undefined) this.score = state.score
@@ -407,6 +532,10 @@ export class GameEngine {
if (this.onStateUpdate) this.onStateUpdate('state', state)
}
/**
* 检测子弹是否击中僵尸
* @param {Object} bullet 子弹数据
*/
_checkBulletHit(bullet) {
if (!bullet) return
for (const [, zombie] of this.zombies) {
@@ -420,48 +549,66 @@ export class GameEngine {
}
}
/**
* 客户端预测校正
* 根据服务器状态重新计算本地玩家位置,重放未确认的输入
*/
_reconcileLocalPlayer(serverState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
let lastProcessedSeq = serverState.lastProcessedSeq || 0
// 直接使用服务器位置
player.x = serverState.x
player.y = serverState.y
player.angle = serverState.angle
player.health = serverState.health
player.waitingForRespawn = serverState.waitingForRespawn || false
player.respawnTimer = serverState.respawnTimer || 0
// 丢弃已确认的输入
this.pendingInputs = this.pendingInputs.filter(input => input.seq > lastProcessedSeq)
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 (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
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
}
newX = Math.max(0.5, Math.min(31.5, newX))
newY = Math.max(0.5, Math.min(31.5, 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)
}
newX = Math.max(0.5, Math.min(31.5, newX))
newY = Math.max(0.5, Math.min(31.5, 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)
}
/**
* 获取手雷蓄力百分比
*/
getGrenadeChargePercent() {
return this.grenadeChargePercent
}
/**
* 销毁游戏引擎
* 释放所有资源
*/
destroy() {
this.stop()
this.network.disconnect()

View File

@@ -1,42 +1,62 @@
import * as THREE from 'three'
import { GRID_SIZE, PLAYER_SIZE, ZOMBIE_SIZE, WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
/**
* 游戏场景类
* 使用Three.js管理3D渲染、摄像机、玩家、僵尸、子弹、特效等
*/
export class GameScene {
constructor(canvas) {
this.canvas = canvas
// Three.js核心组件
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)
// WebGL渲染器
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.bullets = []
this.zombieBullets = []
this.loots = new Map()
this.effects = []
this.wallMeshes = []
// 游戏对象映射
this.players = new Map() // Map<playerId, {model, isLocal}>
this.zombies = new Map() // Map<zombieId, {model, isElite, isSplitter}>
this.bullets = [] // 玩家子弹数组
this.zombieBullets = [] // 僵尸子弹数组
this.loots = new Map() // Map<lootId, {mesh, type}>
this.effects = [] // 特效数组
this.wallMeshes = [] // 墙壁网格
// 摄像机辅助
this.gridHelper = null
this.playerMesh = null
this.bulletTrails = []
this.playerMesh = null // 本地玩家网格引用
// 手雷目标指示器
this.grenadeTargetGroup = null
// 初始化
this._setupLighting()
this._setupResize()
this._setupGrenadeTarget()
}
/**
* 设置光照
* 环境光 + 主方向光(带阴影)+ 点光源
*/
_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
@@ -50,11 +70,15 @@ export class GameScene {
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
@@ -63,10 +87,15 @@ export class GameScene {
})
}
/**
* 设置手雷目标指示器
* 由外圈、内圈、十字线组成
*/
_setupGrenadeTarget() {
this.grenadeTargetGroup = new THREE.Group()
this.grenadeTargetGroup.visible = false
// 外圈
const ringGeo = new THREE.RingGeometry(2.8, 3.0, 32)
const ringMat = new THREE.MeshBasicMaterial({
color: 0xff4400,
@@ -79,6 +108,7 @@ export class GameScene {
ring.position.y = 0.05
this.grenadeTargetGroup.add(ring)
// 内圈
const innerRingGeo = new THREE.RingGeometry(0.8, 1.0, 32)
const innerRingMat = new THREE.MeshBasicMaterial({
color: 0xffaa00,
@@ -91,6 +121,7 @@ export class GameScene {
innerRing.position.y = 0.05
this.grenadeTargetGroup.add(innerRing)
// 十字线
const crossGeo = new THREE.BufferGeometry()
const crossPoints = [
-0.3, 0.05, 0, 0.3, 0.05, 0,
@@ -104,30 +135,44 @@ export class GameScene {
this.scene.add(this.grenadeTargetGroup)
}
/**
* 显示手雷目标指示器
* @param {number} playerX 玩家X坐标
* @param {number} playerY 玩家Y坐标
* @param {number} aimX 目标X坐标
* @param {number} aimY 目标Y坐标
* @param {number} chargePercent 蓄力百分比
*/
showGrenadeTarget(playerX, playerY, aimX, aimY, chargePercent) {
if (!this.grenadeTargetGroup) return
// 根据蓄力百分比计算投掷距离
const minDist = 3.0
const maxDist = 15.0
const dist = minDist + (maxDist - minDist) * chargePercent
// 计算目标方向
const dx = aimX - playerX
const dy = aimY - playerY
const targetDist = Math.sqrt(dx * dx + dy * dy)
let targetX, targetY
if (targetDist < 0.1) {
// 如果目标太近,默认向前
targetX = playerX + minDist
targetY = playerY
} else {
// 按比例缩放到蓄力距离
const scale = Math.min(dist, targetDist) / targetDist
targetX = playerX + dx * scale
targetY = playerY + dy * scale
}
// 限制在地图边界内
targetX = Math.max(0.5, Math.min(31.5, targetX))
targetY = Math.max(0.5, Math.min(31.5, targetY))
// 更新指示器位置和缩放
this.grenadeTargetGroup.position.x = targetX
this.grenadeTargetGroup.position.z = targetY
this.grenadeTargetGroup.visible = true
@@ -135,16 +180,25 @@ export class GameScene {
const scale = 1 + chargePercent * 0.3
this.grenadeTargetGroup.scale.setScalar(scale)
// 根据蓄力调整透明度
this.grenadeTargetGroup.children[0].material.opacity = 0.4 + chargePercent * 0.4
}
/**
* 隐藏手雷目标指示器
*/
hideGrenadeTarget() {
if (this.grenadeTargetGroup) {
this.grenadeTargetGroup.visible = false
}
}
/**
* 构建游戏地图
* @param {Array} mapData 二维数组地图数据
*/
buildMap(mapData) {
// 清除旧的墙壁
for (const mesh of this.wallMeshes) {
this.scene.remove(mesh)
mesh.geometry.dispose()
@@ -152,6 +206,7 @@ export class GameScene {
}
this.wallMeshes = []
// 地面
const floorGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE)
const floorMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a })
const floor = new THREE.Mesh(floorGeo, floorMat)
@@ -161,18 +216,23 @@ export class GameScene {
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 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.15, 1)
const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4400, transparent: true, opacity: 0.7 })
// 遍历地图数据
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
if (mapData[y] && mapData[y][x] === 1) {
// 墙壁
const wall = new THREE.Mesh(wallGeo, wallMat)
wall.position.set(x + 0.5, 0.75, y + 0.5)
wall.castShadow = true
@@ -180,11 +240,13 @@ export class GameScene {
this.scene.add(wall)
this.wallMeshes.push(wall)
} else if (mapData[y] && mapData[y][x] === 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 (mapData[y] && mapData[y][x] === 3) {
// 僵尸出生点
const zombieSpawn = new THREE.Mesh(zombieSpawnGeo, zombieSpawnMat)
zombieSpawn.position.set(x + 0.5, 0.08, y + 0.5)
this.scene.add(zombieSpawn)
@@ -194,9 +256,15 @@ export class GameScene {
}
}
/**
* 创建玩家3D模型
* @param {number} color 玩家颜色
* @returns {THREE.Group} 玩家模型组
*/
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)
@@ -204,6 +272,7 @@ export class GameScene {
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)
@@ -211,6 +280,7 @@ export class GameScene {
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)
@@ -221,13 +291,32 @@ export class GameScene {
return group
}
createZombieModel(isElite = false) {
/**
* 创建僵尸3D模型
* @param {boolean} isElite 是否精英怪
* @param {boolean} isSplitter 是否分裂怪
* @returns {THREE.Group} 僵尸模型组
*/
createZombieModel(isElite = false, isSplitter = false) {
const group = new THREE.Group()
const bodyColor = isElite ? 0x882222 : 0x446633
const headColor = isElite ? 0xaa3333 : 0x557744
const armColor = isElite ? 0xaa3333 : 0x557744
// 不同类型僵尸颜色不同
let bodyColor, headColor, armColor
if (isElite) {
bodyColor = 0x882222
headColor = 0xaa3333
armColor = 0xaa3333
} else if (isSplitter) {
bodyColor = 0x886600
headColor = 0xaa8822
armColor = 0xaa8822
} else {
bodyColor = 0x446633
headColor = 0x557744
armColor = 0x557744
}
// 身体
const bodyGeo = new THREE.CylinderGeometry(ZOMBIE_SIZE / 2, ZOMBIE_SIZE / 2, 0.9, 8)
const bodyMat = new THREE.MeshLambertMaterial({ color: bodyColor })
const body = new THREE.Mesh(bodyGeo, bodyMat)
@@ -235,6 +324,7 @@ export class GameScene {
body.castShadow = true
group.add(body)
// 头部
const headGeo = new THREE.SphereGeometry(0.22, 8, 8)
const headMat = new THREE.MeshLambertMaterial({ color: headColor })
const head = new THREE.Mesh(headGeo, headMat)
@@ -242,6 +332,7 @@ export class GameScene {
head.castShadow = true
group.add(head)
// 手臂
const armGeo = new THREE.BoxGeometry(0.12, 0.6, 0.12)
const armMat = new THREE.MeshLambertMaterial({ color: armColor })
const leftArm = new THREE.Mesh(armGeo, armMat)
@@ -253,6 +344,7 @@ export class GameScene {
rightArm.rotation.x = -0.5
group.add(rightArm)
// 精英僵尸发光效果
if (isElite) {
const glowGeo = new THREE.SphereGeometry(0.6, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
@@ -265,9 +357,30 @@ export class GameScene {
group.add(glow)
}
// 分裂僵尸发光效果
if (isSplitter) {
const glowGeo = new THREE.SphereGeometry(0.5, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffaa00,
transparent: true,
opacity: 0.3
})
const glow = new THREE.Mesh(glowGeo, glowMat)
glow.position.y = 0.5
group.add(glow)
}
return group
}
/**
* 添加玩家
* @param {string} id 玩家ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} color 颜色
* @param {boolean} isLocal 是否本地玩家
*/
addPlayer(id, x, y, color, isLocal = false) {
const model = this.createPlayerModel(color)
model.position.set(x, 0, y)
@@ -276,6 +389,10 @@ export class GameScene {
if (isLocal) this.playerMesh = model
}
/**
* 移除玩家
* @param {string} id 玩家ID
*/
removePlayer(id) {
const player = this.players.get(id)
if (player) {
@@ -284,6 +401,14 @@ export class GameScene {
}
}
/**
* 更新玩家位置和角度
* @param {string} id 玩家ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} angle 旋转角度
* @param {number} health 生命值
*/
updatePlayer(id, x, y, angle, health) {
const player = this.players.get(id)
if (player) {
@@ -296,13 +421,25 @@ export class GameScene {
}
}
addZombie(id, x, y, isElite = false) {
const model = this.createZombieModel(isElite)
/**
* 添加僵尸
* @param {string} id 僵尸ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {boolean} isElite 是否精英
* @param {boolean} isSplitter 是否分裂
*/
addZombie(id, x, y, isElite = false, isSplitter = false) {
const model = this.createZombieModel(isElite, isSplitter)
model.position.set(x, 0, y)
this.scene.add(model)
this.zombies.set(id, { model, isElite })
this.zombies.set(id, { model, isElite, isSplitter })
}
/**
* 移除僵尸
* @param {string} id 僵尸ID
*/
removeZombie(id) {
const zombie = this.zombies.get(id)
if (zombie) {
@@ -315,6 +452,14 @@ export class GameScene {
}
}
/**
* 更新僵尸位置和角度
* @param {string} id 僵尸ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} angle 旋转角度
* @param {number} health 生命值
*/
updateZombie(id, x, y, angle, health) {
const zombie = this.zombies.get(id)
if (zombie) {
@@ -324,12 +469,17 @@ export class GameScene {
}
}
/**
* 添加子弹
* @param {Object} bullet 子弹数据
*/
addBullet(bullet) {
const group = new THREE.Group()
const isGrenade = bullet.weapon === WEAPONS.GRENADE
const z = bullet.z || 0.5
if (isGrenade) {
// 手雷:球体 + 发光 + 拖尾
const geo = new THREE.SphereGeometry(0.12, 8, 8)
const mat = new THREE.MeshBasicMaterial({ color: 0x44ff44 })
const mesh = new THREE.Mesh(geo, mat)
@@ -346,6 +496,7 @@ export class GameScene {
glow.position.set(bullet.x, z, bullet.y)
group.add(glow)
// 拖尾线
const trailGeo = new THREE.BufferGeometry()
const trailMat = new THREE.LineBasicMaterial({
color: 0x88ff88,
@@ -361,9 +512,11 @@ export class GameScene {
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
} else {
// 普通子弹
let bulletSize = 0.06
const angle = bullet.angle || 0
// 根据武器类型调整大小
switch (bullet.weapon) {
case WEAPONS.MACHINE_GUN:
bulletSize = 0.05
@@ -373,6 +526,7 @@ export class GameScene {
break
}
// 拖尾
const trailLength = 2.5
const trailGeo = new THREE.BufferGeometry()
const positions = new Float32Array([
@@ -390,12 +544,14 @@ export class GameScene {
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
// 子弹本体
const geo = new THREE.SphereGeometry(bulletSize, 8, 8)
const mat = new THREE.MeshBasicMaterial({ color: 0xffffaa })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(bullet.x, 0.5, bullet.y)
group.add(mesh)
// 发光效果
const glowGeo = new THREE.SphereGeometry(bulletSize * 2, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffff88,
@@ -420,6 +576,10 @@ export class GameScene {
})
}
/**
* 移除子弹
* @param {string} id 子弹ID
*/
removeBullet(id) {
const idx = this.bullets.findIndex(b => b.id === id)
if (idx >= 0) {
@@ -433,6 +593,13 @@ export class GameScene {
}
}
/**
* 更新子弹位置
* @param {string} id 子弹ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} z Z坐标高度
*/
updateBullet(id, x, y, z) {
const bullet = this.bullets.find(b => b.id === id)
if (bullet) {
@@ -446,6 +613,7 @@ export class GameScene {
child.position.y = bullet.z
child.position.z = y
}
// 更新拖尾位置
if (child.isLine && !bullet.isGrenade) {
const trailLength = 2.5
const positions = child.geometry.attributes.position.array
@@ -461,10 +629,15 @@ export class GameScene {
}
}
/**
* 添加僵尸子弹
* @param {Object} bullet 子弹数据
*/
addZombieBullet(bullet) {
const group = new THREE.Group()
const angle = bullet.angle || 0
// 红色拖尾
const trailLength = 1.2
const trailGeo = new THREE.BufferGeometry()
const positions = new Float32Array([
@@ -481,6 +654,7 @@ export class GameScene {
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
// 子弹本体(红色)
const geo = new THREE.SphereGeometry(0.08, 6, 6)
const mat = new THREE.MeshBasicMaterial({ color: 0xff3333 })
const mesh = new THREE.Mesh(geo, mat)
@@ -497,6 +671,10 @@ export class GameScene {
})
}
/**
* 移除僵尸子弹
* @param {string} id 子弹ID
*/
removeZombieBullet(id) {
const idx = this.zombieBullets.findIndex(b => b.id === id)
if (idx >= 0) {
@@ -510,6 +688,12 @@ export class GameScene {
}
}
/**
* 更新僵尸子弹位置
* @param {string} id 子弹ID
* @param {number} x X坐标
* @param {number} y Y坐标
*/
updateZombieBullet(id, x, y) {
const bullet = this.zombieBullets.find(b => b.id === id)
if (bullet) {
@@ -536,7 +720,14 @@ export class GameScene {
}
}
/**
* 添加爆炸效果
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} radius 爆炸半径
*/
addExplosion(x, y, radius) {
// 白色闪光
const whiteFlashGeo = new THREE.SphereGeometry(radius * 0.8, 16, 16)
const whiteFlashMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
@@ -548,11 +739,13 @@ export class GameScene {
this.scene.add(whiteFlash)
this.effects.push({ mesh: whiteFlash, type: 'whiteFlash', startTime: Date.now(), duration: 300 })
// 点光源
const light = new THREE.PointLight(0xffffff, 3, radius * 4)
light.position.set(x, 2, y)
this.scene.add(light)
this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 400 })
// 爆炸火球(多层)
for (let i = 0; i < 3; i++) {
const r = radius * (0.3 + i * 0.2)
const geo = new THREE.SphereGeometry(r, 12, 12)
@@ -567,6 +760,7 @@ export class GameScene {
this.effects.push({ mesh, type: 'explosion', startTime: Date.now(), duration: 500 + i * 100 })
}
// 碎片
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2
const geo = new THREE.BoxGeometry(0.1, 0.1, 0.1)
@@ -586,9 +780,16 @@ export class GameScene {
}
}
/**
* 添加枪口火焰效果
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {number} angle 射击方向角度
*/
addMuzzleFlash(x, y, angle) {
const dist = 0.6
// 火焰球
const flashGeo = new THREE.SphereGeometry(0.25, 8, 8)
const flashMat = new THREE.MeshBasicMaterial({
color: 0xffff00,
@@ -601,6 +802,7 @@ export class GameScene {
this.scene.add(flash)
this.effects.push({ mesh: flash, type: 'muzzle', startTime: Date.now(), duration: 100 })
// 发光球
const glowGeo = new THREE.SphereGeometry(0.4, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xff6600,
@@ -612,13 +814,20 @@ export class GameScene {
this.scene.add(glow)
this.effects.push({ mesh: glow, type: 'muzzle', startTime: Date.now(), duration: 150 })
// 点光源
const light = new THREE.PointLight(0xffaa00, 2, 5)
light.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
this.scene.add(light)
this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 100 })
}
/**
* 添加命中效果
* @param {number} x X坐标
* @param {number} y Y坐标
*/
addHitEffect(x, y) {
// 红色球体
const geo = new THREE.SphereGeometry(0.2, 8, 8)
const mat = new THREE.MeshBasicMaterial({
color: 0xff0000,
@@ -630,6 +839,7 @@ export class GameScene {
this.scene.add(mesh)
this.effects.push({ mesh, type: 'hit', startTime: Date.now(), duration: 200 })
// 火花粒子
for (let i = 0; i < 5; i++) {
const sparkGeo = new THREE.BoxGeometry(0.05, 0.05, 0.05)
const sparkMat = new THREE.MeshBasicMaterial({ color: 0xff4400 })
@@ -650,11 +860,19 @@ export class GameScene {
}
}
/**
* 添加掉落物
* @param {string} id 掉落物ID
* @param {number} x X坐标
* @param {number} y Y坐标
* @param {string} type 类型ammo/health
*/
addLoot(id, x, y, type = 'ammo') {
const isHealth = type === 'health'
const color = isHealth ? 0xff4444 : 0x00ffcc
const emissive = isHealth ? 0xaa2222 : 0x00aa88
// 主体
const geo = new THREE.BoxGeometry(0.4, 0.4, 0.4)
const mat = new THREE.MeshLambertMaterial({ color, emissive, emissiveIntensity: 0.3 })
const mesh = new THREE.Mesh(geo, mat)
@@ -662,6 +880,7 @@ export class GameScene {
this.scene.add(mesh)
this.loots.set(id, { mesh, type })
// 医疗包额外显示十字
if (isHealth) {
const crossGeo = new THREE.BoxGeometry(0.35, 0.1, 0.1)
const crossMat = new THREE.MeshBasicMaterial({ color: 0xffffff })
@@ -676,6 +895,10 @@ export class GameScene {
}
}
/**
* 移除掉落物
* @param {string} id 掉落物ID
*/
removeLoot(id) {
const loot = this.loots.get(id)
if (loot) {
@@ -693,6 +916,11 @@ export class GameScene {
}
}
/**
* 更新摄像机位置(平滑跟随玩家)
* @param {number} targetX 目标X坐标
* @param {number} targetY 目标Y坐标
*/
updateCamera(targetX, targetY) {
const target = new THREE.Vector3(targetX, 0, targetY)
const desiredPos = target.clone().add(this.cameraOffset)
@@ -700,6 +928,12 @@ export class GameScene {
this.camera.lookAt(target)
}
/**
* 将鼠标屏幕坐标转换为游戏世界地面坐标
* @param {number} mouseX 鼠标X坐标
* @param {number} mouseY 鼠标Y坐标
* @returns {Object} {x, y} 世界坐标
*/
getMouseGroundPos(mouseX, mouseY) {
const mouse = new THREE.Vector2(
(mouseX / window.innerWidth) * 2 - 1,
@@ -716,21 +950,28 @@ export class GameScene {
return { x: 0, y: 0 }
}
/**
* 更新所有特效(每帧调用)
* 处理特效的缩放、透明度、物理运动等
*/
updateEffects() {
const now = Date.now()
const gravity = -9.8
// 遍历所有特效
for (let i = this.effects.length - 1; i >= 0; i--) {
const effect = this.effects[i]
const elapsed = (now - effect.startTime) / 1000
const progress = elapsed * 1000 / effect.duration
// 特效结束,移除
if (progress >= 1) {
this.scene.remove(effect.mesh)
if (effect.mesh.geometry) effect.mesh.geometry.dispose()
if (effect.mesh.material) effect.mesh.material.dispose()
this.effects.splice(i, 1)
} else {
// 根据类型更新特效
if (effect.type === 'whiteFlash') {
const scale = 1 + progress * 2
effect.mesh.scale.setScalar(scale)
@@ -747,6 +988,7 @@ export class GameScene {
effect.mesh.material.opacity = 0.9 * (1 - progress)
effect.mesh.scale.setScalar(1 + progress * 2)
} else if (effect.type === 'spark' || effect.type === 'debris') {
// 物理运动
effect.mesh.position.x += effect.vx * 0.016
effect.mesh.position.z += effect.vz * 0.016
effect.vy += gravity * 0.016
@@ -758,6 +1000,7 @@ export class GameScene {
}
}
// 更新子弹拖尾
for (const bullet of this.bullets) {
if (!bullet.isGrenade && bullet.trail) {
const positions = bullet.trail.geometry.attributes.position.array
@@ -771,17 +1014,24 @@ export class GameScene {
}
}
// 掉落物悬浮动画
for (const [, loot] of this.loots) {
loot.mesh.rotation.y += 0.03
loot.mesh.position.y = 0.3 + Math.sin(Date.now() * 0.003) * 0.1
}
}
/**
* 渲染画面
*/
render() {
this.updateEffects()
this.renderer.render(this.scene, this.camera)
}
/**
* 销毁场景,释放资源
*/
destroy() {
this.renderer.dispose()
this.scene.traverse(child => {
@@ -795,4 +1045,4 @@ export class GameScene {
}
})
}
}
}

View File

@@ -5,40 +5,59 @@ import { HUD } from './ui/hud.js'
import { SettingsUI } from './ui/settings.js'
import './style.css'
// WebSocket服务器地址
const WS_URL = `ws://${window.location.hostname}:8080/ws`
/**
* 游戏主应用类
* 负责管理游戏生命周期、大厅UI、游戏HUD和设置界面
*/
class App {
constructor() {
// 主应用容器
this.appEl = document.getElementById('app')
// 创建各个UI区域的容器
this.lobbyEl = document.createElement('div')
this.gameCanvasEl = document.createElement('canvas')
this.hudEl = document.createElement('div')
this.settingsEl = document.createElement('div')
// 将各容器添加到主容器
this.appEl.appendChild(this.lobbyEl)
this.appEl.appendChild(this.gameCanvasEl)
this.appEl.appendChild(this.hudEl)
this.appEl.appendChild(this.settingsEl)
// 默认隐藏游戏相关界面
this.gameCanvasEl.style.display = 'none'
this.hudEl.style.display = 'none'
this.settingsEl.style.display = 'none'
// 游戏引擎实例
this.engine = null
// UI组件实例
this.lobby = new LobbyUI(this.lobbyEl)
this.hud = new HUD(this.hudEl)
this.settings = new SettingsUI(this.settingsEl)
// 玩家状态
this.playerId = null
this.roomId = null
this.isHost = false
this.playerName = ''
// 初始化大厅和设置事件
this._setupLobby()
this._setupSettings()
}
/**
* 设置大厅界面事件处理
* 绑定各种大厅操作回调
*/
_setupLobby() {
// 创建房间
this.lobby.onCreateRoom = (name) => {
this.playerName = name
this._ensureConnection().then(() => {
@@ -46,6 +65,7 @@ class App {
})
}
// 加入房间
this.lobby.onJoinRoom = (roomId, name) => {
this.playerName = name
this._ensureConnection().then(() => {
@@ -53,20 +73,24 @@ class App {
})
}
// 刷新房间列表
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()
@@ -77,9 +101,14 @@ class App {
}
}
/**
* 设置设置界面事件处理
* 绑定按键配置变更回调
*/
_setupSettings() {
this.settings.onKeyChange = (bindings) => {
if (this.engine) {
// 更新游戏引擎中的按键绑定
for (const [action, key] of Object.entries(bindings)) {
this.engine.input.updateKeyBinding(action, key)
}
@@ -87,8 +116,13 @@ class App {
}
}
/**
* 确保网络连接已建立
* 如果未连接则创建游戏引擎并连接服务器
*/
async _ensureConnection() {
if (!this.engine) {
// 首次连接,创建游戏引擎
this.engine = new GameEngine(this.gameCanvasEl)
try {
await this.engine.connect(WS_URL)
@@ -98,6 +132,7 @@ class App {
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()
@@ -107,13 +142,19 @@ class App {
}
}
/**
* 设置网络消息处理器
* 监听服务器发送的各种消息
*/
_setupNetworkHandlers() {
const net = this.engine.network
// 房间列表更新
net.on(MSG_TYPE.ROOM_LIST, (data) => {
this.lobby.updateRoomList(data.rooms)
})
// 房间状态更新
net.on(MSG_TYPE.ROOM_STATE, (data) => {
this.playerId = data.playerId || this.playerId
this.roomId = data.roomId
@@ -121,13 +162,16 @@ class App {
this.lobby.showRoom(data, this.isHost, this.playerId)
})
// 游戏开始
net.on(MSG_TYPE.GAME_STARTED, (data) => {
this.playerId = data.playerId
// 切换界面显示
this.lobbyEl.style.display = 'none'
this.gameCanvasEl.style.display = 'block'
this.hudEl.style.display = 'flex'
this.hud.show()
// 注册游戏状态更新回调
this.engine.onStateUpdate = (type, state) => {
if (type === 'state') {
this._updateHUD(state)
@@ -135,11 +179,16 @@ class App {
}
})
// 服务器错误
net.on(MSG_TYPE.ERROR, (data) => {
console.error('Error:', data.message)
})
}
/**
* 更新HUD显示
* 根据游戏状态更新血量、武器、波次等信息
*/
_updateHUD(state) {
const localPlayer = this.engine.players.get(this.engine.localPlayerId)
if (localPlayer) {
@@ -154,6 +203,9 @@ class App {
)
}
/**
* 显示设置界面
*/
showSettings() {
if (this.engine) {
this.settings.show(this.engine.input.getKeyBindings())
@@ -161,9 +213,12 @@ class App {
}
}
// 创建应用实例
const app = new App()
// 全局键盘事件处理
window.addEventListener('keydown', (e) => {
// ESC键切换设置界面
if (e.code === 'Escape') {
if (app.settings.visible) {
app.settings.hide()
@@ -171,4 +226,4 @@ window.addEventListener('keydown', (e) => {
app.showSettings()
}
}
})
})

View File

@@ -1,27 +1,38 @@
import { MSG_TYPE } from '../utils/constants.js'
/**
* 网络客户端类
* 负责WebSocket连接和消息收发
*/
export class NetworkClient {
constructor() {
this.ws = null
this.handlers = new Map()
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.connected = false
this.playerId = null
this.roomId = null
this.ws = null // WebSocket连接
this.handlers = new Map() // 消息处理器映射
this.reconnectAttempts = 0 // 重连尝试次数
this.maxReconnectAttempts = 5 // 最大重连次数
this.connected = false // 连接状态
this.playerId = null // 玩家ID
this.roomId = null // 房间ID
}
/**
* 连接到WebSocket服务器
* @param {string} url 服务器地址
* @returns {Promise} 连接成功/失败
*/
connect(url) {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(url)
// 连接成功
this.ws.onopen = () => {
this.connected = true
this.reconnectAttempts = 0
resolve()
}
// 收到消息
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
@@ -31,11 +42,13 @@ export class NetworkClient {
}
}
// 连接关闭
this.ws.onclose = () => {
this.connected = false
this._dispatch({ type: MSG_TYPE.ERROR, data: { message: 'Disconnected from server' } })
}
// 连接错误
this.ws.onerror = (err) => {
reject(err)
}
@@ -45,6 +58,9 @@ export class NetworkClient {
})
}
/**
* 断开连接
*/
disconnect() {
if (this.ws) {
this.ws.close()
@@ -53,6 +69,11 @@ export class NetworkClient {
}
}
/**
* 注册消息处理器
* @param {string} type 消息类型
* @param {Function} handler 处理函数
*/
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, [])
@@ -60,6 +81,11 @@ export class NetworkClient {
this.handlers.get(type).push(handler)
}
/**
* 移除消息处理器
* @param {string} type 消息类型
* @param {Function} handler 处理函数
*/
off(type, handler) {
if (this.handlers.has(type)) {
const list = this.handlers.get(type)
@@ -68,12 +94,21 @@ export class NetworkClient {
}
}
/**
* 发送消息到服务器
* @param {string} type 消息类型
* @param {Object} data 消息数据
*/
send(type, data = {}) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, data }))
}
}
/**
* 分发消息到对应处理器
* @param {Object} msg 消息对象 {type, data}
*/
_dispatch(msg) {
const handlers = this.handlers.get(msg.type)
if (handlers) {
@@ -83,31 +118,58 @@ export class NetworkClient {
}
}
// ========== 游戏房间操作 ==========
/**
* 创建房间
* @param {string} playerName 玩家名称
*/
createRoom(playerName) {
this.send(MSG_TYPE.CREATE_ROOM, { playerName })
}
/**
* 加入房间
* @param {string} roomId 房间ID
* @param {string} playerName 玩家名称
*/
joinRoom(roomId, playerName) {
this.send(MSG_TYPE.JOIN_ROOM, { roomId, playerName })
}
/**
* 离开房间
*/
leaveRoom() {
this.send(MSG_TYPE.LEAVE_ROOM, {})
}
/**
* 请求房间列表
*/
requestRoomList() {
this.send(MSG_TYPE.ROOM_LIST, {})
}
/**
* 玩家准备
*/
ready() {
this.send(MSG_TYPE.READY, {})
}
/**
* 开始游戏(房主)
*/
startGame() {
this.send(MSG_TYPE.START_GAME, {})
}
/**
* 发送玩家输入状态
* @param {Object} inputState 输入状态
*/
sendInput(inputState) {
this.send(MSG_TYPE.PLAYER_INPUT, inputState)
}
}
}

View File

@@ -1,5 +1,9 @@
import { WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
/**
* HUD界面类
* 显示游戏中的血量、武器、弹药等信息
*/
export class HUD {
constructor(container) {
this.container = container
@@ -9,15 +13,21 @@ export class HUD {
this._build()
}
/**
* 构建HUD界面元素
*/
_build() {
// 血量条
this.healthBar = document.createElement('div')
this.healthBar.className = 'hud-health-bar'
this.healthBar.innerHTML = '<div class="hud-health-fill"></div><span class="hud-health-text">100</span>'
this.container.appendChild(this.healthBar)
// 武器面板
this.weaponPanel = document.createElement('div')
this.weaponPanel.className = 'hud-weapon-panel'
// 武器列表
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
this.weaponSlots = []
for (let i = 0; i < weaponList.length; i++) {
@@ -25,14 +35,17 @@ export class HUD {
slot.className = 'hud-weapon-slot'
slot.dataset.index = i
// 按键提示1-4
const keyLabel = document.createElement('span')
keyLabel.className = 'hud-weapon-key'
keyLabel.textContent = (i + 1).toString()
// 武器名称
const weaponName = document.createElement('span')
weaponName.className = 'hud-weapon-name'
weaponName.textContent = WEAPON_CONFIG[weaponList[i]].name
// 弹药数量
const ammoText = document.createElement('span')
ammoText.className = 'hud-weapon-ammo'
@@ -45,12 +58,14 @@ export class HUD {
this.container.appendChild(this.weaponPanel)
// 手雷蓄力条(默认隐藏)
this.grenadeChargeBar = document.createElement('div')
this.grenadeChargeBar.className = 'hud-grenade-charge'
this.grenadeChargeBar.innerHTML = '<div class="hud-grenade-charge-fill"></div>'
this.grenadeChargeBar.style.display = 'none'
this.container.appendChild(this.grenadeChargeBar)
// 信息面板(波次、分数、时间)
this.infoPanel = document.createElement('div')
this.infoPanel.className = 'hud-info-panel'
@@ -71,40 +86,59 @@ export class HUD {
this.infoPanel.appendChild(this.timeText)
this.container.appendChild(this.infoPanel)
// 准星
this.crosshair = document.createElement('div')
this.crosshair.className = 'hud-crosshair'
this.container.appendChild(this.crosshair)
// 击杀信息最近5条
this.killFeed = document.createElement('div')
this.killFeed.className = 'hud-kill-feed'
this.container.appendChild(this.killFeed)
}
/**
* 显示HUD
*/
show() {
this.visible = true
this.container.style.display = 'flex'
}
/**
* 隐藏HUD
*/
hide() {
this.visible = false
this.container.style.display = 'none'
}
/**
* 更新血量显示
* @param {number} health 血量值 0-100
*/
updateHealth(health) {
const fill = this.healthBar.querySelector('.hud-health-fill')
const text = this.healthBar.querySelector('.hud-health-text')
const pct = Math.max(0, Math.min(100, health))
fill.style.width = pct + '%'
// 颜色根据血量变化:绿色>60橙色>30红色<=30
if (pct > 60) fill.style.backgroundColor = '#44ff44'
else if (pct > 30) fill.style.backgroundColor = '#ffaa00'
else fill.style.backgroundColor = '#ff4444'
text.textContent = Math.ceil(pct)
}
/**
* 更新武器显示
* @param {number} currentIndex 当前武器索引
* @param {Object} ammo 各武器弹药数
*/
updateWeapons(currentIndex, ammo) {
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
for (let i = 0; i < this.weaponSlots.length; i++) {
const slot = this.weaponSlots[i]
// 高亮当前武器
slot.slot.classList.toggle('hud-weapon-active', i === currentIndex)
const weaponKey = weaponList[i]
const currentAmmo = ammo[weaponKey]
@@ -116,6 +150,10 @@ export class HUD {
}
}
/**
* 更新手雷蓄力显示
* @param {number} percent 蓄力百分比 0-1
*/
updateGrenadeCharge(percent) {
if (percent > 0) {
this.grenadeChargeBar.style.display = 'block'
@@ -126,6 +164,12 @@ export class HUD {
}
}
/**
* 更新游戏信息
* @param {number} wave 当前波次
* @param {number} score 分数
* @param {number} time 游戏时间(秒)
*/
updateInfo(wave, score, time) {
this.waveText.textContent = 'Wave: ' + wave
this.scoreText.textContent = 'Score: ' + score
@@ -134,16 +178,22 @@ export class HUD {
this.timeText.textContent = `Time: ${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* 添加击杀信息条目
* @param {string} message 击杀信息文本
*/
addKillFeed(message) {
const entry = document.createElement('div')
entry.className = 'hud-kill-entry'
entry.textContent = message
this.killFeed.appendChild(entry)
// 4秒后自动移除
setTimeout(() => {
if (entry.parentNode) entry.parentNode.removeChild(entry)
}, 4000)
// 最多保留5条
while (this.killFeed.children.length > 5) {
this.killFeed.removeChild(this.killFeed.firstChild)
}
}
}
}

View File

@@ -1,32 +1,48 @@
/**
* 大厅界面类
* 处理房间创建、加入、玩家准备等大厅功能
*/
export class LobbyUI {
constructor(container) {
this.container = container
// 事件回调
this.onCreateRoom = null
this.onJoinRoom = null
this.onReady = null
this.onStartGame = null
this.onLeaveRoom = null
this.onRefreshRooms = null
// 状态
this.currentRoom = null
this.isHost = false
this.playerId = null
this.render()
}
/**
* 渲染大厅主界面
* 显示房间列表、创建/加入按钮等
*/
render() {
this.container.innerHTML = ''
this.container.className = 'lobby-container'
// 标题
const title = document.createElement('h1')
title.textContent = '🧟 Zombie Crisis 3'
title.className = 'lobby-title'
this.container.appendChild(title)
// 副标题
const subtitle = document.createElement('p')
subtitle.textContent = 'Multiplayer Online Zombie Shooter'
subtitle.className = 'lobby-subtitle'
this.container.appendChild(subtitle)
// 玩家名称输入
const nameSection = document.createElement('div')
nameSection.className = 'lobby-section'
const nameLabel = document.createElement('label')
@@ -41,9 +57,11 @@ export class LobbyUI {
nameSection.appendChild(this.nameInput)
this.container.appendChild(nameSection)
// 操作按钮区域
const actions = document.createElement('div')
actions.className = 'lobby-actions'
// 创建房间按钮
const createBtn = document.createElement('button')
createBtn.textContent = '🏠 Create Room'
createBtn.className = 'lobby-btn lobby-btn-primary'
@@ -52,6 +70,7 @@ export class LobbyUI {
}
actions.appendChild(createBtn)
// 刷新房间列表按钮
const refreshBtn = document.createElement('button')
refreshBtn.textContent = '🔄 Refresh Rooms'
refreshBtn.className = 'lobby-btn lobby-btn-secondary'
@@ -62,6 +81,7 @@ export class LobbyUI {
this.container.appendChild(actions)
// 房间列表区域
this.roomListSection = document.createElement('div')
this.roomListSection.className = 'lobby-room-list'
const roomTitle = document.createElement('h2')
@@ -75,6 +95,10 @@ export class LobbyUI {
this.container.appendChild(this.roomListSection)
}
/**
* 更新房间列表
* @param {Array} rooms 房间数据数组
*/
updateRoomList(rooms) {
this.roomListContent.innerHTML = ''
if (!rooms || rooms.length === 0) {
@@ -85,6 +109,7 @@ export class LobbyUI {
const roomCard = document.createElement('div')
roomCard.className = 'lobby-room-card'
// 房间信息
const roomInfo = document.createElement('div')
roomInfo.className = 'lobby-room-info'
const roomName = document.createElement('span')
@@ -96,6 +121,7 @@ export class LobbyUI {
roomInfo.appendChild(roomName)
roomInfo.appendChild(roomPlayers)
// 加入按钮(房间满时禁用)
const joinBtn = document.createElement('button')
joinBtn.textContent = 'Join'
joinBtn.className = 'lobby-btn lobby-btn-small'
@@ -110,6 +136,12 @@ export class LobbyUI {
}
}
/**
* 显示房间界面
* @param {Object} roomData 房间数据
* @param {boolean} isHost 是否为房主
* @param {string} playerId 玩家ID
*/
showRoom(roomData, isHost, playerId) {
this.currentRoom = roomData
this.isHost = isHost
@@ -118,22 +150,27 @@ export class LobbyUI {
this.container.innerHTML = ''
this.container.className = 'room-container'
// 房间标题
const title = document.createElement('h2')
title.textContent = '🧟 Room: ' + (roomData.hostName || 'Unknown') + "'s Room"
title.className = 'room-title'
this.container.appendChild(title)
// 玩家列表
const playerList = document.createElement('div')
playerList.className = 'room-player-list'
for (const player of roomData.players) {
const playerCard = document.createElement('div')
// 当前玩家高亮显示
playerCard.className = 'room-player-card' + (player.id === playerId ? ' room-player-local' : '')
// 玩家名称(房主显示皇冠标记)
const playerName = document.createElement('span')
playerName.className = 'room-player-name'
playerName.textContent = player.name + (player.id === roomData.hostId ? ' 👑' : '')
// 玩家状态
const playerStatus = document.createElement('span')
playerStatus.className = 'room-player-status'
if (player.id === roomData.hostId) {
@@ -151,10 +188,12 @@ export class LobbyUI {
this.container.appendChild(playerList)
// 操作按钮区域
const actions = document.createElement('div')
actions.className = 'room-actions'
if (!isHost) {
// 非房主:准备/取消准备按钮
const readyBtn = document.createElement('button')
const myPlayer = roomData.players.find(p => p.id === playerId)
readyBtn.textContent = myPlayer && myPlayer.ready ? '❌ Unready' : '✅ Ready'
@@ -164,6 +203,7 @@ export class LobbyUI {
}
actions.appendChild(readyBtn)
} else {
// 房主开始游戏按钮所有玩家准备好且至少1人才能开始
const allReady = roomData.players.filter(p => p.id !== roomData.hostId).every(p => p.ready)
const startBtn = document.createElement('button')
startBtn.textContent = '🚀 Start Game'
@@ -175,6 +215,7 @@ export class LobbyUI {
actions.appendChild(startBtn)
}
// 离开房间按钮
const leaveBtn = document.createElement('button')
leaveBtn.textContent = '🚪 Leave Room'
leaveBtn.className = 'lobby-btn lobby-btn-danger'
@@ -186,7 +227,10 @@ export class LobbyUI {
this.container.appendChild(actions)
}
/**
* 销毁大厅界面
*/
destroy() {
this.container.innerHTML = ''
}
}
}

View File

@@ -1,8 +1,14 @@
/**
* 设置界面类
* 管理游戏按键配置
*/
export class SettingsUI {
constructor(container) {
this.container = container
this.visible = false
this.onKeyChange = null
// 默认按键绑定
this.defaultBindings = {
moveUp: 'KeyW',
moveDown: 'KeyS',
@@ -16,31 +22,44 @@ export class SettingsUI {
this.bindings = { ...this.defaultBindings }
}
/**
* 显示设置界面
* @param {Object} currentBindings 当前按键绑定
*/
show(currentBindings) {
if (currentBindings) this.bindings = { ...currentBindings }
this.visible = true
this._render()
}
/**
* 隐藏设置界面
*/
hide() {
this.visible = false
this.container.innerHTML = ''
this.container.style.display = 'none'
}
/**
* 渲染设置界面
*/
_render() {
this.container.style.display = 'flex'
this.container.className = 'settings-container'
this.container.innerHTML = ''
// 设置面板
const panel = document.createElement('div')
panel.className = 'settings-panel'
// 标题
const title = document.createElement('h2')
title.textContent = '⚙ Key Bindings'
title.className = 'settings-title'
panel.appendChild(title)
// 按键标签映射
const labels = {
moveUp: 'Move Up',
moveDown: 'Move Down',
@@ -52,6 +71,7 @@ export class SettingsUI {
weapon4: 'Weapon 4 (Grenade)'
}
// 生成每种操作的按键配置行
for (const [action, key] of Object.entries(this.bindings)) {
const row = document.createElement('div')
row.className = 'settings-row'
@@ -60,6 +80,7 @@ export class SettingsUI {
label.className = 'settings-label'
label.textContent = labels[action] || action
// 按键捕获按钮
const keyBtn = document.createElement('button')
keyBtn.className = 'settings-key-btn'
keyBtn.textContent = this._formatKey(key)
@@ -70,9 +91,11 @@ export class SettingsUI {
panel.appendChild(row)
}
// 按钮行
const btnRow = document.createElement('div')
btnRow.className = 'settings-btn-row'
// 保存按钮
const saveBtn = document.createElement('button')
saveBtn.textContent = 'Save'
saveBtn.className = 'lobby-btn lobby-btn-primary'
@@ -81,11 +104,13 @@ export class SettingsUI {
this.hide()
}
// 取消按钮
const cancelBtn = document.createElement('button')
cancelBtn.textContent = 'Cancel'
cancelBtn.className = 'lobby-btn lobby-btn-secondary'
cancelBtn.onclick = () => this.hide()
// 重置默认按钮
const resetBtn = document.createElement('button')
resetBtn.textContent = 'Reset Defaults'
resetBtn.className = 'lobby-btn lobby-btn-danger'
@@ -102,6 +127,11 @@ export class SettingsUI {
this.container.appendChild(panel)
}
/**
* 捕获按键输入
* @param {string} action 操作类型
* @param {HTMLElement} btn 按键按钮元素
*/
_captureKey(action, btn) {
btn.textContent = 'Press a key...'
btn.classList.add('settings-key-btn-capturing')
@@ -109,6 +139,7 @@ export class SettingsUI {
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
// 更新绑定并显示格式化后的按键
this.bindings[action] = e.code
btn.textContent = this._formatKey(e.code)
btn.classList.remove('settings-key-btn-capturing')
@@ -118,6 +149,11 @@ export class SettingsUI {
window.addEventListener('keydown', handler)
}
/**
* 格式化按键代码为可读文本
* @param {string} code 按键代码
* @returns {string} 格式化后的文本
*/
_formatKey(code) {
return code
.replace('Key', '')
@@ -128,4 +164,4 @@ export class SettingsUI {
.replace('Up', '↑')
.replace('Down', '↓')
}
}
}

View File

@@ -1,33 +1,48 @@
// ========== 地图尺寸常量 ==========
// 地图网格大小32x32格子
export const GRID_SIZE = 32
// 每个格子的世界单位大小
export const CELL_SIZE = 1
// 玩家碰撞半径
export const PLAYER_SIZE = 0.8
// 僵尸碰撞半径
export const ZOMBIE_SIZE = 0.8
// 墙壁厚度
export const WALL_SIZE = 1
// 出生点标记
export const SPAWN_SIZE = 1
// ========== 游戏帧率常量 ==========
// 服务器每秒Tick数
export const TICK_RATE = 30
// 每Tick间隔毫秒
export const TICK_INTERVAL = 1000 / TICK_RATE
// ========== 武器枚举 ==========
export const WEAPONS = {
PISTOL: 'pistol',
MACHINE_GUN: 'machine_gun',
SHOTGUN: 'shotgun',
GRENADE: 'grenade'
PISTOL: 'pistol', // 手枪
MACHINE_GUN: 'machine_gun', // 机枪
SHOTGUN: 'shotgun', // 霰弹枪
GRENADE: 'grenade' // 手雷
}
// ========== 武器配置 ==========
export const WEAPON_CONFIG = {
// 手枪配置
[WEAPONS.PISTOL]: {
name: 'Pistol',
damage: 25,
fireRate: 400,
ammo: Infinity,
maxAmmo: Infinity,
speed: 20,
spread: 0,
pellets: 1,
range: 30,
auto: false,
chargeable: false
damage: 25, // 伤害
fireRate: 400, // 射击间隔(毫秒)
ammo: Infinity, // 弹药量(无限)
maxAmmo: Infinity, // 最大弹药
speed: 20, // 子弹速度
spread: 0, // 散布角度
pellets: 1, // 每次射击子弹数
range: 30, // 射程
auto: false, // 非自动武器
chargeable: false // 不可蓄力
},
// 机枪配置
[WEAPONS.MACHINE_GUN]: {
name: 'Machine Gun',
damage: 15,
@@ -35,12 +50,13 @@ export const WEAPON_CONFIG = {
ammo: 100,
maxAmmo: 100,
speed: 25,
spread: 0.05,
spread: 0.05, // 小幅散布
pellets: 1,
range: 25,
auto: true,
auto: true, // 自动武器
chargeable: false
},
// 霰弹枪配置
[WEAPONS.SHOTGUN]: {
name: 'Shotgun',
damage: 20,
@@ -48,12 +64,13 @@ export const WEAPON_CONFIG = {
ammo: 20,
maxAmmo: 20,
speed: 18,
spread: 0.15,
pellets: 6,
spread: 0.15, // 大幅散布
pellets: 6, // 每次6颗弹丸
range: 12,
auto: false,
chargeable: false
},
// 手雷配置
[WEAPONS.GRENADE]: {
name: 'Grenade',
damage: 100,
@@ -65,45 +82,48 @@ export const WEAPON_CONFIG = {
pellets: 1,
range: 15,
auto: false,
chargeable: true,
maxCharge: 2000,
explosionRadius: 3
chargeable: true, // 可蓄力
maxCharge: 2000, // 最大蓄力时间(毫秒)
explosionRadius: 3 // 爆炸半径
}
}
// ========== 僵尸配置 ==========
export const ZOMBIE_CONFIG = {
BASE_HEALTH: 100,
BASE_SPEED: 2,
DAMAGE: 10,
ATTACK_RATE: 1000,
SPAWN_INTERVAL_BASE: 3000,
SPAWN_INTERVAL_MIN: 800,
DIFFICULTY_INCREASE_INTERVAL: 30000,
HEALTH_INCREASE_PER_WAVE: 20,
SPEED_INCREASE_PER_WAVE: 0.1,
LOOT_DROP_CHANCE: 0.3
BASE_HEALTH: 100, // 基础生命值
BASE_SPEED: 2, // 基础移动速度
DAMAGE: 10, // 攻击伤害
ATTACK_RATE: 1000, // 攻击间隔(毫秒)
SPAWN_INTERVAL_BASE: 3000, // 基础生成间隔
SPAWN_INTERVAL_MIN: 800, // 最小生成间隔
DIFFICULTY_INCREASE_INTERVAL: 30000, // 难度增加间隔
HEALTH_INCREASE_PER_WAVE: 20, // 每波生命值增加
SPEED_INCREASE_PER_WAVE: 0.1, // 每波速度增加
LOOT_DROP_CHANCE: 0.3 // 掉落物概率
}
// ========== 玩家配置 ==========
export const PLAYER_CONFIG = {
MAX_HEALTH: 100,
SPEED: 5,
INVULNERABLE_TIME: 500
MAX_HEALTH: 100, // 最大生命值
SPEED: 5, // 移动速度
INVULNERABLE_TIME: 500 // 无敌时间(受伤后)
}
// ========== 网络消息类型 ==========
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',
PING: 'ping',
PONG: 'pong'
}
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', // 错误消息
PING: 'ping', // 心跳检测
PONG: 'pong' // 心跳响应
}

View File

@@ -1,33 +1,61 @@
import { GRID_SIZE, CELL_SIZE, WALL_SIZE } from './constants.js'
/**
* 地图网格类
* 管理地图数据、碰撞检测和路径搜索
*/
export class Grid {
constructor(mapData) {
this.width = GRID_SIZE
this.height = GRID_SIZE
this.cells = []
this.width = GRID_SIZE // 网格宽度
this.height = GRID_SIZE // 网格高度
this.cells = [] // 网格数据(二维数组)
this.parseMap(mapData)
}
/**
* 解析地图数据
* 0 = 可行走1 = 墙壁2 = 玩家出生点3 = 僵尸出生点
* @param {Array} mapData 二维数组地图数据
*/
parseMap(mapData) {
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] ? mapData[y][x] || 0 : 0
}
}
}
/**
* 检查指定网格是否为墙壁
* @param {number} gridX 网格X坐标
* @param {number} gridY 网格Y坐标
* @returns {boolean} 是否为墙壁
*/
isWall(gridX, gridY) {
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true
return this.cells[gridY][gridX] === 1
}
/**
* 检查指定网格是否为玩家出生点
* @param {number} gridX 网格X坐标
* @param {number} gridY 网格Y坐标
* @returns {boolean} 是否为出生点
*/
isSpawnPoint(gridX, gridY) {
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return false
return this.cells[gridY][gridX] === 2
}
/**
* 世界坐标转换为网格坐标
* @param {number} wx 世界X坐标
* @param {number} wy 世界Y坐标
* @returns {Object} {x, y} 网格坐标
*/
worldToGrid(wx, wy) {
return {
x: Math.floor(wx / CELL_SIZE),
@@ -35,6 +63,12 @@ export class Grid {
}
}
/**
* 网格坐标转换为世界坐标(中心点)
* @param {number} gx 网格X坐标
* @param {number} gy 网格Y坐标
* @returns {Object} {x, y} 世界坐标
*/
gridToWorld(gx, gy) {
return {
x: gx * CELL_SIZE + CELL_SIZE / 2,
@@ -42,8 +76,17 @@ export class Grid {
}
}
/**
* 检查世界坐标是否可通行
* 检测实体四个角是否与墙壁碰撞
* @param {number} wx 世界X坐标
* @param {number} wy 世界Y坐标
* @param {number} size 实体半径
* @returns {boolean} 是否可通行
*/
isWalkable(wx, wy, size) {
const half = size / 2
// 检测四个角
const corners = [
{ x: wx - half, y: wy - half },
{ x: wx + half, y: wy - half },
@@ -57,6 +100,10 @@ export class Grid {
return true
}
/**
* 获取所有玩家出生点
* @returns {Array} 出生点坐标数组
*/
getSpawnPoints() {
const points = []
for (let y = 0; y < this.height; y++) {
@@ -69,18 +116,31 @@ export class Grid {
return points
}
/**
* A*路径搜索算法
* 寻找从起点到终点的最短路径
* @param {number} startX 起点世界X坐标
* @param {number} startY 起点世界Y坐标
* @param {number} endX 终点世界X坐标
* @param {number} endY 终点世界Y坐标
* @returns {Array|null} 路径点数组或null无路径
*/
findPath(startX, startY, endX, endY) {
const sg = this.worldToGrid(startX, startY)
const eg = this.worldToGrid(endX, endY)
// 终点是墙壁则无解
if (this.isWall(eg.x, eg.y)) return null
const openSet = []
const closedSet = new Set()
const cameFrom = new Map()
// A*算法数据结构
const openSet = [] // 待处理节点
const closedSet = new Set() // 已处理节点
const cameFrom = new Map() // 路径记录
// 曼哈顿距离启发函数
const heuristic = (ax, ay, bx, by) => Math.abs(ax - bx) + Math.abs(ay - by)
// 起点入队
const startKey = `${sg.x},${sg.y}`
openSet.push({
x: sg.x,
@@ -90,6 +150,7 @@ export class Grid {
f: heuristic(sg.x, sg.y, eg.x, eg.y)
})
// 8个方向4方向+4对角
const directions = [
{ dx: 0, dy: -1 },
{ dx: 0, dy: 1 },
@@ -104,12 +165,15 @@ export class Grid {
let iterations = 0
const maxIterations = 2000
// 主循环
while (openSet.length > 0 && iterations < maxIterations) {
iterations++
// 按f值排序取最小
openSet.sort((a, b) => a.f - b.f)
const current = openSet.shift()
const currentKey = `${current.x},${current.y}`
// 到达终点,生成路径
if (current.x === eg.x && current.y === eg.y) {
const path = []
let key = currentKey
@@ -124,6 +188,7 @@ export class Grid {
closedSet.add(currentKey)
// 遍历邻居
for (const dir of directions) {
const nx = current.x + dir.dx
const ny = current.y + dir.dy
@@ -133,18 +198,22 @@ export class Grid {
if (this.isWall(nx, ny)) continue
if (nx < 0 || nx >= this.width || ny < 0 || ny >= this.height) continue
// 对角线移动时检查是否穿越角落
if (dir.dx !== 0 && dir.dy !== 0) {
if (this.isWall(current.x + dir.dx, current.y) || this.isWall(current.x, current.y + dir.dy)) {
continue
}
}
// 计算移动成本(对角线更长)
const isDiagonal = dir.dx !== 0 && dir.dy !== 0
const moveCost = isDiagonal ? 1.414 : 1
const g = current.g + moveCost
// 检查是否已在openSet中
const existing = openSet.find(n => n.x === nx && n.y === ny)
if (existing) {
// 如果新路径更短则更新
if (g < existing.g) {
existing.g = g
existing.f = g + existing.h
@@ -158,15 +227,21 @@ export class Grid {
}
}
return null
return null // 超过最大迭代,无路径
}
}
/**
* 生成默认地图
* 带有边框墙壁和一些内部障碍物
* @returns {Array} 二维数组地图数据
*/
export function generateDefaultMap() {
const map = []
for (let y = 0; y < GRID_SIZE; y++) {
map[y] = []
for (let x = 0; x < GRID_SIZE; x++) {
// 边框设为墙壁
if (x === 0 || x === GRID_SIZE - 1 || y === 0 || y === GRID_SIZE - 1) {
map[y][x] = 1
} else {
@@ -175,6 +250,7 @@ export function generateDefaultMap() {
}
}
// 墙壁段落列表
const wallSegments = [
{ x1: 5, y1: 5, x2: 5, y2: 10 },
{ x1: 10, y1: 3, x2: 15, y2: 3 },
@@ -192,14 +268,17 @@ export function generateDefaultMap() {
{ x1: 18, y1: 26, x2: 18, y2: 30 }
]
// 绘制墙壁
for (const seg of wallSegments) {
if (seg.x1 === seg.x2) {
// 垂直墙
for (let y = seg.y1; y <= seg.y2; y++) {
if (seg.x1 > 0 && seg.x1 < GRID_SIZE - 1 && y > 0 && y < GRID_SIZE - 1) {
map[y][seg.x1] = 1
}
}
} else {
// 水平墙
for (let x = seg.x1; x <= seg.x2; x++) {
if (x > 0 && x < GRID_SIZE - 1 && seg.y1 > 0 && seg.y1 < GRID_SIZE - 1) {
map[seg.y1][x] = 1
@@ -208,6 +287,7 @@ export function generateDefaultMap() {
}
}
// 玩家出生点(四角)
const spawnPoints = [
{ x: 2, y: 2 },
{ x: 29, y: 2 },
@@ -216,6 +296,7 @@ export function generateDefaultMap() {
]
for (const sp of spawnPoints) {
map[sp.y][sp.x] = 2
// 出生点周围1格清除墙壁
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const ny = sp.y + dy
@@ -227,6 +308,7 @@ export function generateDefaultMap() {
}
}
// 僵尸出生点(中心和四边中点)
const zombieSpawns = [
{ x: 16, y: 2 },
{ x: 2, y: 16 },
@@ -235,6 +317,7 @@ export function generateDefaultMap() {
{ x: 16, y: 16 }
]
for (const sp of zombieSpawns) {
// 僵尸出生点周围清除墙壁
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const ny = sp.y + dy
@@ -247,4 +330,4 @@ export function generateDefaultMap() {
}
return map
}
}

View File

@@ -1,7 +1,15 @@
/**
* 输入管理器类
* 处理键盘和鼠标输入,构建输入状态
*/
export class InputManager {
constructor() {
// 按键状态映射
this.keys = {}
// 鼠标状态
this.mouse = { x: 0, y: 0, left: false, right: false }
// 按键绑定配置
this.keyBindings = {
moveUp: 'KeyW',
moveDown: 'KeyS',
@@ -12,11 +20,16 @@ export class InputManager {
weapon3: 'Digit3',
weapon4: 'Digit4'
}
// 待处理的输入序列(用于客户端预测)
this.pendingActions = []
this.sequenceNumber = 0
// 事件回调
this.onKeyDown = null
this.onKeyUp = null
// 绑定事件处理器使用箭头函数保持this引用
this._onKeyDown = (e) => {
this.keys[e.code] = true
if (this.onKeyDown) this.onKeyDown(e)
@@ -40,6 +53,10 @@ export class InputManager {
this._onContextMenu = (e) => e.preventDefault()
}
/**
* 附加事件监听器
* 开始监听键盘鼠标事件
*/
attach() {
window.addEventListener('keydown', this._onKeyDown)
window.addEventListener('keyup', this._onKeyUp)
@@ -49,6 +66,10 @@ export class InputManager {
window.addEventListener('contextmenu', this._onContextMenu)
}
/**
* 分离事件监听器
* 停止监听键盘鼠标事件
*/
detach() {
window.removeEventListener('keydown', this._onKeyDown)
window.removeEventListener('keyup', this._onKeyUp)
@@ -58,12 +79,18 @@ export class InputManager {
window.removeEventListener('contextmenu', this._onContextMenu)
}
/**
* 获取移动方向向量
* @returns {Object} {dx, dy} 归一化的移动向量
*/
getMovement() {
let dx = 0, dy = 0
// WASD或方向键
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
// 对角线移动时归一化
if (dx !== 0 && dy !== 0) {
dx *= 0.7071
dy *= 0.7071
@@ -71,6 +98,10 @@ export class InputManager {
return { dx, dy }
}
/**
* 获取当前选择的武器
* @returns {number} 武器索引0-3无选择返回-1
*/
getSelectedWeapon() {
if (this.keys[this.keyBindings.weapon1]) return 0
if (this.keys[this.keyBindings.weapon2]) return 1
@@ -79,28 +110,42 @@ export class InputManager {
return -1
}
/**
* 构建当前输入状态
* @param {Object} mouseGroundPos 鼠标在世界坐标中的位置
* @returns {Object} 输入状态对象
*/
buildInputState(mouseGroundPos) {
const movement = this.getMovement()
const weaponIdx = this.getSelectedWeapon()
this.sequenceNumber++
return {
seq: this.sequenceNumber,
dx: movement.dx,
dy: movement.dy,
aimX: mouseGroundPos.x,
aimY: mouseGroundPos.y,
firing: this.mouse.left,
weaponIndex: weaponIdx
seq: this.sequenceNumber, // 序列号(用于客户端预测校正)
dx: movement.dx, // 移动方向X
dy: movement.dy, // 移动方向Y
aimX: mouseGroundPos.x, // 鼠标X坐标世界
aimY: mouseGroundPos.y, // 鼠标Y坐标世界
firing: this.mouse.left, // 是否开火
weaponIndex: weaponIdx // 武器索引
}
}
/**
* 更新按键绑定
* @param {string} action 操作类型
* @param {string} keyCode 按键代码
*/
updateKeyBinding(action, keyCode) {
if (this.keyBindings.hasOwnProperty(action)) {
this.keyBindings[action] = keyCode
}
}
/**
* 获取当前按键绑定
* @returns {Object} 按键绑定副本
*/
getKeyBindings() {
return { ...this.keyBindings }
}
}
}