diff --git a/index.html b/index.html index b66841bc..626e1b56 100644 --- a/index.html +++ b/index.html @@ -14,33 +14,40 @@ width: 100%; height: 100%; overflow-x: hidden; + background-color: #222; } + body { display: flex; flex-flow: column; font-family: 'Tahoma', sans-serif; color: #333; } + a { color: #333; } + header { background: #fff; border-bottom: 1px solid #ddd; padding-bottom: 10px; margin-bottom: 20px; } + header .wrapper { display: flex; flex-flow: column; width: 998px; margin: 0 auto; } + @media (max-width: 998px) { header .wrapper { width: 95%; } } + header .wrapper .line1 { display: flex; flex-wrap: wrap; @@ -48,12 +55,15 @@ justify-content: space-between; line-height: 80px; } + header .wrapper .line2 { text-align: center; } + header .wrapper .line1 .left { display: flex; } + header .title { margin: 0; font-weight: normal; @@ -61,6 +71,7 @@ margin-left: 10px; font-size: 30pt; } + .nav-link { display: flex; padding: 10px; @@ -68,23 +79,27 @@ text-decoration: none; border: 1px solid #ddd; } + .nav-link:hover { background: #f0f0f0; } + .content { margin: 0 auto; width: 960px; height: 600px; } + @media (max-width: 998px) { .content { width: 95%; height: 500px; } } + #hlv-target { - width: 100%; - height: 100%; + width: 800px; + height: 600px; } @@ -130,9 +145,18 @@

HLViewer

