1
This commit is contained in:
470
frontend/src/game/engine.js
Normal file
470
frontend/src/game/engine.js
Normal file
@@ -0,0 +1,470 @@
|
||||
import { TICK_RATE, TICK_INTERVAL, MSG_TYPE, WEAPONS, WEAPON_CONFIG, PLAYER_CONFIG, ZOMBIE_CONFIG } from '../utils/constants.js'
|
||||
import { Grid, generateDefaultMap } from '../utils/grid.js'
|
||||
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
|
||||
this.scene = new GameScene(canvas)
|
||||
this.input = new InputManager()
|
||||
this.network = new NetworkClient()
|
||||
this.grid = null
|
||||
this.mapData = null
|
||||
|
||||
this.localPlayerId = null
|
||||
this.players = new Map()
|
||||
this.zombies = new Map()
|
||||
this.bullets = new Map()
|
||||
this.zombieBullets = new Map()
|
||||
this.loots = new Map()
|
||||
|
||||
this.running = false
|
||||
this.lastTick = 0
|
||||
this.accumulator = 0
|
||||
|
||||
this.pendingInputs = []
|
||||
this.serverStates = []
|
||||
|
||||
this.currentWeaponIndex = 0
|
||||
this.weaponAmmo = {
|
||||
[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.gameTime = 0
|
||||
this.waveNumber = 0
|
||||
this.score = 0
|
||||
|
||||
this.onStateUpdate = null
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
_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,
|
||||
weaponIndex: 0,
|
||||
color,
|
||||
isLocal
|
||||
})
|
||||
this.scene.addPlayer(p.id, p.x, p.y, color, isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
_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,
|
||||
weaponIndex: 0,
|
||||
color,
|
||||
isLocal
|
||||
})
|
||||
this.scene.addPlayer(data.id, data.x, data.y, color, isLocal)
|
||||
}
|
||||
|
||||
_removePlayer(data) {
|
||||
this.players.delete(data.id)
|
||||
this.scene.removePlayer(data.id)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
this.input.attach()
|
||||
this.lastTick = performance.now()
|
||||
this._loop()
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
this.input.detach()
|
||||
}
|
||||
|
||||
_loop() {
|
||||
if (!this.running) return
|
||||
requestAnimationFrame(() => this._loop())
|
||||
|
||||
const now = performance.now()
|
||||
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.input.mouse.groundX || 0, this.input.mouse.groundY || 0,
|
||||
this.grenadeChargePercent)
|
||||
} else {
|
||||
this.scene.hideGrenadeTarget()
|
||||
}
|
||||
}
|
||||
this.scene.render()
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this.localPlayerId) 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
|
||||
this.grenadeChargePercent = Math.min(1, elapsed / WEAPON_CONFIG[WEAPONS.GRENADE].maxCharge)
|
||||
}
|
||||
} else {
|
||||
this.isChargingGrenade = false
|
||||
this.grenadeChargePercent = 0
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
zombie.y = zs.y
|
||||
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)
|
||||
}
|
||||
}
|
||||
for (const [id, zombie] of this.zombies) {
|
||||
if (!serverZombieIds.has(id)) {
|
||||
this.scene.addHitEffect(zombie.x, zombie.y)
|
||||
this.zombies.delete(id)
|
||||
this.scene.removeZombie(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
bullet.z = bs.z
|
||||
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)) {
|
||||
this._checkBulletHit(bullet)
|
||||
this.bullets.delete(id)
|
||||
this.scene.removeBullet(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.removedBullets) {
|
||||
for (const id of state.removedBullets) {
|
||||
if (this.bullets.has(id)) {
|
||||
const bullet = this.bullets.get(id)
|
||||
this._checkBulletHit(bullet)
|
||||
this.bullets.delete(id)
|
||||
}
|
||||
this.scene.removeBullet(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.explosions) {
|
||||
console.log('Explosions received:', state.explosions)
|
||||
for (const exp of state.explosions) {
|
||||
console.log('Creating explosion at:', exp.x, exp.y, 'radius:', exp.radius)
|
||||
this.scene.addExplosion(exp.x, exp.y, exp.radius || 3)
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
this.zombieBullets.set(bs.id, { ...bs })
|
||||
this.scene.addZombieBullet(bs)
|
||||
} else {
|
||||
const bullet = this.zombieBullets.get(bs.id)
|
||||
bullet.x = bs.x
|
||||
bullet.y = bs.y
|
||||
this.scene.updateZombieBullet(bs.id, bs.x, bs.y)
|
||||
}
|
||||
}
|
||||
const serverBulletIds = new Set(state.zombieBullets.map(b => b.id))
|
||||
for (const [id] of this.zombieBullets) {
|
||||
if (!serverBulletIds.has(id)) {
|
||||
this.zombieBullets.delete(id)
|
||||
this.scene.removeZombieBullet(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.removedZombieBullets) {
|
||||
for (const id of state.removedZombieBullets) {
|
||||
this.zombieBullets.delete(id)
|
||||
this.scene.removeZombieBullet(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loots) {
|
||||
for (const ls of state.loots) {
|
||||
if (!this.loots.has(ls.id)) {
|
||||
this.loots.set(ls.id, ls)
|
||||
this.scene.addLoot(ls.id, ls.x, ls.y, ls.type || 'ammo')
|
||||
}
|
||||
}
|
||||
const serverLootIds = new Set(state.loots.map(l => l.id))
|
||||
for (const [id] of this.loots) {
|
||||
if (!serverLootIds.has(id)) {
|
||||
this.loots.delete(id)
|
||||
this.scene.removeLoot(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (this.onStateUpdate) this.onStateUpdate('state', state)
|
||||
}
|
||||
|
||||
_checkBulletHit(bullet) {
|
||||
if (!bullet) return
|
||||
for (const [, zombie] of this.zombies) {
|
||||
const dx = bullet.x - zombie.x
|
||||
const dy = bullet.y - zombie.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 1.0) {
|
||||
this.scene.addHitEffect(zombie.x, zombie.y)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
|
||||
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 (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)
|
||||
}
|
||||
|
||||
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
|
||||
}
|
||||
|
||||
getGrenadeChargePercent() {
|
||||
return this.grenadeChargePercent
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop()
|
||||
this.network.disconnect()
|
||||
this.scene.destroy()
|
||||
}
|
||||
}
|
||||
798
frontend/src/game/scene.js
Normal file
798
frontend/src/game/scene.js
Normal file
@@ -0,0 +1,798 @@
|
||||
import * as THREE from 'three'
|
||||
import { GRID_SIZE, PLAYER_SIZE, ZOMBIE_SIZE, WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
|
||||
|
||||
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.bullets = []
|
||||
this.zombieBullets = []
|
||||
this.loots = new Map()
|
||||
this.effects = []
|
||||
this.wallMeshes = []
|
||||
this.gridHelper = null
|
||||
this.playerMesh = null
|
||||
this.bulletTrails = []
|
||||
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
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
_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,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat)
|
||||
ring.rotation.x = -Math.PI / 2
|
||||
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,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const innerRing = new THREE.Mesh(innerRingGeo, innerRingMat)
|
||||
innerRing.rotation.x = -Math.PI / 2
|
||||
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,
|
||||
0, 0.05, -0.3, 0, 0.05, 0.3
|
||||
]
|
||||
crossGeo.setAttribute('position', new THREE.Float32BufferAttribute(crossPoints, 3))
|
||||
const crossMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })
|
||||
const cross = new THREE.LineSegments(crossGeo, crossMat)
|
||||
this.grenadeTargetGroup.add(cross)
|
||||
|
||||
this.scene.add(this.grenadeTargetGroup)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
buildMap(mapData) {
|
||||
for (const mesh of this.wallMeshes) {
|
||||
this.scene.remove(mesh)
|
||||
mesh.geometry.dispose()
|
||||
mesh.material.dispose()
|
||||
}
|
||||
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)
|
||||
floor.rotation.x = -Math.PI / 2
|
||||
floor.position.set(GRID_SIZE / 2, 0, GRID_SIZE / 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 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
|
||||
wall.receiveShadow = true
|
||||
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)
|
||||
this.wallMeshes.push(zombieSpawn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
group.userData.gun = gun
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
createZombieModel(isElite = false) {
|
||||
const group = new THREE.Group()
|
||||
|
||||
const bodyColor = isElite ? 0x882222 : 0x446633
|
||||
const headColor = isElite ? 0xaa3333 : 0x557744
|
||||
const armColor = isElite ? 0xaa3333 : 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)
|
||||
body.position.y = 0.45
|
||||
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)
|
||||
head.position.y = 1.05
|
||||
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)
|
||||
leftArm.position.set(-0.35, 0.5, 0.2)
|
||||
leftArm.rotation.x = -0.5
|
||||
group.add(leftArm)
|
||||
const rightArm = new THREE.Mesh(armGeo, armMat)
|
||||
rightArm.position.set(0.35, 0.5, 0.2)
|
||||
rightArm.rotation.x = -0.5
|
||||
group.add(rightArm)
|
||||
|
||||
if (isElite) {
|
||||
const glowGeo = new THREE.SphereGeometry(0.6, 8, 8)
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.2
|
||||
})
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat)
|
||||
glow.position.y = 0.5
|
||||
group.add(glow)
|
||||
}
|
||||
|
||||
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 })
|
||||
if (isLocal) this.playerMesh = model
|
||||
}
|
||||
|
||||
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
|
||||
if (health !== undefined) {
|
||||
player.model.userData.health = health
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addZombie(id, x, y, isElite = false) {
|
||||
const model = this.createZombieModel(isElite)
|
||||
model.position.set(x, 0, y)
|
||||
this.scene.add(model)
|
||||
this.zombies.set(id, { model, isElite })
|
||||
}
|
||||
|
||||
removeZombie(id) {
|
||||
const zombie = this.zombies.get(id)
|
||||
if (zombie) {
|
||||
this.scene.remove(zombie.model)
|
||||
zombie.model.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) child.material.dispose()
|
||||
})
|
||||
this.zombies.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
updateZombie(id, x, y, angle, health) {
|
||||
const zombie = this.zombies.get(id)
|
||||
if (zombie) {
|
||||
zombie.model.position.x = x
|
||||
zombie.model.position.z = y
|
||||
zombie.model.rotation.y = angle
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
mesh.position.set(bullet.x, z, bullet.y)
|
||||
group.add(mesh)
|
||||
|
||||
const glowGeo = new THREE.SphereGeometry(0.18, 8, 8)
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x22cc22,
|
||||
transparent: true,
|
||||
opacity: 0.4
|
||||
})
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat)
|
||||
glow.position.set(bullet.x, z, bullet.y)
|
||||
group.add(glow)
|
||||
|
||||
const trailGeo = new THREE.BufferGeometry()
|
||||
const trailMat = new THREE.LineBasicMaterial({
|
||||
color: 0x88ff88,
|
||||
transparent: true,
|
||||
opacity: 0.6
|
||||
})
|
||||
const trailPoints = []
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const t = i / 10
|
||||
trailPoints.push(bullet.x, z - t * 0.5, bullet.y)
|
||||
}
|
||||
trailGeo.setAttribute('position', new THREE.Float32BufferAttribute(trailPoints, 3))
|
||||
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
|
||||
break
|
||||
case WEAPONS.SHOTGUN:
|
||||
bulletSize = 0.04
|
||||
break
|
||||
}
|
||||
|
||||
const trailLength = 2.5
|
||||
const trailGeo = new THREE.BufferGeometry()
|
||||
const positions = new Float32Array([
|
||||
bullet.x - Math.sin(angle) * trailLength, 0.5, bullet.y - Math.cos(angle) * trailLength,
|
||||
bullet.x, 0.5, bullet.y
|
||||
])
|
||||
trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
|
||||
const trailMat = new THREE.LineBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
linewidth: 3
|
||||
})
|
||||
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,
|
||||
transparent: true,
|
||||
opacity: 0.4
|
||||
})
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat)
|
||||
glow.position.set(bullet.x, 0.5, bullet.y)
|
||||
group.add(glow)
|
||||
}
|
||||
|
||||
this.scene.add(group)
|
||||
this.bullets.push({
|
||||
id: bullet.id,
|
||||
group,
|
||||
x: bullet.x,
|
||||
y: bullet.y,
|
||||
z: z,
|
||||
weapon: bullet.weapon,
|
||||
angle: bullet.angle || 0,
|
||||
isGrenade
|
||||
})
|
||||
}
|
||||
|
||||
removeBullet(id) {
|
||||
const idx = this.bullets.findIndex(b => b.id === id)
|
||||
if (idx >= 0) {
|
||||
const b = this.bullets[idx]
|
||||
this.scene.remove(b.group)
|
||||
b.group.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) child.material.dispose()
|
||||
})
|
||||
this.bullets.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
updateBullet(id, x, y, z) {
|
||||
const bullet = this.bullets.find(b => b.id === id)
|
||||
if (bullet) {
|
||||
bullet.x = x
|
||||
bullet.y = y
|
||||
bullet.z = z || 0.5
|
||||
const angle = bullet.angle || 0
|
||||
bullet.group.children.forEach(child => {
|
||||
if (child.isMesh) {
|
||||
child.position.x = x
|
||||
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
|
||||
positions[0] = x - Math.sin(angle) * trailLength
|
||||
positions[1] = bullet.z
|
||||
positions[2] = y - Math.cos(angle) * trailLength
|
||||
positions[3] = x
|
||||
positions[4] = bullet.z
|
||||
positions[5] = y
|
||||
child.geometry.attributes.position.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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([
|
||||
bullet.x - Math.sin(angle) * trailLength, 0.5, bullet.y - Math.cos(angle) * trailLength,
|
||||
bullet.x, 0.5, bullet.y
|
||||
])
|
||||
trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
|
||||
const trailMat = new THREE.LineBasicMaterial({
|
||||
color: 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
})
|
||||
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)
|
||||
mesh.position.set(bullet.x, 0.5, bullet.y)
|
||||
group.add(mesh)
|
||||
|
||||
this.scene.add(group)
|
||||
this.zombieBullets.push({
|
||||
id: bullet.id,
|
||||
group,
|
||||
x: bullet.x,
|
||||
y: bullet.y,
|
||||
angle
|
||||
})
|
||||
}
|
||||
|
||||
removeZombieBullet(id) {
|
||||
const idx = this.zombieBullets.findIndex(b => b.id === id)
|
||||
if (idx >= 0) {
|
||||
const b = this.zombieBullets[idx]
|
||||
this.scene.remove(b.group)
|
||||
b.group.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) child.material.dispose()
|
||||
})
|
||||
this.zombieBullets.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
updateZombieBullet(id, x, y) {
|
||||
const bullet = this.zombieBullets.find(b => b.id === id)
|
||||
if (bullet) {
|
||||
bullet.x = x
|
||||
bullet.y = y
|
||||
const angle = bullet.angle || 0
|
||||
bullet.group.children.forEach(child => {
|
||||
if (child.isMesh) {
|
||||
child.position.x = x
|
||||
child.position.z = y
|
||||
}
|
||||
if (child.isLine) {
|
||||
const trailLength = 1.2
|
||||
const positions = child.geometry.attributes.position.array
|
||||
positions[0] = x - Math.sin(angle) * trailLength
|
||||
positions[1] = 0.5
|
||||
positions[2] = y - Math.cos(angle) * trailLength
|
||||
positions[3] = x
|
||||
positions[4] = 0.5
|
||||
positions[5] = y
|
||||
child.geometry.attributes.position.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
addExplosion(x, y, radius) {
|
||||
const whiteFlashGeo = new THREE.SphereGeometry(radius * 0.8, 16, 16)
|
||||
const whiteFlashMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
})
|
||||
const whiteFlash = new THREE.Mesh(whiteFlashGeo, whiteFlashMat)
|
||||
whiteFlash.position.set(x, 0.5, y)
|
||||
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)
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: i === 0 ? 0xffffcc : (i === 1 ? 0xff6600 : 0xff4400),
|
||||
transparent: true,
|
||||
opacity: 0.6 - i * 0.15
|
||||
})
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.position.set(x, 0.3, y)
|
||||
this.scene.add(mesh)
|
||||
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)
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xff8800 })
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.position.set(x, 0.3, y)
|
||||
this.scene.add(mesh)
|
||||
this.effects.push({
|
||||
mesh,
|
||||
type: 'debris',
|
||||
startTime: Date.now(),
|
||||
duration: 600,
|
||||
vx: Math.cos(angle) * 2,
|
||||
vy: 1.5 + Math.random() * 1,
|
||||
vz: Math.sin(angle) * 2
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
addMuzzleFlash(x, y, angle) {
|
||||
const dist = 0.6
|
||||
|
||||
const flashGeo = new THREE.SphereGeometry(0.25, 8, 8)
|
||||
const flashMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffff00,
|
||||
transparent: true,
|
||||
opacity: 1.0
|
||||
})
|
||||
const flash = new THREE.Mesh(flashGeo, flashMat)
|
||||
flash.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
|
||||
flash.scale.set(1.5, 1, 1)
|
||||
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,
|
||||
transparent: true,
|
||||
opacity: 0.6
|
||||
})
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat)
|
||||
glow.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
|
||||
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 })
|
||||
}
|
||||
|
||||
addHitEffect(x, y) {
|
||||
const geo = new THREE.SphereGeometry(0.2, 8, 8)
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
})
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.position.set(x, 0.5, y)
|
||||
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 })
|
||||
const spark = new THREE.Mesh(sparkGeo, sparkMat)
|
||||
spark.position.set(x, 0.5, y)
|
||||
this.scene.add(spark)
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = 1 + Math.random() * 2
|
||||
this.effects.push({
|
||||
mesh: spark,
|
||||
type: 'spark',
|
||||
startTime: Date.now(),
|
||||
duration: 300,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: 1 + Math.random() * 2,
|
||||
vz: Math.sin(angle) * speed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
mesh.position.set(x, 0.3, y)
|
||||
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 })
|
||||
const crossH = new THREE.Mesh(crossGeo, crossMat)
|
||||
crossH.position.set(x, 0.55, y)
|
||||
this.scene.add(crossH)
|
||||
const crossV = new THREE.Mesh(crossGeo, crossMat)
|
||||
crossV.position.set(x, 0.55, y)
|
||||
crossV.rotation.y = Math.PI / 2
|
||||
this.scene.add(crossV)
|
||||
this.loots.get(id).cross = [crossH, crossV]
|
||||
}
|
||||
}
|
||||
|
||||
removeLoot(id) {
|
||||
const loot = this.loots.get(id)
|
||||
if (loot) {
|
||||
this.scene.remove(loot.mesh)
|
||||
loot.mesh.geometry.dispose()
|
||||
loot.mesh.material.dispose()
|
||||
if (loot.cross) {
|
||||
loot.cross.forEach(c => {
|
||||
this.scene.remove(c)
|
||||
c.geometry.dispose()
|
||||
c.material.dispose()
|
||||
})
|
||||
}
|
||||
this.loots.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
if (effect.mesh.material.transparent !== false) {
|
||||
effect.mesh.material.opacity = 1.0 * (1 - progress)
|
||||
}
|
||||
} else if (effect.type === 'explosion') {
|
||||
effect.mesh.material.opacity = (0.8 - effect.mesh.material.opacity * 0.2) * (1 - progress)
|
||||
effect.mesh.scale.setScalar(1 + progress * 1.5)
|
||||
} else if (effect.type === 'muzzle') {
|
||||
effect.mesh.material.opacity = (1 - progress)
|
||||
effect.mesh.scale.setScalar(1 + progress * 0.5)
|
||||
} else if (effect.type === 'hit') {
|
||||
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
|
||||
effect.mesh.position.y += effect.vy * 0.016
|
||||
effect.mesh.material.opacity = 1 - progress
|
||||
} else if (effect.type === 'light') {
|
||||
effect.mesh.intensity = 2 * (1 - progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bullet of this.bullets) {
|
||||
if (!bullet.isGrenade && bullet.trail) {
|
||||
const positions = bullet.trail.geometry.attributes.position.array
|
||||
positions[0] = bullet.x - Math.sin(bullet.angle) * 1.5
|
||||
positions[1] = 0.5
|
||||
positions[2] = bullet.y - Math.cos(bullet.angle) * 1.5
|
||||
positions[3] = bullet.x
|
||||
positions[4] = 0.5
|
||||
positions[5] = bullet.y
|
||||
bullet.trail.geometry.attributes.position.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(m => m.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
174
frontend/src/main.js
Normal file
174
frontend/src/main.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { MSG_TYPE } from './utils/constants.js'
|
||||
import { GameEngine } from './game/engine.js'
|
||||
import { LobbyUI } from './ui/lobby.js'
|
||||
import { HUD } from './ui/hud.js'
|
||||
import { SettingsUI } from './ui/settings.js'
|
||||
import './style.css'
|
||||
|
||||
const WS_URL = `ws://${window.location.hostname}:8080/ws`
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.appEl = document.getElementById('app')
|
||||
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
|
||||
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(() => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_setupSettings() {
|
||||
this.settings.onKeyChange = (bindings) => {
|
||||
if (this.engine) {
|
||||
for (const [action, key] of Object.entries(bindings)) {
|
||||
this.engine.input.updateKeyBinding(action, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
this.isHost = data.isHost || false
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
net.on(MSG_TYPE.ERROR, (data) => {
|
||||
console.error('Error:', data.message)
|
||||
})
|
||||
}
|
||||
|
||||
_updateHUD(state) {
|
||||
const localPlayer = this.engine.players.get(this.engine.localPlayerId)
|
||||
if (localPlayer) {
|
||||
this.hud.updateHealth(localPlayer.health)
|
||||
}
|
||||
this.hud.updateWeapons(this.engine.currentWeaponIndex, this.engine.weaponAmmo)
|
||||
this.hud.updateGrenadeCharge(this.engine.getGrenadeChargePercent())
|
||||
this.hud.updateInfo(
|
||||
this.engine.waveNumber,
|
||||
this.engine.score,
|
||||
this.engine.gameTime
|
||||
)
|
||||
}
|
||||
|
||||
showSettings() {
|
||||
if (this.engine) {
|
||||
this.settings.show(this.engine.input.getKeyBindings())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App()
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Escape') {
|
||||
if (app.settings.visible) {
|
||||
app.settings.hide()
|
||||
} else if (app.engine && app.engine.running) {
|
||||
app.showSettings()
|
||||
}
|
||||
}
|
||||
})
|
||||
113
frontend/src/network/client.js
Normal file
113
frontend/src/network/client.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { MSG_TYPE } from '../utils/constants.js'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
this._dispatch(msg)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false
|
||||
this._dispatch({ type: MSG_TYPE.ERROR, data: { message: 'Disconnected from server' } })
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
reject(err)
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
this.connected = false
|
||||
}
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, [])
|
||||
}
|
||||
this.handlers.get(type).push(handler)
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
if (this.handlers.has(type)) {
|
||||
const list = this.handlers.get(type)
|
||||
const idx = list.indexOf(handler)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
send(type, data = {}) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type, data }))
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(msg) {
|
||||
const handlers = this.handlers.get(msg.type)
|
||||
if (handlers) {
|
||||
for (const h of handlers) {
|
||||
h(msg.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createRoom(playerName) {
|
||||
this.send(MSG_TYPE.CREATE_ROOM, { 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, {})
|
||||
}
|
||||
|
||||
sendInput(inputState) {
|
||||
this.send(MSG_TYPE.PLAYER_INPUT, inputState)
|
||||
}
|
||||
}
|
||||
499
frontend/src/style.css
Normal file
499
frontend/src/style.css
Normal file
@@ -0,0 +1,499 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #0a0a1a;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lobby-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 50%, #0a0a2e 100%);
|
||||
}
|
||||
|
||||
.lobby-title {
|
||||
font-size: 48px;
|
||||
color: #ff4444;
|
||||
text-shadow: 0 0 20px rgba(255, 68, 68, 0.5);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.lobby-subtitle {
|
||||
font-size: 16px;
|
||||
color: #8888aa;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.lobby-section {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lobby-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #aaaacc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lobby-input {
|
||||
padding: 12px 20px;
|
||||
border: 2px solid #333366;
|
||||
border-radius: 8px;
|
||||
background: #1a1a3e;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
width: 280px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.lobby-input:focus {
|
||||
border-color: #4488ff;
|
||||
}
|
||||
|
||||
.lobby-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.lobby-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lobby-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.lobby-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lobby-btn-primary {
|
||||
background: linear-gradient(135deg, #4488ff, #2266dd);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lobby-btn-secondary {
|
||||
background: linear-gradient(135deg, #555577, #444466);
|
||||
color: #ccccee;
|
||||
}
|
||||
|
||||
.lobby-btn-danger {
|
||||
background: linear-gradient(135deg, #ff4444, #cc2222);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lobby-btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, #44aa44, #228822);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lobby-room-list {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.lobby-room-title {
|
||||
font-size: 20px;
|
||||
color: #aaaacc;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #333366;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.lobby-room-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lobby-empty {
|
||||
text-align: center;
|
||||
color: #666688;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lobby-room-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a3e;
|
||||
border: 1px solid #333366;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.lobby-room-card:hover {
|
||||
border-color: #4488ff;
|
||||
}
|
||||
|
||||
.lobby-room-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lobby-room-name {
|
||||
font-weight: 600;
|
||||
color: #e0e0ff;
|
||||
}
|
||||
|
||||
.lobby-room-players {
|
||||
font-size: 13px;
|
||||
color: #8888aa;
|
||||
}
|
||||
|
||||
.room-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 50%, #0a0a2e 100%);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 28px;
|
||||
color: #ff8844;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.room-player-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.room-player-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a3e;
|
||||
border: 1px solid #333366;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.room-player-local {
|
||||
border-color: #4488ff;
|
||||
}
|
||||
|
||||
.room-player-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.room-player-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-host {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.status-ready {
|
||||
color: #44ff44;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.room-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hud-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud-health-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
width: 200px;
|
||||
height: 24px;
|
||||
background: #333;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
.hud-health-fill {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #44ff44;
|
||||
transition: width 0.3s, background-color 0.3s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hud-health-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.hud-weapon-panel {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hud-weapon-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(20, 20, 40, 0.85);
|
||||
border: 2px solid #444466;
|
||||
border-radius: 8px;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.hud-weapon-active {
|
||||
border-color: #4488ff;
|
||||
background: rgba(30, 30, 80, 0.9);
|
||||
box-shadow: 0 0 10px rgba(68, 136, 255, 0.3);
|
||||
}
|
||||
|
||||
.hud-weapon-key {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.hud-weapon-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ccccee;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.hud-weapon-ammo {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.hud-grenade-charge {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
right: 20px;
|
||||
width: 200px;
|
||||
height: 12px;
|
||||
background: #333;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
.hud-grenade-charge-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #44ff44, #ff4444);
|
||||
transition: width 0.1s;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.hud-info-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hud-info-item {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e0ff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.hud-crosshair {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-crosshair::before,
|
||||
.hud-crosshair::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hud-crosshair::before {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hud-crosshair::after {
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.hud-kill-feed {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.hud-kill-entry {
|
||||
padding: 6px 12px;
|
||||
background: rgba(20, 20, 40, 0.75);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #ffaa00;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: #1a1a3e;
|
||||
border: 2px solid #333366;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 24px;
|
||||
color: #aaaacc;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #222244;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 14px;
|
||||
color: #aaaacc;
|
||||
}
|
||||
|
||||
.settings-key-btn {
|
||||
padding: 6px 16px;
|
||||
background: #2a2a4e;
|
||||
border: 1px solid #444466;
|
||||
border-radius: 6px;
|
||||
color: #e0e0ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-key-btn:hover {
|
||||
border-color: #4488ff;
|
||||
}
|
||||
|
||||
.settings-key-btn-capturing {
|
||||
border-color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.settings-btn-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
149
frontend/src/ui/hud.js
Normal file
149
frontend/src/ui/hud.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
|
||||
|
||||
export class HUD {
|
||||
constructor(container) {
|
||||
this.container = container
|
||||
this.container.className = 'hud-container'
|
||||
this.container.innerHTML = ''
|
||||
this.visible = false
|
||||
this._build()
|
||||
}
|
||||
|
||||
_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++) {
|
||||
const slot = document.createElement('div')
|
||||
slot.className = 'hud-weapon-slot'
|
||||
slot.dataset.index = i
|
||||
|
||||
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'
|
||||
|
||||
slot.appendChild(keyLabel)
|
||||
slot.appendChild(weaponName)
|
||||
slot.appendChild(ammoText)
|
||||
this.weaponSlots.push({ slot, ammoText })
|
||||
this.weaponPanel.appendChild(slot)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
this.waveText = document.createElement('span')
|
||||
this.waveText.className = 'hud-info-item'
|
||||
this.waveText.textContent = 'Wave: 0'
|
||||
|
||||
this.scoreText = document.createElement('span')
|
||||
this.scoreText.className = 'hud-info-item'
|
||||
this.scoreText.textContent = 'Score: 0'
|
||||
|
||||
this.timeText = document.createElement('span')
|
||||
this.timeText.className = 'hud-info-item'
|
||||
this.timeText.textContent = 'Time: 0:00'
|
||||
|
||||
this.infoPanel.appendChild(this.waveText)
|
||||
this.infoPanel.appendChild(this.scoreText)
|
||||
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)
|
||||
|
||||
this.killFeed = document.createElement('div')
|
||||
this.killFeed.className = 'hud-kill-feed'
|
||||
this.container.appendChild(this.killFeed)
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true
|
||||
this.container.style.display = 'flex'
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.container.style.display = 'none'
|
||||
}
|
||||
|
||||
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 + '%'
|
||||
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)
|
||||
}
|
||||
|
||||
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]
|
||||
if (currentAmmo === Infinity) {
|
||||
slot.ammoText.textContent = '∞'
|
||||
} else {
|
||||
slot.ammoText.textContent = currentAmmo + '/' + WEAPON_CONFIG[weaponKey].maxAmmo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateGrenadeCharge(percent) {
|
||||
if (percent > 0) {
|
||||
this.grenadeChargeBar.style.display = 'block'
|
||||
const fill = this.grenadeChargeBar.querySelector('.hud-grenade-charge-fill')
|
||||
fill.style.width = (percent * 100) + '%'
|
||||
} else {
|
||||
this.grenadeChargeBar.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
updateInfo(wave, score, time) {
|
||||
this.waveText.textContent = 'Wave: ' + wave
|
||||
this.scoreText.textContent = 'Score: ' + score
|
||||
const mins = Math.floor(time / 60)
|
||||
const secs = Math.floor(time % 60)
|
||||
this.timeText.textContent = `Time: ${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
addKillFeed(message) {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'hud-kill-entry'
|
||||
entry.textContent = message
|
||||
this.killFeed.appendChild(entry)
|
||||
setTimeout(() => {
|
||||
if (entry.parentNode) entry.parentNode.removeChild(entry)
|
||||
}, 4000)
|
||||
while (this.killFeed.children.length > 5) {
|
||||
this.killFeed.removeChild(this.killFeed.firstChild)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
frontend/src/ui/lobby.js
Normal file
192
frontend/src/ui/lobby.js
Normal file
@@ -0,0 +1,192 @@
|
||||
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')
|
||||
nameLabel.textContent = 'Player Name:'
|
||||
nameLabel.className = 'lobby-label'
|
||||
this.nameInput = document.createElement('input')
|
||||
this.nameInput.type = 'text'
|
||||
this.nameInput.value = 'Player' + Math.floor(Math.random() * 1000)
|
||||
this.nameInput.className = 'lobby-input'
|
||||
this.nameInput.maxLength = 16
|
||||
nameSection.appendChild(nameLabel)
|
||||
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'
|
||||
createBtn.onclick = () => {
|
||||
if (this.onCreateRoom) this.onCreateRoom(this.nameInput.value)
|
||||
}
|
||||
actions.appendChild(createBtn)
|
||||
|
||||
const refreshBtn = document.createElement('button')
|
||||
refreshBtn.textContent = '🔄 Refresh Rooms'
|
||||
refreshBtn.className = 'lobby-btn lobby-btn-secondary'
|
||||
refreshBtn.onclick = () => {
|
||||
if (this.onRefreshRooms) this.onRefreshRooms()
|
||||
}
|
||||
actions.appendChild(refreshBtn)
|
||||
|
||||
this.container.appendChild(actions)
|
||||
|
||||
this.roomListSection = document.createElement('div')
|
||||
this.roomListSection.className = 'lobby-room-list'
|
||||
const roomTitle = document.createElement('h2')
|
||||
roomTitle.textContent = 'Available Rooms'
|
||||
roomTitle.className = 'lobby-room-title'
|
||||
this.roomListSection.appendChild(roomTitle)
|
||||
this.roomListContent = document.createElement('div')
|
||||
this.roomListContent.className = 'lobby-room-content'
|
||||
this.roomListContent.innerHTML = '<p class="lobby-empty">No rooms available. Create one!</p>'
|
||||
this.roomListSection.appendChild(this.roomListContent)
|
||||
this.container.appendChild(this.roomListSection)
|
||||
}
|
||||
|
||||
updateRoomList(rooms) {
|
||||
this.roomListContent.innerHTML = ''
|
||||
if (!rooms || rooms.length === 0) {
|
||||
this.roomListContent.innerHTML = '<p class="lobby-empty">No rooms available. Create one!</p>'
|
||||
return
|
||||
}
|
||||
for (const room of rooms) {
|
||||
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')
|
||||
roomName.className = 'lobby-room-name'
|
||||
roomName.textContent = room.hostName + "'s Room"
|
||||
const roomPlayers = document.createElement('span')
|
||||
roomPlayers.className = 'lobby-room-players'
|
||||
roomPlayers.textContent = `${room.playerCount}/4 players`
|
||||
roomInfo.appendChild(roomName)
|
||||
roomInfo.appendChild(roomPlayers)
|
||||
|
||||
const joinBtn = document.createElement('button')
|
||||
joinBtn.textContent = 'Join'
|
||||
joinBtn.className = 'lobby-btn lobby-btn-small'
|
||||
joinBtn.disabled = room.playerCount >= 4
|
||||
joinBtn.onclick = () => {
|
||||
if (this.onJoinRoom) this.onJoinRoom(room.id, this.nameInput.value)
|
||||
}
|
||||
|
||||
roomCard.appendChild(roomInfo)
|
||||
roomCard.appendChild(joinBtn)
|
||||
this.roomListContent.appendChild(roomCard)
|
||||
}
|
||||
}
|
||||
|
||||
showRoom(roomData, isHost, playerId) {
|
||||
this.currentRoom = roomData
|
||||
this.isHost = isHost
|
||||
this.playerId = playerId
|
||||
|
||||
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) {
|
||||
playerStatus.textContent = 'Host'
|
||||
playerStatus.classList.add('status-host')
|
||||
} else {
|
||||
playerStatus.textContent = player.ready ? '✅ Ready' : '⏳ Not Ready'
|
||||
playerStatus.classList.add(player.ready ? 'status-ready' : 'status-waiting')
|
||||
}
|
||||
|
||||
playerCard.appendChild(playerName)
|
||||
playerCard.appendChild(playerStatus)
|
||||
playerList.appendChild(playerCard)
|
||||
}
|
||||
|
||||
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'
|
||||
readyBtn.className = 'lobby-btn ' + (myPlayer && myPlayer.ready ? 'lobby-btn-secondary' : 'lobby-btn-primary')
|
||||
readyBtn.onclick = () => {
|
||||
if (this.onReady) this.onReady()
|
||||
}
|
||||
actions.appendChild(readyBtn)
|
||||
} else {
|
||||
const allReady = roomData.players.filter(p => p.id !== roomData.hostId).every(p => p.ready)
|
||||
const startBtn = document.createElement('button')
|
||||
startBtn.textContent = '🚀 Start Game'
|
||||
startBtn.className = 'lobby-btn lobby-btn-primary'
|
||||
startBtn.disabled = !allReady || roomData.players.length < 1
|
||||
startBtn.onclick = () => {
|
||||
if (this.onStartGame) this.onStartGame()
|
||||
}
|
||||
actions.appendChild(startBtn)
|
||||
}
|
||||
|
||||
const leaveBtn = document.createElement('button')
|
||||
leaveBtn.textContent = '🚪 Leave Room'
|
||||
leaveBtn.className = 'lobby-btn lobby-btn-danger'
|
||||
leaveBtn.onclick = () => {
|
||||
if (this.onLeaveRoom) this.onLeaveRoom()
|
||||
}
|
||||
actions.appendChild(leaveBtn)
|
||||
|
||||
this.container.appendChild(actions)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.container.innerHTML = ''
|
||||
}
|
||||
}
|
||||
131
frontend/src/ui/settings.js
Normal file
131
frontend/src/ui/settings.js
Normal file
@@ -0,0 +1,131 @@
|
||||
export class SettingsUI {
|
||||
constructor(container) {
|
||||
this.container = container
|
||||
this.visible = false
|
||||
this.onKeyChange = null
|
||||
this.defaultBindings = {
|
||||
moveUp: 'KeyW',
|
||||
moveDown: 'KeyS',
|
||||
moveLeft: 'KeyA',
|
||||
moveRight: 'KeyD',
|
||||
weapon1: 'Digit1',
|
||||
weapon2: 'Digit2',
|
||||
weapon3: 'Digit3',
|
||||
weapon4: 'Digit4'
|
||||
}
|
||||
this.bindings = { ...this.defaultBindings }
|
||||
}
|
||||
|
||||
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',
|
||||
moveLeft: 'Move Left',
|
||||
moveRight: 'Move Right',
|
||||
weapon1: 'Weapon 1 (Pistol)',
|
||||
weapon2: 'Weapon 2 (MG)',
|
||||
weapon3: 'Weapon 3 (Shotgun)',
|
||||
weapon4: 'Weapon 4 (Grenade)'
|
||||
}
|
||||
|
||||
for (const [action, key] of Object.entries(this.bindings)) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'settings-row'
|
||||
|
||||
const label = document.createElement('span')
|
||||
label.className = 'settings-label'
|
||||
label.textContent = labels[action] || action
|
||||
|
||||
const keyBtn = document.createElement('button')
|
||||
keyBtn.className = 'settings-key-btn'
|
||||
keyBtn.textContent = this._formatKey(key)
|
||||
keyBtn.onclick = () => this._captureKey(action, keyBtn)
|
||||
|
||||
row.appendChild(label)
|
||||
row.appendChild(keyBtn)
|
||||
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'
|
||||
saveBtn.onclick = () => {
|
||||
if (this.onKeyChange) this.onKeyChange(this.bindings)
|
||||
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'
|
||||
resetBtn.onclick = () => {
|
||||
this.bindings = { ...this.defaultBindings }
|
||||
this._render()
|
||||
}
|
||||
|
||||
btnRow.appendChild(saveBtn)
|
||||
btnRow.appendChild(cancelBtn)
|
||||
btnRow.appendChild(resetBtn)
|
||||
panel.appendChild(btnRow)
|
||||
|
||||
this.container.appendChild(panel)
|
||||
}
|
||||
|
||||
_captureKey(action, btn) {
|
||||
btn.textContent = 'Press a key...'
|
||||
btn.classList.add('settings-key-btn-capturing')
|
||||
|
||||
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')
|
||||
window.removeEventListener('keydown', handler)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
}
|
||||
|
||||
_formatKey(code) {
|
||||
return code
|
||||
.replace('Key', '')
|
||||
.replace('Digit', '')
|
||||
.replace('Arrow', '')
|
||||
.replace('Left', '←')
|
||||
.replace('Right', '→')
|
||||
.replace('Up', '↑')
|
||||
.replace('Down', '↓')
|
||||
}
|
||||
}
|
||||
109
frontend/src/utils/constants.js
Normal file
109
frontend/src/utils/constants.js
Normal file
@@ -0,0 +1,109 @@
|
||||
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
|
||||
export const TICK_RATE = 30
|
||||
export const TICK_INTERVAL = 1000 / TICK_RATE
|
||||
|
||||
export const WEAPONS = {
|
||||
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
|
||||
},
|
||||
[WEAPONS.MACHINE_GUN]: {
|
||||
name: 'Machine Gun',
|
||||
damage: 15,
|
||||
fireRate: 100,
|
||||
ammo: 100,
|
||||
maxAmmo: 100,
|
||||
speed: 25,
|
||||
spread: 0.05,
|
||||
pellets: 1,
|
||||
range: 25,
|
||||
auto: true,
|
||||
chargeable: false
|
||||
},
|
||||
[WEAPONS.SHOTGUN]: {
|
||||
name: 'Shotgun',
|
||||
damage: 20,
|
||||
fireRate: 800,
|
||||
ammo: 20,
|
||||
maxAmmo: 20,
|
||||
speed: 18,
|
||||
spread: 0.15,
|
||||
pellets: 6,
|
||||
range: 12,
|
||||
auto: false,
|
||||
chargeable: false
|
||||
},
|
||||
[WEAPONS.GRENADE]: {
|
||||
name: 'Grenade',
|
||||
damage: 100,
|
||||
fireRate: 1500,
|
||||
ammo: 10,
|
||||
maxAmmo: 10,
|
||||
speed: 12,
|
||||
spread: 0,
|
||||
pellets: 1,
|
||||
range: 15,
|
||||
auto: false,
|
||||
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
|
||||
}
|
||||
|
||||
export const PLAYER_CONFIG = {
|
||||
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'
|
||||
}
|
||||
250
frontend/src/utils/grid.js
Normal file
250
frontend/src/utils/grid.js
Normal file
@@ -0,0 +1,250 @@
|
||||
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.parseMap(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isWall(gridX, gridY) {
|
||||
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true
|
||||
return this.cells[gridY][gridX] === 1
|
||||
}
|
||||
|
||||
isSpawnPoint(gridX, gridY) {
|
||||
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return false
|
||||
return this.cells[gridY][gridX] === 2
|
||||
}
|
||||
|
||||
worldToGrid(wx, wy) {
|
||||
return {
|
||||
x: Math.floor(wx / CELL_SIZE),
|
||||
y: Math.floor(wy / CELL_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
gridToWorld(gx, gy) {
|
||||
return {
|
||||
x: gx * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: gy * CELL_SIZE + CELL_SIZE / 2
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
getSpawnPoints() {
|
||||
const points = []
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
if (this.cells[y][x] === 2) {
|
||||
points.push({ x, y })
|
||||
}
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
y: sg.y,
|
||||
g: 0,
|
||||
h: heuristic(sg.x, sg.y, eg.x, eg.y),
|
||||
f: heuristic(sg.x, sg.y, eg.x, eg.y)
|
||||
})
|
||||
|
||||
const directions = [
|
||||
{ dx: 0, dy: -1 },
|
||||
{ dx: 0, dy: 1 },
|
||||
{ dx: -1, dy: 0 },
|
||||
{ dx: 1, dy: 0 },
|
||||
{ dx: -1, dy: -1 },
|
||||
{ dx: 1, dy: -1 },
|
||||
{ dx: -1, dy: 1 },
|
||||
{ dx: 1, dy: 1 }
|
||||
]
|
||||
|
||||
let iterations = 0
|
||||
const maxIterations = 2000
|
||||
|
||||
while (openSet.length > 0 && iterations < maxIterations) {
|
||||
iterations++
|
||||
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
|
||||
while (cameFrom.has(key)) {
|
||||
const [cx, cy] = key.split(',').map(Number)
|
||||
const wp = this.gridToWorld(cx, cy)
|
||||
path.unshift({ x: wp.x, y: wp.y })
|
||||
key = cameFrom.get(key)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
closedSet.add(currentKey)
|
||||
|
||||
for (const dir of directions) {
|
||||
const nx = current.x + dir.dx
|
||||
const ny = current.y + dir.dy
|
||||
const nKey = `${nx},${ny}`
|
||||
|
||||
if (closedSet.has(nKey)) continue
|
||||
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
|
||||
|
||||
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
|
||||
cameFrom.set(nKey, currentKey)
|
||||
}
|
||||
} else {
|
||||
const h = heuristic(nx, ny, eg.x, eg.y)
|
||||
openSet.push({ x: nx, y: ny, g, h, f: g + h })
|
||||
cameFrom.set(nKey, currentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
map[y][x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wallSegments = [
|
||||
{ x1: 5, y1: 5, x2: 5, y2: 10 },
|
||||
{ x1: 10, y1: 3, x2: 15, y2: 3 },
|
||||
{ x1: 20, y1: 5, x2: 20, y2: 12 },
|
||||
{ x1: 8, y1: 15, x2: 14, y2: 15 },
|
||||
{ x1: 25, y1: 10, x2: 25, y2: 18 },
|
||||
{ x1: 3, y1: 20, x2: 8, y2: 20 },
|
||||
{ x1: 15, y1: 20, x2: 15, y2: 26 },
|
||||
{ x1: 22, y1: 22, x2: 28, y2: 22 },
|
||||
{ x1: 5, y1: 25, x2: 10, y2: 25 },
|
||||
{ x1: 12, y1: 8, x2: 12, y2: 12 },
|
||||
{ x1: 18, y1: 16, x2: 22, y2: 16 },
|
||||
{ x1: 27, y1: 5, x2: 27, y2: 9 },
|
||||
{ x1: 8, y1: 27, x2: 13, y2: 27 },
|
||||
{ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnPoints = [
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 29, y: 2 },
|
||||
{ x: 2, y: 29 },
|
||||
{ x: 29, y: 29 }
|
||||
]
|
||||
for (const sp of spawnPoints) {
|
||||
map[sp.y][sp.x] = 2
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const ny = sp.y + dy
|
||||
const nx = sp.x + dx
|
||||
if (ny > 0 && ny < GRID_SIZE - 1 && nx > 0 && nx < GRID_SIZE - 1) {
|
||||
if (map[ny][nx] === 1) map[ny][nx] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zombieSpawns = [
|
||||
{ x: 16, y: 2 },
|
||||
{ x: 2, y: 16 },
|
||||
{ x: 29, y: 16 },
|
||||
{ x: 16, y: 29 },
|
||||
{ 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
|
||||
const nx = sp.x + dx
|
||||
if (ny > 0 && ny < GRID_SIZE - 1 && nx > 0 && nx < GRID_SIZE - 1) {
|
||||
if (map[ny][nx] === 1) map[ny][nx] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
106
frontend/src/utils/input.js
Normal file
106
frontend/src/utils/input.js
Normal file
@@ -0,0 +1,106 @@
|
||||
export class InputManager {
|
||||
constructor() {
|
||||
this.keys = {}
|
||||
this.mouse = { x: 0, y: 0, left: false, right: false }
|
||||
this.keyBindings = {
|
||||
moveUp: 'KeyW',
|
||||
moveDown: 'KeyS',
|
||||
moveLeft: 'KeyA',
|
||||
moveRight: 'KeyD',
|
||||
weapon1: 'Digit1',
|
||||
weapon2: 'Digit2',
|
||||
weapon3: 'Digit3',
|
||||
weapon4: 'Digit4'
|
||||
}
|
||||
this.pendingActions = []
|
||||
this.sequenceNumber = 0
|
||||
this.onKeyDown = null
|
||||
this.onKeyUp = null
|
||||
|
||||
this._onKeyDown = (e) => {
|
||||
this.keys[e.code] = true
|
||||
if (this.onKeyDown) this.onKeyDown(e)
|
||||
}
|
||||
this._onKeyUp = (e) => {
|
||||
this.keys[e.code] = false
|
||||
if (this.onKeyUp) this.onKeyUp(e)
|
||||
}
|
||||
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
|
||||
}
|
||||
this._onContextMenu = (e) => e.preventDefault()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
if (dx !== 0 && dy !== 0) {
|
||||
dx *= 0.7071
|
||||
dy *= 0.7071
|
||||
}
|
||||
return { dx, dy }
|
||||
}
|
||||
|
||||
getSelectedWeapon() {
|
||||
if (this.keys[this.keyBindings.weapon1]) return 0
|
||||
if (this.keys[this.keyBindings.weapon2]) return 1
|
||||
if (this.keys[this.keyBindings.weapon3]) return 2
|
||||
if (this.keys[this.keyBindings.weapon4]) return 3
|
||||
return -1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
updateKeyBinding(action, keyCode) {
|
||||
if (this.keyBindings.hasOwnProperty(action)) {
|
||||
this.keyBindings[action] = keyCode
|
||||
}
|
||||
}
|
||||
|
||||
getKeyBindings() {
|
||||
return { ...this.keyBindings }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user