diff --git a/assets/arrow.blend b/assets/arrow.blend new file mode 100644 index 00000000..453b014f Binary files /dev/null and b/assets/arrow.blend differ diff --git a/assets/arrow.blend1 b/assets/arrow.blend1 new file mode 100644 index 00000000..a2c33070 Binary files /dev/null and b/assets/arrow.blend1 differ diff --git a/deno.lock b/deno.lock index 32044da7..b7f481da 100644 --- a/deno.lock +++ b/deno.lock @@ -11331,7 +11331,6 @@ "npm:@types/three@0.169", "npm:autoprefixer@^10.4.14", "npm:dot-prop@9", - "npm:express@^4.21.1", "npm:fdir@^6.4.2", "npm:front-matter@^4.0.2", "npm:joi@^17.13.3", diff --git a/docs/Items.md b/docs/Items.md index 98bc520f..8474aba7 100644 --- a/docs/Items.md +++ b/docs/Items.md @@ -1,7 +1,10 @@ ## Items + Item ID list with implementation info -| ID | Class | Item Description | Inventory | World | -|----|--------------|---------------------------------------|-------------|-------| -| 0 | ItemBase.ts | Base item template (freaky green cube)| ✅ | ✅ | -| 1 | BananaGun.ts | Banana gun | ✅ | ✅ | -| 1 | FishGun.ts | Fish gun >:) | ✅ | ✅ | + +| ID | Class | Item Description | Inventory | World | +| -- | ------------ | -------------------------------------- | --------- | ----- | +| 0 | ItemBase.ts | Base item template (freaky green cube) | ✅ | ✅ | +| 1 | BananaGun.ts | Banana gun | ✅ | ✅ | +| 2 | FishGun.ts | Fish gun >:) | ✅ | ✅ | +| 4 | FlagItem.ts | FlagItem used in CTF | ✅ | ✅ | diff --git a/docs/blender-texture-resolution.md b/docs/blender-texture-resolution.md new file mode 100644 index 00000000..9082f1cc --- /dev/null +++ b/docs/blender-texture-resolution.md @@ -0,0 +1,42 @@ +# Reduce Texture Resolution of a Blender File + +The following python script reduces the resolution of all textures in a Blender file by 50%. + +```python +import bpy + +def resize_image(image, scale_factor=0.5): + # Calculate new dimensions + new_width = int(image.size[0] * scale_factor) + new_height = int(image.size[1] * scale_factor) + + # Ensure the image is packed or has a file path + if image.packed_file: + image.unpack(method='USE_ORIGINAL') + + # Get the file path + filepath = bpy.path.abspath(image.filepath) + + # Skip images without a valid file path + if not filepath: + print(f"Skipping image '{image.name}' because it has no valid file path.") + return + + # Scale the image + image.scale(new_width, new_height) + + # Save the resized image + image.filepath_raw = filepath + image.file_format = 'PNG' # Change format if needed + image.save() + +def main(): + # Iterate over all images in the Blender file + for image in bpy.data.images: + if image.size[0] > 0 and image.size[1] > 0: + print(f"Processing image: {image.name}") + resize_image(image) + +if __name__ == "__main__": + main() +``` diff --git a/docs/gltf-compression.md b/docs/gltf-compression.md index 83499a64..9270dbef 100644 --- a/docs/gltf-compression.md +++ b/docs/gltf-compression.md @@ -1,38 +1,46 @@ # GLTF Compression ### use gltf-pipeline for compression + https://github.com/CesiumGS/gltf-pipeline Can be installed using npm: + ```bash npm install -g gltf-pipeline ``` ### sane person compression if it's already stylized: + ```bash gltf-pipeline -i input.glb -o output.glb --draco.compressionLevel 10 --texcomp.quality 50 --texcomp.powerOfTwoImage true ``` + This command: 1. Uses Draco compression with maximum level (10) -2. Reduces the precision of various attributes (position, normal, texture coordinates, color, and other generic attributes) by specifying fewer bits for each. This effectively removes data points. 3. +2. Reduces the precision of various attributes (position, normal, texture coordinates, color, and other generic + attributes) by specifying fewer bits for each. This effectively removes data points. 3. 3. Applies the --optimize.simplify flag, which attempts to simplify the geometry while preserving the overall shape. -You can adjust the quantization bits (the numbers after each quantize...Bits option) to be even lower for more aggressive simplification. For example: +You can adjust the quantization bits (the numbers after each quantize...Bits option) to be even lower for more +aggressive simplification. For example: ```bash --draco.quantizePositionBits 8 --draco.quantizeNormalBits 6 --draco.quantizeTexcoordBits 6 --draco.quantizeColorBits 6 --draco.quantizeGenericBits 6 ``` - ### really abysmal compression to "stylize" something normal -(used for possum and banana so far)- uses 8 for quantization leading to gross blocky look sometimes unintended with holes + +(used for possum and banana so far)- uses 8 for quantization leading to gross blocky look sometimes unintended with +holes ```bash gltf-pipeline -i possum.glb -o simplified_possum.glb --draco.compressionLevel 10 --draco.quantizePositionBits 6 --draco.quantizeNormalBits 4 --draco.quantizeTexcoordBits 4 --draco.quantizeColorBits 4 --draco.quantizeGenericBits 4 --optimize.simplify ``` #### not sure what this one does, added a bunch more flags might break things lmk tho + ```bash gltf-pipeline -i possum.glb -o simplified_possum.glb --draco.compressionLevel 10 --draco.quantizePositionBits 6 --draco.quantizeNormalBits 4 --draco.quantizeTexcoordBits 4 --draco.quantizeColorBits 4 --draco.quantizeGenericBits 4 --optimize.simplify --optimize.pruneUnused --optimize.mergeInstances --optimize.mergeMaterials --optimize.stripJoints -``` \ No newline at end of file +``` diff --git a/public/models/arrow.glb b/public/models/arrow.glb new file mode 100644 index 00000000..208c1fa4 Binary files /dev/null and b/public/models/arrow.glb differ diff --git a/public/models/flamingo.glb b/public/models/flamingo.glb new file mode 100644 index 00000000..60d12d23 Binary files /dev/null and b/public/models/flamingo.glb differ diff --git a/public/models/pizza.glb b/public/models/pizza.glb new file mode 100644 index 00000000..cc0de213 Binary files /dev/null and b/public/models/pizza.glb differ diff --git a/public/models/simplified_flamingo.glb b/public/models/simplified_flamingo.glb new file mode 100644 index 00000000..0b0cd401 Binary files /dev/null and b/public/models/simplified_flamingo.glb differ diff --git a/public/models/simplified_pizza.glb b/public/models/simplified_pizza.glb new file mode 100644 index 00000000..e38b9e20 Binary files /dev/null and b/public/models/simplified_pizza.glb differ diff --git a/src/client/core/Game.ts b/src/client/core/Game.ts index 0447f4c7..3dcbf723 100644 --- a/src/client/core/Game.ts +++ b/src/client/core/Game.ts @@ -59,7 +59,6 @@ export class Game { this.networking.updatePlayerData(); this.chatOverlay.onFrame(); this.inventoryManager.onFrame(); - this.healthIndicator.onFrame(); this.renderer.onFrame(this.localPlayer); if (this.networking.getServerInfo().mapName) { this.mapLoader.load('/maps/' + this.networking.getServerInfo().mapName + '/map.glb'); diff --git a/src/client/core/Inventory.ts b/src/client/core/Inventory.ts index 4ab7fce1..4e29122a 100644 --- a/src/client/core/Inventory.ts +++ b/src/client/core/Inventory.ts @@ -7,6 +7,7 @@ import { Networking } from './Networking.ts'; import { ItemBase, ItemType } from '../items/ItemBase.ts'; import { FishGun } from '../items/FishGun.ts'; import { Player } from '../../shared/Player.ts'; +import { FlagItem } from '../items/FlagItem.ts'; export class Inventory { private inventoryItems: ItemBase[] = []; @@ -63,6 +64,11 @@ export class Inventory { this.inventoryItems.push(fish); break; } + case 4: { + const flag = new FlagItem(this.renderer, i, ItemType.InventoryItem); + this.inventoryItems.push(flag); + break; + } default: { const testItem = new ItemBase( ItemType.InventoryItem, diff --git a/src/client/core/Networking.ts b/src/client/core/Networking.ts index 3ffefcb9..f0a7c094 100644 --- a/src/client/core/Networking.ts +++ b/src/client/core/Networking.ts @@ -19,6 +19,7 @@ interface ServerInfo { version: string; gameMode: string; playerMaxHealth: number; + skyColor: string; } interface LastUploadedLocalPlayer { @@ -68,6 +69,7 @@ export class Networking { version: '', gameMode: '', playerMaxHealth: 0, + skyColor: '#000000', }; this.setupSocketListeners(); @@ -104,7 +106,9 @@ export class Networking { }); this.socket.on('serverInfo', (data) => { - this.serverInfo = data; + this.serverInfo = { + ...data, + }; this.onServerInfo(); }); } @@ -176,17 +180,34 @@ export class Networking { } if (remotePlayer.health < this.localPlayer.health) this.damagedTimestamp = Date.now() / 1000; this.localPlayer.health = remotePlayer.health; + this.localPlayer.highlightedVectors = remotePlayer.highlightedVectors.map( + (vec) => new THREE.Vector3(vec.x, vec.y, vec.z), + ); + this.localPlayer.directionIndicatorVector = remotePlayer.directionIndicatorVector + ? new THREE.Vector3( + remotePlayer.directionIndicatorVector.x, + remotePlayer.directionIndicatorVector.y, + remotePlayer.directionIndicatorVector.z, + ) + : undefined; + this.localPlayer.idLastDamagedBy = remotePlayer.idLastDamagedBy; this.localPlayer.inventory = remotePlayer.inventory; this.localPlayer.playerSpectating = remotePlayer.playerSpectating; this.localPlayer.gameMsgs = remotePlayer.gameMsgs; this.localPlayer.gameMsgs2 = remotePlayer.gameMsgs2; + this.localPlayer.doPhysics = remotePlayer.doPhysics; continue; } if (remotePlayer.chatActive) { this.messagesBeingTyped.push(`${remotePlayer.name}: ${remotePlayer.chatMsg}`); } } + if ( + this.getServerInfo().version && this.localPlayer.gameVersion !== this.getServerInfo().version + ) { + this.localPlayer.gameMsgs = ['&c Your client may be outdated. Try refreshing the page.']; + } } private playersAreEqualEnough(player1: Player, player2: LastUploadedLocalPlayer | null) { @@ -217,7 +238,7 @@ export class Networking { const chatMessage = { message: msg, id: this.localPlayer.id, - name: this.localPlayer.name, + name: this.getRemotePlayerData().find((player) => player.id === this.localPlayer.id)!.name, }; if (msg.length < 1) return; if (chatMessage.message.startsWith('>')) chatMessage.message = '&2' + chatMessage.message; diff --git a/src/client/core/RemoteItemRenderer.ts b/src/client/core/RemoteItemRenderer.ts index ddff5e29..42bbf6c9 100644 --- a/src/client/core/RemoteItemRenderer.ts +++ b/src/client/core/RemoteItemRenderer.ts @@ -4,6 +4,7 @@ import { Renderer } from './Renderer.ts'; import { ItemBase, ItemType } from '../items/ItemBase.ts'; import { BananaGun } from '../items/BananaGun.ts'; import { FishGun } from '../items/FishGun.ts'; +import { FlagItem } from '../items/FlagItem.ts'; // Custom types type Vector3Data = { @@ -89,8 +90,11 @@ export class RemoteItemRenderer { return new BananaGun(this.renderer, this.networking, 0, ItemType.WorldItem); case 2: return new FishGun(this.renderer, this.networking, 0, ItemType.WorldItem); + case 4: + return new FlagItem(this.renderer, 0, ItemType.WorldItem); default: // Return a generic item + console.log('Unknown item type:', itemType); return new ItemBase( ItemType.WorldItem, this.renderer.getEntityScene(), diff --git a/src/client/core/Renderer.ts b/src/client/core/Renderer.ts index 256a9a08..88c6b793 100644 --- a/src/client/core/Renderer.ts +++ b/src/client/core/Renderer.ts @@ -6,6 +6,9 @@ import { InputHandler } from '../input/InputHandler.ts'; import { SettingsManager } from './SettingsManager.ts'; import { CollisionManager } from '../input/CollisionManager.ts'; import { Player, PlayerData } from '../../shared/Player.ts'; +import { IndicatorBase } from '../ui/IndicatorBase.ts'; +import { HealthIndicator } from '../ui/HealthIndicator.ts'; +import { DirectionIndicator } from '../ui/DirectionIndicator.ts'; export class Renderer { private clock: THREE.Clock; @@ -33,8 +36,7 @@ export class Renderer { public crosshairIsFlashing: boolean = false; public lastShotSomeoneTimestamp: number = 0; public playerHitMarkers: { hitPoint: THREE.Vector3; shotVector: THREE.Vector3; timestamp: number }[] = []; - private healthIndicatorScene: THREE.Scene; - private healthIndicatorCamera: THREE.PerspectiveCamera; + private screenPixelsInGamePixel: number = 1; private inventoryMenuScene: THREE.Scene; private inventoryMenuCamera: THREE.OrthographicCamera; @@ -44,6 +46,11 @@ export class Renderer { private spectateGroundTruthPosition: THREE.Vector3 | null = null; + // List of indicators + private indicators: IndicatorBase[] = []; + private healthIndicator: HealthIndicator; + private directionIndicator: DirectionIndicator; + constructor(container: HTMLElement, networking: Networking, localPlayer: Player, chatOverlay: ChatOverlay) { this.networking = networking; this.localPlayer = localPlayer; @@ -53,7 +60,6 @@ export class Renderer { this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(90, globalThis.innerWidth / globalThis.innerHeight, 0.01, 1000); this.renderer = new THREE.WebGLRenderer(); - //document.body.appendChild(this.renderer.domElement); container.appendChild(this.renderer.domElement); this.renderer.domElement.style.imageRendering = 'pixelated'; this.renderer.setAnimationLoop(null); @@ -64,12 +70,6 @@ export class Renderer { this.heldItemCamera.position.set(0, 0, 5); this.heldItemCamera.lookAt(0, 0, 0); - // Create a new scene and camera for the health indicator - this.healthIndicatorScene = new THREE.Scene(); - this.healthIndicatorCamera = new THREE.PerspectiveCamera(70, 1, 0.01, 1000); - this.healthIndicatorCamera.position.set(0, 0, 0); - this.healthIndicatorCamera.lookAt(0, 0, 1); - this.inventoryMenuScene = new THREE.Scene(); this.inventoryMenuCamera = new THREE.OrthographicCamera(-0.5, 0.5, 2.5, -2.5, 0.01, 10); this.inventoryMenuCamera.position.set(0, 0, 5); @@ -80,7 +80,7 @@ export class Renderer { // Ambient lights this.ambientLight = new THREE.AmbientLight(0xffffff, 0.5); const ambientLight2 = new THREE.AmbientLight(0xffffff, 0.5); - const ambientLight3 = new THREE.AmbientLight(0xffffff, 0.5); // Ambient light for remote players scene + const ambientLight3 = new THREE.AmbientLight(0xffffff, 0.5); this.scene.add(this.ambientLight); this.heldItemScene.add(ambientLight2); @@ -88,7 +88,6 @@ export class Renderer { // Fog settings this.scene.fog = new THREE.FogExp2('#111111', 0.05); this.heldItemScene.fog = new THREE.FogExp2('#111111', 0.05); - this.healthIndicatorScene.fog = new THREE.FogExp2('#111111', 0.05); // Add fog to health indicator scene this.framerate = 0; this.framesInFramerateSample = 30; @@ -108,13 +107,24 @@ export class Renderer { this.camera, this.scene, ); - this.remotePlayerRenderer.getEntityScene().fog = new THREE.FogExp2('#111111', 0.1); // Add fog to remote players scene - this.remotePlayerRenderer.getEntityScene().add(ambientLight3); // Add ambient light to remote players scene + this.remotePlayerRenderer.getEntityScene().fog = new THREE.FogExp2('#111111', 0.1); + this.remotePlayerRenderer.getEntityScene().add(ambientLight3); this.renderer.domElement.style.touchAction = 'none'; this.renderer.domElement.style.position = 'absolute'; this.onWindowResize(); globalThis.addEventListener('resize', this.onWindowResize.bind(this), false); globalThis.addEventListener('orientationchange', this.onWindowResize.bind(this), false); + + // Initialize indicators + this.healthIndicator = new HealthIndicator(this, this.localPlayer, this.networking); + this.indicators.push(this.healthIndicator); + + this.directionIndicator = new DirectionIndicator(this, this.localPlayer, this.networking); + this.indicators.push(this.directionIndicator); + + // Initialize indicators + this.healthIndicator.init(); + this.directionIndicator.init(); } public onFrame(localPlayer: Player) { @@ -136,32 +146,16 @@ export class Renderer { // Render the held item scene normally (full screen) this.renderer.render(this.heldItemScene, this.heldItemCamera); - // Set up the scissor and viewport for the health indicator scene rendering + // Update and render indicators + for (const indicator of this.indicators) { + indicator.onFrame(this.deltaTime); + indicator.render(); + } + + // Render inventory view const screenWidth = globalThis.innerWidth; const screenHeight = globalThis.innerHeight; - const healthIndicatorWidth = 60; // native - const healthIndicatorHeight = healthIndicatorWidth; // 1:1 aspect ratio - - // Set up scissor and viewport for a region from (0, 0) to (50, 50) - this.renderer.setScissorTest(true); - this.renderer.setScissor( - 2 * this.screenPixelsInGamePixel, - screenHeight - (healthIndicatorHeight + 1 + this.chatOverlay.getDebugTextHeight()) * this.screenPixelsInGamePixel, - healthIndicatorWidth * this.screenPixelsInGamePixel, - healthIndicatorHeight * this.screenPixelsInGamePixel, - ); - this.renderer.setViewport( - 2 * this.screenPixelsInGamePixel, - screenHeight - (healthIndicatorHeight + 1 + this.chatOverlay.getDebugTextHeight()) * this.screenPixelsInGamePixel, - healthIndicatorWidth * this.screenPixelsInGamePixel, - healthIndicatorHeight * this.screenPixelsInGamePixel, - ); - - // Render the health indicator scene - this.renderer.render(this.healthIndicatorScene, this.healthIndicatorCamera); - - // Render inventory view const inventoryWidth = 20; const inventoryHeight = inventoryWidth * 5; this.renderer.setScissorTest(true); @@ -179,11 +173,11 @@ export class Renderer { ); this.renderer.render(this.inventoryMenuScene, this.inventoryMenuCamera); - // Reset scissor test and viewport after rendering the health indicator + // Reset scissor test and viewport this.renderer.setScissorTest(false); this.renderer.setViewport(0, 0, screenWidth, screenHeight); - // Restore autoClear to true if necessary + // Restore autoClear to true this.renderer.autoClear = true; if (localPlayer.playerSpectating !== -1) { @@ -191,7 +185,6 @@ export class Renderer { player.id === localPlayer.playerSpectating ); if (remotePlayer !== undefined) { - // Initialize ground truth position if not set if (!this.spectateGroundTruthPosition) { this.spectateGroundTruthPosition = new THREE.Vector3( remotePlayer.position.x, @@ -200,12 +193,10 @@ export class Renderer { ); } - // Update ground truth position based on velocity this.spectateGroundTruthPosition.x += remotePlayer.velocity.x * this.deltaTime; this.spectateGroundTruthPosition.y += remotePlayer.velocity.y * this.deltaTime; this.spectateGroundTruthPosition.z += remotePlayer.velocity.z * this.deltaTime; - // If forced update, set directly to remote position if (remotePlayer.forced) { this.spectateGroundTruthPosition.set( remotePlayer.position.x, @@ -214,7 +205,6 @@ export class Renderer { ); } - // Lerp ground truth position towards actual position this.spectateGroundTruthPosition.lerp( new THREE.Vector3( remotePlayer.position.x, @@ -224,10 +214,8 @@ export class Renderer { 0.1 * this.deltaTime * 60, ); - // Update camera position and rotation this.camera.position.copy(this.spectateGroundTruthPosition); - // Simple quaternion slerp this.camera.quaternion.slerp( new THREE.Quaternion( remotePlayer.lookQuaternion.x, @@ -239,7 +227,6 @@ export class Renderer { ); } } else { - // Reset spectate position when not spectating this.spectateGroundTruthPosition = null; this.camera.position.copy(localPlayer.position); this.camera.setRotationFromQuaternion(this.localPlayer.lookQuaternion); @@ -253,7 +240,6 @@ export class Renderer { player.id === this.localPlayer.idLastDamagedBy ); if (remotePlayer !== undefined) { - //console.log("Player was damaged by " + remotePlayer.name); const diff = new THREE.Vector3().subVectors(this.localPlayer.position, remotePlayer.position); this.knockbackVector.copy(diff.normalize().multiplyScalar(0.2)); } @@ -289,15 +275,13 @@ export class Renderer { this.bobCycle += this.deltaTime * 4.8 * vel; this.camera.position.y = this.camera.position.y + (Math.sin(this.bobCycle) * .03 * SettingsManager.settings.viewBobbingStrength); - //console.log(this.camera.position.y); } let newHandX = Math.sin(this.bobCycle / 1.9) * .02 * SettingsManager.settings.viewBobbingStrength; let newHandY = -(Math.sin(this.bobCycle) * .07 * SettingsManager.settings.viewBobbingStrength); let newHandZ = Math.sin(this.bobCycle / 1.8) * .015 * SettingsManager.settings.viewBobbingStrength; - newHandY += localPlayer.velocity.y * 0.04 * SettingsManager.settings.viewBobbingStrength; //move hand up when falling, down when jumping + newHandY += localPlayer.velocity.y * 0.04 * SettingsManager.settings.viewBobbingStrength; - //banana lags behind player slightly const playerVelocity = new THREE.Vector3().copy(localPlayer.velocity); playerVelocity.applyQuaternion(localPlayer.lookQuaternion.clone().invert()); newHandX += playerVelocity.x * 0.02 * SettingsManager.settings.viewBobbingStrength; @@ -321,6 +305,8 @@ export class Renderer { this.camera.quaternion.setFromEuler(euler); + this.scene.background = new THREE.Color(this.networking.getServerInfo().skyColor); + this.updateFramerate(); } @@ -349,10 +335,6 @@ export class Renderer { return this.heldItemScene; } - public getHealthIndicatorScene(): THREE.Scene { - return this.healthIndicatorScene; - } - public getInventoryMenuScene(): THREE.Scene { return this.inventoryMenuScene; } @@ -361,16 +343,16 @@ export class Renderer { return this.inventoryMenuCamera; } - private onWindowResize() { - this.camera.aspect = globalThis.innerWidth / globalThis.innerHeight; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(globalThis.innerWidth, globalThis.innerHeight); - this.renderer.setPixelRatio(200 / globalThis.innerHeight); + public getWebGLRenderer(): THREE.WebGLRenderer { + return this.renderer; + } - this.screenPixelsInGamePixel = globalThis.innerHeight / 200; - // Update held item camera aspect ratio - this.heldItemCamera.aspect = globalThis.innerWidth / globalThis.innerHeight; - this.heldItemCamera.updateProjectionMatrix(); + public getScreenPixelsInGamePixel(): number { + return this.screenPixelsInGamePixel; + } + + public getChatOverlay(): ChatOverlay { + return this.chatOverlay; } public getShotVectorsToPlayersInCrosshair(): { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] { @@ -400,6 +382,17 @@ export class Renderer { this.collisionManager = collisionManager; } + private onWindowResize() { + this.camera.aspect = globalThis.innerWidth / globalThis.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(globalThis.innerWidth, globalThis.innerHeight); + this.renderer.setPixelRatio(200 / globalThis.innerHeight); + + this.screenPixelsInGamePixel = globalThis.innerHeight / 200; + this.heldItemCamera.aspect = globalThis.innerWidth / globalThis.innerHeight; + this.heldItemCamera.updateProjectionMatrix(); + } + private static approachNumber(input: number, step: number, approach: number): number { if (input == approach) return approach; let output: number; diff --git a/src/client/input/CollisionManager.ts b/src/client/input/CollisionManager.ts index d4317880..7e4c2360 100644 --- a/src/client/input/CollisionManager.ts +++ b/src/client/input/CollisionManager.ts @@ -61,7 +61,7 @@ export class CollisionManager { this.prevPosition.copy(localPlayer.position); const jump: boolean = this.inputHandler.jump; - localPlayer.gravity += deltaTime * -30; + if (localPlayer.doPhysics) localPlayer.gravity += deltaTime * -30; localPlayer.inputVelocity.y += localPlayer.gravity; localPlayer.inputVelocity.y = (localPlayer.inputVelocity.y + this.inputHandler.prevInputVelocity.y) * .25; localPlayer.position.add(localPlayer.inputVelocity.clone().multiplyScalar(deltaTime)); @@ -73,45 +73,47 @@ export class CollisionManager { this.collided = false; - bvh.shapecast({ - intersectsBounds: (box: THREE.Box3) => { - return box.intersectsSphere(this.colliderSphere); - }, - - intersectsTriangle: (tri: THREE.Triangle) => { - // Get delta between the closest point and center - tri.closestPointToPoint(this.colliderSphere.center, this.deltaVec); - this.deltaVec.sub(this.colliderSphere.center); - const distance: number = this.deltaVec.length(); - - if (distance < this.colliderSphere.radius) { - // Move the sphere position to be outside the triangle - const radius: number = this.colliderSphere.radius; - const depth: number = distance - radius; - this.deltaVec.multiplyScalar(1 / distance); - - tri.getNormal(this.triNormal); - const angle: number = this.triNormal.dot(this.upVector); - - if (angle >= CollisionManager.maxAngle) { - localPlayer.position.addScaledVector(this.deltaVec, depth); - localPlayer.inputVelocity.y = 0; - localPlayer.gravity = 0; - this.coyoteTime = 0; - this.collided = true; - } else { - localPlayer.position.addScaledVector(this.deltaVec, depth); + if (localPlayer.doPhysics) { + bvh.shapecast({ + intersectsBounds: (box: THREE.Box3) => { + return box.intersectsSphere(this.colliderSphere); + }, + + intersectsTriangle: (tri: THREE.Triangle) => { + // Get delta between the closest point and center + tri.closestPointToPoint(this.colliderSphere.center, this.deltaVec); + this.deltaVec.sub(this.colliderSphere.center); + const distance: number = this.deltaVec.length(); + + if (distance < this.colliderSphere.radius) { + // Move the sphere position to be outside the triangle + const radius: number = this.colliderSphere.radius; + const depth: number = distance - radius; + this.deltaVec.multiplyScalar(1 / distance); + + tri.getNormal(this.triNormal); + const angle: number = this.triNormal.dot(this.upVector); + + if (angle >= CollisionManager.maxAngle) { + localPlayer.position.addScaledVector(this.deltaVec, depth); + localPlayer.inputVelocity.y = 0; + localPlayer.gravity = 0; + this.coyoteTime = 0; + this.collided = true; + } else { + localPlayer.position.addScaledVector(this.deltaVec, depth); + } } - } - this.colliderSphere.center = localPlayer.position.clone(); - return false; - }, + this.colliderSphere.center = localPlayer.position.clone(); + return false; + }, - boundsTraverseOrder: (box: THREE.Box3) => { - return box.distanceToPoint(this.colliderSphere.center) - this.colliderSphere.radius; - }, - }); + boundsTraverseOrder: (box: THREE.Box3) => { + return box.distanceToPoint(this.colliderSphere.center) - this.colliderSphere.radius; + }, + }); + } if (!this.collided) { this.coyoteTime += deltaTime; diff --git a/src/client/items/FlagItem.ts b/src/client/items/FlagItem.ts new file mode 100644 index 00000000..bafab14b --- /dev/null +++ b/src/client/items/FlagItem.ts @@ -0,0 +1,169 @@ +import { ItemBase, ItemType } from './ItemBase.ts'; +import { HeldItemInput } from '../input/HeldItemInput.ts'; +import * as THREE from 'three'; +import { Renderer } from '../core/Renderer.ts'; +import { AssetManager } from '../core/AssetManager.ts'; + +const showInHandDelay = 0.1; + +const scopedPosition = new THREE.Vector3(0, -0.6, 3.5); +const unscopedPosition = new THREE.Vector3(0.85, -0.8, 3.2); +const hiddenPosition = new THREE.Vector3(0.85, -2.7, 3.2); +const scopedQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 1.5, 0, 'XYZ')); +const inventoryQuaternionBase = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 1.5, 0, 'XYZ')); + +export class FlagItem extends ItemBase { + private renderer!: Renderer; + private addedToHandScene: boolean; + + // deno-lint-ignore constructor-super + constructor(renderer: Renderer, index: number, itemType: ItemType) { + if (itemType === ItemType.WorldItem) { + super(itemType, renderer.getEntityScene(), renderer.getInventoryMenuScene(), index); + } else { + super(itemType, renderer.getHeldItemScene(), renderer.getInventoryMenuScene(), index); + } + this.renderer = renderer; + this.addedToHandScene = false; + } + + public override init() { + AssetManager.getInstance().loadAsset('models/simplified_pizza.glb', (scene) => { + this.object = scene; + if (this.itemType === ItemType.InventoryItem) { + this.object.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + child.renderOrder = 999; + const mesh = child as THREE.Mesh; + if (Array.isArray(mesh.material)) { + mesh.material.forEach((mat) => mat.depthTest = false); + } else { + mesh.material.depthTest = false; + } + } + }); + } + + if (this.itemType === ItemType.WorldItem) { + this.object.scale.set(0.66, 0.66, 0.66); + } + + this.inventoryMenuObject = this.object.clone(); + this.inventoryMenuObject.scale.set(0.8, 0.8, 0.8); + this.inventoryMenuObject.rotation.x += Math.PI / 2; + + if (this.itemType === ItemType.WorldItem) { + this.object.scale.set(0.45, 0.45, 0.45); + this.object.rotation.z -= Math.PI / 2; + } + }); + } + + public override onFrame(input: HeldItemInput, selectedIndex: number) { + if (!this.object) return; + const deltaTime = this.clock.getDelta(); + this.timeAccum += deltaTime; + this.angleAccum += deltaTime; + + if (this.itemType === ItemType.WorldItem) { + this.worldOnFrame(deltaTime); + } else if (this.itemType === ItemType.InventoryItem) { + this.inventoryOnFrame(deltaTime, selectedIndex); + this.handOnFrame(deltaTime, input); + } + } + + // No need to override worldOnFrame if default behavior is sufficient + // If specific behavior is needed, you can override it here + + public override inventoryOnFrame(deltaTime: number, selectedIndex: number) { + if (!this.addedToInventoryItemScenes) { + this.inventoryMenuScene.add(this.inventoryMenuObject); + this.addedToInventoryItemScenes = true; + } + + this.angleAccum += deltaTime; + this.inventoryMenuObject.position.set(0, this.index, 0); + + const targetQuaternion = inventoryQuaternionBase.clone(); + if (this.index === selectedIndex) { + rotateAroundWorldAxis(targetQuaternion, new THREE.Vector3(0, 1, 0), this.angleAccum * 4); + + this.showInHand(); + } else { + this.hideInHand(); + } + this.inventoryMenuObject.quaternion.slerp(targetQuaternion, 0.1 * 60 * deltaTime); + } + + public override handOnFrame(deltaTime: number, input: HeldItemInput) { + if (!this.object) return; + + if (this.shownInHand && !this.addedToHandScene) { + this.scene.add(this.object); + this.addedToHandScene = true; + } + + if (this.shownInHand && Date.now() / 1000 - this.shownInHandTimestamp > showInHandDelay) { + this.handleInput(input, deltaTime); + } else { + this.handPosition.lerp(hiddenPosition, 0.1 * 60 * deltaTime); + this.object.position.copy(this.handPosition); + // Remove the object after it has slid out of view + if (this.handPosition.distanceTo(hiddenPosition) < 0.1) { + if (this.addedToHandScene) { + this.scene.remove(this.object); + this.addedToHandScene = false; + } + } + } + + // Update crosshair flashing based on last shot timestamp + this.renderer.crosshairIsFlashing = Date.now() / 1000 - this.renderer.lastShotSomeoneTimestamp < 0.05; + } + + private handleInput(input: HeldItemInput, deltaTime: number) { + if (input.rightClick) { + moveTowardsPos(this.handPosition, scopedPosition, 0.3 * deltaTime * 60); + } else { + moveTowardsPos(this.handPosition, unscopedPosition, 0.1 * deltaTime * 60); + } + + this.object.position.copy(this.handPosition); + + moveTowardsRot(this.object.quaternion, scopedQuaternion, 0.1 * deltaTime * 60); + } + + public override showInHand() { + if (this.shownInHand) return; + this.shownInHand = true; + this.shownInHandTimestamp = Date.now() / 1000; + if (!this.addedToHandScene && this.object) { + this.scene.add(this.object); + this.addedToHandScene = true; + } + } + + public override hideInHand() { + if (!this.shownInHand) return; + this.shownInHand = false; + } + + // Method to set world position when used as WorldItem + public override setWorldPosition(vector: THREE.Vector3) { + super.setWorldPosition(vector); + } +} + +function rotateAroundWorldAxis(source: THREE.Quaternion, axis: THREE.Vector3, angle: number) { + const rotationQuat = new THREE.Quaternion().setFromAxisAngle(axis, angle); + source.multiplyQuaternions(rotationQuat, source); +} + +function moveTowardsPos(source: THREE.Vector3, target: THREE.Vector3, frac: number) { + source.lerp(target, frac); +} + +function moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { + source.slerp(target, frac); +} diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index b820b340..222200c1 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -5,6 +5,7 @@ import { CommandManager } from '../core/CommandManager.ts'; import { SettingsManager } from '../core/SettingsManager.ts'; import { TouchInputHandler } from '../input/TouchInputHandler.ts'; import { Player } from '../../shared/Player.ts'; +import * as THREE from 'three'; interface ChatMessage { id: number; @@ -172,7 +173,10 @@ export class ChatOverlay { this.clearOldMessages(); this.chatCtx.clearRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); + this.renderHitMarkers(); + this.renderSparkles(); + this.renderChatMessages(); this.renderGameText(); this.renderDebugText(); @@ -420,6 +424,7 @@ export class ChatOverlay { //linesToRender.push('tickRate: ' + this.networking.getServerInfo().tickRate); //linesToRender.push('playerMaxHealth: ' + this.networking.getServerInfo().playerMaxHealth); //linesToRender.push('health: ' + this.localPlayer.health); + //linesToRender.push('pos:' +this.localPlayer.position.x.toFixed(2) + ',' + this.localPlayer.position.y.toFixed(2) + ',' +this.localPlayer.position.z.toFixed(2),); for (const msg of this.localPlayer.gameMsgs2) { linesToRender.push(msg); @@ -703,12 +708,114 @@ export class ChatOverlay { this.joystickInputY = y; } - public renderHitMarkers() { - const numDots = 10; // Number of dots to render around each hit point + private getProjected3D(vec3: THREE.Vector3): { x: number; y: number } { + const projected = vec3.clone().project(this.renderer.getCamera()); + return { + x: Math.round((projected.x + 1) * this.screenWidth / 2), + y: Math.round((-projected.y + 1) * 200 / 2), + }; + } + + private getSize(distance: number): number { + // Convert world distance to a screen-space scaling factor + // This helps match the scaling of the underlying 3D scene + const fov = this.renderer.getCamera().fov; // in degrees + const fovRadians = (fov * Math.PI) / 180; + + // Calculate apparent size based on FOV and distance + // This gives us a size that matches the perspective of the 3D scene + const size = Math.tan(fovRadians / 2) / distance; + + // Scale factor to make the size reasonable for screen space + // Adjust this based on your desired base size + const scaleFactor = 9; + + return size * scaleFactor; + } + + private sparkleParticles: { + basePos: THREE.Vector3; + offset: THREE.Vector3; + speed: number; + phase: number; + radius: number; + }[] = []; + + private readonly SPARKLE_COUNT = 6; + private readonly SPARKLE_RADIUS = 0.5; // World units instead of screen pixels + + public renderSparkles() { + const positions = this.localPlayer.highlightedVectors.slice(0); + //const positions = [new THREE.Vector3(0, 3 + Math.sin(Date.now() / 1000), 0)]; + + // Initialize or update sparkle particles + while (this.sparkleParticles.length < positions.length * this.SPARKLE_COUNT) { + this.sparkleParticles.push({ + basePos: new THREE.Vector3(), + offset: new THREE.Vector3( + Math.random() * 2 - 1, + Math.random() * 2 - 1, + Math.random() * 2 - 1, + ).normalize(), + speed: 0.5 + Math.random() * 1.5, + phase: Math.random() * Math.PI * 2, + radius: this.SPARKLE_RADIUS * (0.5 + Math.random() * 0.5), + }); + } + // Remove excess particles + while (this.sparkleParticles.length > positions.length * this.SPARKLE_COUNT) { + this.sparkleParticles.pop(); + } + + const ctx = this.chatCtx; + ctx.fillStyle = 'rgba(46,163,46,0.8)'; + const time = Date.now() / 1000; + + // Update and render each sparkle + for (let i = 0; i < positions.length; i++) { + const basePos = positions[i]; + + // Update and draw sparkles for this position + for (let j = 0; j < this.SPARKLE_COUNT; j++) { + const particle = this.sparkleParticles[i * this.SPARKLE_COUNT + j]; + particle.basePos.copy(basePos); + + // Calculate 3D orbital motion + const angle = particle.phase + time * particle.speed; + const wobble = Math.sin(time * 3 + particle.phase) * 0.3; + + // Create orbital motion around the base position + const orbitPos = new THREE.Vector3().copy(particle.offset) + .multiplyScalar(particle.radius * (1 + wobble)) + .applyAxisAngle(new THREE.Vector3(0, 1, 0), angle) + .add(particle.basePos); + + // Project to screen space + const projected = this.getProjected3D(orbitPos); + + // Skip if behind camera + if (orbitPos.clone().project(this.renderer.getCamera()).z >= 1) continue; + + // Calculate size based on distance to camera + const distance = orbitPos.distanceTo(this.renderer.getCamera().position); + const size = Math.max(1, Math.min(3, this.getSize(distance) * 0.5)); + + // Draw particle + ctx.fillRect( + Math.round(projected.x - size / 2), + Math.round(projected.y - size / 2), + Math.ceil(size), + Math.ceil(size), + ); + } + } + } + + public renderHitMarkers() { for (let i = this.renderer.playerHitMarkers.length - 1; i >= 0; i--) { if (this.renderer.playerHitMarkers[i].timestamp === -1) { - this.renderer.playerHitMarkers[i].timestamp = Date.now() / 1000; // Set timestamp if not set + this.renderer.playerHitMarkers[i].timestamp = Date.now() / 1000; } const timeSinceHit = Date.now() / 1000 - this.renderer.playerHitMarkers[i].timestamp; @@ -720,24 +827,26 @@ export class ChatOverlay { } const hitVec = this.renderer.playerHitMarkers[i].hitPoint; - const projected = hitVec.clone().project(this.renderer.getCamera()); - const projectedX = Math.round((projected.x + 1) * this.screenWidth / 2); - const projectedY = Math.round((-projected.y + 1) * 200 / 2); + const projected = this.getProjected3D(hitVec); - if (projected.z < 1) { + if (hitVec.clone().project(this.renderer.getCamera()).z < 1) { this.chatCtx.fillStyle = 'rgba(255,0,0,' + (1 - Math.pow(lifePercent, 1.25)) + ')'; - // Calculate sizeMultiplier - const sizeMultiplier = 1 + 2 / this.renderer.playerHitMarkers[i].shotVector.length(); + const sizeMultiplier = this.getSize(this.renderer.playerHitMarkers[i].shotVector.length()); + const radius = Math.pow(lifePercent, 0.7) * 7 * sizeMultiplier; + const numDots = Math.min(Math.max(sizeMultiplier * 5, 3), 15); - // Calculate and render dots - const radius = Math.pow(lifePercent, 0.7) * 7 * sizeMultiplier; // Radius of the circle in which dots are placed for (let j = 0; j < numDots; j++) { const angle = (Math.PI * 2 / numDots) * j; - const dotX = Math.round(projectedX + radius * Math.cos(angle)); - const dotY = Math.round(projectedY + radius * Math.sin(angle)); - - this.chatCtx.fillRect(dotX, dotY, 1, 1); // Render a 1px by 1px dot + const dotX = Math.round(projected.x + radius * Math.cos(angle)); + const dotY = Math.round(projected.y + radius * Math.sin(angle)); + const dotSize = Math.min(Math.max(Math.round(sizeMultiplier / 3), 1), 6); + this.chatCtx.fillRect( + Math.floor(dotX - dotSize / 2), + Math.floor(dotY - dotSize / 2), + Math.ceil(dotSize), + Math.ceil(dotSize), + ); } } } @@ -853,17 +962,16 @@ export class ChatOverlay { } if (e.key.toLowerCase() === 't' && !this.nameSettingActive) { - if (this.localPlayer.name.length > 0) this.localPlayer.chatActive = true; - else this.nameSettingActive = true; + //if (this.localPlayer.name.length > 0) + this.localPlayer.chatActive = true; + //else this.nameSettingActive = true; } if (e.key === '/' && !this.nameSettingActive && !this.localPlayer.chatActive) { - if (this.localPlayer.name.length > 0) { - this.localPlayer.chatActive = true; - this.localPlayer.chatMsg = '/'; - } else { - this.nameSettingActive = true; - } + //if (this.localPlayer.name.length > 0) { + this.localPlayer.chatActive = true; + this.localPlayer.chatMsg = '/'; + //} else this.nameSettingActive = true; } if (e.key.toLowerCase() === 'n' && !this.localPlayer.chatActive) { diff --git a/src/client/ui/DirectionIndicator.ts b/src/client/ui/DirectionIndicator.ts new file mode 100644 index 00000000..81d1d1b2 --- /dev/null +++ b/src/client/ui/DirectionIndicator.ts @@ -0,0 +1,129 @@ +import * as THREE from 'three'; +import { Player } from '../../shared/Player.ts'; +import { IndicatorBase } from './IndicatorBase.ts'; +import { Networking } from '../core/Networking.ts'; +import { Renderer } from '../core/Renderer.ts'; + +export class DirectionIndicator extends IndicatorBase { + private directionObject!: THREE.Object3D; + private sceneAdded: boolean = false; + + constructor( + parentRenderer: Renderer, + localPlayer: Player, + networking: Networking, + ) { + super(parentRenderer, localPlayer, networking); + // Set up orthographic camera + this.camera = new THREE.PerspectiveCamera(10, 1, 0.1, 20); + this.camera.position.set(0, 8, -3); + this.camera.lookAt(0, 0, 0); + } + + public init() { + this.loadModel('models/arrow.glb') + .then((model) => { + this.directionObject = model; + this.directionObject.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + (child as THREE.Mesh).renderOrder = 999; + this.applyDepthTestToMesh(child as THREE.Mesh); + } + }); + // Scale down the direction object + this.directionObject.scale.set(0.5, 0.5, 0.5); + }) + .catch((error) => { + console.log('DirectionIndicator model loading error:', error); + }); + this.scene.fog = new THREE.Fog(0xee0000, 5, 13); + } + public onFrame(deltaTime: number) { + const worldVector = this.localPlayer.directionIndicatorVector; + if (!worldVector) { + if (this.sceneAdded && this.directionObject) { + this.scene.remove(this.directionObject); + this.sceneAdded = false; + } + return; + } + + if (!this.directionObject || !this.sceneAdded || !worldVector) { + if (!this.sceneAdded && this.directionObject) { + this.scene.add(this.directionObject); + this.sceneAdded = true; + } + return; + } + + const playerPosition = this.localPlayer.position; + const playerRotation = this.localPlayer.lookQuaternion.clone().normalize(); + + // Calculate direction from player to target + const direction = new THREE.Vector3() + .copy(worldVector) + .sub(playerPosition) + .normalize(); + + // Convert to local space + const inverseRotation = playerRotation.clone().invert(); + const localDirection = direction.clone().applyQuaternion(inverseRotation); + + // Calculate horizontal angle + const horizontalAngle = Math.atan2(localDirection.x, localDirection.z); + + // Calculate vertical angle + const verticalAngle = Math.atan2( + localDirection.y, + Math.sqrt( + localDirection.x * localDirection.x + + localDirection.z * localDirection.z, + ), + ); + + // Create target quaternion + const targetRotation = new THREE.Quaternion() + .setFromEuler(new THREE.Euler(verticalAngle, 0, -horizontalAngle)); + + // Get current rotation as quaternion + const currentRotation = new THREE.Quaternion() + .setFromEuler(this.directionObject.rotation); + + // Smoothly interpolate between current and target rotation + this.moveTowardsRot(currentRotation, targetRotation, deltaTime * 60 * 0.2); // Adjust the multiplier to control rotation speed + + // Apply the interpolated rotation + this.directionObject.quaternion.copy(currentRotation); + } + + protected setupScissorAndViewport(): void { + const screenWidth = globalThis.innerWidth; + const screenHeight = globalThis.innerHeight; + + // Smaller size + const directionIndicatorWidth = 40; + const directionIndicatorHeight = 40; + + // Center bottom placement + const xOffset = (screenWidth - directionIndicatorWidth * this.parentRenderer.getScreenPixelsInGamePixel()) / 2; + const yOffset = screenHeight - (directionIndicatorHeight + 150) * this.parentRenderer.getScreenPixelsInGamePixel(); + + this.scissor.set( + xOffset, + yOffset, + directionIndicatorWidth * this.parentRenderer.getScreenPixelsInGamePixel(), + directionIndicatorHeight * this.parentRenderer.getScreenPixelsInGamePixel(), + ); + + this.viewport.set( + xOffset, + yOffset, + directionIndicatorWidth * this.parentRenderer.getScreenPixelsInGamePixel(), + directionIndicatorHeight * this.parentRenderer.getScreenPixelsInGamePixel(), + ); + } + + private applyDepthTestToMesh(mesh: THREE.Mesh) { + super.applyDepthTest(mesh); + } +} diff --git a/src/client/ui/HealthIndicator.ts b/src/client/ui/HealthIndicator.ts index 41706472..0cb15ac6 100644 --- a/src/client/ui/HealthIndicator.ts +++ b/src/client/ui/HealthIndicator.ts @@ -1,64 +1,46 @@ -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import * as THREE from 'three'; -import { Renderer } from '../core/Renderer.ts'; -import { Networking } from '../core/Networking.ts'; import { Player } from '../../shared/Player.ts'; +import { IndicatorBase } from './IndicatorBase.ts'; +import { Networking } from '../core/Networking.ts'; +import { Renderer } from '../core/Renderer.ts'; -const clock = new THREE.Clock(); - -export class HealthIndicator { - private scene: THREE.Scene; +export class HealthIndicator extends IndicatorBase { private possumObject!: THREE.Object3D; private sceneAdded: boolean = false; private targetQuaternion: THREE.Quaternion = new THREE.Quaternion(0, 0, 0, 1); private targetPosition: THREE.Vector3 = new THREE.Vector3(0, 0, 0); private rotatedAngle: number = 0; - private ambientLight: THREE.AmbientLight; private lastHealth: number = 0; private lastHealthChangeWasDamage: boolean = false; private lightRGBI: number[] = [0, 0, 0, 0]; - constructor(private renderer: Renderer, private localPlayer: Player, private networking: Networking) { - this.scene = renderer.getHealthIndicatorScene(); - this.ambientLight = new THREE.AmbientLight(rgbToHex(0, 0, 0), 0); - this.scene.add(this.ambientLight); + constructor( + parentRenderer: Renderer, + localPlayer: Player, + networking: Networking, + ) { + super(parentRenderer, localPlayer, networking); } public init() { - const loader = new GLTFLoader(); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('/draco/'); - loader.setDRACOLoader(dracoLoader); - loader.load( - 'models/simplified_possum.glb', - (gltf) => { - this.possumObject = gltf.scene; + this.loadModel('models/simplified_possum.glb') + .then((model) => { + this.possumObject = model; this.possumObject.traverse((child) => { if ((child as THREE.Mesh).isMesh) { - child.renderOrder = 999; - const applyDepthTest = (material: THREE.Material | THREE.Material[]) => { - if (Array.isArray(material)) { - material.forEach((mat) => applyDepthTest(mat)); // Recursively handle array elements - } else { - material.depthTest = false; - } - }; - const mesh = child as THREE.Mesh; - applyDepthTest(mesh.material); + (child as THREE.Mesh).renderOrder = 999; + this.applyDepthTest(child as THREE.Mesh); } }); - }, - undefined, - () => { - console.log('overlay possum loading error'); - }, - ); + }) + .catch((error) => { + console.log('HealthIndicator model loading error:', error); + }); } - public onFrame() { + public onFrame(deltaTime: number) { if (!this.possumObject) return; - if (!this.sceneAdded) { + if (!this.scene.children.includes(this.possumObject)) { this.scene.add(this.possumObject); this.sceneAdded = true; } @@ -66,9 +48,9 @@ export class HealthIndicator { let maxHealth = this.networking.getServerInfo().playerMaxHealth; if (maxHealth === 0) maxHealth = 0.001; - const deltaTime = clock.getDelta(); - const scaredLevel = 1 - Math.pow(this.localPlayer.health / maxHealth, 1); //0-1 - this.renderer.scaredLevel = scaredLevel; + const scaredLevel = 1 - Math.pow(this.localPlayer.health / maxHealth, 1); // 0-1 + + this.parentRenderer.scaredLevel = scaredLevel; this.targetPosition.copy(basePosition); this.targetPosition.y += scaredLevel * 0.5 * Math.sin(1.1 * Math.PI * this.rotatedAngle); @@ -77,17 +59,17 @@ export class HealthIndicator { this.targetPosition.z += (Math.random() - 0.5) * 0.2 * scaredLevel; this.targetQuaternion.copy(baseQuaternion); - rotateAroundWorldAxis( + this.rotateAroundWorldAxis( this.targetQuaternion, new THREE.Vector3(0, 0, 1), - Math.PI - this.localPlayer.health * Math.PI / maxHealth, + Math.PI - (this.localPlayer.health * Math.PI) / maxHealth, ); - this.rotatedAngle += 4 * deltaTime / (Math.max(0.001, (1 - scaredLevel) * 3)); - rotateAroundWorldAxis(this.targetQuaternion, new THREE.Vector3(0, 1, 0), this.rotatedAngle); + this.rotatedAngle += (4 * deltaTime) / Math.max(0.001, (1 - scaredLevel) * 3); + this.rotateAroundWorldAxis(this.targetQuaternion, new THREE.Vector3(0, 1, 0), this.rotatedAngle); - moveTowardsPos(this.possumObject.position, this.targetPosition, 0.8 * deltaTime * 60); - moveTowardsRot(this.possumObject.quaternion, this.targetQuaternion, 0.5 * deltaTime * 60); + this.moveTowardsPos(this.possumObject.position, this.targetPosition, 0.8 * deltaTime * 60); + this.moveTowardsRot(this.possumObject.quaternion, this.targetQuaternion, 0.5 * deltaTime * 60); let targetRGBI: number[]; @@ -98,11 +80,10 @@ export class HealthIndicator { } for (let i = 0; i < 4; i++) { - this.lightRGBI[i] = this.lightRGBI[i] + (targetRGBI[i] - this.lightRGBI[i]) * 0.4 * deltaTime * 60; + this.lightRGBI[i] += (targetRGBI[i] - this.lightRGBI[i]) * 0.4 * deltaTime * 60; } - this.ambientLight.copy( - new THREE.AmbientLight(rgbToHex(this.lightRGBI[0], this.lightRGBI[1], this.lightRGBI[2]), this.lightRGBI[3]), - ); + this.ambientLight.intensity = this.lightRGBI[3]; + this.ambientLight.color = new THREE.Color(this.rgbToHex(this.lightRGBI[0], this.lightRGBI[1], this.lightRGBI[2])); if (this.lastHealth < this.localPlayer.health) { this.lastHealthChangeWasDamage = false; @@ -111,23 +92,37 @@ export class HealthIndicator { } this.lastHealth = this.localPlayer.health; } -} -function rotateAroundWorldAxis(source: THREE.Quaternion, axis: THREE.Vector3, angle: number) { - const rotationQuat = new THREE.Quaternion().setFromAxisAngle(axis, angle); - source.multiplyQuaternions(rotationQuat, source); -} + protected setupScissorAndViewport(): void { + const screenHeight = globalThis.innerHeight; -function moveTowardsPos(source: THREE.Vector3, target: THREE.Vector3, frac: number) { - source.lerp(target, frac); -} + const healthIndicatorWidth = 60; // native + const healthIndicatorHeight = healthIndicatorWidth; // 1:1 aspect ratio -function moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { - source.slerp(target, frac); -} + this.scissor.set( + 2 * this.parentRenderer.getScreenPixelsInGamePixel(), + screenHeight - + (healthIndicatorHeight + 1 + this.parentRenderer.getChatOverlay().getDebugTextHeight()) * + this.parentRenderer.getScreenPixelsInGamePixel(), + healthIndicatorWidth * this.parentRenderer.getScreenPixelsInGamePixel(), + healthIndicatorHeight * this.parentRenderer.getScreenPixelsInGamePixel(), + ); + + this.viewport.set( + 2 * this.parentRenderer.getScreenPixelsInGamePixel(), + screenHeight - + (healthIndicatorHeight + 1 + this.parentRenderer.getChatOverlay().getDebugTextHeight()) * + this.parentRenderer.getScreenPixelsInGamePixel(), + healthIndicatorWidth * this.parentRenderer.getScreenPixelsInGamePixel(), + healthIndicatorHeight * this.parentRenderer.getScreenPixelsInGamePixel(), + ); + } -function rgbToHex(r: number, g: number, b: number) { - return (r << 16) + (g << 8) + b; + /** + * Override the applyDepthTest method to avoid naming conflicts. + * Applies depth testing adjustments to the mesh. + * @param mesh The mesh to modify. + */ } const basePosition = new THREE.Vector3(0, 0, 1.2); diff --git a/src/client/ui/IndicatorBase.ts b/src/client/ui/IndicatorBase.ts new file mode 100644 index 00000000..6a89e656 --- /dev/null +++ b/src/client/ui/IndicatorBase.ts @@ -0,0 +1,151 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; +import { Player } from '../../shared/Player.ts'; +import { Networking } from '../core/Networking.ts'; +import { Renderer } from '../core/Renderer.ts'; + +export abstract class IndicatorBase { + protected scene: THREE.Scene; + protected camera: THREE.Camera; + protected model!: THREE.Object3D; + protected ambientLight: THREE.AmbientLight; + + // Scissor and viewport dimensions + protected scissor = new THREE.Vector4(); + protected viewport = new THREE.Vector4(); + + constructor( + protected parentRenderer: Renderer, + protected localPlayer: Player, + protected networking: Networking, + ) { + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(70, 1, 0.01, 1000); + this.camera.position.set(0, 0, 0); + this.camera.lookAt(0, 0, 1); + + // Add ambient light to the scene + this.ambientLight = new THREE.AmbientLight(0xffffff, 0.5); + this.scene.add(this.ambientLight); + } + + /** + * Initialize the indicator (e.g., load models). + */ + abstract init(): void; + + /** + * Update the indicator each frame. + * @param deltaTime Time elapsed since the last frame. + */ + abstract onFrame(deltaTime: number): void; + + /** + * Render the indicator by setting up scissor test and viewport. + */ + public render(): void { + this.setupScissorAndViewport(); + + const renderer = this.parentRenderer.getWebGLRenderer(); + + renderer.setScissorTest(true); + renderer.setScissor( + this.scissor.x, + this.scissor.y, + this.scissor.z, + this.scissor.w, + ); + renderer.setViewport( + this.viewport.x, + this.viewport.y, + this.viewport.z, + this.viewport.w, + ); + + renderer.render(this.scene, this.camera); + + renderer.setScissorTest(false); + } + + /** + * Define scissor and viewport settings. + * Must be implemented by subclasses. + */ + protected abstract setupScissorAndViewport(): void; + + /** + * Utility method to load a GLTF model with Draco compression. + * @param modelPath Path to the GLTF model. + * @returns A promise that resolves to the loaded Object3D. + */ + protected loadModel(modelPath: string): Promise { + const loader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath('/draco/'); + loader.setDRACOLoader(dracoLoader); + return new Promise((resolve, reject) => { + loader.load( + modelPath, + (gltf) => { + const object = gltf.scene; + object.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + (child as THREE.Mesh).renderOrder = 999; + this.applyDepthTest(child as THREE.Mesh); + } + }); + resolve(object); + }, + undefined, + () => { + reject(new Error(`Failed to load model: ${modelPath}`)); + }, + ); + }); + } + + /** + * Recursively disable depth testing on materials. + * @param mesh The mesh whose materials will have depth testing disabled. + */ + protected applyDepthTest(mesh: THREE.Mesh) { + if ((mesh as THREE.Mesh).isMesh) { + const meshInstance = mesh as THREE.Mesh; + const apply = (material: THREE.Material | THREE.Material[]) => { + if (Array.isArray(material)) { + material.forEach((mat) => this.applyDepthTestOnMaterial(mat)); + } else { + this.applyDepthTestOnMaterial(material); + } + }; + apply(meshInstance.material); + } + } + + /** + * Disable depth testing on a single material. + * @param material The material to modify. + */ + private applyDepthTestOnMaterial(material: THREE.Material) { + material.depthTest = false; + } + + // Utility functions + public rotateAroundWorldAxis(source: THREE.Quaternion, axis: THREE.Vector3, angle: number) { + const rotationQuat = new THREE.Quaternion().setFromAxisAngle(axis, angle); + source.multiplyQuaternions(rotationQuat, source); + } + + public moveTowardsPos(source: THREE.Vector3, target: THREE.Vector3, frac: number) { + source.lerp(target, frac); + } + + public moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { + source.slerp(target, frac); + } + + public rgbToHex(r: number, g: number, b: number): number { + return (r << 16) + (g << 8) + b; + } +} diff --git a/src/server/DataValidator.ts b/src/server/DataValidator.ts index 64f0c1a9..a4a8a565 100644 --- a/src/server/DataValidator.ts +++ b/src/server/DataValidator.ts @@ -53,6 +53,7 @@ export class DataValidator { chatMsg: z.string().max(300), latency: z.number(), health: z.number(), + protection: z.number(), forced: z.boolean(), forcedAcknowledged: z.boolean(), updateTimestamp: z.number().optional(), @@ -62,6 +63,9 @@ export class DataValidator { playerSpectating: z.number(), gameMsgs: z.array(z.string()), gameMsgs2: z.array(z.string()), + directionIndicatorVector: this.vector3Schema.nullable().optional(), + highlightedVectors: z.array(this.vector3Schema), + doPhysics: z.boolean(), }).strict().transform((data) => Player.fromObject(data as Player)); static chatMsgSchema = z.object({ diff --git a/src/server/GameEngine.ts b/src/server/GameEngine.ts index 1cfb0632..642e9eab 100644 --- a/src/server/GameEngine.ts +++ b/src/server/GameEngine.ts @@ -10,18 +10,19 @@ import { FFAGamemode } from './gamemodes/FFAGamemode.ts'; import { CustomServer } from '../shared/messages.ts'; import { Player } from '../shared/Player.ts'; import * as THREE from 'three'; +import { SoloCTFGamemode } from './gamemodes/SoloCTFGamemode.ts'; export class GameEngine { private lastPlayerTickTimestamp: number = Date.now() / 1000; private lastItemUpdateTimestamp: number = Date.now() / 1000; public playerUpdateSinceLastEmit: boolean = false; private itemUpdateSinceLastEmit: boolean = false; - private serverInfo: ServerInfo = new ServerInfo(); + public serverInfo: ServerInfo = new ServerInfo(); public gamemode: Gamemode | false = false; constructor( public playerManager: PlayerManager, - private itemManager: ItemManager, + public itemManager: ItemManager, public chatManager: ChatManager, private damageSystem: DamageSystem, private io: CustomServer, @@ -144,12 +145,15 @@ export class GameEngine { case 'ffa': this.gamemode = new FFAGamemode(this); break; + case 'ctf': + this.gamemode = new SoloCTFGamemode(this); + break; default: console.log('⚠️ invalid gamemode supplied (check your config!)', config.game.mode); break; } } catch (error) { - console.error('⚠ error initializing gamemode:', error); + console.error('⚠️ error initializing gamemode:', error); } } } diff --git a/src/server/config.ts b/src/server/config.ts index f040b7cb..1c440015 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -5,10 +5,10 @@ const defaults = { SERVER_URL: 'https://example.com', SERVER_DEFAULT_MAP: 'crackhouse_1', SERVER_TICK_RATE: '15', - SERVER_CLEANUP_INTERVAL: '2000', + SERVER_CLEANUP_INTERVAL: '1000', // Player settings - PLAYER_DISCONNECT_TIME: '8', + PLAYER_DISCONNECT_TIME: '10', PLAYER_AFK_KICK_TIME: '600', PLAYER_MAX_HEALTH: '100', PLAYER_BASE_INVENTORY: '[]', @@ -16,14 +16,16 @@ const defaults = { //Game settings GAME_MODE: 'ffa', GAME_MAX_PLAYERS: '20', + RESPAWN_DELAY: '10', + POINTS_TO_WIN: '100', // Health settings - HEALTH_REGEN_DELAY: '5', - HEALTH_REGEN_RATE: '3', + HEALTH_REGEN_DELAY: '6', + HEALTH_REGEN_RATE: '5', //Item settings - MAX_ITEMS_IN_WORLD: '7', - ITEM_RESPAWN_TIME: '10', + MAX_ITEMS_IN_WORLD: '10', + ITEM_RESPAWN_TIME: '7', }; async function updateEnvFile(defaults: Record) { @@ -69,6 +71,8 @@ function parseConfig(env: Record) { game: { mode: env.GAME_MODE, maxPlayers: parseInt(env.GAME_MAX_PLAYERS), + respawnDelay: parseInt(env.RESPAWN_DELAY), + pointsToWin: parseInt(env.POINTS_TO_WIN), }, player: { disconnectTime: parseInt(env.PLAYER_DISCONNECT_TIME), diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 24c3a7d5..26f70600 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -17,15 +17,16 @@ export class FFAGamemode extends Gamemode { tick(): void { const currentTime = Date.now() / 1000; for (const [player, timestamp] of this.spectateTimeouts) { - if (currentTime - timestamp > 10) { + if (currentTime - timestamp > config.game.respawnDelay) { this.gameEngine.playerManager.respawnPlayer(player); player.playerSpectating = -1; this.spectateTimeouts.delete(player); this.gameEngine.setGameMessage(player, '', 0); + this.gameEngine.setGameMessage(player, '', 1); } else { this.gameEngine.setGameMessage( player, - '&crespawn in ' + Math.floor(10 + timestamp - currentTime) + ' seconds', + '&crespawn in ' + Math.floor(config.game.respawnDelay + timestamp - currentTime) + ' seconds', 1, 0.5, ); @@ -38,7 +39,12 @@ export class FFAGamemode extends Gamemode { // send kill death stats to all players for (const player of this.gameEngine.playerManager.getAllPlayers()) { const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); - if (extras) player.gameMsgs2 = ['&7' + extras.kills + ' kills, ' + extras.deaths + ' deaths']; + if (extras) { + let colorPrefix = '&6'; + if (extras.kills > extras.deaths) colorPrefix = '&a'; + if (extras.kills < extras.deaths) colorPrefix = '&c'; + player.gameMsgs2 = [colorPrefix + extras.kills + ' kills, ' + extras.deaths + ' deaths']; + } } } @@ -65,7 +71,7 @@ export class FFAGamemode extends Gamemode { for (const otherPlayer of this.gameEngine.playerManager.getAllPlayers()) { if (otherPlayer.playerSpectating === player.id) { otherPlayer.playerSpectating = killer.id; - this.gameEngine.setGameMessage(otherPlayer, '&cspectating ' + killer.name, 0, 10); + this.gameEngine.setGameMessage(otherPlayer, '&cspectating ' + killer.name, 0, config.game.respawnDelay); } } @@ -74,8 +80,8 @@ export class FFAGamemode extends Gamemode { player.health = config.player.maxHealth; this.gameEngine.playerManager.dropAllItems(player); - this.gameEngine.setGameMessage(player, '&cspectating ' + killer.name, 0, 10); - this.gameEngine.setGameMessage(player, '&crespawn in 10 seconds', 1, 2); + this.gameEngine.setGameMessage(player, '&cspectating ' + killer.name, 0, config.game.respawnDelay); + this.gameEngine.setGameMessage(player, `&crespawn in ${config.game.respawnDelay} seconds`, 1, 2); this.gameEngine.setGameMessage(killer, '&akilled ' + player.name, 0, 5); // Add the dead player to the spectate timeout list diff --git a/src/server/gamemodes/SoloCTFGamemode.ts b/src/server/gamemodes/SoloCTFGamemode.ts new file mode 100644 index 00000000..4a9e570d --- /dev/null +++ b/src/server/gamemodes/SoloCTFGamemode.ts @@ -0,0 +1,404 @@ +import { FFAGamemode } from './FFAGamemode.ts'; +import { Player } from '../../shared/Player.ts'; +import config from '../config.ts'; +import { WorldItem } from '../models/WorldItem.ts'; +import { ItemRespawnPoint } from '../models/ItemRespawnPoint.ts'; + +export class SoloCTFGamemode extends FFAGamemode { + private readonly FLAG_ITEM_TYPE: number = 4; + private gameActive: boolean = true; + private resetTimestamp: number | null = null; + private isAnnouncingWin: boolean = false; // Flag to indicate win announcement + private winner: Player | null = null; + + override init(): void { + super.init(); + console.log('🚩 Solo CTF Gamemode initialized'); + } + + override tick(): void { + super.tick(); + + if (this.isAnnouncingWin) this.doWinAnnouncement(); + + if (!this.gameActive) { + // Game is resetting, check if it's time to reset + if (this.resetTimestamp && Date.now() / 1000 >= this.resetTimestamp) { + this.resetGame(); + } + return; + } + + const currentTime = Date.now() / 1000; + + // Only spawn flag if it doesn't exist AND we're not in reset state + if (this.gameActive && !this.flagExists()) { + console.log('No flag found, spawning new flag'); + this.spawnFlag(); + } + + const players = this.gameEngine.playerManager.getAllPlayers(); + const flagHolder = this.getFlagHolder(players); + let winner: Player | null = null; + + if (flagHolder) { + // Increment points for the flag holder + const extras = this.gameEngine.playerManager.getPlayerExtrasById(flagHolder.id); + if (extras) { + if (currentTime - extras.lastPointIncrementTime >= 1) { // 1 second has passed + extras.points += 1; + extras.lastPointIncrementTime = currentTime; + + // Check for win condition + if (extras.points >= config.game.pointsToWin) { + winner = flagHolder; + this.announceWin(winner); + this.gameActive = false; + this.resetTimestamp = currentTime + config.game.respawnDelay; + } + } + } + } + + // Update direction indicators + if (flagHolder) { + // Flag is held by a player + for (const player of players) { + if (player.id === flagHolder.id) { + player.directionIndicatorVector = undefined; + } else { + player.directionIndicatorVector = flagHolder.position.clone(); + } + } + } else { + // Flag is in the world + const flagItem = this.getFlagInWorld(); + if (flagItem) { + for (const player of players) { + player.directionIndicatorVector = flagItem.vector.clone(); + } + } + } + + // Set directionIndicatorVector to undefined for spectating players + for (const player of players) { + if (player.playerSpectating !== -1) { + player.directionIndicatorVector = undefined; + } + } + + this.gameEngine.playerUpdateSinceLastEmit = true; + } + + override onPeriodicCleanup(): void { + super.onPeriodicCleanup(); + + // Do not update gameMsgs during win announcement + if (this.isAnnouncingWin) { + return; + } + + const players = this.gameEngine.playerManager.getAllPlayers(); + let leader: Player | null = null; + let maxPoints = -1; + let flagHolder: Player | null = null; + + // Determine leader and flag holder + for (const player of players) { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (extras) { + if (extras.points > maxPoints) { + maxPoints = extras.points; + leader = player; + } + if (player.inventory.includes(this.FLAG_ITEM_TYPE)) { + flagHolder = player; + } + } + } + + for (const player of players) player.doPhysics = true; + + for (const player of players) { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (!extras) continue; + + const personalSeconds = config.game.pointsToWin - extras.points; + + // Only update gameMsgs if player is not spectating + if (player.playerSpectating === -1) { + if (player.inventory.includes(this.FLAG_ITEM_TYPE)) { + player.gameMsgs = [ + '&ayou have the flag', + `&a${personalSeconds} seconds. &4DON'T DIE.`, + ]; + } else if (flagHolder) { + const flagHolderExtras = this.gameEngine.playerManager.getPlayerExtrasById(flagHolder.id); + const flagHolderSeconds = flagHolderExtras ? config.game.pointsToWin - flagHolderExtras.points : 0; + player.gameMsgs = [ + `&c${flagHolder.name} has the flag `, + `&c${flagHolderSeconds} seconds remain`, + ]; + } else { + player.gameMsgs = [ + '&6the flag has been dropped', + ]; + } + } + + // Always update gameMsgs2 + let colorPrefix = '&a'; + if (leader) { + const leaderExtras = this.gameEngine.playerManager.getPlayerExtrasById(leader.id); + if (leaderExtras) { + if (leader.id === player.id) { + player.gameMsgs2[2] = colorPrefix + 'you are leading'; + } else { + colorPrefix = '&c'; + player.gameMsgs2[2] = colorPrefix + + `&c${config.game.pointsToWin - leaderExtras.points} seconds for ${leader.name}`; + } + } + } else { + player.gameMsgs2[2] = ''; + } + player.gameMsgs2[1] = colorPrefix + `${personalSeconds} seconds to win`; + } + } + + override onPlayerConnect(player: Player): void { + super.onPlayerConnect(player); + // Additional connection logic if needed + } + + override onPlayerDisconnect(player: Player): void { + super.onPlayerDisconnect(player); + // Additional disconnection logic if needed + } + + override onPlayerDeath(player: Player): void { + super.onPlayerDeath(player); + // Additional death logic if needed + } + + override onPlayerKill(player: Player): void { + super.onPlayerKill(player); + // Additional kill logic if needed + } + + override onItemPickup(player: Player): void { + super.onItemPickup(player); + + // Check if the picked up item is the flag + if (player.inventory.includes(this.FLAG_ITEM_TYPE)) { + // Reset the player's flag-related data + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (extras) { + extras.points = 0; + extras.lastPointIncrementTime = Date.now() / 1000; + } + } + } + + /** + * Checks if the flag exists either in the world or in any player's inventory. + */ + private flagExists(): boolean { + // Check in the world + const flagInWorld = this.gameEngine.itemManager.getAllItems().some((item) => item.itemType === this.FLAG_ITEM_TYPE); + + if (flagInWorld) return true; + + // Check in players' inventories + const players = this.gameEngine.playerManager.getAllPlayers(); + for (const player of players) { + if (player.inventory.includes(this.FLAG_ITEM_TYPE)) { + return true; + } + } + + return false; + } + + /** + * Spawns the flag at a random spawn point. + */ + private spawnFlag(): void { + const spawnPoint = this.getRandomSpawnPoint(); + if (spawnPoint) { + const flag = new WorldItem(spawnPoint.position.clone(), this.FLAG_ITEM_TYPE); + this.gameEngine.itemManager.pushItem(flag); + console.log(`🚩 Flag spawned at (${spawnPoint.position.x}, ${spawnPoint.position.y}, ${spawnPoint.position.z})`); + } else { + console.error('⚠️ No spawn points available to spawn the flag.'); + } + } + + /** + * Retrieves a random spawn point from the map data. + */ + private getRandomSpawnPoint(): ItemRespawnPoint | null { + const itemSpawnPoints = this.gameEngine.itemManager['mapData'].itemRespawnPoints; + if (!itemSpawnPoints || itemSpawnPoints.length === 0) return null; + + const randomIndex = Math.floor(Math.random() * itemSpawnPoints.length); + return itemSpawnPoints[randomIndex]; + } + + /** + * Finds the player currently holding the flag. + */ + private getFlagHolder(players: Player[]): Player | null { + for (const player of players) { + if (player.inventory.includes(this.FLAG_ITEM_TYPE)) { + return player; + } + } + return null; + } + + /** + * Finds the flag item in the world. + */ + private getFlagInWorld(): WorldItem | null { + const flagItems = this.gameEngine.itemManager.getAllItems().filter((item) => item.itemType === this.FLAG_ITEM_TYPE); + if (flagItems.length > 0) { + return flagItems[0]; + } + return null; + } + + /** + * Announces the winner to all players, sets spectators, and updates their game messages. + */ + private announceWin(winner: Player): void { + this.isAnnouncingWin = true; // Set the flag to indicate win announcement + this.winner = winner; + this.gameEngine.serverInfo.skyColor = '#FFFFFF'; + // Schedule to unset the win announcement flag after the respawn delay and reset the game + winner.doPhysics = false; + winner.gravity = 2.5; + winner.forced = true; + setTimeout(() => { + this.resetAfterWin(); + this.isAnnouncingWin = false; + this.winner = null; + }, config.game.respawnDelay * 1000); + console.log(`🏆 ${winner.name} has won the Solo CTF game!`); + } + + private doWinAnnouncement() { + const winner = this.winner; + if (!winner) return; + for (const player of this.gameEngine.playerManager.getAllPlayers()) { + if (player.id !== winner.id) { + // Set player to spectate the winner + player.playerSpectating = winner.id; + + // Clear the player's inventory + player.inventory = []; + + // Optionally, you can also reset other player states if needed + } + + if (player.id === winner.id) { + this.gameEngine.setGameMessage(player, `&ayou have won!`, 0, config.game.respawnDelay); + this.gameEngine.setGameMessage(player, ``, 1, config.game.respawnDelay); + } else { + this.gameEngine.setGameMessage( + player, + `&c${winner.name} has transcended.`, + 0, + config.game.respawnDelay, + ); + this.gameEngine.setGameMessage(player, ``, 1, config.game.respawnDelay); + } + } + } + + /** + * Resets all players after a win: respawns them and clears their inventories. + */ + private resetAfterWin(): void { + for (const player of this.gameEngine.playerManager.getAllPlayers()) { + // Respawn the player + this.gameEngine.playerManager.respawnPlayer(player); + + // Clear the player's inventory + player.inventory = []; + + // Remove spectate status + player.playerSpectating = -1; + + //make player do physics again + player.doPhysics = true; + + // Clear direction indicators + player.directionIndicatorVector = undefined; + + // Clear game messages + this.gameEngine.setGameMessage(player, '', 0); + this.gameEngine.setGameMessage(player, '', 1); + } + + // Reset the game state + this.resetGame(); + } + + /** + * Resets the game by clearing points, removing the flag, and respawning it. + */ + private resetGame(): void { + console.log('🔄 Resetting Solo CTF game...'); + for (const player of this.gameEngine.playerManager.getAllPlayers()) { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (extras) { + extras.points = 0; + extras.lastPointIncrementTime = 0; + extras.kills = 0; + extras.deaths = 0; + extras.killStreak = 0; + } + + // Remove flag from player's inventory if they have it + const flagIndex = player.inventory.indexOf(this.FLAG_ITEM_TYPE); + if (flagIndex !== -1) { + player.inventory.splice(flagIndex, 1); + } + + // Clear direction indicators + player.directionIndicatorVector = undefined; + + // Reset spectate status + player.playerSpectating = -1; + + // Clear game messages + this.gameEngine.setGameMessage(player, '', 0); + this.gameEngine.setGameMessage(player, '', 1); + + // Optionally, respawn the player to ensure they are back in the game + this.gameEngine.playerManager.respawnPlayer(player); + } + + // Remove flag from the world if it exists + //const allItems = this.gameEngine.itemManager.getAllItems(); + //allItems.filter((item) => item.itemType !== this.FLAG_ITEM_TYPE); + // Directly modifying private property 'worldItems' (not recommended) + + // Clear all world items + this.gameEngine.itemManager.worldItems = []; + this.gameEngine.itemManager.triggerUpdateFlag(); + + // Spawn a new flag + this.spawnFlag(); + + //set sky color to black + this.gameEngine.serverInfo.skyColor = '#000000'; + + // Reset game state + this.gameActive = true; + this.resetTimestamp = null; + + console.log('✅ Solo CTF game has been reset.'); + } +} diff --git a/src/server/managers/DamageSystem.ts b/src/server/managers/DamageSystem.ts index bbb9f311..94310e8b 100644 --- a/src/server/managers/DamageSystem.ts +++ b/src/server/managers/DamageSystem.ts @@ -50,7 +50,7 @@ export class DamageSystem { } // Apply damage - targetPlayer.health -= data.damage; + targetPlayer.health -= data.damage / targetPlayer.protection; targetPlayer.lastDamageTime = Date.now() / 1000; targetPlayer.idLastDamagedBy = localPlayer.id; diff --git a/src/server/managers/ItemManager.ts b/src/server/managers/ItemManager.ts index 7ae5c3a6..9f2031f9 100644 --- a/src/server/managers/ItemManager.ts +++ b/src/server/managers/ItemManager.ts @@ -1,13 +1,14 @@ import { WorldItem } from '../models/WorldItem.ts'; -import { MapData } from '../models/MapData.ts'; import config from '../config.ts'; import { PlayerManager } from './PlayerManager.ts'; import { ChatManager } from './ChatManager.ts'; -import { Gamemode } from '../gamemodes/Gamemode.ts'; import * as THREE from 'three'; +import { Gamemode } from '../gamemodes/Gamemode.ts'; +import { MapData } from '../models/MapData.ts'; +import { SoloCTFGamemode } from '../gamemodes/SoloCTFGamemode.ts'; export class ItemManager { - private worldItems: WorldItem[] = []; + worldItems: WorldItem[] = []; private lastItemCreationTimestamp: number = Date.now() / 1000; private itemUpdateFlag: boolean = false; private gamemode: Gamemode | false = false; @@ -19,12 +20,18 @@ export class ItemManager { } tick(currentTime: number) { - this.checkForPickups(); - if (currentTime - this.lastItemCreationTimestamp > config.items.respawnTime) { - this.createItem(); - this.lastItemCreationTimestamp = currentTime; + try { + this.checkForPickups(); + // Only create random items if we're not in CTF mode + if (!this.gamemode || !(this.gamemode instanceof SoloCTFGamemode)) { + if (currentTime - this.lastItemCreationTimestamp > config.items.respawnTime) { + this.createItem(); + this.lastItemCreationTimestamp = currentTime; + } + } + } catch (error) { + console.error('⚠ Error in ItemManager tick:', error); } - // Additional item-related logic can be added here } createItem() { @@ -51,46 +58,54 @@ export class ItemManager { checkForPickups() { const players = this.playerManager.getAllPlayers(); - for (const player of players) { if (player.playerSpectating !== -1) continue; if (player.health <= 0) continue; - const itemIndex = this.worldItems.findIndex((item) => player.position.distanceTo(item.vector) < 0.5); - - if (itemIndex === -1) continue; - const item = this.worldItems[itemIndex]; - let shouldPickup = false; - - switch (item.itemType) { - case 0: // Cube - player.inventory.push(0); - shouldPickup = true; - this.chatManager.broadcastChat(`${player.name} picked up [Object]!`); - console.log(`🍌 ${player.name} picked up cube!`); - break; - - case 1: // Banana - if (!player.inventory.includes(1)) { - player.inventory.push(1); - shouldPickup = true; - console.log(`🍌 ${player.name} picked up banana!`); - } - break; - - case 2: // Fish - if (!player.inventory.includes(2)) { - player.inventory.push(2); + // Find all items within pickup range, not just the first one + const nearbyItems = this.worldItems.filter( + (item) => player.position.distanceTo(item.vector) < 0.5, + ); + + // Process each nearby item + for (const item of nearbyItems) { + let shouldPickup = false; + switch (item.itemType) { + case 0: // Cube + player.inventory.push(0); shouldPickup = true; - console.log(`🍌 ${player.name} picked up fish!`); - } - break; - } - - if (shouldPickup) { - if (this.gamemode) this.gamemode.onItemPickup(player); - this.worldItems.splice(itemIndex, 1); - this.itemUpdateFlag = true; + this.chatManager.broadcastChat(`${player.name} picked up [Object]!`); + console.log(`🍌 ${player.name} picked up cube!`); + break; + case 1: // Banana + if (!player.inventory.includes(1)) { + player.inventory.push(1); + shouldPickup = true; + console.log(`🍌 ${player.name} picked up banana!`); + } + break; + case 2: // Fish + if (!player.inventory.includes(2)) { + player.inventory.push(2); + shouldPickup = true; + console.log(`🍌 ${player.name} picked up fish!`); + } + break; + case 4: // Flag + if (!player.inventory.includes(4)) { + player.inventory.push(4); + shouldPickup = true; + console.log(`🚩 ${player.name} picked up the flag!`); + } + break; + } + + if (shouldPickup) { + if (this.gamemode) this.gamemode.onItemPickup(player); + const itemIndex = this.worldItems.indexOf(item); + this.worldItems.splice(itemIndex, 1); + this.itemUpdateFlag = true; + } } } } diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index 3a1ad017..7ed05b90 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -49,12 +49,18 @@ export class PlayerManager { } // Update existing player, preserving certain fields + player.speed = existingPlayerData.player.speed; + player.acceleration = existingPlayerData.player.acceleration; player.health = existingPlayerData.player.health; + player.protection = existingPlayerData.player.protection; player.inventory = existingPlayerData.player.inventory; player.lastDamageTime = existingPlayerData.player.lastDamageTime; + player.idLastDamagedBy = existingPlayerData.player.idLastDamagedBy; + player.forced = existingPlayerData.player.forced; player.gameMsgs = existingPlayerData.player.gameMsgs; player.gameMsgs2 = existingPlayerData.player.gameMsgs2; player.playerSpectating = existingPlayerData.player.playerSpectating; + player.doPhysics = existingPlayerData.player.doPhysics; player.updateTimestamp = Date.now() / 1000; const updatedData: PlayerWithExtras = { diff --git a/src/server/models/PlayerExtras.ts b/src/server/models/PlayerExtras.ts index f16eff18..54598bd0 100644 --- a/src/server/models/PlayerExtras.ts +++ b/src/server/models/PlayerExtras.ts @@ -4,4 +4,5 @@ export class PlayerExtras { deaths: number = 0; killStreak: number = 0; points: number = 0; + lastPointIncrementTime: number = 0; } diff --git a/src/server/models/ServerInfo.ts b/src/server/models/ServerInfo.ts index 6dc1e906..85d900dc 100644 --- a/src/server/models/ServerInfo.ts +++ b/src/server/models/ServerInfo.ts @@ -3,20 +3,25 @@ import config from '../config.ts'; export class ServerInfo { public name: string; public maxPlayers: number; - public currentPlayers: number; + public currentPlayers: number = 0; public mapName: string; public tickRate: number; - public version: string; + public version: string = ''; public gameMode: string; public playerMaxHealth: number; + public skyColor: string; constructor() { this.name = config.server.name; this.maxPlayers = config.game.maxPlayers; - this.currentPlayers = 0; this.mapName = config.server.defaultMap; this.tickRate = config.server.tickRate; - this.version = ''; this.gameMode = config.game.mode; this.playerMaxHealth = config.player.maxHealth; + this.skyColor = '#000000'; + } + toJSON() { + return { + ...this, + }; } } diff --git a/src/shared/Player.ts b/src/shared/Player.ts index a3cc79f3..6c267a44 100644 --- a/src/shared/Player.ts +++ b/src/shared/Player.ts @@ -5,30 +5,36 @@ import * as THREE from 'three'; export type PlayerData = z.input; export class Player { - public position = new THREE.Vector3(0, 100, 0); + //server-controlled simply means the server ignores updates from the client, client can sometimes still init these values before joining. + + public position = new THREE.Vector3(0, 100, 0); //initial client spawn position, immediately updatd by server public velocity = new THREE.Vector3(0, 0, 0); public inputVelocity = new THREE.Vector3(); public gravity = 0; - public lookQuaternion = new THREE.Quaternion(); - public quaternion = new THREE.Quaternion(); - public id = Math.floor(Math.random() * 1000000000); - public gameVersion = ''; + public lookQuaternion = new THREE.Quaternion(); //actual look direction + public quaternion = new THREE.Quaternion(); // model rotation, used for movement and remotePlayer model rotation + public id = Math.floor(Math.random() * 1000000000); //unique player id, generated on client + public gameVersion = ''; //client game version, pulled from dist/gameVersion.json public name = ''; - public speed = 5; - public acceleration = 100; + public speed = 5; //server-controlled, default 5 + public acceleration = 100; //server-controlled, default 100 public chatActive = false; public chatMsg = ''; public latency = 1000; - public health = 100; - public forced = false; + public health = 100; //server-controlled + public protection = 1; //server-controlled + public forced = false; //server-controlled public forcedAcknowledged = false; - public inventory: number[] = []; - public idLastDamagedBy?: number = -1; - public playerSpectating = -1; - public gameMsgs: string[] = []; - public gameMsgs2: string[] = []; - public updateTimestamp?: number; - public lastDamageTime?: number; + public inventory: number[] = []; //server-controlled + public idLastDamagedBy?: number = -1; //server-controlled + public playerSpectating = -1; //server-controlled + public gameMsgs: string[] = []; //server-controlled + public gameMsgs2: string[] = []; //server-controlled + public updateTimestamp?: number; //server-controlled + public lastDamageTime?: number; //server-controlled + public directionIndicatorVector?: THREE.Vector3 = undefined; //server-controlled + public highlightedVectors: THREE.Vector3[] = []; //new THREE.Vector3(5.92, 1.21, -4.10) + public doPhysics: boolean = true; //server-controlled static fromObject(data: Player): Player { const instance = new Player(); @@ -48,6 +54,7 @@ export class Player { inputVelocity: serializableVec3(this.inputVelocity), lookQuaternion: serializableQuaternion(this.lookQuaternion), quaternion: serializableQuaternion(this.quaternion), + directionIndicatorVector: this.directionIndicatorVector ? serializableVec3(this.directionIndicatorVector) : null, }; } }