diff --git a/package-lock.json b/package-lock.json index 8885f548..5139207a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1197,6 +1198,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1536,6 +1538,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1637,6 +1640,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.2.1.tgz", "integrity": "sha512-yBxFFs3zmkvKNmR0pFSU//rIsYjuX418TnlDmc2weaq5XFDqDIV/NOMPBoLrbxjLH42p4UzRuXHryXh9dYcKcw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -1658,6 +1662,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.5.tgz", "integrity": "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", @@ -1766,18 +1771,19 @@ "license": "ISC" }, "node_modules/vite": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", - "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" diff --git a/res/maps/kzra_axn_tamlair.bsp b/res/maps/kzra_axn_tamlair.bsp new file mode 100644 index 00000000..c0f0e676 Binary files /dev/null and b/res/maps/kzra_axn_tamlair.bsp differ diff --git a/res/replays/kzra_axn_tamlair_0_0_96712031_pure_1234.dat b/res/replays/kzra_axn_tamlair_0_0_96712031_pure_1234.dat new file mode 100644 index 00000000..ad66b76a Binary files /dev/null and b/res/replays/kzra_axn_tamlair_0_0_96712031_pure_1234.dat differ diff --git a/res/skies/desertbk.tga b/res/skies/desertbk.tga new file mode 100644 index 00000000..a06f23ef Binary files /dev/null and b/res/skies/desertbk.tga differ diff --git a/res/skies/desertdn.tga b/res/skies/desertdn.tga new file mode 100644 index 00000000..f26583d0 Binary files /dev/null and b/res/skies/desertdn.tga differ diff --git a/res/skies/desertft.tga b/res/skies/desertft.tga new file mode 100644 index 00000000..b066ab4b Binary files /dev/null and b/res/skies/desertft.tga differ diff --git a/res/skies/desertlf.tga b/res/skies/desertlf.tga new file mode 100644 index 00000000..7f2e9a1a Binary files /dev/null and b/res/skies/desertlf.tga differ diff --git a/res/skies/desertrt.tga b/res/skies/desertrt.tga new file mode 100644 index 00000000..abc99fce Binary files /dev/null and b/res/skies/desertrt.tga differ diff --git a/res/skies/desertup.tga b/res/skies/desertup.tga new file mode 100644 index 00000000..3f454b0c Binary files /dev/null and b/res/skies/desertup.tga differ diff --git a/res/skies/nature01bk.tga b/res/skies/nature01bk.tga new file mode 100755 index 00000000..8f2c3d4b Binary files /dev/null and b/res/skies/nature01bk.tga differ diff --git a/res/skies/nature01dn.tga b/res/skies/nature01dn.tga new file mode 100755 index 00000000..10e447f7 Binary files /dev/null and b/res/skies/nature01dn.tga differ diff --git a/res/skies/nature01ft.tga b/res/skies/nature01ft.tga new file mode 100755 index 00000000..358ad79d Binary files /dev/null and b/res/skies/nature01ft.tga differ diff --git a/res/skies/nature01lf.tga b/res/skies/nature01lf.tga new file mode 100755 index 00000000..31ba33cd Binary files /dev/null and b/res/skies/nature01lf.tga differ diff --git a/res/skies/nature01rt.tga b/res/skies/nature01rt.tga new file mode 100755 index 00000000..5fef76e6 Binary files /dev/null and b/res/skies/nature01rt.tga differ diff --git a/res/skies/nature01up.tga b/res/skies/nature01up.tga new file mode 100755 index 00000000..b2eff3db Binary files /dev/null and b/res/skies/nature01up.tga differ diff --git a/res/sprites/chaosad/fire2.spr b/res/sprites/chaosad/fire2.spr new file mode 100644 index 00000000..27489bf2 Binary files /dev/null and b/res/sprites/chaosad/fire2.spr differ diff --git a/src/Game.ts b/src/Game.ts index a1b71179..8de470b2 100644 --- a/src/Game.ts +++ b/src/Game.ts @@ -2,7 +2,7 @@ import { createNanoEvents, type Emitter as EventEmitter } from 'nanoevents' import type { Bsp } from './Bsp' import type { Sound } from './Sound' import type { Config } from './Config' -import type { Replay } from './Replay/Replay' +import type { Replay, ReplayType } from './Replay/Replay' import * as Time from './Time' import { Loader } from './Loader' import { Mouse } from './Input/Mouse' @@ -101,6 +101,9 @@ export class Game { title = '' mode: PlayerMode pointerLocked = false + showKeys = true + showTimer = true + fullbright = false touch: Touch = new Touch() mouse: Mouse = new Mouse() @@ -192,10 +195,10 @@ export class Game { this.camera.rotation[2] = 0 } - changeReplay(replay: Replay) { + changeReplay(replay: Replay, replayType: ReplayType) { this.events.emit('prereplaychange', this, replay) - this.player.changeReplay(replay) + this.player.changeReplay(replay, replayType) this.events.emit('postreplaychange', this, replay) } @@ -214,9 +217,24 @@ export class Game { return this.title } + changeShowKeys() { + this.showKeys = !this.showKeys + this.events.emit('showkeyschange', this.showKeys) + } + + changeShowTimer() { + this.showTimer = !this.showTimer + this.events.emit('showtimerchange', this.showTimer) + } + + changeFullbright() { + this.fullbright = !this.fullbright + this.worldScene.setFullbright(this.fullbright) + } + onLoadAll = (loader: Loader) => { if (loader?.replay) { - this.changeReplay(loader.replay.data) + this.changeReplay(loader.replay.data, loader.replayType) this.changeMode(PlayerMode.REPLAY) } @@ -400,8 +418,8 @@ export class Game { onMouseMove = (e: MouseEvent) => { if (this.pointerLocked) { - this.mouse.delta[0] = e.movementX * 0.5 // mul 0.5 to lower sensitivity - this.mouse.delta[1] = e.movementY * 0.5 // + this.mouse.delta[0] = e.movementX + this.mouse.delta[1] = e.movementY this.mouse.position[0] = e.pageX this.mouse.position[1] = e.pageY diff --git a/src/Graphics/Camera.ts b/src/Graphics/Camera.ts index 412ac131..e8e6ad84 100644 --- a/src/Graphics/Camera.ts +++ b/src/Graphics/Camera.ts @@ -7,9 +7,9 @@ export class Camera { projectionMatrix: mat4 = mat4.create() aspect: number - fov: number = glMatrix.toRadian(60) + fov: number = glMatrix.toRadian(87) near = 1.0 - far = 8192.0 + far = 16384.0 viewMatrix: mat4 = mat4.create() position = vec3.create() diff --git a/src/Graphics/WorldScene.ts b/src/Graphics/WorldScene.ts index 9e7ca04a..a233bd5c 100644 --- a/src/Graphics/WorldScene.ts +++ b/src/Graphics/WorldScene.ts @@ -25,6 +25,14 @@ type SceneInfo = { models: ModelInfo[] } +type Texture = { + name: string + width: number + height: number + data: Uint8Array + handle: WebGLTexture +} + export class WorldScene { static init(context: Context) { const shader = MainShader.init(context) @@ -55,13 +63,7 @@ export class WorldScene { models: [] } private bsp: Bsp | null = null - private textures: { - name: string - width: number - height: number - data: Uint8Array - handle: WebGLTexture - }[] = [] + private textures: Texture[] = [] private sprites: { [name: string]: Sprite } = {} private lightmap: { data: Uint8Array @@ -81,6 +83,10 @@ export class WorldScene { this.bsp = bsp } + setFullbright(enabled: boolean) { + this.shader.setFullbright(this.context.gl, enabled ? 1.0 : 0.0) + } + private fillBuffer(bsp: Bsp) { const gl = this.context.gl const models = bsp.models @@ -94,7 +100,6 @@ export class WorldScene { 'invisible', 'skip', 'trigger', - 'sky', 'fog' ] @@ -463,10 +468,10 @@ export class WorldScene { gl.activeTexture(gl.TEXTURE0) + const nonTriggerEntities = entities.filter(e => !e.classname.startsWith('trigger_')) const opaqueEntities = [] const transparentEntities = [] - for (let i = 1; i < entities.length; ++i) { - const e = entities[i] + for (const e of nonTriggerEntities) { if (e.model) { if (!e.rendermode || e.rendermode === RenderMode.Normal || e.rendermode === RenderMode.Solid) { if (e.model[0] === '*') { @@ -500,18 +505,49 @@ export class WorldScene { } } + private drawModel(gl: WebGLRenderingContext, model: ModelInfo) { + for (let j = 0; j < model.faces.length; ++j) { + const face = model.faces[j] + const texture = this.textures[face.textureIndex] + // Draw sky model into depth buffer only to prevent drawing things behind the skybox + if (texture.name === 'sky') { + gl.colorMask(false, false, false, false) + const old = gl.getParameter(gl.DEPTH_WRITEMASK) + gl.depthMask(true) + gl.bindTexture(gl.TEXTURE_2D, texture.handle) + gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) + gl.colorMask(true, true, true, true) + gl.depthMask(old) + } else { + gl.bindTexture(gl.TEXTURE_2D, texture.handle) + gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) + } + } + } + + private drawEntityModel(gl: WebGLRenderingContext, texture: Texture) { + if (texture.name === 'sky') { + gl.colorMask(false, false, false, false) + const old = gl.getParameter(gl.DEPTH_WRITEMASK) + gl.depthMask(true) + gl.bindTexture(gl.TEXTURE_2D, texture.handle) + gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + gl.colorMask(true, true, true, true) + gl.depthMask(old) + } else { + gl.bindTexture(gl.TEXTURE_2D, texture.handle) + gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + } + } + + private renderWorldSpawn() { const model = this.sceneInfo.models[0] const gl = this.context.gl mat4.identity(this.modelMatrix) this.shader.setModelMatrix(gl, this.modelMatrix) - - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } + this.drawModel(gl, model) } private renderOpaqueEntities(camera: Camera, entities: any[]) { @@ -524,7 +560,6 @@ export class WorldScene { const modelIndex = Number.parseInt(entity.model.substr(1)) const model = this.sceneInfo.models[modelIndex] if (model) { - const angles = entity.angles || [0, 0, 0] const origin = entity.origin ? vec3.fromValues(entity.origin[0], entity.origin[1], entity.origin[2]) : vec3.create() @@ -533,16 +568,9 @@ export class WorldScene { // TODO: this seems to work, but needs further research mat4.identity(mmx) mat4.translate(mmx, mmx, origin) - // mat4.rotateY(mmx, mmx, (angles[0] * Math.PI) / 180) // dunno this - mat4.rotateZ(mmx, mmx, (angles[1] * Math.PI) / 180) - mat4.rotateX(this.modelMatrix, this.modelMatrix, (angles[2] * Math.PI) / 180) - shader.setModelMatrix(gl, this.modelMatrix) - - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } + shader.setModelMatrix(gl, mmx) + + this.drawModel(gl, model) } else if (entity.model.indexOf('.spr') > -1) { const texture = this.textures.find((a) => a.name === entity.model) const sprite = this.sprites[entity.model] @@ -611,44 +639,21 @@ export class WorldScene { switch (renderMode) { case RenderMode.Normal: { shader.setOpacity(gl, 1) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Color: { - // TODO: not properly implemented - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Texture: { - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Glow: { - // TODO: not properly implemented - gl.blendFunc(gl.SRC_ALPHA, gl.DST_ALPHA) - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + this.drawEntityModel(gl, texture) break } + case RenderMode.Color: + case RenderMode.Texture: case RenderMode.Solid: { - // TODO: not properly implemented shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + this.drawEntityModel(gl, texture) break } + case RenderMode.Glow: case RenderMode.Additive: { gl.blendFunc(gl.SRC_ALPHA, gl.DST_ALPHA) shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + this.drawEntityModel(gl, texture) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) break } @@ -680,78 +685,35 @@ export class WorldScene { const modelIndex = Number.parseInt(entity.model.substr(1)) const model = this.sceneInfo.models[modelIndex] if (model) { - const angles = entity.angles || [0, 0, 0] const origin = entity.origin || [0, 0, 0] origin[0] += model.origin[0] origin[1] += model.origin[1] origin[2] += model.origin[2] - // TODO: this seems to work, but needs further research mat4.identity(mmx) mat4.translate(mmx, mmx, origin) - mat4.rotateZ(mmx, mmx, (angles[1] * Math.PI) / 180) - // mat4.rotateY(mmx, mmx, (angles[2] * Math.PI) / 180) // dunno this - mat4.rotateX(this.modelMatrix, this.modelMatrix, (angles[2] * Math.PI) / 180) - shader.setModelMatrix(gl, this.modelMatrix) + shader.setModelMatrix(gl, mmx) const renderMode = entity.rendermode || RenderMode.Normal switch (renderMode) { case RenderMode.Normal: { shader.setOpacity(gl, 1) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } - break - } - case RenderMode.Color: { - // TODO: not properly implemented - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } - break - } - case RenderMode.Texture: { - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } - break - } - case RenderMode.Glow: { - // TODO: not properly implemented - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } + this.drawModel(gl, model) break } + case RenderMode.Color: + case RenderMode.Texture: + case RenderMode.Glow: case RenderMode.Solid: { // TODO: not properly implemented shader.setOpacity(gl, (entity.renderamt || 255) / 255) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } + this.drawModel(gl, model) break } case RenderMode.Additive: { gl.blendFunc(gl.SRC_ALPHA, gl.DST_ALPHA) shader.setOpacity(gl, (entity.renderamt || 255) / 255) - for (let j = 0; j < model.faces.length; ++j) { - const face = model.faces[j] - gl.bindTexture(gl.TEXTURE_2D, this.textures[face.textureIndex].handle) - gl.drawArrays(gl.TRIANGLES, face.offset / 7, face.length / 7) - } + this.drawModel(gl, model) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) break } @@ -824,44 +786,21 @@ export class WorldScene { switch (renderMode) { case RenderMode.Normal: { shader.setOpacity(gl, 1) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Color: { - // TODO: not properly implemented - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Texture: { - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - break - } - case RenderMode.Glow: { - // TODO: not properly implemented - gl.blendFunc(gl.SRC_ALPHA, gl.DST_ALPHA) - shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + this.drawEntityModel(gl, texture) break } + case RenderMode.Color: + case RenderMode.Texture: case RenderMode.Solid: { - // TODO: not properly implemented shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + this.drawEntityModel(gl, texture) break } + case RenderMode.Glow: case RenderMode.Additive: { gl.blendFunc(gl.SRC_ALPHA, gl.DST_ALPHA) shader.setOpacity(gl, (entity.renderamt || 255) / 255) - gl.bindTexture(gl.TEXTURE_2D, texture.handle) - gl.drawArrays(gl.TRIANGLES, this.sceneInfo.models[this.sceneInfo.models.length - 1].offset / 7, 6) + this.drawEntityModel(gl, texture) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) break } diff --git a/src/Graphics/WorldShader/WorldShader.ts b/src/Graphics/WorldShader/WorldShader.ts index 3850662e..a277239b 100644 --- a/src/Graphics/WorldShader/WorldShader.ts +++ b/src/Graphics/WorldShader/WorldShader.ts @@ -8,6 +8,7 @@ precision highp float; uniform sampler2D diffuse; uniform sampler2D lightmap; uniform float opacity; +uniform float fullbright; varying vec2 vTexCoord; varying vec2 vLightmapCoord; @@ -16,7 +17,11 @@ void main(void) { vec4 diffuseColor = texture2D(diffuse, vTexCoord); vec4 lightColor = texture2D(lightmap, vLightmapCoord); - gl_FragColor = vec4(diffuseColor.rgb * lightColor.rgb, diffuseColor.a * opacity); + vec3 lightmappedRGB = pow(diffuseColor.rgb * lightColor.rgb, vec3(0.85)); + vec3 fullbrightRGB = diffuseColor.rgb; + vec3 finalRGB = mix(lightmappedRGB, fullbrightRGB, fullbright); + + gl_FragColor = vec4(finalRGB, diffuseColor.a * opacity); }` const vertexSrc = `#ifdef GL_ES @@ -50,7 +55,8 @@ export class MainShader { 'projectionMatrix', 'diffuse', 'lightmap', - 'opacity' + 'opacity', + 'fullbright', ] const program = context.createProgram({ vertexShaderSrc: vertexSrc, @@ -76,6 +82,7 @@ export class MainShader { private uDiffuse: WebGLUniformLocation private uLightmap: WebGLUniformLocation private uOpacity: WebGLUniformLocation + private uFullbright: WebGLUniformLocation private constructor(program: Program) { this.program = program.handle @@ -88,6 +95,7 @@ export class MainShader { this.uDiffuse = program.uniforms.diffuse this.uLightmap = program.uniforms.lightmap this.uOpacity = program.uniforms.opacity + this.uFullbright = program.uniforms.fullbright } useProgram(gl: WebGLRenderingContext) { @@ -118,6 +126,10 @@ export class MainShader { gl.uniform1f(this.uOpacity, val) } + setFullbright(gl: WebGLRenderingContext, val: number) { + gl.uniform1f(this.uFullbright, val) + } + enableVertexAttribs(gl: WebGLRenderingContext) { gl.enableVertexAttribArray(this.aPosition) gl.enableVertexAttribArray(this.aTexCoord) diff --git a/src/Loader.ts b/src/Loader.ts index 1fc9d771..e5548d46 100644 --- a/src/Loader.ts +++ b/src/Loader.ts @@ -5,10 +5,11 @@ import { extname } from './Util' import type { Config } from './Config' import { Tga } from './Parsers/Tga' import { Wad } from './Parsers/Wad' -import { Replay } from './Replay/Replay' +import { Replay, ReplayType } from './Replay/Replay' import { Sprite } from './Parsers/Sprite' import { xhr, type ProgressCallback } from './Xhr' import { BspParser } from './Parsers/BspParser' +import { HlkzReplay } from './Replay/HlkzReplay' enum LoadItemStatus { Loading = 1, @@ -91,6 +92,7 @@ export class Loader { config: Config replay?: LoadItemReplay + replayType = ReplayType.DEMO map?: LoadItemBsp skies: LoadItemSky[] wads: LoadItemWad[] @@ -161,7 +163,9 @@ export class Loader { load(name: string) { const extension = extname(name) - if (extension === '.dem') { + if (extension === '.dat') { + this.loadHlkzReplay(name) + } else if (extension === '.dem') { this.loadReplay(name) } else if (extension === '.bsp') { this.loadMap(name) @@ -170,7 +174,51 @@ export class Loader { } } + async loadHlkzReplay(name: string) { + // Format is mapName_X_Y_Z_(pure|pro|nub)_id.dat where X:Y:Z is SteamID + this.replayType = ReplayType.HLKZ + const split = name.split('_') + if (split.length < 6) { + return + } + const runType = split[split.length - 2] + const mapName = split.slice(0, split.length - 5).join('_') + + const buffer = await this.setupReplay(name) + if (this.replay!.isError()) { + return + } + this.loadMap(`${mapName}.bsp`) + const replay = new HlkzReplay(runType, mapName, buffer) + this.replay!.done(replay) + this.events.emit('load', this.replay) + this.checkStatus() + } + async loadReplay(name: string) { + const buffer = await this.setupReplay(name) + + if (this.replay!.isError()) { + return + } + + const replay = Replay.parseIntoChunks(buffer) + this.replay!.done(replay) + + this.loadMap(`${replay.maps[0].name}.bsp`) + + const sounds = replay.maps[0].resources.sounds + for (const sound of sounds) { + if (sound.used) { + this.loadSound(sound.name, sound.index) + } + } + + this.events.emit('load', this.replay) + this.checkStatus() + } + + private async setupReplay(name: string) { this.replay = new LoadItemReplay(name) this.events.emit('loadstart', this.replay) @@ -183,7 +231,7 @@ export class Loader { } const replayPath = this.config.getReplaysPath() - const buffer = await xhr(`${replayPath}/${name}`, { + const buffer: ArrayBuffer = await xhr(`${replayPath}/${name}`, { method: 'GET', isBinary: true, progressCallback @@ -193,25 +241,7 @@ export class Loader { } this.events.emit('error', err, this.replay) }) - - if (this.replay.isError()) { - return - } - - const replay = Replay.parseIntoChunks(buffer) - this.replay.done(replay) - - this.loadMap(`${replay.maps[0].name}.bsp`) - - const sounds = replay.maps[0].resources.sounds - for (const sound of sounds) { - if (sound.used) { - this.loadSound(sound.name, sound.index) - } - } - - this.events.emit('load', this.replay) - this.checkStatus() + return buffer } async loadMap(name: string) { diff --git a/src/Parsers/Hlkz.ts b/src/Parsers/Hlkz.ts new file mode 100644 index 00000000..77ae7881 --- /dev/null +++ b/src/Parsers/Hlkz.ts @@ -0,0 +1,54 @@ +import { Reader } from '../Reader' + +export interface HlkzFrame { + gametime: number + x: number + y: number + z: number + angle_x: number + angle_y: number + angle_z: number + buttons: number +} + +export class HlkzButtonConstants { + static BTN_JUMP = (1 << 1) + static BTN_DUCK = (1 << 2) + static BTN_FORWARD = (1 << 3) + static BTN_BACK = (1 << 4) + static BTN_USE = (1 << 5) + static BTN_MOVELEFT = (1 << 9) + static BTN_MOVERIGHT = (1 << 10) +} + +export class Hlkz { + static parse(buffer: ArrayBuffer): HlkzFrame[] { + const r = new Reader(buffer) + + let entries = buffer.byteLength / 30 // sizeof HlkzFrame + const hlkzFrames = Array(entries) + + const initialTime = r.f() + r.seek(0) + for (let i = 0; i < entries; i++) { + hlkzFrames[i] = Hlkz.readFrame(r, initialTime) + } + return hlkzFrames + } + + private static readFrame(r: Reader, initialTime?: number): HlkzFrame { + let frame: HlkzFrame = { + gametime: r.f() - (initialTime ?? 0), + x: r.f(), + y: r.f(), + z: r.f() + 28, // view height difference from origin + angle_x: r.f() * -3, // weird pitch inversion and scaling correction + angle_y: r.f(), + angle_z: r.f(), + buttons: r.us() + } + if (frame.buttons & HlkzButtonConstants.BTN_DUCK) + frame.z -= 16 // ducking view height difference + return frame + } +} diff --git a/src/PlayerInterface/App/index.tsx b/src/PlayerInterface/App/index.tsx index 0855eb8a..19878ace 100644 --- a/src/PlayerInterface/App/index.tsx +++ b/src/PlayerInterface/App/index.tsx @@ -7,6 +7,8 @@ import { Fullscreen } from '../../Fullscreen' import { GameStateContext } from '../GameState' import { type Game, PlayerMode } from '../../Game' import './style.css' +import { KeyDisplay } from '../KeyDisplay' +import { TimerDisplay } from '../TimerDisplay' export function App(props: { game: Game; root: Element }) { let screen: HTMLButtonElement | null = null @@ -23,7 +25,9 @@ export function App(props: { game: Game; root: Element }) { time: props.game.player.currentTime, volume: props.game.soundSystem.getVolume(), isPlaying: props.game.player.isPlaying, - isPaused: props.game.player.isPaused + isPaused: props.game.player.isPaused, + showKeys: props.game.showKeys, + showTimer: props.game.showTimer, }) onMount(() => { @@ -46,6 +50,11 @@ export function App(props: { game: Game; root: Element }) { const offVolumeChange = props.game.soundSystem.events.on('volumeChange', () => { setGameState({ volume: props.game.soundSystem.getVolume() }) }) + const offShowKeys = game.events.on('showkeyschange', (showKeys: boolean) => setGameState({ showKeys })) + const offShowTimer = game.events.on('showtimerchange', (showTimer: boolean) => { + console.log(showTimer) + setGameState({ showTimer }) + }) let interval: number const onPlay = () => { @@ -80,6 +89,8 @@ export function App(props: { game: Game; root: Element }) { offPause?.() offStop?.() offVolumeChange?.() + offShowKeys?.() + offShowTimer?.() offPlayTimer?.() offPauseTimer?.() offStopTimer?.() @@ -255,6 +266,10 @@ export function App(props: { game: Game; root: Element }) { + + + + + Display + + + ) diff --git a/src/PlayerInterface/KeyDisplay/index.tsx b/src/PlayerInterface/KeyDisplay/index.tsx new file mode 100644 index 00000000..022a2bd2 --- /dev/null +++ b/src/PlayerInterface/KeyDisplay/index.tsx @@ -0,0 +1,58 @@ +import { onCleanup, onMount } from 'solid-js' +import { createStore } from 'solid-js/store' +import type { Game } from '../../Game' +import './style.css' +import { HlkzButtonConstants } from '../../Parsers/Hlkz' + +export function KeyDisplay(props: { game: Game; visible: boolean }) { + const [keysStore, setKeysStore] = createStore({ + jump: false, + duck: false, + forward: false, + back: false, + use: false, + moveleft: false, + moveright: false, + }) + + onMount(() => { + const loaderEvents = props.game.player.events + const offKeysPressed = loaderEvents.on('keyspressed', onKeysPressed) + onCleanup(() => { + offKeysPressed?.() + }) + }) + + const onKeysPressed = (buttons: number) => { + setKeysStore('jump', (buttons & HlkzButtonConstants.BTN_JUMP) != 0) + setKeysStore('duck', (buttons & HlkzButtonConstants.BTN_DUCK) != 0) + setKeysStore('use', (buttons & HlkzButtonConstants.BTN_USE) != 0) + const forward = (buttons & HlkzButtonConstants.BTN_FORWARD) != 0 + const back = (buttons & HlkzButtonConstants.BTN_BACK) != 0 + const left = (buttons & HlkzButtonConstants.BTN_MOVELEFT) != 0 + const right = (buttons & HlkzButtonConstants.BTN_MOVERIGHT) != 0 + setKeysStore('forward', forward && !back) + setKeysStore('back', back && !forward) + setKeysStore('moveleft', left && !right) + setKeysStore('moveright', right && !left) + } + + return ( +
+
+
W
+
A
+
S
+
D
+
DUCK
+
JUMP
+
USE
+
+
+ ) +} diff --git a/src/PlayerInterface/KeyDisplay/style.css b/src/PlayerInterface/KeyDisplay/style.css new file mode 100644 index 00000000..84106c0f --- /dev/null +++ b/src/PlayerInterface/KeyDisplay/style.css @@ -0,0 +1,52 @@ +.hlv-keys-hud { + position: absolute; + padding: 10px; + font-family: sans-serif; + top: 80%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 0; + opacity: 0; + user-select: none; +} + +.hlv-keys-hud.visible { + opacity: 1; + z-index: 30; +} + +.hlv-keyboard { + display: grid; + grid-template-columns: repeat(3, 30px); + grid-template-rows: repeat(3, 20px); + gap: 4px; +} + +.hlv-key { + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(85, 85, 85, 0.3); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: x-small; + text-align: center; +} + +.hlv-key.activated { + background: rgba(255, 215, 0, 0.3); + border: 2px solid rgba(255, 215, 0, 0.3); +} + + +/* WASD positioning */ +.hlv-key-w { grid-column: 2; grid-row: 1; } +.hlv-key-a { grid-column: 1; grid-row: 2; } +.hlv-key-s { grid-column: 2; grid-row: 2; } +.hlv-key-d { grid-column: 3; grid-row: 2; } + +/* Other controls */ +.hlv-key-jump { grid-column: 2 / span 2; grid-row: 3; } +.hlv-key-duck { grid-column: 1; grid-row: 3; } +.hlv-key-use { grid-column: 3; grid-row: 1; } diff --git a/src/PlayerInterface/ReplayMode/index.tsx b/src/PlayerInterface/ReplayMode/index.tsx index 7aa25957..63e202b5 100644 --- a/src/PlayerInterface/ReplayMode/index.tsx +++ b/src/PlayerInterface/ReplayMode/index.tsx @@ -1,3 +1,4 @@ +import { Show } from 'solid-js' import { Time } from '../Time' import type { Game } from '../../Game' import { Timeline } from '../Timeline' @@ -10,6 +11,7 @@ import { SpeedUpButton } from '../Buttons/SpeedUpButton' import { SpeedDownButton } from '../Buttons/SpeedDownButton' import { SettingsButton } from '../Buttons/SettingsButton' import { FullscreenButton } from '../Buttons/FullscreenButton' +import { ReplayType } from '../../Replay/Replay' export function ReplayMode(props: { class: string; game: Game; root: Element; visible: boolean }) { const gameState = useGameState() @@ -49,9 +51,11 @@ export function ReplayMode(props: { class: string; game: Game; root: Element; vi onSpeedUp()} />
- onVolumeClick()} /> - -
diff --git a/src/PlayerInterface/Time/index.tsx b/src/PlayerInterface/Time/index.tsx index 4f2d3764..c5d5b3b0 100644 --- a/src/PlayerInterface/Time/index.tsx +++ b/src/PlayerInterface/Time/index.tsx @@ -1,12 +1,26 @@ import { formatTime } from '../../Time' -import type { ReplayPlayer } from '../../ReplayPlayer' +import { Game } from '../../Game' import { useGameState } from '../GameState' import './style.css' +import { createSignal, onCleanup, onMount } from 'solid-js' -export function Time(props: { player: ReplayPlayer }) { +export function Time(props: { game: Game }) { const gameState = useGameState() - const current = () => formatTime(gameState.time) - const total = () => formatTime(props.player.replay.length) + const current = () => formatTime(gameState.time, 3) + const [total, setTotal] = createSignal(formatTime(props.game.player.replay.length, 3)) + + onMount(() => { + const offReplayChange = props.game.events.on('postreplaychange', + (game: Game) => { + console.log("new replay", game.player.replay.length) + setTotal(formatTime(game.player.replay.length, 3)) + } + ) + + onCleanup(() => { + offReplayChange?.() + }) + }) return (
diff --git a/src/PlayerInterface/TimerDisplay/index.tsx b/src/PlayerInterface/TimerDisplay/index.tsx new file mode 100644 index 00000000..bdeeae86 --- /dev/null +++ b/src/PlayerInterface/TimerDisplay/index.tsx @@ -0,0 +1,17 @@ +import { formatTime } from '../../Time' +import { useGameState } from '../GameState' +import './style.css' + +export function TimerDisplay(props: { visible: boolean }) { + const gameState = useGameState() + const current = () => formatTime(gameState.time, 1) + + return ( +
+ {current()} +
+ ) +} diff --git a/src/PlayerInterface/TimerDisplay/style.css b/src/PlayerInterface/TimerDisplay/style.css new file mode 100644 index 00000000..1eda2536 --- /dev/null +++ b/src/PlayerInterface/TimerDisplay/style.css @@ -0,0 +1,18 @@ +.hlv-timer-display { + font-family: sans-serif; + position: absolute; + top: 20%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 0; + opacity: 0; + paint-order: stroke fill; + font-size: 1em; + -webkit-text-stroke: 4px rgba(0, 0, 0, 0.80); +} + + +.hlv-timer-display.visible { + opacity: 80%; + z-index: 30; +} diff --git a/src/Replay/HlkzReplay.ts b/src/Replay/HlkzReplay.ts new file mode 100644 index 00000000..bd0d619b --- /dev/null +++ b/src/Replay/HlkzReplay.ts @@ -0,0 +1,15 @@ +import { Hlkz, HlkzFrame } from "../Parsers/Hlkz"; + +export class HlkzReplay { + runType: any + mapName: string + data: HlkzFrame[] + length: number + + constructor(runType: string, mapName: string, dataBuffer: ArrayBuffer) { + this.runType = runType; + this.mapName = mapName; + this.data = Hlkz.parse(dataBuffer); + this.length = this.data[this.data.length - 1].gametime + } +} \ No newline at end of file diff --git a/src/Replay/Replay.ts b/src/Replay/Replay.ts index 19291948..72776083 100644 --- a/src/Replay/Replay.ts +++ b/src/Replay/Replay.ts @@ -145,6 +145,11 @@ const readFrame = (r: Reader, deltaDecoders: any, customMessages: any) => { return frame } +export enum ReplayType { + DEMO, + HLKZ, +} + export class Replay { header: any mapName: string diff --git a/src/Replay/ReplayState.ts b/src/Replay/ReplayState.ts index 96de4270..b576ed07 100644 --- a/src/Replay/ReplayState.ts +++ b/src/Replay/ReplayState.ts @@ -1,3 +1,5 @@ +import { HlkzFrame } from "../Parsers/Hlkz" + export class ReplayState { cameraPos: any[] cameraRot: any[] @@ -34,6 +36,16 @@ export class ReplayState { } } + feedHlkzFrame(frame: HlkzFrame) { + this.cameraPos[0] = frame.x + this.cameraPos[1] = frame.y + this.cameraPos[2] = frame.z + + this.cameraRot[0] = frame.angle_x + this.cameraRot[1] = frame.angle_y + this.cameraRot[2] = frame.angle_z + } + clone() { return new ReplayState(this) } diff --git a/src/ReplayPlayer.ts b/src/ReplayPlayer.ts index 4714d83b..783df69c 100644 --- a/src/ReplayPlayer.ts +++ b/src/ReplayPlayer.ts @@ -1,8 +1,9 @@ import { glMatrix } from 'gl-matrix' import { createNanoEvents, type Emitter as EventEmitter } from 'nanoevents' import type { Game } from './Game' -import { Replay } from './Replay/Replay' +import { Replay, ReplayType } from './Replay/Replay' import { ReplayState } from './Replay/ReplayState' +import { HlkzFrame } from './Parsers/Hlkz' const updateGame = (game: Game, state: ReplayState) => { game.camera.position[0] = state.cameraPos[0] @@ -13,10 +14,16 @@ const updateGame = (game: Game, state: ReplayState) => { game.camera.rotation[2] = glMatrix.toRadian(state.cameraRot[2]) } +const updateGameAndButtons = (game: Game, state: ReplayState, events: EventEmitter, buttons: number) => { + updateGame(game, state); + events.emit('keyspressed', buttons) +} + export class ReplayPlayer { game: Game state: ReplayState replay: any + replayType: ReplayType events: EventEmitter currentMap = 0 @@ -32,6 +39,7 @@ export class ReplayPlayer { this.game = game this.state = new ReplayState() this.replay = null + this.replayType = ReplayType.DEMO this.events = createNanoEvents() } @@ -45,15 +53,16 @@ export class ReplayPlayer { this.isPaused = false this.speed = 1 - if (this.replay) { + if (this.replay && this.replayType == ReplayType.DEMO) { const firstChunk = this.replay.maps[0].chunks[0] firstChunk.reader.seek(0) this.state = firstChunk.state.clone() } } - changeReplay(replay: Replay) { + changeReplay(replay: Replay, replayType: ReplayType) { this.replay = replay + this.replayType = replayType; this.reset() } @@ -90,7 +99,31 @@ export class ReplayPlayer { seek(value: number) { const t = Math.max(0, Math.min(this.replay.length, value)) + if (this.replayType == ReplayType.DEMO) { + this.seekDemo(t) + } else { + this.seekHlkz(t) + } + } + + private seekHlkz(t: number) { + const frames: HlkzFrame[] = this.replay.data + let buttons = 0 + for (const [i, frame] of frames.entries()) { + if (frame.gametime <= t) { + this.state.feedHlkzFrame(frame) + } else { + this.currentTick = i + this.currentTime = frame.gametime + buttons = frame.buttons + break + } + } + this.events.emit('seek', t) + updateGameAndButtons(this.game, this.state, this.events, buttons) + } + private seekDemo(t: number) { const maps = this.replay.maps for (let i = 0; i < maps.length; ++i) { const chunks = maps[i].chunks @@ -134,6 +167,41 @@ export class ReplayPlayer { } update(dt: number) { + if (this.replayType == ReplayType.DEMO) { + this.updateDemo(dt) + } else { + this.updateHlkz(dt) + } + } + + private updateHlkz(dt: number) { + if (!this.isPlaying || this.isPaused) { + return + } + + const frameData: HlkzFrame[] = this.replay.data + const endTime = this.currentTime + dt * this.speed + let buttons = 0 + + let frame: HlkzFrame + while (this.currentTick < frameData.length) { + frame = frameData[this.currentTick++] + if (frame.gametime > endTime) { + this.state.feedHlkzFrame(frame) + this.currentTime = frame.gametime + buttons = frame.buttons + break + } + } + + updateGameAndButtons(this.game, this.state, this.events, buttons); + this.currentTime = endTime + if (this.currentTick == frameData.length) { + this.stop() + } + } + + private updateDemo(dt: number) { if (!this.isPlaying || this.isPaused) { return } diff --git a/src/Time.ts b/src/Time.ts index 4b6be1cf..3309c6df 100644 --- a/src/Time.ts +++ b/src/Time.ts @@ -1,9 +1,18 @@ export const now = performance.now.bind(performance) -export const formatTime = (seconds: number) => { +export const formatTime = (seconds: number, decimals?: number) => { const m = Math.floor(seconds / 60) - const s = Math.floor(seconds - m * 60) - const mm = m < 10 ? `0${m}` : m.toString() - const ss = s < 10 ? `0${s}` : s.toString() + const s = seconds - m * 60 + let formattedSeconds: string + if (decimals !== undefined) { + const factor = Math.pow(10, decimals) + const truncated = Math.floor(s * factor) / factor + formattedSeconds = truncated.toFixed(decimals) + } else { + formattedSeconds = Math.floor(s).toString() + } + const mm = m.toString().padStart(2, '0') + const ssPadLength = decimals ? 3 + decimals : 2 + const ss = formattedSeconds.padStart(ssPadLength, '0') return `${mm}:${ss}` }