From 58edaa720741e51abe89f92035499bfab7ee0760 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:44:55 -0500 Subject: [PATCH 01/18] feat: press q for fast weapon switching --- src/client/core/Inventory.ts | 41 +++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/client/core/Inventory.ts b/src/client/core/Inventory.ts index 9c08620a..65c3a15e 100644 --- a/src/client/core/Inventory.ts +++ b/src/client/core/Inventory.ts @@ -15,6 +15,7 @@ export class Inventory { private networking: Networking; private inventoryScene: THREE.Scene; private selectedInventoryItem: number = 0; + private lastSelectedInventoryItem: number = 0; private cameraY: number = 0; private cameraX: number = 0; private clock: THREE.Clock; @@ -23,8 +24,13 @@ export class Inventory { private localPlayer: Player; private oldInventory: number[] = []; + private oldDownPressed: boolean = false; private oldUpPressed: boolean = false; + private oldQPressed: boolean = false; + private oldNumsPressed: boolean[] = new Array(10).fill(false); + + constructor(renderer: Renderer, inputHandler: InputHandler, networking:Networking, localPlayer:Player) { this.renderer = renderer; @@ -89,37 +95,65 @@ export class Inventory { const heldItemInput = new HeldItemInput(this.inputHandler.getShoot(), this.inputHandler.getAim(), false); let downPressed =( this.inputHandler.getKey('[') || this.inputHandler.getInventoryIterationTouched()) && !this.localPlayer.chatActive; let upPressed = this.inputHandler.getKey(']') && !this.localPlayer.chatActive; + const qPressed = this.inputHandler.getKey('q') && !this.localPlayer.chatActive; if (gamepadInputs.leftShoulder && !this.localPlayer.chatActive) upPressed = true; if (gamepadInputs.rightShoulder && !this.localPlayer.chatActive) downPressed = true; const lastScroll = this.inputHandler.getScrollClicks(); if(lastScroll > 0) upPressed = true; if(lastScroll < 0) downPressed = true; + if(!this.localPlayer.chatActive){ const nums = ['1','2','3','4','5','6','7','8','9','0']; for(let i = 0; i < nums.length; i++) { - if(this.inputHandler.getKey(nums[i])) { + const numPressed = this.inputHandler.getKey(nums[i]); + if(numPressed && !this.oldNumsPressed[i]) { + this.lastSelectedInventoryItem = this.selectedInventoryItem; this.selectedInventoryItem = i; this.lastInventoryTouchTime = Date.now() / 1000; break; } } + + for(let i = 0; i < nums.length; i++) { + this.oldNumsPressed[i] = this.inputHandler.getKey(nums[i]); + } } + + if(downPressed || upPressed) this.lastInventoryTouchTime = Date.now() / 1000; const deltaTime = this.clock.getDelta(); - if(downPressed && !this.oldDownPressed) + if(downPressed && !this.oldDownPressed){ + this.lastSelectedInventoryItem = this.selectedInventoryItem; this.selectedInventoryItem++; - if(upPressed && !this.oldUpPressed) + } + if(upPressed && !this.oldUpPressed){ + this.lastSelectedInventoryItem = this.selectedInventoryItem; this.selectedInventoryItem--; + } if(this.inputHandler.getKey('enter')) this.lastInventoryTouchTime = 0; //hide inventory + if(qPressed && !this.oldQPressed) { + const temp = this.selectedInventoryItem; + this.selectedInventoryItem = this.lastSelectedInventoryItem; + this.lastSelectedInventoryItem = temp; + //this.lastInventoryTouchTime = Date.now() / 1000 - 1.25; + } + + + if(this.selectedInventoryItem < 0) this.selectedInventoryItem = this.inventoryItems.length - 1; if(this.selectedInventoryItem >= this.inventoryItems.length) this.selectedInventoryItem = 0; + if(this.lastSelectedInventoryItem < 0) + this.lastSelectedInventoryItem = this.inventoryItems.length - 1; + if(this.lastSelectedInventoryItem >= this.inventoryItems.length) + this.lastSelectedInventoryItem = 0; + this.cameraY = this.selectedInventoryItem; //might be backwards if(Date.now()/1000 - this.lastInventoryTouchTime > 2) this.cameraX = -1; @@ -135,5 +169,6 @@ export class Inventory { this.oldDownPressed = downPressed; this.oldUpPressed = upPressed; + this.oldQPressed = qPressed; } } \ No newline at end of file From d8092378583cffdd5a83956fe2d5d29f07aa41f6 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Mon, 30 Dec 2024 13:39:10 -0500 Subject: [PATCH 02/18] initial gameText render test --- src/client/ui/ChatOverlay.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index 103066d3..35ae6a08 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -115,6 +115,7 @@ export class ChatOverlay { this.chatCtx.clearRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); this.renderHitMarkers(); this.renderChatMessages(); + this.renderGameText(); this.renderDebugText(); if (this.inputHandler.getKey('tab')) this.renderPlayerList(); @@ -389,6 +390,31 @@ export class ChatOverlay { this.debugTextHeight = 7 * linesToRender.length; } + private renderGameText() { + const ctx = this.chatCtx; + ctx.font = '8px Tiny5'; + + // Example game messages to render (you can modify this array as needed) + const gameMessages = [ + '&akilled hob', + ]; + + // Calculate vertical center position + const centerY = this.chatCanvas.height / 2 + 64; + + // Render each message + for (let i = 0; i < gameMessages.length; i++) { + const message = gameMessages[i]; + const textWidth = this.chatCtx.measureText(message.replace(/&[0-9a-f]/g, '')).width; + const x = Math.floor((this.screenWidth - textWidth) / 2); + const y = Math.floor(centerY + (i * 10)); // 10 pixels spacing between lines + + // Use the existing renderPixelText method to handle color codes + this.renderPixelText(message, x, y, 'white'); + } + } + + public renderTouchControls() { if(Date.now() / 1000 - this.lastTouchTimestamp > 10) return; From 46b5c31c97db4cd70b3b95ce3fc830410966d82b Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Mon, 30 Dec 2024 14:25:43 -0500 Subject: [PATCH 03/18] working gameText tracking and fade --- src/client/ui/ChatOverlay.ts | 147 +++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index 35ae6a08..8e94830e 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -13,6 +13,13 @@ interface ChatMessage { timestamp: number; } +interface AnimatedGameMessage { + message: string; + state: 'animatingIn' | 'animatingOut' | 'idle'; + animationProgress: number; // Ranges from 0 to 1 + timestamp: number; // Time when the current animation state started +} + const hitMarkerLifetime = 0.3; export class ChatOverlay { @@ -43,6 +50,14 @@ export class ChatOverlay { private offscreenCanvas: HTMLCanvasElement; private offscreenCtx: CanvasRenderingContext2D; + public gameMessages: string[] = []; + private previousGameMessages: string[] = []; + + + private animatedGameMessages: AnimatedGameMessage[] = []; + private animationDuration: number = 0.25; //seconds + + constructor(container: HTMLElement,localPlayer: Player) { this.localPlayer = localPlayer; this.chatCanvas = document.createElement('canvas'); @@ -111,6 +126,12 @@ export class ChatOverlay { public onFrame() { const startTime = Date.now(); + const now = Date.now() / 1000; + this.gameMessages = [Math.floor(now) + '']; + + this.detectGameMessagesChanges(now); + this.updateAnimatedGameMessages(now); + this.clearOldMessages(); this.chatCtx.clearRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); this.renderHitMarkers(); @@ -390,32 +411,132 @@ export class ChatOverlay { this.debugTextHeight = 7 * linesToRender.length; } + private detectGameMessagesChanges(now: number) { + const previous = this.previousGameMessages; + const current = this.gameMessages; + + const maxLength = Math.max(previous.length, current.length); + + for (let i = 0; i < maxLength; i++) { + const prevMsg = previous[i]; + const currMsg = current[i]; + + if (prevMsg && currMsg) { + if (prevMsg !== currMsg) { + // Message has changed at this index + this.animateMessageChange(i, prevMsg, currMsg, now); + } + // If messages are the same, do nothing + } else if (!prevMsg && currMsg) { + // New message added + this.animateMessageAddition(i, currMsg, now); + } else if (prevMsg && !currMsg) { + // Message removed + this.animateMessageRemoval(i, prevMsg, now); + } + } + + // Update previousGameMessages for the next frame + this.previousGameMessages = [...current]; + } + + private animateMessageChange(index: number, oldMsg: string, newMsg: string, now: number) { + // Animate out the old message + const animatedMsg = this.animatedGameMessages[index]; + if (animatedMsg && animatedMsg.state !== 'animatingOut') { + this.animatedGameMessages[index].state = 'animatingOut'; + this.animatedGameMessages[index].timestamp = now; + } + + // Animate in the new message after the old one has faded out + setTimeout(() => { + const updatedAnimatedMsg: AnimatedGameMessage = { + message: newMsg, + state: 'animatingIn', + animationProgress: 0, + timestamp: Date.now() / 1000, + }; + this.animatedGameMessages[index] = updatedAnimatedMsg; + }, this.animationDuration * 1000); + } + + private animateMessageAddition(index: number, newMsg: string, now: number) { + const animatedMsg: AnimatedGameMessage = { + message: newMsg, + state: 'animatingIn', + animationProgress: 0, + timestamp: now, + }; + this.animatedGameMessages[index] = animatedMsg; + } + + private animateMessageRemoval(index: number, oldMsg: string, now: number) { + const animatedMsg = this.animatedGameMessages[index]; + if (animatedMsg && animatedMsg.state !== 'animatingOut') { + this.animatedGameMessages[index].state = 'animatingOut'; + this.animatedGameMessages[index].timestamp = now; + } + } + + private updateAnimatedGameMessages(now: number) { + for (let i = this.animatedGameMessages.length - 1; i >= 0; i--) { + const msg = this.animatedGameMessages[i]; + const elapsed = now - msg.timestamp; + msg.animationProgress = Math.min(elapsed / this.animationDuration, 1); + + if (msg.state === 'animatingOut' && msg.animationProgress >= 1) { + // Remove the message after fading out + this.animatedGameMessages.splice(i, 1); + continue; + } + + if (msg.state === 'animatingIn' && msg.animationProgress >= 1) { + // Transition to idle state after fading in + this.animatedGameMessages[i].state = 'idle'; + this.animatedGameMessages[i].animationProgress = 1; + } + } + } + private renderGameText() { const ctx = this.chatCtx; ctx.font = '8px Tiny5'; - // Example game messages to render (you can modify this array as needed) - const gameMessages = [ - '&akilled hob', - ]; - // Calculate vertical center position const centerY = this.chatCanvas.height / 2 + 64; - // Render each message - for (let i = 0; i < gameMessages.length; i++) { - const message = gameMessages[i]; - const textWidth = this.chatCtx.measureText(message.replace(/&[0-9a-f]/g, '')).width; + for (let i = 0; i < this.animatedGameMessages.length; i++) { + const msg = this.animatedGameMessages[i]; + let alpha = 1; + let xOffset = 0; + + // Determine alpha based on animation state + if (msg.state === 'animatingIn') { + alpha = msg.animationProgress; // From 0 to 1 + } else if (msg.state === 'animatingOut') { + alpha = 1 - msg.animationProgress; // From 1 to 0 + } else if (msg.state === 'idle') { + alpha = 1; // Fully opaque + } + + // Set global alpha for fade effect + ctx.globalAlpha = alpha; + + // Calculate text dimensions + const plainMessage = msg.message.replace(/&[0-9a-f]/g, ''); // Remove color codes for measurement + const textWidth = this.chatCtx.measureText(plainMessage).width; const x = Math.floor((this.screenWidth - textWidth) / 2); - const y = Math.floor(centerY + (i * 10)); // 10 pixels spacing between lines + const y = Math.floor(centerY + (i * 10)); - // Use the existing renderPixelText method to handle color codes - this.renderPixelText(message, x, y, 'white'); + // Render the text + this.renderPixelText(msg.message, x, y, 'white'); + + // Reset global alpha + ctx.globalAlpha = 1; } } - public renderTouchControls() { if(Date.now() / 1000 - this.lastTouchTimestamp > 10) return; if(this.touchJoystickEngaged) { From 75219340d01e2d19f05b85cead4d639b0ff3397a Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Mon, 30 Dec 2024 17:38:17 -0500 Subject: [PATCH 04/18] better gameText transition --- src/client/ui/ChatOverlay.ts | 349 ++++++++++++++++++----------------- 1 file changed, 180 insertions(+), 169 deletions(-) diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index 8e94830e..79ada4f9 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -14,12 +14,18 @@ interface ChatMessage { } interface AnimatedGameMessage { + id: string; // Unique identifier message: string; state: 'animatingIn' | 'animatingOut' | 'idle'; animationProgress: number; // Ranges from 0 to 1 timestamp: number; // Time when the current animation state started } +interface LineMessage { + currentMessage: AnimatedGameMessage | null; + pendingMessage: string | null; +} + const hitMarkerLifetime = 0.3; export class ChatOverlay { @@ -36,7 +42,7 @@ export class ChatOverlay { private screenWidth: number; private inputHandler!: InputHandler; private debugTextHeight!: number; - private oldScreenWidth:number = 0; + private oldScreenWidth: number = 0; private readonly commandManager: CommandManager; private lastTouchTimestamp: number = 0; private touchJoystickEngaged: boolean = false; @@ -53,12 +59,31 @@ export class ChatOverlay { public gameMessages: string[] = []; private previousGameMessages: string[] = []; + // Removed animatedGameMessages in favor of per-line management + private lines: LineMessage[] = []; + private animationDuration: number = 1; // Adjusted for smoother animation - private animatedGameMessages: AnimatedGameMessage[] = []; - private animationDuration: number = 0.25; //seconds - + // Color code mapping + COLOR_CODES: { [key: string]: string } = { + '0': '#000000', // Black + '1': '#0000AA', // Dark Blue + '2': '#00AA00', // Dark Green + '3': '#00AAAA', // Dark Aqua + '4': '#AA0000', // Dark Red + '5': '#AA00AA', // Dark Purple + '6': '#FFAA00', // Gold + '7': '#AAAAAA', // Gray + '8': '#555555', // Dark Gray + '9': '#5555FF', // Blue + 'a': '#55FF55', // Green + 'b': '#55FFFF', // Aqua + 'c': '#FF5555', // Red + 'd': '#FF55FF', // Light Purple + 'e': '#FFFF55', // Yellow + 'f': '#FFFFFF' // White + }; - constructor(container: HTMLElement,localPlayer: Player) { + constructor(container: HTMLElement, localPlayer: Player) { this.localPlayer = localPlayer; this.chatCanvas = document.createElement('canvas'); this.chatCtx = this.chatCanvas.getContext('2d') as CanvasRenderingContext2D; @@ -69,7 +94,7 @@ export class ChatOverlay { this.chatCanvas.height = 200; this.chatMessages = []; - this.chatMessageLifespan = 40; // 20 seconds + this.chatMessageLifespan = 40; // 40 seconds this.charsToRemovePerSecond = 30; this.maxMessagesOnScreen = 12; @@ -86,7 +111,7 @@ export class ChatOverlay { this.chatCanvas.style.top = '0'; this.chatCanvas.style.left = '0'; - this.chatCanvas.style.height = '100vh'; + this.chatCanvas.style.height = '100vh'; document.body.style.margin = '0'; this.chatCanvas.style.imageRendering = 'pixelated'; this.chatCanvas.style.textRendering = 'pixelated'; @@ -96,14 +121,17 @@ export class ChatOverlay { this.offscreenCanvas = document.createElement('canvas'); this.offscreenCtx = this.offscreenCanvas.getContext('2d') as CanvasRenderingContext2D; + // Initialize lines for per-line message management + this.lines = Array(this.maxMessagesOnScreen).fill(null).map(() => ({ + currentMessage: null, + pendingMessage: null + })); + //document.body.appendChild(this.chatCanvas); container.appendChild(this.chatCanvas); globalThis.addEventListener('resize', this.onWindowResize.bind(this)); globalThis.addEventListener('orientationchange', this.onWindowResize.bind(this)); - - - } public setRenderer(renderer: Renderer) { @@ -122,12 +150,17 @@ export class ChatOverlay { document.addEventListener('keydown', this.onKeyDown.bind(this)); } - - public onFrame() { const startTime = Date.now(); const now = Date.now() / 1000; - this.gameMessages = [Math.floor(now) + '']; + //this.gameMessages = ['&a'+Math.floor(now/4 % 10) + ' kills']; + + if ((now / 10) % 1 < 0.5) { + this.gameMessages = ['killed hob', 'you died! spectating possum165']; + + } else { + this.gameMessages = [ 'alived hob','you died! spectating possum165']; + } this.detectGameMessagesChanges(now); this.updateAnimatedGameMessages(now); @@ -144,31 +177,27 @@ export class ChatOverlay { this.renderCrosshair(); this.renderTouchControls(); - - this.screenWidth = Math.floor(this.renderer.getCamera().aspect * 200); - if(this.oldScreenWidth !== this.screenWidth){ + if (this.oldScreenWidth !== this.screenWidth) { //if(this.chatCanvas.width < this.screenWidth) - this.chatCanvas.width = this.screenWidth; + this.chatCanvas.width = this.screenWidth; this.oldScreenWidth = this.screenWidth; } - // this.chatCanvas.width = this.screenWidth; // this.chatCtx.fillRect(0,0,10,10); this.onWindowResize(); this.inputHandler.nameSettingActive = this.nameSettingActive; - if(Math.random()<0.03) + if (Math.random() < 0.03) this.lastRoutineMs = Date.now() - startTime; } - private onWindowResize() { + private onWindowResize() { this.chatCanvas.style.width = globalThis.innerWidth + 'px'; - this.chatCanvas.style.height = globalThis.innerHeight+ 'px'; - + this.chatCanvas.style.height = globalThis.innerHeight + 'px'; } private renderChatMessages() { @@ -220,8 +249,8 @@ export class ChatOverlay { } if (this.localPlayer.chatActive) { - if(this.localPlayer.chatMsg.startsWith('>')) - linesToRender.push('&2'+usermsg + cursor); + if (this.localPlayer.chatMsg.startsWith('>')) + linesToRender.push('&2' + usermsg + cursor); else linesToRender.push(usermsg + cursor); pixOffsets.push(0); @@ -266,25 +295,6 @@ export class ChatOverlay { ctx.fillRect(2, 200 - 20 - 7, width + 1, 9); } } -// Color code mapping - COLOR_CODES:{[key: string]: string} = { - '0': '#000000', // Black - '1': '#0000AA', // Dark Blue - '2': '#00AA00', // Dark Green - '3': '#00AAAA', // Dark Aqua - '4': '#AA0000', // Dark Red - '5': '#AA00AA', // Dark Purple - '6': '#FFAA00', // Gold - '7': '#AAAAAA', // Gray - '8': '#555555', // Dark Gray - '9': '#5555FF', // Blue - 'a': '#55FF55', // Green - 'b': '#55FFFF', // Aqua - 'c': '#FF5555', // Red - 'd': '#FF55FF', // Light Purple - 'e': '#FFFF55', // Yellow - 'f': '#FFFFFF' // White - }; private renderPrettyText(text: string, x: number, y: number, defaultColor: string) { let currentX = x; @@ -369,17 +379,13 @@ export class ChatOverlay { } private renderPixelText(text: string, x: number, y: number, color: string) { - if(SettingsManager.settings.doPrettyText) + if (SettingsManager.settings.doPrettyText) this.renderPrettyText(text, x, y, color); else this.renderUglyText(text, x, y, color); } - - - private renderDebugText() { - const ctx = this.chatCtx; ctx.font = '8px Tiny5'; ctx.fillStyle = 'teal'; @@ -392,8 +398,8 @@ export class ChatOverlay { //const playerX = Math.round(this.localPlayer.position.x); - linesToRender.push('candiru ' + this.localPlayer.gameVersion + ' @ ' + Math.round(framerate) + 'fps, '+ Math.round(this.localPlayer.latency) + 'ms'); - linesToRender.push('connected to: '+this.networking.getServerInfo().name); + linesToRender.push('candiru ' + this.localPlayer.gameVersion + ' @ ' + Math.round(framerate) + 'fps, ' + Math.round(this.localPlayer.latency) + 'ms'); + linesToRender.push('connected to: ' + this.networking.getServerInfo().name); //linesToRender.push('players: ' + this.networking.getServerInfo().currentPlayers + '/' + this.networking.getServerInfo().maxPlayers); linesToRender.push('map: ' + this.networking.getServerInfo().mapName); linesToRender.push('mode: ' + this.networking.getServerInfo().gameMode); @@ -412,183 +418,185 @@ export class ChatOverlay { } private detectGameMessagesChanges(now: number) { - const previous = this.previousGameMessages; const current = this.gameMessages; - const maxLength = Math.max(previous.length, current.length); - - for (let i = 0; i < maxLength; i++) { - const prevMsg = previous[i]; - const currMsg = current[i]; + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + const currentMessage = current[i] || null; + + if (!line.currentMessage) { + if (currentMessage) { + line.currentMessage = { + id: this.generateUniqueId(), + message: currentMessage, + state: 'animatingIn', + animationProgress: 0, + timestamp: now, + }; + } + continue; + } - if (prevMsg && currMsg) { - if (prevMsg !== currMsg) { - // Message has changed at this index - this.animateMessageChange(i, prevMsg, currMsg, now); + if (currentMessage !== line.currentMessage.message) { + if (line.currentMessage.state === 'idle') { + line.currentMessage.state = 'animatingOut'; + line.currentMessage.timestamp = now; + line.pendingMessage = currentMessage; + } else { + line.pendingMessage = currentMessage; } - // If messages are the same, do nothing - } else if (!prevMsg && currMsg) { - // New message added - this.animateMessageAddition(i, currMsg, now); - } else if (prevMsg && !currMsg) { - // Message removed - this.animateMessageRemoval(i, prevMsg, now); } } - // Update previousGameMessages for the next frame this.previousGameMessages = [...current]; } - private animateMessageChange(index: number, oldMsg: string, newMsg: string, now: number) { - // Animate out the old message - const animatedMsg = this.animatedGameMessages[index]; - if (animatedMsg && animatedMsg.state !== 'animatingOut') { - this.animatedGameMessages[index].state = 'animatingOut'; - this.animatedGameMessages[index].timestamp = now; - } - - // Animate in the new message after the old one has faded out - setTimeout(() => { - const updatedAnimatedMsg: AnimatedGameMessage = { - message: newMsg, - state: 'animatingIn', - animationProgress: 0, - timestamp: Date.now() / 1000, - }; - this.animatedGameMessages[index] = updatedAnimatedMsg; - }, this.animationDuration * 1000); - } - - private animateMessageAddition(index: number, newMsg: string, now: number) { - const animatedMsg: AnimatedGameMessage = { - message: newMsg, - state: 'animatingIn', - animationProgress: 0, - timestamp: now, - }; - this.animatedGameMessages[index] = animatedMsg; - } - - private animateMessageRemoval(index: number, oldMsg: string, now: number) { - const animatedMsg = this.animatedGameMessages[index]; - if (animatedMsg && animatedMsg.state !== 'animatingOut') { - this.animatedGameMessages[index].state = 'animatingOut'; - this.animatedGameMessages[index].timestamp = now; - } - } private updateAnimatedGameMessages(now: number) { - for (let i = this.animatedGameMessages.length - 1; i >= 0; i--) { - const msg = this.animatedGameMessages[i]; - const elapsed = now - msg.timestamp; - msg.animationProgress = Math.min(elapsed / this.animationDuration, 1); - - if (msg.state === 'animatingOut' && msg.animationProgress >= 1) { - // Remove the message after fading out - this.animatedGameMessages.splice(i, 1); + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + if (!line.currentMessage) continue; // Early return if null + + const elapsed = now - line.currentMessage.timestamp; + let progress = Math.min(elapsed / this.animationDuration, 1); + progress = this.easeOut(progress); + + line.currentMessage.animationProgress = progress; + + if (line.currentMessage.state === 'animatingOut' && progress >= 1) { + // Remove the message after fade-out + if (line.pendingMessage) { + line.currentMessage = { + id: this.generateUniqueId(), + message: line.pendingMessage, + state: 'animatingIn', + animationProgress: 0, + timestamp: now, + }; + line.pendingMessage = null; + } else { + line.currentMessage = null; + } continue; } - if (msg.state === 'animatingIn' && msg.animationProgress >= 1) { - // Transition to idle state after fading in - this.animatedGameMessages[i].state = 'idle'; - this.animatedGameMessages[i].animationProgress = 1; + if (line.currentMessage.state === 'animatingIn' && progress >= 1) { + line.currentMessage.state = 'idle'; + line.currentMessage.animationProgress = 1; } } } + private renderGameText() { const ctx = this.chatCtx; ctx.font = '8px Tiny5'; - // Calculate vertical center position - const centerY = this.chatCanvas.height / 2 + 64; - - for (let i = 0; i < this.animatedGameMessages.length; i++) { - const msg = this.animatedGameMessages[i]; - let alpha = 1; - let xOffset = 0; - - // Determine alpha based on animation state - if (msg.state === 'animatingIn') { - alpha = msg.animationProgress; // From 0 to 1 - } else if (msg.state === 'animatingOut') { - alpha = 1 - msg.animationProgress; // From 1 to 0 - } else if (msg.state === 'idle') { - alpha = 1; // Fully opaque + // Calculate vertical center position (adjust as needed) + const centerY = this.chatCanvas.height / 2 + 48; + + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + if (line.currentMessage) { + let visibleText = line.currentMessage.message; + + // Determine the visible portion based on animation state + if (line.currentMessage.state === 'animatingIn' || line.currentMessage.state === 'animatingOut') { + visibleText = this.getVisibleText(line.currentMessage.message, line.currentMessage.state, line.currentMessage.animationProgress); + } + + // Calculate text dimensions + const plainMessage = visibleText.replace(/&[0-9a-f]/g, ''); // Remove color codes for measurement + const textWidth = this.chatCtx.measureText(plainMessage).width; + const x = Math.floor((this.screenWidth - textWidth) / 2); + const y = Math.floor(centerY + (i * 10)); + + // Render the text + this.renderPixelText(visibleText, x, y, 'white'); } + } + } - // Set global alpha for fade effect - ctx.globalAlpha = alpha; + private getVisibleText(message: string, state: 'animatingIn' | 'animatingOut' | 'idle', progress: number): string { + if (state === 'idle') { + return message; + } - // Calculate text dimensions - const plainMessage = msg.message.replace(/&[0-9a-f]/g, ''); // Remove color codes for measurement - const textWidth = this.chatCtx.measureText(plainMessage).width; - const x = Math.floor((this.screenWidth - textWidth) / 2); - const y = Math.floor(centerY + (i * 10)); + const length = message.length; + if (length === 0) return ''; - // Render the text - this.renderPixelText(msg.message, x, y, 'white'); + let charsToShow = 0; - // Reset global alpha - ctx.globalAlpha = 1; + if (state === 'animatingIn') { + charsToShow = Math.floor(progress * length); + charsToShow = Math.min(charsToShow, length); // Ensure it doesn't exceed the message length + return message.substring(0, charsToShow); + } else if (state === 'animatingOut') { + charsToShow = Math.floor((1 - progress) * length); + charsToShow = Math.max(charsToShow, 0); // Ensure it doesn't go below zero + return message.substring(0, charsToShow); } + + return message; } + private easeOut(progress: number): number { + return 1 - Math.pow(1 - progress, 1.5); + } public renderTouchControls() { - if(Date.now() / 1000 - this.lastTouchTimestamp > 10) return; - if(this.touchJoystickEngaged) { - //draw circle for movement + if (Date.now() / 1000 - this.lastTouchTimestamp > 10) return; + if (this.touchJoystickEngaged) { + // Draw circle for movement this.chatCtx.fillStyle = 'rgba(255,255,255,0.25)'; this.chatCtx.beginPath(); this.chatCtx.arc(this.joystickX, this.joystickY, TouchInputHandler.joystickRadius, 0, 2 * Math.PI); this.chatCtx.fill(); - //smaller circle for joystick-- offset from center + // Smaller circle for joystick-- offset from center this.chatCtx.fillStyle = 'rgba(255,255,255,0.5)'; this.chatCtx.beginPath(); - this.chatCtx.arc(this.joystickX + this.joystickInputX * TouchInputHandler.joystickRadius, this.joystickY + this.joystickInputY * TouchInputHandler.joystickRadius, 10, 0, 2 * Math.PI); + this.chatCtx.arc( + this.joystickX + this.joystickInputX * TouchInputHandler.joystickRadius, + this.joystickY + this.joystickInputY * TouchInputHandler.joystickRadius, + 10, + 0, + 2 * Math.PI + ); this.chatCtx.fill(); - - - } // Draw rounded square center right for jumping const squareWidth = 24; const squareHeight = 24; const cornerRadius = 6; - const x = this.chatCanvas.width - squareWidth - 12; // 10px from the right edge - let y = (this.chatCanvas.height - squareHeight) / 2 ; // Center vertically - - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius,'●',1,0); - y-= squareHeight + 4; - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius,'↑',1,-1); - y+= squareHeight + 4; - y+= squareHeight + 4; - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius,'[]',1,1); + const x = this.chatCanvas.width - squareWidth - 12; // 12px from the right edge + let y = (this.chatCanvas.height - squareHeight) / 2; // Center vertically + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '●', 1, 0); + y -= squareHeight + 4; + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '↑', 1, -1); + y += squareHeight + 4; + y += squareHeight + 4; + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '[]', 1, 1); } public setButtonsHeld(buttons: number[]) { this.buttonsHeld = buttons; } - private drawButton(x:number, y:number, width:number, height:number, cornerRadius:number, text:string,textOffset:number, index:number) { - if(this.buttonsHeld.includes(index)) + private drawButton(x: number, y: number, width: number, height: number, cornerRadius: number, text: string, textOffset: number, index: number) { + if (this.buttonsHeld.includes(index)) this.chatCtx.fillStyle = 'rgba(100,100,100,0.3)'; else this.chatCtx.fillStyle = 'rgba(255,255,255,0.15)'; this.drawRoundedSquare(x, y, width, height, cornerRadius); - //draw character inside square + // Draw character inside square this.chatCtx.fillStyle = 'rgba(0,0,0,0.5)'; this.chatCtx.font = '16px Tiny5'; const textWidth = this.chatCtx.measureText(text).width; - this.chatCtx.fillText(text, Math.floor(x + width / 2 - textWidth / 2 + textOffset),Math.floor( y + height / 2 + 16 / 2 - 2)); - + this.chatCtx.fillText(text, Math.floor(x + width / 2 - textWidth / 2 + textOffset), Math.floor(y + height / 2 + 16 / 2 - 2)); } private drawRoundedSquare(x: number, y: number, width: number, height: number, cornerRadius: number) { @@ -609,13 +617,16 @@ export class ChatOverlay { public setLastTouchTimestamp(timestamp: number) { this.lastTouchTimestamp = timestamp; } + public setTouchJoystickEngaged(value: boolean) { this.touchJoystickEngaged = value; } + public setJoystickPosition(x: number, y: number) { this.joystickX = x; this.joystickY = y; } + public setJoystickInput(x: number, y: number) { this.joystickInputX = x; this.joystickInputY = y; @@ -660,7 +671,6 @@ export class ChatOverlay { } } - public getDebugTextHeight(): number { return this.debugTextHeight; } @@ -697,7 +707,6 @@ export class ChatOverlay { } } - private renderEvil() { const ctx = this.chatCtx; if (Date.now() / 1000 - this.networking.getDamagedTimestamp() < 0.05) { @@ -730,7 +739,7 @@ export class ChatOverlay { if (e.key === 'Enter') { if (this.localPlayer.chatActive) { - if(!this.commandManager.runCmd(this.localPlayer.chatMsg)) this.networking.sendMessage(this.localPlayer.chatMsg); + if (!this.commandManager.runCmd(this.localPlayer.chatMsg)) this.networking.sendMessage(this.localPlayer.chatMsg); } if (this.nameSettingActive) { this.localPlayer.name = this.localPlayer.chatMsg.toString(); @@ -832,5 +841,7 @@ export class ChatOverlay { return resultLines; } - + private generateUniqueId(): string { + return Math.random().toString(36).substr(2, 9); + } } \ No newline at end of file From 34fddaa833aeac8cac8e570b8e2c9d51d7464d52 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Mon, 30 Dec 2024 22:53:37 -0500 Subject: [PATCH 05/18] fix: make server-side data validation strict --- src/client/core/Player.ts | 4 ++++ src/client/ui/ChatOverlay.ts | 9 +-------- src/server/DataValidator.ts | 10 ++++++---- src/server/managers/PlayerManager.ts | 3 ++- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/client/core/Player.ts b/src/client/core/Player.ts index 3df80322..f098b7b8 100644 --- a/src/client/core/Player.ts +++ b/src/client/core/Player.ts @@ -21,6 +21,8 @@ export class Player { public forcedAcknowledged: boolean; public inventory: number[]; public idLastDamagedBy: number; + public playerSpectating: number; + public gameMsgs: string[]; constructor() { this.position = new THREE.Vector3(0,100,0); @@ -42,6 +44,8 @@ export class Player { this.forcedAcknowledged = false; this.inventory = []; this.idLastDamagedBy = -1; + this.playerSpectating = -1; + this.gameMsgs = []; const storedName = SettingsManager.settings.name; if (storedName) this.name = storedName; diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index 79ada4f9..a2986813 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -153,15 +153,8 @@ export class ChatOverlay { public onFrame() { const startTime = Date.now(); const now = Date.now() / 1000; - //this.gameMessages = ['&a'+Math.floor(now/4 % 10) + ' kills']; - - if ((now / 10) % 1 < 0.5) { - this.gameMessages = ['killed hob', 'you died! spectating possum165']; - - } else { - this.gameMessages = [ 'alived hob','you died! spectating possum165']; - } + this.gameMessages = this.localPlayer.gameMsgs; this.detectGameMessagesChanges(now); this.updateAnimatedGameMessages(now); diff --git a/src/server/DataValidator.ts b/src/server/DataValidator.ts index ba31cf79..13ceab2d 100644 --- a/src/server/DataValidator.ts +++ b/src/server/DataValidator.ts @@ -24,7 +24,7 @@ export class DataValidator { x: z.number(), y: z.number(), z: z.number(), - }); + }).strict(); private static playerDataSchema = z.object({ id: z.number(), @@ -50,19 +50,21 @@ export class DataValidator { lastDamageTime: z.number().optional(), inventory: z.array(z.number()), idLastDamagedBy: z.number().optional(), - }); + playerSpectating: z.number(), + gameMsgs: z.array(z.string()), + }).strict(); private static chatMsgSchema = z.object({ id: z.number(), name: z.string().max(42), message: z.string().max(300), - }); + }).strict(); private static damageRequestSchema = z.object({ localPlayer: DataValidator.playerDataSchema, targetPlayer: DataValidator.playerDataSchema, damage: z.number(), - }); + }).strict(); static validatePlayerData(data: Player) { return DataValidator.playerDataSchema.safeParse(data); diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index b89a349c..2a6f252d 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -24,7 +24,8 @@ export class PlayerManager { const { error } = DataValidator.validatePlayerData(data); if (error) { //throw new Error(`Invalid player data: ${error.message}`); - console.log('⚠️ invalid player data recieved') + throw new Error(`⚠️ invalid player data `); + // console.log('⚠️ invalid player data recieved') } const existingPlayer = this.players.get(data.id); From b654b6824f36daf498b56a86b15a5ef721524f94 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Tue, 31 Dec 2024 13:29:42 -0500 Subject: [PATCH 06/18] feat: spectating MVP --- src/client/core/Networking.ts | 1 + src/client/core/RemotePlayerRenderer.ts | 3 ++ src/client/core/Renderer.ts | 63 +++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/client/core/Networking.ts b/src/client/core/Networking.ts index fbb2be14..44cd08db 100644 --- a/src/client/core/Networking.ts +++ b/src/client/core/Networking.ts @@ -17,6 +17,7 @@ export interface RemotePlayer { inventory: number[]; chatActive: boolean; chatMsg: string; + playerSpectating: number; } interface WorldItem { diff --git a/src/client/core/RemotePlayerRenderer.ts b/src/client/core/RemotePlayerRenderer.ts index a02b256d..0729deef 100644 --- a/src/client/core/RemotePlayerRenderer.ts +++ b/src/client/core/RemotePlayerRenderer.ts @@ -16,6 +16,7 @@ interface RemotePlayerData { quaternion: [number, number, number, number]; // Add quaternion as required forced: boolean; name: string; + playerSpectating: number; } interface RemotePlayer extends Omit { @@ -126,6 +127,8 @@ export class RemotePlayerRenderer { remotePlayerData.forEach((remotePlayer) => { if (remotePlayer.id === localPlayerId) return; + if(remotePlayer.id === this.localPlayer.playerSpectating) return; + if(remotePlayer.playerSpectating !== -1) return; const playerDataWithQuaternion: RemotePlayerData = { ...remotePlayer, diff --git a/src/client/core/Renderer.ts b/src/client/core/Renderer.ts index 44fd340b..4fa1f664 100644 --- a/src/client/core/Renderer.ts +++ b/src/client/core/Renderer.ts @@ -42,6 +42,9 @@ export class Renderer { private inputHandler!: InputHandler; private collisionManager!: CollisionManager; + private spectateGroundTruthPosition: THREE.Vector3 | null = null; + + constructor(container: HTMLElement, networking: Networking, localPlayer: Player, chatOverlay: ChatOverlay) { this.networking = networking; this.localPlayer = localPlayer; @@ -185,9 +188,63 @@ export class Renderer { // Restore autoClear to true if necessary this.renderer.autoClear = true; - // Update camera position and rotation for local player - this.camera.position.copy(localPlayer.position); - this.camera.setRotationFromQuaternion(this.localPlayer.lookQuaternion); + if(localPlayer.playerSpectating !== -1) { + const remotePlayer = this.networking.getRemotePlayerData().find((player) => 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, + remotePlayer.position.y, + remotePlayer.position.z + ); + } + + // 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, + remotePlayer.position.y, + remotePlayer.position.z + ); + } + + // Lerp ground truth position towards actual position + this.spectateGroundTruthPosition.lerp( + new THREE.Vector3( + remotePlayer.position.x, + remotePlayer.position.y, + remotePlayer.position.z + ), + 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[0], + remotePlayer.lookQuaternion[1], + remotePlayer.lookQuaternion[2], + remotePlayer.lookQuaternion[3] + ), 0.3 * this.deltaTime * 60); + } + } else { + // Reset spectate position when not spectating + this.spectateGroundTruthPosition = null; + this.camera.position.copy(localPlayer.position); + this.camera.setRotationFromQuaternion(this.localPlayer.lookQuaternion); + } + + + + this.camera.position.add(this.knockbackVector); this.knockbackVector.lerp(new THREE.Vector3(), 0.05 * this.deltaTime * 60); From 4d7d1bac193f5d31a953351dae97adb87793dae6 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Tue, 31 Dec 2024 18:40:55 -0500 Subject: [PATCH 07/18] add second gameMsg field, rainbow text --- .gitignore | 2 ++ src/client/core/Networking.ts | 5 +++++ src/client/core/Player.ts | 2 ++ src/client/ui/ChatOverlay.ts | 34 ++++++++++++++++++++++++++-------- src/server/DataValidator.ts | 1 + src/server/models/Player.ts | 2 ++ 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index dfda8377..2abc637b 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,5 @@ dist .vite/deps/ .vscode/ + +.nx/workspace-data/ diff --git a/src/client/core/Networking.ts b/src/client/core/Networking.ts index 44cd08db..d1a7be05 100644 --- a/src/client/core/Networking.ts +++ b/src/client/core/Networking.ts @@ -18,6 +18,8 @@ export interface RemotePlayer { chatActive: boolean; chatMsg: string; playerSpectating: number; + gameMsgs: string[]; + gameMsgs2: string[]; } interface WorldItem { @@ -185,6 +187,9 @@ export class Networking { this.localPlayer.health = remotePlayer.health; 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; continue; } if (remotePlayer.chatActive) diff --git a/src/client/core/Player.ts b/src/client/core/Player.ts index f098b7b8..a875deee 100644 --- a/src/client/core/Player.ts +++ b/src/client/core/Player.ts @@ -23,6 +23,7 @@ export class Player { public idLastDamagedBy: number; public playerSpectating: number; public gameMsgs: string[]; + public gameMsgs2: string[]; constructor() { this.position = new THREE.Vector3(0,100,0); @@ -46,6 +47,7 @@ export class Player { this.idLastDamagedBy = -1; this.playerSpectating = -1; this.gameMsgs = []; + this.gameMsgs2 = []; const storedName = SettingsManager.settings.name; if (storedName) this.name = storedName; diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index a2986813..a977f650 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -80,9 +80,24 @@ export class ChatOverlay { 'c': '#FF5555', // Red 'd': '#FF55FF', // Light Purple 'e': '#FFFF55', // Yellow - 'f': '#FFFFFF' // White + 'f': '#FFFFFF', // White + 'g': this.getRainbowColor() }; + private getColorCode(code: string): string | false { + if (code === 'g') { + return this.getRainbowColor(); + } + return this.COLOR_CODES[code] || false; + } + + + private getRainbowColor(): string { + const hue = (Date.now() / 20) % 360; + return `hsl(${hue}, 100%, 50%)`; + } + + constructor(container: HTMLElement, localPlayer: Player) { this.localPlayer = localPlayer; this.chatCanvas = document.createElement('canvas'); @@ -297,11 +312,11 @@ export class ChatOverlay { // Parse color codes and split into segments for (let i = 0; i < text.length; i++) { - if (text[i] === '&' && i + 1 < text.length && this.COLOR_CODES[text[i + 1]]) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { if (currentSegment) { segments.push({ text: currentSegment, color: currentColor }); } - currentColor = this.COLOR_CODES[text[i + 1]]; + currentColor = this.getColorCode(text[i + 1]); currentSegment = ''; i++; // Skip the color code character } else { @@ -349,14 +364,14 @@ export class ChatOverlay { let currentSegment = ''; for (let i = 0; i < text.length; i++) { - if (text[i] === '&' && i + 1 < text.length && this.COLOR_CODES[text[i + 1]]) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { if (currentSegment) { this.chatCtx.font = '8px Tiny5'; this.chatCtx.fillStyle = currentColor; this.chatCtx.fillText(currentSegment, currentX, y); currentX += this.chatCtx.measureText(currentSegment).width; } - currentColor = this.COLOR_CODES[text[i + 1]]; + currentColor = this.getColorCode(text[i + 1]); currentSegment = ''; i++; // Skip the color code character } else { @@ -392,15 +407,18 @@ export class ChatOverlay { //const playerX = Math.round(this.localPlayer.position.x); linesToRender.push('candiru ' + this.localPlayer.gameVersion + ' @ ' + Math.round(framerate) + 'fps, ' + Math.round(this.localPlayer.latency) + 'ms'); - linesToRender.push('connected to: ' + this.networking.getServerInfo().name); + //linesToRender.push('connected to: ' + this.networking.getServerInfo().name); //linesToRender.push('players: ' + this.networking.getServerInfo().currentPlayers + '/' + this.networking.getServerInfo().maxPlayers); - linesToRender.push('map: ' + this.networking.getServerInfo().mapName); - linesToRender.push('mode: ' + this.networking.getServerInfo().gameMode); + //linesToRender.push('map: ' + this.networking.getServerInfo().mapName); + //linesToRender.push('mode: ' + this.networking.getServerInfo().gameMode); //linesToRender.push('serverVersion: ' + this.networking.getServerInfo().version); //linesToRender.push('tickRate: ' + this.networking.getServerInfo().tickRate); //linesToRender.push('playerMaxHealth: ' + this.networking.getServerInfo().playerMaxHealth); //linesToRender.push('health: ' + this.localPlayer.health); + for(const msg of this.localPlayer.gameMsgs2) + linesToRender.push(msg) + //linesToRender.push('routineTime: ' + this.lastRoutineMs + 'ms'); for (let i = 0; i < linesToRender.length; i++) { diff --git a/src/server/DataValidator.ts b/src/server/DataValidator.ts index 13ceab2d..153bd05f 100644 --- a/src/server/DataValidator.ts +++ b/src/server/DataValidator.ts @@ -52,6 +52,7 @@ export class DataValidator { idLastDamagedBy: z.number().optional(), playerSpectating: z.number(), gameMsgs: z.array(z.string()), + gameMsgs2: z.array(z.string()), }).strict(); private static chatMsgSchema = z.object({ diff --git a/src/server/models/Player.ts b/src/server/models/Player.ts index 14ee3899..355279ad 100644 --- a/src/server/models/Player.ts +++ b/src/server/models/Player.ts @@ -22,4 +22,6 @@ export interface Player { lastDamageTime?: number; inventory: number[]; idLastDamagedBy?: number; + gameMsgs:string[]; + gameMsgs2:string[]; } \ No newline at end of file From 97e65ec000e83a4ea0ff3be34185e5670982a201 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 01:32:12 -0500 Subject: [PATCH 08/18] spectator fixes --- src/client/core/RemotePlayerRenderer.ts | 41 +++++++++++++++++++++---- src/client/core/Renderer.ts | 2 +- src/client/input/InputHandler.ts | 8 +++++ src/server/models/Player.ts | 2 ++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/client/core/RemotePlayerRenderer.ts b/src/client/core/RemotePlayerRenderer.ts index 0729deef..d1805869 100644 --- a/src/client/core/RemotePlayerRenderer.ts +++ b/src/client/core/RemotePlayerRenderer.ts @@ -125,14 +125,44 @@ export class RemotePlayerRenderer { const remotePlayerData: RemotePlayer[] = this.networking.getRemotePlayerData(); const localPlayerId = this.localPlayer.id; + // First, remove all players that should be hidden + this.playersToRender = this.playersToRender.filter((player) => { + const remotePlayer = remotePlayerData.find(rp => rp.id === player.id); + const shouldHide = + !remotePlayer || // Player no longer exists + remotePlayer.id === localPlayerId || // Is local player + remotePlayer.id === this.localPlayer.playerSpectating || // Is being spectated + remotePlayer.playerSpectating !== -1; // Is spectating someone + + if (shouldHide) { + // Remove the player's objects from scenes + this.entityScene.remove(player.object); + this.entityScene.remove(player.nameLabel); + this.sphereScene.remove(player.sphere); + + // Clean up associated data + delete this.groundTruthPositions[player.id]; + delete this.isAnimating[player.id]; + delete this.animationPhase[player.id]; + delete this.previousVelocity[player.id]; + delete this.lastRunningYOffset[player.id]; + } + + return !shouldHide; + }); + + // Then, update or add remaining valid players remotePlayerData.forEach((remotePlayer) => { - if (remotePlayer.id === localPlayerId) return; - if(remotePlayer.id === this.localPlayer.playerSpectating) return; - if(remotePlayer.playerSpectating !== -1) return; + // Skip if player should be hidden + if (remotePlayer.id === localPlayerId || + remotePlayer.id === this.localPlayer.playerSpectating || + remotePlayer.playerSpectating !== -1) { + return; + } const playerDataWithQuaternion: RemotePlayerData = { ...remotePlayer, - quaternion: remotePlayer.quaternion || [0, 0, 0, 1], // Provide default quaternion if missing + quaternion: remotePlayer.quaternion || [0, 0, 0, 1], }; const existingPlayer = this.playersToRender.find((player) => player.id === remotePlayer.id); @@ -142,10 +172,9 @@ export class RemotePlayerRenderer { this.addNewPlayer(playerDataWithQuaternion); } }); - - this.removeInactivePlayers(remotePlayerData); } + private updatePlayerPosition(playerObject: THREE.Object3D, playerSphere: THREE.Object3D, remotePlayerData: RemotePlayerData): void { const velocity = Math.sqrt( Math.pow(remotePlayerData.velocity.x, 2) + diff --git a/src/client/core/Renderer.ts b/src/client/core/Renderer.ts index 4fa1f664..58fd3b8d 100644 --- a/src/client/core/Renderer.ts +++ b/src/client/core/Renderer.ts @@ -272,7 +272,7 @@ export class Renderer { const vel = Math.sqrt(Math.pow(this.localPlayer.inputVelocity.x,2) + Math.pow(this.localPlayer.inputVelocity.z,2)) - if(vel == 0 || this.collisionManager.isPlayerInAir()) { + if(vel == 0 || this.collisionManager.isPlayerInAir() || this.localPlayer.playerSpectating !== -1) { this.bobCycle = 0; } else { this.bobCycle += this.deltaTime * 4.8 * vel; diff --git a/src/client/input/InputHandler.ts b/src/client/input/InputHandler.ts index 1b29d980..b50c4c3b 100644 --- a/src/client/input/InputHandler.ts +++ b/src/client/input/InputHandler.ts @@ -181,6 +181,14 @@ export class InputHandler { if (this.leftMouseDown || this.touchButtons.includes(0)) this.shoot = true; if (this.rightMouseDown) this.aim = true; + if(this.localPlayer.playerSpectating !== -1) { + this.inputX = 0; + this.inputZ = 0; + this.jump = false; + this.shoot = false; + this.aim = false; + } + } public getKey(key: string):boolean { diff --git a/src/server/models/Player.ts b/src/server/models/Player.ts index 355279ad..3f63639f 100644 --- a/src/server/models/Player.ts +++ b/src/server/models/Player.ts @@ -22,6 +22,8 @@ export interface Player { lastDamageTime?: number; inventory: number[]; idLastDamagedBy?: number; + playerSpectating:number; gameMsgs:string[]; gameMsgs2:string[]; + } \ No newline at end of file From a966d7d25f20d85bd0a99eb2e673619bbde705a4 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 01:32:41 -0500 Subject: [PATCH 09/18] feat: FFA gamemode --- src/server/GameEngine.ts | 69 +++++++++++++++++++--- src/server/GameServer.ts | 3 + src/server/gamemodes/FFAGamemode.ts | 72 +++++++++++++++++++++++ src/server/gamemodes/Gamemode.ts | 24 ++++++++ src/server/managers/DamageSystem.ts | 12 +++- src/server/managers/GameMsgManager.ts | 4 ++ src/server/managers/ItemManager.ts | 7 ++- src/server/managers/PlayerManager.ts | 84 ++++++++++++++++++++------- src/server/models/PlayerExtras.ts | 4 ++ 9 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 src/server/gamemodes/FFAGamemode.ts create mode 100644 src/server/gamemodes/Gamemode.ts create mode 100644 src/server/managers/GameMsgManager.ts create mode 100644 src/server/models/PlayerExtras.ts diff --git a/src/server/GameEngine.ts b/src/server/GameEngine.ts index 948b737a..a0fbabcf 100644 --- a/src/server/GameEngine.ts +++ b/src/server/GameEngine.ts @@ -7,16 +7,20 @@ import config from "./config.ts"; import { Vector3 } from "./models/Vector3.ts"; import { ServerInfo } from "./models/ServerInfo.ts"; import {DataValidator} from "./DataValidator.ts"; +import {Player} from "./models/Player.ts"; +import {Gamemode} from "./gamemodes/Gamemode.ts"; +import {FFAGamemode} from "./gamemodes/FFAGamemode.ts"; export class GameEngine { private lastPlayerTickTimestamp: number = Date.now() / 1000; private lastItemUpdateTimestamp: number = Date.now() / 1000; - private playerUpdateSinceLastEmit: boolean = false; + public playerUpdateSinceLastEmit: boolean = false; private itemUpdateSinceLastEmit: boolean = false; private serverInfo: ServerInfo = new ServerInfo(); + public gamemode: Gamemode | false = false; constructor( - private playerManager: PlayerManager, + public playerManager: PlayerManager, private itemManager: ItemManager, private chatManager: ChatManager, private damageSystem: DamageSystem, @@ -27,6 +31,7 @@ export class GameEngine { setInterval(() => this.serverTick(), 1000 / config.server.tickRate); setInterval(() => this.periodicCleanup(), config.server.cleanupInterval); setInterval(() => this.emitServerInfo(), config.server.cleanupInterval); + this.initGamemode(); } private serverTick() { @@ -34,6 +39,7 @@ export class GameEngine { const currentTime = Date.now() / 1000; this.playerManager.regenerateHealth(); this.itemManager.tick(currentTime); + if(this.gamemode) this.gamemode.tick(); // Emit player data if there are updates or enough time has passed if (this.playerUpdateSinceLastEmit || currentTime - this.lastPlayerTickTimestamp > 1 / config.server.tickRate) { @@ -42,7 +48,7 @@ export class GameEngine { this.playerUpdateSinceLastEmit = false; this.lastPlayerTickTimestamp = currentTime; } catch (err) { - console.error('Error emitting player data:', err); + console.error('⚠ error emitting player data:', err); } } @@ -52,15 +58,18 @@ export class GameEngine { this.io.emit('worldItemData', this.itemManager.getAllItems()); this.itemUpdateSinceLastEmit = false; } catch (err) { - console.error('Error emitting item data:', err); + console.error('⚠ error emitting item data:', err); } } } catch (error) { - console.error('Error in serverTick:', error); + console.error('⚠ error in serverTick:', error); } } - private periodicCleanup() { + public periodicCleanup() { + // for(const player of this.playerManager.getAllPlayers()) + // console.log(player.gameMsgs) + try { const currentTime = Date.now() / 1000; const players = this.playerManager.getAllPlayers(); @@ -74,16 +83,33 @@ export class GameEngine { } if (player.health <= 0) { - this.playerManager.respawnPlayer(player); + if(this.gamemode) this.gamemode.onPlayerDeath(player); //gamemode now handles } if ((player.updateTimestamp || 0) + config.player.disconnectTime < currentTime) { + if(this.gamemode) this.gamemode.onPlayerDisconnect(player); console.log(`🟠 ${player.name}(${player.id}) left`); this.chatManager.broadcastChat(`${player.name} left`); this.playerManager.removePlayer(player.id); } }); + const playerData = this.playerManager.getAllPlayerData(); + playerData.forEach(playerData => { + for(let i = 0; i playerData.extras.gameMsgsTimeouts[i] && playerData.extras.gameMsgsTimeouts[i] !== -1){ + + playerData.player.gameMsgs[i] = ''; + playerData.extras.gameMsgsTimeouts[i] = -1; + this.playerUpdateSinceLastEmit = true; + + } + } + }); + + + const items = this.itemManager.getAllItems(); items.forEach(item => { if (item.vector.y < -5) { @@ -92,7 +118,7 @@ export class GameEngine { } }); } catch (error) { - console.error('Error in periodicCleanup:', error); + console.error('⚠ error in periodicCleanup:', error); } } @@ -102,4 +128,31 @@ export class GameEngine { this.serverInfo.currentPlayers = this.playerManager.getAllPlayers().length; this.io.emit('serverInfo', this.serverInfo); } + + + + public setGameMessage(player:Player, message:string, index:number, timeout?:number){ + player.gameMsgs[index] = message; + const extras = this.playerManager.getPlayerExtrasById(player.id); + if(timeout && timeout > 0 && extras){ + extras.gameMsgsTimeouts[index] = Date.now()/1000 + timeout; + } + } + + + + private initGamemode(){ + try { + switch(config.game.mode){ + case 'ffa': + this.gamemode = new FFAGamemode(this); + break; + default: + console.log('⚠️ invalid gamemode supplied (check your config!)', config.game.mode); + break; + } + } catch (error) { + console.error('⚠ error initializing gamemode:', error); + } + } } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b00e49e1..f597bc4c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -42,6 +42,8 @@ export class GameServer { this.damageSystem, this.io ); + this.itemManager.setGamemode(this.gameEngine.gamemode); + this.damageSystem.setGameEngine(this.gameEngine); this.gameEngine.start(); DataValidator.updateServerVersion(); @@ -61,6 +63,7 @@ export class GameServer { try { const result = this.playerManager.addOrUpdatePlayer(data); if (result.isNew && result.player) { + if(this.gameEngine.gamemode) this.gameEngine.gamemode.onPlayerConnect(result.player); this.chatManager.broadcastChat(`${result.player.name} joined`); console.log(`🟢 ${result.player.name}(${result.player.id}) joined`); this.gameEngine.emitServerInfo(); diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts new file mode 100644 index 00000000..5055c4b9 --- /dev/null +++ b/src/server/gamemodes/FFAGamemode.ts @@ -0,0 +1,72 @@ +import {GameEngine} from "../GameEngine.ts"; +import {Player} from "../models/Player.ts"; +import {Gamemode} from "./Gamemode.ts"; +import config from "../config.ts"; + + +export class FFAGamemode extends Gamemode { + + private spectateTimeouts: Map = new Map(); + constructor(gameEngine: GameEngine) { + super(gameEngine); + this.init(); + } + + init(): void { + console.log('🐙 FFA Gamemode initialized'); + } + + tick(): void { + const currentTime = Date.now() / 1000; + for (const [player, timestamp] of this.spectateTimeouts) { + if (currentTime - timestamp > 10) { + this.gameEngine.playerManager.respawnPlayer(player); + player.playerSpectating = -1; + this.spectateTimeouts.delete(player); + } + player.health = config.player.maxHealth; + } + } + + onPeriodicCleanup(): void { + + } + + onPlayerConnect(player: Player): void { + } + + onPlayerDisconnect(player: Player): void { + } + + onPlayerDeath(player: Player): void { + + if(player.lastDamageTime && player.idLastDamagedBy && + Date.now()/1000 - player.lastDamageTime < 5 ){ + const killer = this.gameEngine.playerManager.getPlayerById(player.idLastDamagedBy); + if(killer){ + player.playerSpectating = player.idLastDamagedBy; + player.health = config.player.maxHealth; + // player.gameMsgs[0] = '&cspectating ' + killer.name; + // player.gameMsgs[1] = '&crespawn in 10 seconds'; + // killer.gameMsgs[0] = '&akilled ' + player.name; + + this.gameEngine.setGameMessage(player, '&cspectating ' + killer.name, 0, 8); + this.gameEngine.setGameMessage(player, '&crespawn in 10 seconds', 1, 8); + this.gameEngine.setGameMessage(killer, '&akilled ' + player.name, 0, 5); + this.spectateTimeouts.set(player, Date.now()/1000); + this.gameEngine.playerUpdateSinceLastEmit = true; + + + }else{ + this.gameEngine.playerManager.respawnPlayer(player); + } + }else{ + this.gameEngine.playerManager.respawnPlayer(player); + } + } + + onItemPickup(player: Player): void { + } + + +} diff --git a/src/server/gamemodes/Gamemode.ts b/src/server/gamemodes/Gamemode.ts new file mode 100644 index 00000000..9df17da8 --- /dev/null +++ b/src/server/gamemodes/Gamemode.ts @@ -0,0 +1,24 @@ +import {GameEngine} from "../GameEngine.ts"; +import {Player} from "../models/Player.ts"; + +export abstract class Gamemode { + + constructor(protected gameEngine: GameEngine) {} + + abstract init(): void; + + abstract tick(): void; + + abstract onPeriodicCleanup(): void; + + abstract onPlayerConnect(player:Player): void; + + abstract onPlayerDisconnect(player:Player): void; + + abstract onPlayerDeath(player:Player): void; + + abstract onItemPickup(player:Player): void; + + + +} \ No newline at end of file diff --git a/src/server/managers/DamageSystem.ts b/src/server/managers/DamageSystem.ts index 3d1d40bc..d079ac1f 100644 --- a/src/server/managers/DamageSystem.ts +++ b/src/server/managers/DamageSystem.ts @@ -3,12 +3,17 @@ import { ChatManager } from "./ChatManager.ts"; import { DamageRequest } from "../models/DamageRequest.ts"; import { DataValidator } from "../DataValidator.ts"; import { Vector3 } from "../models/Vector3.ts"; +import {GameEngine} from "../GameEngine.ts"; export class DamageSystem { constructor( private playerManager: PlayerManager, - private chatManager: ChatManager + private chatManager: ChatManager, ) {} + private gameEngine!:GameEngine; + public setGameEngine(gameEngine:GameEngine){ + this.gameEngine = gameEngine; + } handleDamageRequest(data: DamageRequest) { const validationResult = DataValidator.validateDamageRequest(data); @@ -51,9 +56,10 @@ export class DamageSystem { if (targetPlayer.health <= 0) { const killerName = localPlayer.name; const killedName = targetPlayer.name; - this.chatManager.broadcastChat(`${killerName} &fkilled ${killedName}`); + //this.chatManager.broadcastChat(`${killerName} &fkilled ${killedName}`); console.log(`💔 ${killerName} killed ${killedName}`); - this.playerManager.respawnPlayer(targetPlayer); + //this.playerManager.respawnPlayer(targetPlayer); + this.gameEngine.periodicCleanup(); } // Update player data diff --git a/src/server/managers/GameMsgManager.ts b/src/server/managers/GameMsgManager.ts new file mode 100644 index 00000000..96ab7fa3 --- /dev/null +++ b/src/server/managers/GameMsgManager.ts @@ -0,0 +1,4 @@ +export class GameMsgManager { + constructor() { + } +} \ No newline at end of file diff --git a/src/server/managers/ItemManager.ts b/src/server/managers/ItemManager.ts index bfbab32b..1a1b5e73 100644 --- a/src/server/managers/ItemManager.ts +++ b/src/server/managers/ItemManager.ts @@ -4,14 +4,18 @@ import { Vector3 } from '../models/Vector3.ts'; import config from "../config.ts"; import {PlayerManager} from "./PlayerManager.ts"; import {ChatManager} from "./ChatManager.ts"; +import {Gamemode} from "../gamemodes/Gamemode.ts"; export class ItemManager { private worldItems: WorldItem[] = []; private lastItemCreationTimestamp: number = Date.now() / 1000; private itemUpdateFlag: boolean = false; + private gamemode: Gamemode | false = false; - constructor(private mapData: MapData, private playerManager:PlayerManager, private chatManager:ChatManager) {} + constructor(private mapData: MapData, public playerManager:PlayerManager, private chatManager:ChatManager) {} + + public setGamemode(gamemode: Gamemode | false) {this.gamemode = gamemode;} tick(currentTime: number) { this.checkForPickups(); @@ -83,6 +87,7 @@ export class ItemManager { } if (shouldPickup) { + if(this.gamemode) this.gamemode.onItemPickup(player); this.worldItems.splice(itemIndex, 1); this.itemUpdateFlag = true; } diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index 2a6f252d..5dd3d0e2 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -6,9 +6,15 @@ import { MapData } from "../models/MapData.ts"; import config from "../config.ts"; import { WorldItem } from "../models/WorldItem.ts"; import { ItemManager } from "./ItemManager.ts"; +import {PlayerExtras} from "../models/PlayerExtras.ts"; + +interface PlayerData { + player: Player; + extras: PlayerExtras; +} export class PlayerManager { - private players: Map = new Map(); + private players: Map = new Map(); private mapData: MapData; private itemManager!: ItemManager; @@ -23,43 +29,56 @@ export class PlayerManager { addOrUpdatePlayer(data: Player): { isNew: boolean; player?: Player } { const { error } = DataValidator.validatePlayerData(data); if (error) { - //throw new Error(`Invalid player data: ${error.message}`); throw new Error(`⚠️ invalid player data `); - // console.log('⚠️ invalid player data recieved') } - const existingPlayer = this.players.get(data.id); + const existingPlayerData = this.players.get(data.id); if (data.name.length < 1) data.name = 'possum' + data.id.toString().substring(0,3); if(data.chatMsg.startsWith('/admin ')) data.chatMsg = '/admin ' + data.chatMsg.substring(7).replace(/./g, '*'); if(data.chatMsg.startsWith('>')) data.chatMsg = '&2'+data.chatMsg; if(!data.chatMsg.startsWith('&f')) data.chatMsg = '&f'+data.chatMsg; - if (existingPlayer) { + + if (existingPlayerData) { // Handle forced acknowledgment - if (existingPlayer.forced && !data.forcedAcknowledged) { + if (existingPlayerData.player.forced && !data.forcedAcknowledged) { return { isNew: false }; } - if (existingPlayer.forced && data.forcedAcknowledged) { - existingPlayer.forced = false; - //console.log(`🟢 ${data.name}(${data.id}) acknowledged force`); + if (existingPlayerData.player.forced && data.forcedAcknowledged) { + existingPlayerData.player.forced = false; } // Update existing player, preserving certain fields - data.health = existingPlayer.health; - data.inventory = existingPlayer.inventory; - data.lastDamageTime = existingPlayer.lastDamageTime; + data.health = existingPlayerData.player.health; + data.inventory = existingPlayerData.player.inventory; + data.lastDamageTime = existingPlayerData.player.lastDamageTime; + data.gameMsgs = existingPlayerData.player.gameMsgs; + data.gameMsgs2 = existingPlayerData.player.gameMsgs2; + data.playerSpectating = existingPlayerData.player.playerSpectating; data.updateTimestamp = Date.now() / 1000; - this.players.set(data.id, data); + const updatedData: PlayerData = { + player: data, + extras: existingPlayerData.extras + }; + this.players.set(data.id, updatedData); return { isNew: false }; } else { - // New playera + // New player data.inventory = [...config.player.baseInventory]; const spawnPoint = this.getRandomSpawnPoint(); data.position = spawnPoint.vec; data.health = config.player.maxHealth; + data.gameMsgs = []; + data.gameMsgs2 = []; + data.playerSpectating = -1; data.lookQuaternion = [spawnPoint.quaternion.x, spawnPoint.quaternion.y, spawnPoint.quaternion.z, spawnPoint.quaternion.w]; data.forced = true; - this.players.set(data.id, data); + + const newPlayerData: PlayerData = { + player: data, + extras: { gameMsgsTimeouts: []} + }; + this.players.set(data.id, newPlayerData); this.itemManager.triggerUpdateFlag(); return { isNew: true, player: data }; @@ -71,14 +90,32 @@ export class PlayerManager { } getAllPlayers(): Player[] { - return Array.from(this.players.values()); + return Array.from(this.players.values()).map(playerData => playerData.player); } getPlayerById(playerId: number): Player | undefined { + const playerData = this.players.get(playerId); + return playerData?.player; + } + + getPlayerDataById(playerId: number): PlayerData | undefined { return this.players.get(playerId); } + getPlayerExtrasById(playerId: number): PlayerExtras | undefined { + const playerData = this.players.get(playerId); + return playerData?.extras; + } + + getAllPlayerData(): PlayerData[] { + return Array.from(this.players.values()); + } + + respawnPlayer(player: Player) { + const playerData = this.players.get(player.id); + if (!playerData) return; + const spawnPoint = this.getRandomSpawnPoint(); for(let i = 0; i < player.inventory.length; i++){ this.itemManager.pushItem(new WorldItem(player.position, player.inventory[i])); @@ -90,15 +127,21 @@ export class PlayerManager { player.gravity = 0; player.velocity = new Vector3(0, 0, 0); player.forced = true; - this.players.set(player.id, player); + + const updatedPlayerData: PlayerData = { + player: player, + extras: playerData.extras + }; + this.players.set(player.id, updatedPlayerData); } regenerateHealth() { const currentTime = Date.now() / 1000; - for (const player of this.players.values()) { + for (const playerData of this.players.values()) { + const player = playerData.player; const lastDamage = player.lastDamageTime ?? 0; if (player.health < config.player.maxHealth && (lastDamage + config.health.regenDelay < currentTime)) { - player.health += config.health.regenRate / config.server.tickRate; // Adjusted per tick + player.health += config.health.regenRate / config.server.tickRate; if (player.health > config.player.maxHealth) player.health = config.player.maxHealth; } } @@ -106,7 +149,6 @@ export class PlayerManager { private getRandomSpawnPoint(): { vec: Vector3; quaternion: Quaternion } { if (!this.mapData) { - // Default spawn point if map data is unavailable return { vec: new Vector3(2, 1, 0), quaternion: new Quaternion(0, 0, 0, 1) }; } @@ -114,4 +156,4 @@ export class PlayerManager { const respawnPoint = this.mapData.respawnPoints[randomIndex]; return { vec: respawnPoint.position, quaternion: respawnPoint.quaternion }; } -} \ No newline at end of file +} diff --git a/src/server/models/PlayerExtras.ts b/src/server/models/PlayerExtras.ts new file mode 100644 index 00000000..7135389d --- /dev/null +++ b/src/server/models/PlayerExtras.ts @@ -0,0 +1,4 @@ +export interface PlayerExtras { +gameMsgsTimeouts: number[]; + +} \ No newline at end of file From ce077f233e31ab9dd945baf24edcd0e8ca268e74 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 01:59:42 -0500 Subject: [PATCH 10/18] quick update for second counters in game msg --- src/client/ui/ChatOverlay.ts | 50 +++++++++++++++++++---------- src/server/gamemodes/FFAGamemode.ts | 5 +++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index a977f650..81a7953b 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -502,32 +502,48 @@ export class ChatOverlay { private renderGameText() { const ctx = this.chatCtx; ctx.font = '8px Tiny5'; - - // Calculate vertical center position (adjust as needed) const centerY = this.chatCanvas.height / 2 + 48; for (let i = 0; i < this.maxMessagesOnScreen; i++) { const line = this.lines[i]; - if (line.currentMessage) { - let visibleText = line.currentMessage.message; - - // Determine the visible portion based on animation state - if (line.currentMessage.state === 'animatingIn' || line.currentMessage.state === 'animatingOut') { - visibleText = this.getVisibleText(line.currentMessage.message, line.currentMessage.state, line.currentMessage.animationProgress); - } + if (!line.currentMessage) continue; + + let visibleText = line.currentMessage.message; + + // Check if we should skip animation + const shouldSkipAnimation = + line.currentMessage.state === 'animatingOut' && + line.pendingMessage !== null && + line.currentMessage.message.includes('seconds') && + line.pendingMessage.includes('seconds'); + + if (shouldSkipAnimation && line.pendingMessage) { + // Directly update to the pending message + line.currentMessage = { + id: this.generateUniqueId(), + message: line.pendingMessage, + state: 'idle', + animationProgress: 1, + timestamp: Date.now() / 1000, + }; + line.pendingMessage = null; + visibleText = line.currentMessage.message; + } else if (line.currentMessage.state === 'animatingIn' || line.currentMessage.state === 'animatingOut') { + visibleText = this.getVisibleText(line.currentMessage.message, line.currentMessage.state, line.currentMessage.animationProgress); + } - // Calculate text dimensions - const plainMessage = visibleText.replace(/&[0-9a-f]/g, ''); // Remove color codes for measurement - const textWidth = this.chatCtx.measureText(plainMessage).width; - const x = Math.floor((this.screenWidth - textWidth) / 2); - const y = Math.floor(centerY + (i * 10)); + // Calculate text dimensions and render + const plainMessage = visibleText.replace(/&[0-9a-f]/g, ''); + const textWidth = this.chatCtx.measureText(plainMessage).width; + const x = Math.floor((this.screenWidth - textWidth) / 2); + const y = Math.floor(centerY + (i * 10)); - // Render the text - this.renderPixelText(visibleText, x, y, 'white'); - } + this.renderPixelText(visibleText, x, y, 'white'); } } + + private getVisibleText(message: string, state: 'animatingIn' | 'animatingOut' | 'idle', progress: number): string { if (state === 'idle') { return message; diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 5055c4b9..5f096498 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -23,8 +23,13 @@ export class FFAGamemode extends Gamemode { this.gameEngine.playerManager.respawnPlayer(player); player.playerSpectating = -1; this.spectateTimeouts.delete(player); + this.gameEngine.setGameMessage(player, '', 0); + }else{ + this.gameEngine.setGameMessage(player, '&crespawn in ' + Math.floor(10 + timestamp - currentTime)+ ' seconds', 1, 0.5); } player.health = config.player.maxHealth; + + } } From ef180ac9d8a529485f0c7d48762a157bfe55700d Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 02:02:56 -0500 Subject: [PATCH 11/18] style: let me leave these here pls thx --- src/server/gamemodes/FFAGamemode.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 5f096498..d8c45d53 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -37,10 +37,10 @@ export class FFAGamemode extends Gamemode { } - onPlayerConnect(player: Player): void { + onPlayerConnect(_player: Player): void { } - onPlayerDisconnect(player: Player): void { + onPlayerDisconnect(_player: Player): void { } onPlayerDeath(player: Player): void { @@ -70,7 +70,7 @@ export class FFAGamemode extends Gamemode { } } - onItemPickup(player: Player): void { + onItemPickup(_player: Player): void { } From 72d5c32827838b63c9653907bef8c3521566e159 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 17:29:16 -0500 Subject: [PATCH 12/18] spectate your killer's killer (and so on) --- src/server/gamemodes/FFAGamemode.ts | 40 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index d8c45d53..2cf7ec40 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -44,32 +44,42 @@ export class FFAGamemode extends Gamemode { } onPlayerDeath(player: Player): void { - - if(player.lastDamageTime && player.idLastDamagedBy && - Date.now()/1000 - player.lastDamageTime < 5 ){ + if (player.lastDamageTime && player.idLastDamagedBy && + Date.now() / 1000 - player.lastDamageTime < 5) { const killer = this.gameEngine.playerManager.getPlayerById(player.idLastDamagedBy); - if(killer){ + if (killer) { + // Redirect spectators of the dead player to the killer + 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); + } + } + + // Set the dead player to spectate the killer player.playerSpectating = player.idLastDamagedBy; player.health = config.player.maxHealth; - // player.gameMsgs[0] = '&cspectating ' + killer.name; - // player.gameMsgs[1] = '&crespawn in 10 seconds'; - // killer.gameMsgs[0] = '&akilled ' + player.name; - - this.gameEngine.setGameMessage(player, '&cspectating ' + killer.name, 0, 8); - this.gameEngine.setGameMessage(player, '&crespawn in 10 seconds', 1, 8); + player.inventory = []; + this.gameEngine.setGameMessage(player, '&cspectating ' + killer.name, 0, 10); + this.gameEngine.setGameMessage(player, '&crespawn in 10 seconds', 1, 2); this.gameEngine.setGameMessage(killer, '&akilled ' + player.name, 0, 5); - this.spectateTimeouts.set(player, Date.now()/1000); - this.gameEngine.playerUpdateSinceLastEmit = true; - - }else{ + // Add the dead player to the spectate timeout list + this.spectateTimeouts.set(player, Date.now() / 1000); + this.gameEngine.playerUpdateSinceLastEmit = true; + } else { + // Respawn the player if no killer is found this.gameEngine.playerManager.respawnPlayer(player); } - }else{ + } else { + // Respawn the player if no valid killer is found this.gameEngine.playerManager.respawnPlayer(player); } } + + + onItemPickup(_player: Player): void { } From ff0fbef7aa8363e493ab1214e3b9200db6b8e0e9 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:15:34 -0500 Subject: [PATCH 13/18] feat: FFA killstreak announcements --- src/client/ui/ChatOverlay.ts | 33 +++++++++++++++++++++++++--- src/server/GameEngine.ts | 3 ++- src/server/gamemodes/FFAGamemode.ts | 33 ++++++++++++++++++++++++++++ src/server/managers/PlayerManager.ts | 2 +- src/server/models/PlayerExtras.ts | 8 +++++-- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index 81a7953b..d9c00925 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -532,9 +532,8 @@ export class ChatOverlay { visibleText = this.getVisibleText(line.currentMessage.message, line.currentMessage.state, line.currentMessage.animationProgress); } - // Calculate text dimensions and render - const plainMessage = visibleText.replace(/&[0-9a-f]/g, ''); - const textWidth = this.chatCtx.measureText(plainMessage).width; + // Calculate the actual width of the rendered text, including color codes + const textWidth = this.getRenderedTextWidth(visibleText); const x = Math.floor((this.screenWidth - textWidth) / 2); const y = Math.floor(centerY + (i * 10)); @@ -543,6 +542,34 @@ export class ChatOverlay { } + private getRenderedTextWidth(text: string): number { + let totalWidth = 0; + let currentSegment = ''; + const ctx = this.chatCtx; + + for (let i = 0; i < text.length; i++) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { + // Measure the current segment before switching color + if (currentSegment) { + totalWidth += ctx.measureText(currentSegment).width; + currentSegment = ''; + } + i++; // Skip the color code character + } else { + currentSegment += text[i]; + } + } + + // Measure the last segment + if (currentSegment) { + totalWidth += ctx.measureText(currentSegment).width; + } + + return totalWidth; + } + + + private getVisibleText(message: string, state: 'animatingIn' | 'animatingOut' | 'idle', progress: number): string { if (state === 'idle') { diff --git a/src/server/GameEngine.ts b/src/server/GameEngine.ts index a0fbabcf..f6cd93b1 100644 --- a/src/server/GameEngine.ts +++ b/src/server/GameEngine.ts @@ -22,7 +22,7 @@ export class GameEngine { constructor( public playerManager: PlayerManager, private itemManager: ItemManager, - private chatManager: ChatManager, + public chatManager: ChatManager, private damageSystem: DamageSystem, private io: Server ) {} @@ -84,6 +84,7 @@ export class GameEngine { if (player.health <= 0) { if(this.gamemode) this.gamemode.onPlayerDeath(player); //gamemode now handles + else this.playerManager.respawnPlayer(player); } if ((player.updateTimestamp || 0) + config.player.disconnectTime < currentTime) { diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 2cf7ec40..03a8ec33 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -44,6 +44,9 @@ export class FFAGamemode extends Gamemode { } onPlayerDeath(player: Player): void { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if(extras) {extras.deaths++; extras.killStreak = 0;} + if (player.lastDamageTime && player.idLastDamagedBy && Date.now() / 1000 - player.lastDamageTime < 5) { const killer = this.gameEngine.playerManager.getPlayerById(player.idLastDamagedBy); @@ -67,6 +70,10 @@ export class FFAGamemode extends Gamemode { // Add the dead player to the spectate timeout list this.spectateTimeouts.set(player, Date.now() / 1000); this.gameEngine.playerUpdateSinceLastEmit = true; + + this.onPlayerKill(killer); + + } else { // Respawn the player if no killer is found this.gameEngine.playerManager.respawnPlayer(player); @@ -75,6 +82,32 @@ export class FFAGamemode extends Gamemode { // Respawn the player if no valid killer is found this.gameEngine.playerManager.respawnPlayer(player); } + + + } + + onPlayerKill(player:Player){ + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + console.log('kill') + if(extras) { + console.log('kill2') + extras.kills++; + extras.killStreak++; + console.log(extras.killStreak) + + + let colorCode = '&a'; + if(extras.killStreak >= 5) colorCode = '&b'; + if(extras.killStreak >= 10) colorCode = '&6'; + if(extras.killStreak >= 15) colorCode = '&g'; + + if(extras.killStreak >= 3) + this.gameEngine.setGameMessage(player, colorCode+extras.killStreak+' kill streak', 1, 5); + if(extras.killStreak >= 5) + this.gameEngine.chatManager.broadcastChat(colorCode+player.name+' is on a '+extras.killStreak+' kill streak'); + + + } } diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index 5dd3d0e2..a21d3cdf 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -76,7 +76,7 @@ export class PlayerManager { const newPlayerData: PlayerData = { player: data, - extras: { gameMsgsTimeouts: []} + extras: new PlayerExtras() }; this.players.set(data.id, newPlayerData); this.itemManager.triggerUpdateFlag(); diff --git a/src/server/models/PlayerExtras.ts b/src/server/models/PlayerExtras.ts index 7135389d..610cf4e6 100644 --- a/src/server/models/PlayerExtras.ts +++ b/src/server/models/PlayerExtras.ts @@ -1,4 +1,8 @@ -export interface PlayerExtras { -gameMsgsTimeouts: number[]; +export class PlayerExtras { +gameMsgsTimeouts: number[] = []; +kills: number = 0; +deaths: number = 0; +killStreak: number = 0; +points: number = 0; } \ No newline at end of file From 777710ea94acae6ffc871aabb5a8bcb6b952a0a8 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:33:27 -0500 Subject: [PATCH 14/18] feat: show kills and deaths in FFA --- src/server/GameEngine.ts | 3 +++ src/server/gamemodes/FFAGamemode.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/server/GameEngine.ts b/src/server/GameEngine.ts index f6cd93b1..486e9666 100644 --- a/src/server/GameEngine.ts +++ b/src/server/GameEngine.ts @@ -118,6 +118,9 @@ export class GameEngine { this.itemUpdateSinceLastEmit = true; } }); + + if(this.gamemode) this.gamemode.onPeriodicCleanup(); + } catch (error) { console.error('⚠ error in periodicCleanup:', error); } diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 03a8ec33..d11276fc 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -34,6 +34,12 @@ export class FFAGamemode extends Gamemode { } onPeriodicCleanup(): void { + // 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']; + } + } From d2874b1c6e804814e2b19948440c499918f2166a Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:39:42 -0500 Subject: [PATCH 15/18] fix: players can't pick up items as spectators or dead people --- src/server/gamemodes/FFAGamemode.ts | 8 +++----- src/server/managers/ItemManager.ts | 2 ++ src/server/managers/PlayerManager.ts | 11 +++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index d11276fc..70f61183 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -65,10 +65,12 @@ export class FFAGamemode extends Gamemode { } } + // Set the dead player to spectate the killer player.playerSpectating = player.idLastDamagedBy; player.health = config.player.maxHealth; - player.inventory = []; + 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(killer, '&akilled ' + player.name, 0, 5); @@ -94,13 +96,9 @@ export class FFAGamemode extends Gamemode { onPlayerKill(player:Player){ const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); - console.log('kill') if(extras) { - console.log('kill2') extras.kills++; extras.killStreak++; - console.log(extras.killStreak) - let colorCode = '&a'; if(extras.killStreak >= 5) colorCode = '&b'; diff --git a/src/server/managers/ItemManager.ts b/src/server/managers/ItemManager.ts index 1a1b5e73..3b75d9bb 100644 --- a/src/server/managers/ItemManager.ts +++ b/src/server/managers/ItemManager.ts @@ -52,6 +52,8 @@ export class ItemManager { 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 => Vector3.distanceTo(player.position, item.vector) < 0.5 ); diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index a21d3cdf..5e041515 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -111,16 +111,19 @@ export class PlayerManager { return Array.from(this.players.values()); } + public dropAllItems(player:Player){ + for(let i = 0; i < player.inventory.length; i++){ + this.itemManager.pushItem(new WorldItem(player.position, player.inventory[i])); + } + player.inventory = []; + + } respawnPlayer(player: Player) { const playerData = this.players.get(player.id); if (!playerData) return; const spawnPoint = this.getRandomSpawnPoint(); - for(let i = 0; i < player.inventory.length; i++){ - this.itemManager.pushItem(new WorldItem(player.position, player.inventory[i])); - } - player.inventory = [...config.player.baseInventory]; player.position = spawnPoint.vec; player.lookQuaternion = [spawnPoint.quaternion.x, spawnPoint.quaternion.y, spawnPoint.quaternion.z, spawnPoint.quaternion.w]; player.health = config.player.maxHealth; From 18640d6b605bdb49153587ccc6df0a16291285a7 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:52:53 -0500 Subject: [PATCH 16/18] style: add deno fmt config, CI job --- .github/workflows/ci.yml | 29 +++++++++++++++++++++-------- deno.json | 8 ++++++-- src/app/game/game.component.spec.ts | 23 ----------------------- 3 files changed, 27 insertions(+), 33 deletions(-) delete mode 100644 src/app/game/game.component.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc7e1e0..c7526ece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - '**' + - "**" pull_request: branches: - main @@ -24,7 +24,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v1 with: - deno-version: 'latest' + deno-version: "latest" - name: Build project run: deno task build - name: List build directory contents @@ -39,7 +39,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v1 with: - deno-version: 'latest' + deno-version: "latest" - name: Build project run: deno task build - name: Run lint @@ -53,11 +53,25 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v1 with: - deno-version: 'latest' + deno-version: "latest" - name: install run: deno install - name: check src run: deno check ./src/client ./src/server + # Format Job + format: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: "latest" + - name: install + run: deno install + - name: fmt check src + run: deno fmt --check src # Healthcheck Job health: @@ -68,7 +82,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v1 with: - deno-version: 'latest' + deno-version: "latest" - name: Start server run: deno task start & - name: Health check @@ -122,7 +136,7 @@ jobs: - name: Set up Deno uses: denoland/setup-deno@v1 with: - deno-version: 'latest' + deno-version: "latest" - name: Run security audit run: npm audit --audit-level=high @@ -137,7 +151,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 with: - persist-credentials: 'false' + persist-credentials: "false" fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v3 @@ -153,4 +167,3 @@ jobs: GIT_COMMITTER_NAME: "github-actions[bot]" GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com" run: npx semantic-release --extends ./release.config.js - diff --git a/deno.json b/deno.json index e0bd5091..1b4833b9 100644 --- a/deno.json +++ b/deno.json @@ -18,6 +18,10 @@ "lint": { "include": ["src/"], "exclude": ["dist/", "node_modules/", "public/"] - + }, + "fmt": { + "options": { + "lineWidth": 120 } -} \ No newline at end of file + } +} diff --git a/src/app/game/game.component.spec.ts b/src/app/game/game.component.spec.ts deleted file mode 100644 index 47c16c9b..00000000 --- a/src/app/game/game.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { GameComponent } from './game.component'; - -describe('GameComponent', () => { - let component: GameComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GameComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(GameComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); From d83aa932c0ec05014ce1a5c14c809c7d68ef0769 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:10:05 -0500 Subject: [PATCH 17/18] style: use tabs and single quotes --- deno.json | 52 +++++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/deno.json b/deno.json index 1b4833b9..b99aba1b 100644 --- a/deno.json +++ b/deno.json @@ -1,27 +1,29 @@ { - "tasks": { - "dev": "deno run -A --node-modules-dir npm:vite", - "build": "deno run -A --node-modules-dir npm:vite build", - "preview": "deno run -A --node-modules-dir npm:vite preview", - "serve": "deno run --allow-net --allow-read jsr:@std/http@1/file-server dist/", - "start": "deno task build && deno run --allow-read --allow-env --allow-net --allow-write main.ts" - }, - "compilerOptions": { - "lib": ["ES2020", "DOM", "DOM.Iterable", "deno.ns"] - }, - "imports": { - "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.0", - "@oak/oak": "jsr:@oak/oak@^17.1.3", - "@std/http": "jsr:@std/http@^1.0.10", - "vite": "npm:vite@^5.4.8" - }, - "lint": { - "include": ["src/"], - "exclude": ["dist/", "node_modules/", "public/"] - }, - "fmt": { - "options": { - "lineWidth": 120 - } - } + "tasks": { + "dev": "deno run -A --node-modules-dir npm:vite", + "build": "deno run -A --node-modules-dir npm:vite build", + "preview": "deno run -A --node-modules-dir npm:vite preview", + "serve": "deno run --allow-net --allow-read jsr:@std/http@1/file-server dist/", + "start": "deno task build && deno run --allow-read --allow-env --allow-net --allow-write main.ts" + }, + "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable", "deno.ns"] + }, + "imports": { + "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.0", + "@oak/oak": "jsr:@oak/oak@^17.1.3", + "@std/http": "jsr:@std/http@^1.0.10", + "vite": "npm:vite@^5.4.8" + }, + "lint": { + "include": ["src/"], + "exclude": ["dist/", "node_modules/", "public/"] + }, + "fmt": { + "options": { + "lineWidth": 120, + "useTabs": true, + "singleQuote": true + } + } } From f93bdcee73497770ab803b024ce387890b94ac21 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Fri, 3 Jan 2025 01:08:09 -0500 Subject: [PATCH 18/18] style: run deno format --- src/app/app.component.ts | 8 +- src/app/app.config.server.ts | 6 +- src/app/app.config.ts | 24 +- src/app/game/game.component.css | 1 + src/app/game/game.component.html | 2 +- src/app/game/game.component.ts | 29 +- src/app/pages/index.page.ts | 16 +- src/client/core/AssetManager.ts | 142 +- src/client/core/CommandManager.ts | 337 +++-- src/client/core/Game.ts | 132 +- src/client/core/Inventory.ts | 337 ++--- src/client/core/MapLoader.ts | 41 +- src/client/core/Networking.ts | 475 +++--- src/client/core/Player.ts | 100 +- src/client/core/RemoteItemRenderer.ts | 157 +- src/client/core/RemotePlayerRenderer.ts | 1016 ++++++------- src/client/core/Renderer.ts | 802 +++++----- src/client/core/SettingsManager.ts | 62 +- src/client/input/CollisionManager.ts | 276 ++-- src/client/input/HeldItemInput.ts | 18 +- src/client/input/InputHandler.ts | 683 ++++----- src/client/input/PointerLockControl.ts | 126 +- src/client/input/TouchInputHandler.ts | 358 +++-- src/client/items/BananaGun.ts | 374 +++-- src/client/items/FishGun.ts | 454 +++--- src/client/items/ItemBase.ts | 290 ++-- src/client/main.ts | 2 +- src/client/ui/ChatOverlay.ts | 1823 ++++++++++++----------- src/client/ui/HealthIndicator.ts | 224 +-- src/main.server.ts | 26 +- src/main.ts | 4 +- src/server/DataValidator.ts | 135 +- src/server/GameEngine.ts | 311 ++-- src/server/GameServer.ts | 301 ++-- src/server/config.ts | 143 +- src/server/gamemodes/FFAGamemode.ts | 238 ++- src/server/gamemodes/Gamemode.ts | 26 +- src/server/managers/ChatManager.ts | 151 +- src/server/managers/DamageSystem.ts | 112 +- src/server/managers/GameMsgManager.ts | 6 +- src/server/managers/ItemManager.ts | 236 ++- src/server/managers/PlayerManager.ts | 315 ++-- src/server/models/ChatMessage.ts | 8 +- src/server/models/DamageRequest.ts | 10 +- src/server/models/ItemRespawnPoint.ts | 12 +- src/server/models/MapData.ts | 54 +- src/server/models/Player.ts | 53 +- src/server/models/PlayerExtras.ts | 13 +- src/server/models/Quaternion.ts | 4 +- src/server/models/RespawnPoint.ts | 4 +- src/server/models/ServerInfo.ts | 40 +- src/server/models/Vector3.ts | 32 +- src/server/models/WorldItem.ts | 12 +- src/styles.css | 92 +- 54 files changed, 5369 insertions(+), 5284 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 83d32386..812d0c0b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,9 +2,9 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; @Component({ - selector: 'app-root', - standalone: true, - imports: [RouterOutlet], - template: ``, + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, }) export class AppComponent {} diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index de174d78..b872e63d 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -1,10 +1,10 @@ -import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { provideServerRendering } from '@angular/platform-server'; -import { appConfig } from "./app.config.ts"; +import { appConfig } from './app.config.ts'; const serverConfig: ApplicationConfig = { - providers: [provideServerRendering()], + providers: [provideServerRendering()], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a1a58ee8..0a47c409 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,20 +1,16 @@ -import { - provideHttpClient, - withFetch, - withInterceptors, -} from '@angular/common/http'; +import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration } from '@angular/platform-browser'; import { provideFileRouter, requestContextInterceptor } from '@analogjs/router'; export const appConfig: ApplicationConfig = { - providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), - provideFileRouter(), - provideHttpClient( - withFetch(), - withInterceptors([requestContextInterceptor]) - ), - provideClientHydration(), - ], + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideFileRouter(), + provideHttpClient( + withFetch(), + withInterceptors([requestContextInterceptor]), + ), + provideClientHydration(), + ], }; diff --git a/src/app/game/game.component.css b/src/app/game/game.component.css index e69de29b..8b137891 100644 --- a/src/app/game/game.component.css +++ b/src/app/game/game.component.css @@ -0,0 +1 @@ + diff --git a/src/app/game/game.component.html b/src/app/game/game.component.html index ba88aad9..8485102f 100644 --- a/src/app/game/game.component.html +++ b/src/app/game/game.component.html @@ -1,2 +1,2 @@

game works!

-
\ No newline at end of file +
diff --git a/src/app/game/game.component.ts b/src/app/game/game.component.ts index 0b062685..2680403a 100644 --- a/src/app/game/game.component.ts +++ b/src/app/game/game.component.ts @@ -1,22 +1,23 @@ // game.component.ts -import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; -import { Game } from "../../client/core/Game.ts"; +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { Game } from '../../client/core/Game.ts'; @Component({ - selector: 'app-game', - templateUrl: './game.component.html', - standalone: true, + selector: 'app-game', + templateUrl: './game.component.html', + standalone: true, }) export class GameComponent implements AfterViewInit { - @ViewChild('rendererContainer') rendererContainer!: ElementRef; - private game?: Game; + @ViewChild('rendererContainer') + rendererContainer!: ElementRef; + private game?: Game; - ngAfterViewInit() { - this.game = new Game(this.rendererContainer.nativeElement); - this.game.start(); - } + ngAfterViewInit() { + this.game = new Game(this.rendererContainer.nativeElement); + this.game.start(); + } - ngOnDestroy() { - // Add cleanup if needed - } + ngOnDestroy() { + // Add cleanup if needed + } } diff --git a/src/app/pages/index.page.ts b/src/app/pages/index.page.ts index 749676b4..9441705d 100644 --- a/src/app/pages/index.page.ts +++ b/src/app/pages/index.page.ts @@ -1,13 +1,13 @@ import { Component } from '@angular/core'; -import {GameComponent} from "../game/game.component.ts"; +import { GameComponent } from '../game/game.component.ts'; @Component({ - selector: 'app-home', - standalone: true, - template: ``, - styles: ``, - imports: [ - GameComponent - ] + selector: 'app-home', + standalone: true, + template: ``, + styles: ``, + imports: [ + GameComponent, + ], }) export default class HomeComponent {} diff --git a/src/client/core/AssetManager.ts b/src/client/core/AssetManager.ts index 11b66b1e..6acfa9c3 100644 --- a/src/client/core/AssetManager.ts +++ b/src/client/core/AssetManager.ts @@ -3,87 +3,87 @@ import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import * as THREE from 'three'; interface AssetEntry { - scene: THREE.Group; - isLoaded: boolean; - callbacks: Array<(scene: THREE.Group) => void>; + scene: THREE.Group; + isLoaded: boolean; + callbacks: Array<(scene: THREE.Group) => void>; } export class AssetManager { - private static instance: AssetManager; - private assets: Map; - private gltfLoader: GLTFLoader; + private static instance: AssetManager; + private assets: Map; + private gltfLoader: GLTFLoader; - private constructor() { - this.assets = new Map(); - this.gltfLoader = new GLTFLoader(); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('/draco/'); - this.gltfLoader.setDRACOLoader(dracoLoader); - } + private constructor() { + this.assets = new Map(); + this.gltfLoader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath('/draco/'); + this.gltfLoader.setDRACOLoader(dracoLoader); + } - public static getInstance(): AssetManager { - if (!AssetManager.instance) { - AssetManager.instance = new AssetManager(); - } - return AssetManager.instance; - } + public static getInstance(): AssetManager { + if (!AssetManager.instance) { + AssetManager.instance = new AssetManager(); + } + return AssetManager.instance; + } - private cloneWithNewMaterials(scene: THREE.Group): THREE.Group { - const clonedScene = scene.clone(); + private cloneWithNewMaterials(scene: THREE.Group): THREE.Group { + const clonedScene = scene.clone(); - clonedScene.traverse((node) => { - if ((node as THREE.Mesh).isMesh) { - const mesh = node as THREE.Mesh; - if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map(mat => mat.clone()); - } else { - mesh.material = mesh.material.clone(); - } - } - }); + clonedScene.traverse((node) => { + if ((node as THREE.Mesh).isMesh) { + const mesh = node as THREE.Mesh; + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map((mat) => mat.clone()); + } else { + mesh.material = mesh.material.clone(); + } + } + }); - return clonedScene; - } + return clonedScene; + } - public loadAsset(url: string, callback: (scene: THREE.Group) => void): void { - if (this.assets.has(url)) { - const assetEntry = this.assets.get(url)!; - if (assetEntry.isLoaded) { - // Asset is already loaded, clone with new materials - callback(this.cloneWithNewMaterials(assetEntry.scene)); - } else { - // Asset is loading, add callback to the list - assetEntry.callbacks.push(callback); - } - } else { - // Asset not loaded yet, start loading - this.assets.set(url, { scene: new THREE.Group(), isLoaded: false, callbacks: [callback] }); - this.gltfLoader.load( - url, - (gltf) => { - const assetEntry = this.assets.get(url)!; - assetEntry.scene = gltf.scene; - assetEntry.isLoaded = true; + public loadAsset(url: string, callback: (scene: THREE.Group) => void): void { + if (this.assets.has(url)) { + const assetEntry = this.assets.get(url)!; + if (assetEntry.isLoaded) { + // Asset is already loaded, clone with new materials + callback(this.cloneWithNewMaterials(assetEntry.scene)); + } else { + // Asset is loading, add callback to the list + assetEntry.callbacks.push(callback); + } + } else { + // Asset not loaded yet, start loading + this.assets.set(url, { scene: new THREE.Group(), isLoaded: false, callbacks: [callback] }); + this.gltfLoader.load( + url, + (gltf) => { + const assetEntry = this.assets.get(url)!; + assetEntry.scene = gltf.scene; + assetEntry.isLoaded = true; - // Call all callbacks waiting for this asset with new material clones - assetEntry.callbacks.forEach((cb) => { - cb(this.cloneWithNewMaterials(gltf.scene)); - }); - assetEntry.callbacks = []; - }, - undefined, - (error) => { - console.error(`Error loading asset ${url}:`, error); - } - ); - } - } + // Call all callbacks waiting for this asset with new material clones + assetEntry.callbacks.forEach((cb) => { + cb(this.cloneWithNewMaterials(gltf.scene)); + }); + assetEntry.callbacks = []; + }, + undefined, + (error) => { + console.error(`Error loading asset ${url}:`, error); + }, + ); + } + } - public preloadAsset(url: string): void { - this.loadAsset(url, () => {}); - } + public preloadAsset(url: string): void { + this.loadAsset(url, () => {}); + } - public clearCache(): void { - this.assets.clear(); - } + public clearCache(): void { + this.assets.clear(); + } } diff --git a/src/client/core/CommandManager.ts b/src/client/core/CommandManager.ts index 66612493..44344fed 100644 --- a/src/client/core/CommandManager.ts +++ b/src/client/core/CommandManager.ts @@ -1,175 +1,190 @@ -import {Player} from "./Player.ts"; -import {ChatOverlay} from "../ui/ChatOverlay.ts"; -import {SettingsManager} from "./SettingsManager.ts"; +import { Player } from './Player.ts'; +import { ChatOverlay } from '../ui/ChatOverlay.ts'; +import { SettingsManager } from './SettingsManager.ts'; export class CommandManager { - private localPlayer: Player; - private chatOverlay: ChatOverlay; - private readonly commands: Command[]; - - constructor(localPlayer: Player, chatOverlay: ChatOverlay) { - this.localPlayer = localPlayer; - this.chatOverlay = chatOverlay; - this.commands = []; - this.init(); - } - - public init() { - this.commands.push(new Command('sense', (args: string[]): string => { - if (args[1] == null) { - return "sensitivity is currently " + (SettingsManager.settings.sense); - } - const sense: number = Number(args[1]); - if (Number.isNaN(sense)) { - return args[1] + " is not a number"; - } - if (sense > 10 || sense <= 0) { - return "sensitivity is not in the valid range of 0 to 10"; - } - SettingsManager.settings.sense = sense; - SettingsManager.write(); - return "sensitivity is now set to " + (sense); - })); - - this.commands.push(new Command('resetSettings', (): string => { - SettingsManager.reset(); - SettingsManager.write(); - return "settings have been reverted to their default states"; - })); - - this.commands.push(new Command('controllerSense', (args: string[]): string => { - if (args[1] == null) { - return "controller sensitivity is currently " + (SettingsManager.settings.controllerSense); - } - const sense = Number(args[1]); - if (Number.isNaN(sense)) { - return args[1] + " is not a number"; - } - if (sense > 10 || sense <= 0) { - return "controller sensitivity is not in the valid range of 0 to 10"; - } - SettingsManager.settings.controllerSense = sense; - SettingsManager.write(); - return "controller sensitivity is now set to " + (sense); - })); - - this.commands.push(new Command('crosshairColor', (args: string[]): string => { - if ((args[1] && args[2] && args[3])) { - for (let i = 1; i < args.length; i++) { - if (Number.isNaN(Number(args[i]))) return args[i] + " is not a number"; - if (Number(args[i]) < 0 || Number(args[i]) > 255) { - return args[i] + ' is not in range 0-255'; - } - } - SettingsManager.settings.crosshairColor = '#' + componentToHex(Number(args[1])) + componentToHex(Number(args[2])) + componentToHex(Number(args[3])); - } else if (args[1]) { - const color : string | null = cssToHex(args[1]); - if(!color) { - return args[1] + ' is not a valid color'; - } - SettingsManager.settings.crosshairColor = color; - } else { - return 'invalid input'; - } - SettingsManager.write(); - return 'crosshair color set to ' + SettingsManager.settings.crosshairColor; - })); - - this.commands.push(new Command('crosshairType', (args: string[]): string => { - if (args[1] == 'cross') SettingsManager.settings.crosshairType = 0; - else if (args[1] == 'dot') SettingsManager.settings.crosshairType = 1; - else return 'not a valid type (dot or cross)'; - SettingsManager.write(); - return 'crosshair type set to ' + args[1]; - } )); - - this.commands.push(new Command('bobbing', (args: string[]) : string => { - const bobbing = Number(args[1]); - if (Number.isNaN(bobbing)) { - return args[1] + ' is not a number' - } - if (bobbing < 0 || bobbing > 2) { - return args[1] + ' is not in range 0 to 2'; - } - SettingsManager.settings.viewBobbingStrength = bobbing; - SettingsManager.write(); - return 'view bobbing strength is now set to ' + args[1]; - })); - - this.commands.push(new Command('prettyText', (args: string[]): string => { - if(args[1] == null) return 'prettyText is currently ' + SettingsManager.settings.doPrettyText; - if (args[1] == 'true') SettingsManager.settings.doPrettyText = true; - else if (args[1] == 'false') SettingsManager.settings.doPrettyText = false; - else return 'invalid input (true/false)'; - SettingsManager.write(); - return 'prettyText set to ' + args[1]; - } )); - } - - public runCmd(cmd: string): boolean { - let match: boolean = false; - const args: string[] = cmd.substring(1).split(" ") - for (let i = 0; i < this.commands.length; i++) { - if (args[0].toLowerCase() === this.commands[i].getCmdName()) { - match = true; - const msg = this.commands[i].run(args); - - const chatMessage = { - id: this.localPlayer.id, - name: '', - message: cmd + " -> " + msg - }; - - this.chatOverlay.addChatMessage(chatMessage); - break; - } - } - return match; - } + private localPlayer: Player; + private chatOverlay: ChatOverlay; + private readonly commands: Command[]; + + constructor(localPlayer: Player, chatOverlay: ChatOverlay) { + this.localPlayer = localPlayer; + this.chatOverlay = chatOverlay; + this.commands = []; + this.init(); + } + + public init() { + this.commands.push( + new Command('sense', (args: string[]): string => { + if (args[1] == null) { + return 'sensitivity is currently ' + (SettingsManager.settings.sense); + } + const sense: number = Number(args[1]); + if (Number.isNaN(sense)) { + return args[1] + ' is not a number'; + } + if (sense > 10 || sense <= 0) { + return 'sensitivity is not in the valid range of 0 to 10'; + } + SettingsManager.settings.sense = sense; + SettingsManager.write(); + return 'sensitivity is now set to ' + sense; + }), + ); + + this.commands.push( + new Command('resetSettings', (): string => { + SettingsManager.reset(); + SettingsManager.write(); + return 'settings have been reverted to their default states'; + }), + ); + + this.commands.push( + new Command('controllerSense', (args: string[]): string => { + if (args[1] == null) { + return 'controller sensitivity is currently ' + (SettingsManager.settings.controllerSense); + } + const sense = Number(args[1]); + if (Number.isNaN(sense)) { + return args[1] + ' is not a number'; + } + if (sense > 10 || sense <= 0) { + return 'controller sensitivity is not in the valid range of 0 to 10'; + } + SettingsManager.settings.controllerSense = sense; + SettingsManager.write(); + return 'controller sensitivity is now set to ' + sense; + }), + ); + + this.commands.push( + new Command('crosshairColor', (args: string[]): string => { + if ((args[1] && args[2] && args[3])) { + for (let i = 1; i < args.length; i++) { + if (Number.isNaN(Number(args[i]))) return args[i] + ' is not a number'; + if (Number(args[i]) < 0 || Number(args[i]) > 255) { + return args[i] + ' is not in range 0-255'; + } + } + SettingsManager.settings.crosshairColor = '#' + componentToHex(Number(args[1])) + + componentToHex(Number(args[2])) + componentToHex(Number(args[3])); + } else if (args[1]) { + const color: string | null = cssToHex(args[1]); + if (!color) { + return args[1] + ' is not a valid color'; + } + SettingsManager.settings.crosshairColor = color; + } else { + return 'invalid input'; + } + SettingsManager.write(); + return 'crosshair color set to ' + SettingsManager.settings.crosshairColor; + }), + ); + + this.commands.push( + new Command('crosshairType', (args: string[]): string => { + if (args[1] == 'cross') SettingsManager.settings.crosshairType = 0; + else if (args[1] == 'dot') SettingsManager.settings.crosshairType = 1; + else return 'not a valid type (dot or cross)'; + SettingsManager.write(); + return 'crosshair type set to ' + args[1]; + }), + ); + + this.commands.push( + new Command('bobbing', (args: string[]): string => { + const bobbing = Number(args[1]); + if (Number.isNaN(bobbing)) { + return args[1] + ' is not a number'; + } + if (bobbing < 0 || bobbing > 2) { + return args[1] + ' is not in range 0 to 2'; + } + SettingsManager.settings.viewBobbingStrength = bobbing; + SettingsManager.write(); + return 'view bobbing strength is now set to ' + args[1]; + }), + ); + + this.commands.push( + new Command('prettyText', (args: string[]): string => { + if (args[1] == null) return 'prettyText is currently ' + SettingsManager.settings.doPrettyText; + if (args[1] == 'true') SettingsManager.settings.doPrettyText = true; + else if (args[1] == 'false') SettingsManager.settings.doPrettyText = false; + else return 'invalid input (true/false)'; + SettingsManager.write(); + return 'prettyText set to ' + args[1]; + }), + ); + } + + public runCmd(cmd: string): boolean { + let match: boolean = false; + const args: string[] = cmd.substring(1).split(' '); + for (let i = 0; i < this.commands.length; i++) { + if (args[0].toLowerCase() === this.commands[i].getCmdName()) { + match = true; + const msg = this.commands[i].run(args); + + const chatMessage = { + id: this.localPlayer.id, + name: '', + message: cmd + ' -> ' + msg, + }; + + this.chatOverlay.addChatMessage(chatMessage); + break; + } + } + return match; + } } class Command { - private readonly cmdName: string; - private readonly func: (args: string[]) => string; - constructor(cmd: string, func: (args: string[]) => string) { - this.cmdName = cmd.toLowerCase(); - this.func = func; - } - public run(args: string[]): string { - return this.func(args); - } - - public getCmdName() { - return this.cmdName; - } + private readonly cmdName: string; + private readonly func: (args: string[]) => string; + constructor(cmd: string, func: (args: string[]) => string) { + this.cmdName = cmd.toLowerCase(); + this.func = func; + } + public run(args: string[]): string { + return this.func(args); + } + + public getCmdName() { + return this.cmdName; + } } function componentToHex(c: number): string { - const hex = c.toString(16); - return hex.length === 1 ? "0" + hex : hex; + const hex = c.toString(16); + return hex.length === 1 ? '0' + hex : hex; } function cssToHex(color: string) { - // Create a dummy div to get computed color style - if (!isColor(color)) return null; - const div = document.createElement('div'); - div.style.color = color; - document.body.appendChild(div); - const computedColor = getComputedStyle(div).color; - document.body.removeChild(div); - - // Extract rgb values - const rgbMatch = computedColor.match(/\d+/g); - if (rgbMatch) { - const [r, g, b] = rgbMatch.map(Number); - return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b); - } - return null; + // Create a dummy div to get computed color style + if (!isColor(color)) return null; + const div = document.createElement('div'); + div.style.color = color; + document.body.appendChild(div); + const computedColor = getComputedStyle(div).color; + document.body.removeChild(div); + + // Extract rgb values + const rgbMatch = computedColor.match(/\d+/g); + if (rgbMatch) { + const [r, g, b] = rgbMatch.map(Number); + return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b); + } + return null; } function isColor(strColor: string) { - const s = new Option().style; - s.color = strColor; - // If the color is recognized, s.color won't be empty - return s.color !== ''; -} \ No newline at end of file + const s = new Option().style; + s.color = strColor; + // If the color is recognized, s.color won't be empty + return s.color !== ''; +} diff --git a/src/client/core/Game.ts b/src/client/core/Game.ts index dbc9143a..aa449bf8 100644 --- a/src/client/core/Game.ts +++ b/src/client/core/Game.ts @@ -1,73 +1,73 @@ -import {Player} from './Player.ts'; -import {Renderer} from './Renderer.ts'; -import {ChatOverlay} from '../ui/ChatOverlay.ts'; -import {InputHandler} from '../input/InputHandler.ts'; -import {Networking} from './Networking.ts'; -import {CollisionManager} from '../input/CollisionManager.ts'; -import {Inventory} from './Inventory.ts'; -import {HealthIndicator} from '../ui/HealthIndicator.ts'; -import {MapLoader} from './MapLoader.ts'; -import {RemoteItemRenderer} from "./RemoteItemRenderer.ts"; -import { TouchInputHandler } from "../input/TouchInputHandler.ts"; +import { Player } from './Player.ts'; +import { Renderer } from './Renderer.ts'; +import { ChatOverlay } from '../ui/ChatOverlay.ts'; +import { InputHandler } from '../input/InputHandler.ts'; +import { Networking } from './Networking.ts'; +import { CollisionManager } from '../input/CollisionManager.ts'; +import { Inventory } from './Inventory.ts'; +import { HealthIndicator } from '../ui/HealthIndicator.ts'; +import { MapLoader } from './MapLoader.ts'; +import { RemoteItemRenderer } from './RemoteItemRenderer.ts'; +import { TouchInputHandler } from '../input/TouchInputHandler.ts'; export class Game { - private localPlayer: Player; - private renderer: Renderer; - private chatOverlay: ChatOverlay; - private inputHandler: InputHandler; - private touchInputHandler: TouchInputHandler; - private networking: Networking; - private collisionManager: CollisionManager; - private inventoryManager: Inventory; - private mapLoader: MapLoader; - private healthIndicator: HealthIndicator; - private remoteItemRenderer: RemoteItemRenderer; - private gameIndex: number; - private static nextGameIndex: number = 0; + private localPlayer: Player; + private renderer: Renderer; + private chatOverlay: ChatOverlay; + private inputHandler: InputHandler; + private touchInputHandler: TouchInputHandler; + private networking: Networking; + private collisionManager: CollisionManager; + private inventoryManager: Inventory; + private mapLoader: MapLoader; + private healthIndicator: HealthIndicator; + private remoteItemRenderer: RemoteItemRenderer; + private gameIndex: number; + private static nextGameIndex: number = 0; + constructor(container: HTMLElement) { + this.gameIndex = Game.nextGameIndex++; + this.localPlayer = new Player(); + this.chatOverlay = new ChatOverlay(container, this.localPlayer); + this.networking = new Networking(this.localPlayer, this.chatOverlay); + this.renderer = new Renderer(container, this.networking, this.localPlayer, this.chatOverlay); + this.chatOverlay.setRenderer(this.renderer); + this.inputHandler = new InputHandler(this.renderer, this.localPlayer, this.gameIndex); + this.touchInputHandler = new TouchInputHandler(this.inputHandler, this.chatOverlay); + this.renderer.setInputHandler(this.inputHandler); + this.collisionManager = new CollisionManager(this.inputHandler); + this.renderer.setCollisionManager(this.collisionManager); + this.inventoryManager = new Inventory(this.renderer, this.inputHandler, this.networking, this.localPlayer); + this.chatOverlay.setNetworking(this.networking); + this.chatOverlay.setInputHandler(this.inputHandler); + this.mapLoader = new MapLoader(this.renderer); + this.healthIndicator = new HealthIndicator(this.renderer, this.localPlayer, this.networking); + this.remoteItemRenderer = new RemoteItemRenderer(this.networking, this.renderer); + } - constructor(container: HTMLElement) { - this.gameIndex = Game.nextGameIndex++; - this.localPlayer = new Player(); - this.chatOverlay = new ChatOverlay(container,this.localPlayer); - this.networking = new Networking(this.localPlayer, this.chatOverlay); - this.renderer = new Renderer(container, this.networking, this.localPlayer, this.chatOverlay); - this.chatOverlay.setRenderer(this.renderer); - this.inputHandler = new InputHandler(this.renderer, this.localPlayer, this.gameIndex); - this.touchInputHandler = new TouchInputHandler(this.inputHandler, this.chatOverlay); - this.renderer.setInputHandler(this.inputHandler); - this.collisionManager = new CollisionManager(this.inputHandler); - this.renderer.setCollisionManager(this.collisionManager); - this.inventoryManager = new Inventory(this.renderer, this.inputHandler, this.networking, this.localPlayer); - this.chatOverlay.setNetworking(this.networking); - this.chatOverlay.setInputHandler(this.inputHandler); - this.mapLoader = new MapLoader(this.renderer); - this.healthIndicator = new HealthIndicator(this.renderer,this.localPlayer, this.networking); - this.remoteItemRenderer = new RemoteItemRenderer(this.networking, this.renderer); - } + init() { + this.inventoryManager.init(); + this.healthIndicator.init(); + } - init() { - this.inventoryManager.init(); - this.healthIndicator.init(); - } + animate() { + this.inputHandler.handleInputs(); + this.touchInputHandler.onFrame(); + this.collisionManager.collisionPeriodic(this.localPlayer); + 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'); + } + this.remoteItemRenderer.onFrame(); + requestAnimationFrame(this.animate.bind(this)); + } - animate() { - this.inputHandler.handleInputs(); - this.touchInputHandler.onFrame(); - this.collisionManager.collisionPeriodic(this.localPlayer); - 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'); - this.remoteItemRenderer.onFrame(); - requestAnimationFrame(this.animate.bind(this)); - } - - start() { - this.init(); - this.animate(); - } + start() { + this.init(); + this.animate(); + } } diff --git a/src/client/core/Inventory.ts b/src/client/core/Inventory.ts index 65c3a15e..48a74713 100644 --- a/src/client/core/Inventory.ts +++ b/src/client/core/Inventory.ts @@ -3,172 +3,175 @@ import { Renderer } from './Renderer.ts'; import { InputHandler } from '../input/InputHandler.ts'; import { BananaGun } from '../items/BananaGun.ts'; import { HeldItemInput } from '../input/HeldItemInput.ts'; -import {Networking} from "./Networking.ts"; -import {Player} from "./Player.ts"; -import {ItemBase, ItemType} from "../items/ItemBase.ts"; -import {FishGun} from "../items/FishGun.ts"; +import { Networking } from './Networking.ts'; +import { Player } from './Player.ts'; +import { ItemBase, ItemType } from '../items/ItemBase.ts'; +import { FishGun } from '../items/FishGun.ts'; export class Inventory { - private inventoryItems: ItemBase[] = []; - private renderer: Renderer; - private inputHandler: InputHandler; - private networking: Networking; - private inventoryScene: THREE.Scene; - private selectedInventoryItem: number = 0; - private lastSelectedInventoryItem: number = 0; - private cameraY: number = 0; - private cameraX: number = 0; - private clock: THREE.Clock; - private camera: THREE.Camera; - private lastInventoryTouchTime: number = 0; - private localPlayer: Player; - private oldInventory: number[] = []; - - - private oldDownPressed: boolean = false; - private oldUpPressed: boolean = false; - private oldQPressed: boolean = false; - private oldNumsPressed: boolean[] = new Array(10).fill(false); - - - - constructor(renderer: Renderer, inputHandler: InputHandler, networking:Networking, localPlayer:Player) { - this.renderer = renderer; - this.inputHandler = inputHandler; - this.networking = networking; - this.inventoryScene = renderer.getInventoryMenuScene(); - this.clock = new THREE.Clock(); - this.camera = renderer.getInventoryMenuCamera(); - this.localPlayer = localPlayer; - } - - public init() { - - } - - private updateInventoryItems(){ - if(!this.arraysEqual(this.oldInventory, this.localPlayer.inventory)) { - for(let i = this.inventoryItems.length - 1; i >= 0; i--) { - this.inventoryItems[i].destroy(); - this.inventoryItems.splice(i, 1); - } - - //iterate through every number in localPlayer.inventory - for(let i = 0; i < this.localPlayer.inventory.length; i++) { - const num = this.localPlayer.inventory[i]; - switch(num) { - case 1: { - const banana = new BananaGun(this.renderer, this.networking, i, ItemType.InventoryItem); - this.inventoryItems.push(banana); - break; - } - case 2: { - const fish = new FishGun(this.renderer, this.networking, i, ItemType.InventoryItem); - this.inventoryItems.push(fish); - break; - } - default: { - const testItem = new ItemBase(ItemType.InventoryItem, this.renderer.getHeldItemScene(), this.inventoryScene, i); - this.inventoryItems.push(testItem); - break; - } - } - - } - } - - this.oldInventory = this.localPlayer.inventory; - } - - public arraysEqual(a: number[], b: number[]) { - if (a === b) return true; - if (a == null || b == null) return false; - if (a.length != b.length) return false; - for (let i = 0; i < a.length; ++i) - if (a[i] !== b[i]) return false; - return true; - } - - public onFrame() { - this.updateInventoryItems(); - const gamepadInputs = this.inputHandler.getGamepadInputs(); - const heldItemInput = new HeldItemInput(this.inputHandler.getShoot(), this.inputHandler.getAim(), false); - let downPressed =( this.inputHandler.getKey('[') || this.inputHandler.getInventoryIterationTouched()) && !this.localPlayer.chatActive; - let upPressed = this.inputHandler.getKey(']') && !this.localPlayer.chatActive; - const qPressed = this.inputHandler.getKey('q') && !this.localPlayer.chatActive; - if (gamepadInputs.leftShoulder && !this.localPlayer.chatActive) upPressed = true; - if (gamepadInputs.rightShoulder && !this.localPlayer.chatActive) downPressed = true; - const lastScroll = this.inputHandler.getScrollClicks(); - if(lastScroll > 0) upPressed = true; - if(lastScroll < 0) downPressed = true; - - - if(!this.localPlayer.chatActive){ - const nums = ['1','2','3','4','5','6','7','8','9','0']; - for(let i = 0; i < nums.length; i++) { - const numPressed = this.inputHandler.getKey(nums[i]); - if(numPressed && !this.oldNumsPressed[i]) { - this.lastSelectedInventoryItem = this.selectedInventoryItem; - this.selectedInventoryItem = i; - this.lastInventoryTouchTime = Date.now() / 1000; - break; - } - } - - for(let i = 0; i < nums.length; i++) { - this.oldNumsPressed[i] = this.inputHandler.getKey(nums[i]); - } - } - - - if(downPressed || upPressed) this.lastInventoryTouchTime = Date.now() / 1000; - const deltaTime = this.clock.getDelta(); - - if(downPressed && !this.oldDownPressed){ - this.lastSelectedInventoryItem = this.selectedInventoryItem; - this.selectedInventoryItem++; - } - if(upPressed && !this.oldUpPressed){ - this.lastSelectedInventoryItem = this.selectedInventoryItem; - this.selectedInventoryItem--; - } - if(this.inputHandler.getKey('enter')) - this.lastInventoryTouchTime = 0; //hide inventory - - if(qPressed && !this.oldQPressed) { - const temp = this.selectedInventoryItem; - this.selectedInventoryItem = this.lastSelectedInventoryItem; - this.lastSelectedInventoryItem = temp; - //this.lastInventoryTouchTime = Date.now() / 1000 - 1.25; - } - - - - if(this.selectedInventoryItem < 0) - this.selectedInventoryItem = this.inventoryItems.length - 1; - if(this.selectedInventoryItem >= this.inventoryItems.length) - this.selectedInventoryItem = 0; - - if(this.lastSelectedInventoryItem < 0) - this.lastSelectedInventoryItem = this.inventoryItems.length - 1; - if(this.lastSelectedInventoryItem >= this.inventoryItems.length) - this.lastSelectedInventoryItem = 0; - - this.cameraY = this.selectedInventoryItem; //might be backwards - if(Date.now()/1000 - this.lastInventoryTouchTime > 2) - this.cameraX = -1; - else - this.cameraX = 0; - - - this.camera.position.lerp(new THREE.Vector3(this.cameraX, this.selectedInventoryItem, 5), 0.4 * deltaTime * 60); - - for(const item of this.inventoryItems) { - item.onFrame(heldItemInput, this.selectedInventoryItem); - } - - this.oldDownPressed = downPressed; - this.oldUpPressed = upPressed; - this.oldQPressed = qPressed; - } -} \ No newline at end of file + private inventoryItems: ItemBase[] = []; + private renderer: Renderer; + private inputHandler: InputHandler; + private networking: Networking; + private inventoryScene: THREE.Scene; + private selectedInventoryItem: number = 0; + private lastSelectedInventoryItem: number = 0; + private cameraY: number = 0; + private cameraX: number = 0; + private clock: THREE.Clock; + private camera: THREE.Camera; + private lastInventoryTouchTime: number = 0; + private localPlayer: Player; + private oldInventory: number[] = []; + + private oldDownPressed: boolean = false; + private oldUpPressed: boolean = false; + private oldQPressed: boolean = false; + private oldNumsPressed: boolean[] = new Array(10).fill(false); + + constructor(renderer: Renderer, inputHandler: InputHandler, networking: Networking, localPlayer: Player) { + this.renderer = renderer; + this.inputHandler = inputHandler; + this.networking = networking; + this.inventoryScene = renderer.getInventoryMenuScene(); + this.clock = new THREE.Clock(); + this.camera = renderer.getInventoryMenuCamera(); + this.localPlayer = localPlayer; + } + + public init() { + } + + private updateInventoryItems() { + if (!this.arraysEqual(this.oldInventory, this.localPlayer.inventory)) { + for (let i = this.inventoryItems.length - 1; i >= 0; i--) { + this.inventoryItems[i].destroy(); + this.inventoryItems.splice(i, 1); + } + + //iterate through every number in localPlayer.inventory + for (let i = 0; i < this.localPlayer.inventory.length; i++) { + const num = this.localPlayer.inventory[i]; + switch (num) { + case 1: { + const banana = new BananaGun(this.renderer, this.networking, i, ItemType.InventoryItem); + this.inventoryItems.push(banana); + break; + } + case 2: { + const fish = new FishGun(this.renderer, this.networking, i, ItemType.InventoryItem); + this.inventoryItems.push(fish); + break; + } + default: { + const testItem = new ItemBase( + ItemType.InventoryItem, + this.renderer.getHeldItemScene(), + this.inventoryScene, + i, + ); + this.inventoryItems.push(testItem); + break; + } + } + } + } + + this.oldInventory = this.localPlayer.inventory; + } + + public arraysEqual(a: number[], b: number[]) { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false; + } + return true; + } + + public onFrame() { + this.updateInventoryItems(); + const gamepadInputs = this.inputHandler.getGamepadInputs(); + const heldItemInput = new HeldItemInput(this.inputHandler.getShoot(), this.inputHandler.getAim(), false); + let downPressed = (this.inputHandler.getKey('[') || this.inputHandler.getInventoryIterationTouched()) && + !this.localPlayer.chatActive; + let upPressed = this.inputHandler.getKey(']') && !this.localPlayer.chatActive; + const qPressed = this.inputHandler.getKey('q') && !this.localPlayer.chatActive; + if (gamepadInputs.leftShoulder && !this.localPlayer.chatActive) upPressed = true; + if (gamepadInputs.rightShoulder && !this.localPlayer.chatActive) downPressed = true; + const lastScroll = this.inputHandler.getScrollClicks(); + if (lastScroll > 0) upPressed = true; + if (lastScroll < 0) downPressed = true; + + if (!this.localPlayer.chatActive) { + const nums = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + for (let i = 0; i < nums.length; i++) { + const numPressed = this.inputHandler.getKey(nums[i]); + if (numPressed && !this.oldNumsPressed[i]) { + this.lastSelectedInventoryItem = this.selectedInventoryItem; + this.selectedInventoryItem = i; + this.lastInventoryTouchTime = Date.now() / 1000; + break; + } + } + + for (let i = 0; i < nums.length; i++) { + this.oldNumsPressed[i] = this.inputHandler.getKey(nums[i]); + } + } + + if (downPressed || upPressed) this.lastInventoryTouchTime = Date.now() / 1000; + const deltaTime = this.clock.getDelta(); + + if (downPressed && !this.oldDownPressed) { + this.lastSelectedInventoryItem = this.selectedInventoryItem; + this.selectedInventoryItem++; + } + if (upPressed && !this.oldUpPressed) { + this.lastSelectedInventoryItem = this.selectedInventoryItem; + this.selectedInventoryItem--; + } + if (this.inputHandler.getKey('enter')) { + this.lastInventoryTouchTime = 0; //hide inventory + } + + if (qPressed && !this.oldQPressed) { + const temp = this.selectedInventoryItem; + this.selectedInventoryItem = this.lastSelectedInventoryItem; + this.lastSelectedInventoryItem = temp; + //this.lastInventoryTouchTime = Date.now() / 1000 - 1.25; + } + + if (this.selectedInventoryItem < 0) { + this.selectedInventoryItem = this.inventoryItems.length - 1; + } + if (this.selectedInventoryItem >= this.inventoryItems.length) { + this.selectedInventoryItem = 0; + } + + if (this.lastSelectedInventoryItem < 0) { + this.lastSelectedInventoryItem = this.inventoryItems.length - 1; + } + if (this.lastSelectedInventoryItem >= this.inventoryItems.length) { + this.lastSelectedInventoryItem = 0; + } + + this.cameraY = this.selectedInventoryItem; //might be backwards + if (Date.now() / 1000 - this.lastInventoryTouchTime > 2) { + this.cameraX = -1; + } else { + this.cameraX = 0; + } + + this.camera.position.lerp(new THREE.Vector3(this.cameraX, this.selectedInventoryItem, 5), 0.4 * deltaTime * 60); + + for (const item of this.inventoryItems) { + item.onFrame(heldItemInput, this.selectedInventoryItem); + } + + this.oldDownPressed = downPressed; + this.oldUpPressed = upPressed; + this.oldQPressed = qPressed; + } +} diff --git a/src/client/core/MapLoader.ts b/src/client/core/MapLoader.ts index d17ebb3a..6587db3a 100644 --- a/src/client/core/MapLoader.ts +++ b/src/client/core/MapLoader.ts @@ -1,29 +1,28 @@ import * as THREE from 'three'; import { Renderer } from './Renderer.ts'; -import {computeBoundsTree} from "three-mesh-bvh"; -import {CollisionManager} from "../input/CollisionManager.ts"; -import { AssetManager } from "./AssetManager.ts"; +import { computeBoundsTree } from 'three-mesh-bvh'; +import { CollisionManager } from '../input/CollisionManager.ts'; +import { AssetManager } from './AssetManager.ts'; THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; export class MapLoader { - private scene: THREE.Scene; - private mapObject: THREE.Group | undefined; - private mapUrl: string = ''; + private scene: THREE.Scene; + private mapObject: THREE.Group | undefined; + private mapUrl: string = ''; - constructor(renderer: Renderer) { - this.scene = renderer.getScene(); - } + constructor(renderer: Renderer) { + this.scene = renderer.getScene(); + } - public load(mapUrl: string) { - if(mapUrl === this.mapUrl) return; - this.mapUrl = mapUrl; - AssetManager.getInstance().loadAsset(mapUrl, (scene) => { - if(this.mapObject) this.scene.remove(this.mapObject); - this.mapObject = scene; - CollisionManager.staticGeometry(scene); - this.scene.add(this.mapObject); - }); - } - -} \ No newline at end of file + public load(mapUrl: string) { + if (mapUrl === this.mapUrl) return; + this.mapUrl = mapUrl; + AssetManager.getInstance().loadAsset(mapUrl, (scene) => { + if (this.mapObject) this.scene.remove(this.mapObject); + this.mapObject = scene; + CollisionManager.staticGeometry(scene); + this.scene.add(this.mapObject); + }); + } +} diff --git a/src/client/core/Networking.ts b/src/client/core/Networking.ts index d1a7be05..e6e79acb 100644 --- a/src/client/core/Networking.ts +++ b/src/client/core/Networking.ts @@ -4,251 +4,256 @@ import { ChatOverlay } from '../ui/ChatOverlay.ts'; import * as THREE from 'three'; export interface RemotePlayer { - idLastDamagedBy: number; - latency: number; - id: number; - position: { x: number, y: number, z: number }; - velocity: { x: number, y: number, z: number }; - lookQuaternion: [number, number, number, number]; - name: string; - gravity: number; - forced: boolean; - health: number; - inventory: number[]; - chatActive: boolean; - chatMsg: string; - playerSpectating: number; - gameMsgs: string[]; - gameMsgs2: string[]; + idLastDamagedBy: number; + latency: number; + id: number; + position: { x: number; y: number; z: number }; + velocity: { x: number; y: number; z: number }; + lookQuaternion: [number, number, number, number]; + name: string; + gravity: number; + forced: boolean; + health: number; + inventory: number[]; + chatActive: boolean; + chatMsg: string; + playerSpectating: number; + gameMsgs: string[]; + gameMsgs2: string[]; } interface WorldItem { - vector: { x: number, y: number, z: number }; - id: number; - itemType: number; + vector: { x: number; y: number; z: number }; + id: number; + itemType: number; } interface ServerInfo { - name: string; - maxPlayers: number; - currentPlayers: number; - mapName: string; - tickRate: number; - version: string; - gameMode: string; - playerMaxHealth: number; + name: string; + maxPlayers: number; + currentPlayers: number; + mapName: string; + tickRate: number; + version: string; + gameMode: string; + playerMaxHealth: number; } interface LastUploadedLocalPlayer { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - chatMsg: string; - velocity: THREE.Vector3; - name: string; + position: THREE.Vector3; + quaternion: THREE.Quaternion; + chatMsg: string; + velocity: THREE.Vector3; + name: string; } export class Networking { - private socket: Socket; - private gameVersion: string = ''; - private remotePlayers: RemotePlayer[] = []; - private worldItems: WorldItem[] = []; - private lastUploadedLocalPlayer: LastUploadedLocalPlayer | null = null; - private lastUploadTime: number; - private uploadWait: number; - private lastLatencyTestEmit: number; - private lastLatencyTestGotResponse: boolean; - private latencyTestWait: number; - private messagesBeingTyped: string[] = []; - private localPlayer: Player; - private chatOverlay: ChatOverlay; - private damagedTimestamp: number = 0; - private serverInfo: ServerInfo; - - constructor(localPlayer: Player, chatOverlay: ChatOverlay) { - this.localPlayer = localPlayer; - this.chatOverlay = chatOverlay; - - this.socket = io(); - this.fetchVersion(); - - this.lastUploadTime = Date.now() / 1000; - this.uploadWait = 0.05; //gets replaced by server info - this.lastLatencyTestEmit = 0; - this.lastLatencyTestGotResponse = false; - this.latencyTestWait = 5; - - this.serverInfo = { - name: '', - maxPlayers: 0, - currentPlayers: 0, - mapName: '', - tickRate: 0, - version: '', - gameMode: '', - playerMaxHealth: 0 - } - - this.setupSocketListeners(); - } - - private async fetchVersion() { - try { - const response = await fetch('gameVersion.json'); - const data = await response.json(); - this.gameVersion = data['version']; - } catch (e) { - console.error(e); - } - } - - private setupSocketListeners() { - this.socket.on('latencyTest', () => { - this.localPlayer.latency = (Date.now() / 1000 - this.lastLatencyTestEmit) * 1000; - this.lastLatencyTestGotResponse = true; - }); - - this.socket.on('remotePlayerData', (data: RemotePlayer[]) => { - this.remotePlayers = data; - this.processRemotePlayerData(); - }); - - this.socket.on('worldItemData', (data: WorldItem[]) => { - this.worldItems = data; - this.processWorldItemData(); - }); - - this.socket.on('chatMsg', (data: { id: number, name: string, message: string }) => { - if (data.id !== this.localPlayer.id) this.chatOverlay.addChatMessage(data); - }); - - this.socket.on('serverInfo', (data: ServerInfo) => { - this.serverInfo = data; - this.onServerInfo(); - }); - } - - private onServerInfo() { - this.uploadWait = 1 / this.serverInfo.tickRate; - } - public updatePlayerData() { - const currentTime = Date.now() / 1000; - this.localPlayer.gameVersion = this.gameVersion; - if (currentTime - this.lastUploadTime < this.uploadWait) return; - - if (this.localPlayer.gameVersion === '') return; - - if (this.playersAreEqualEnough(this.localPlayer, this.lastUploadedLocalPlayer) && currentTime - this.lastUploadTime < 4) - return; - - this.socket.emit('playerData', this.localPlayer); - this.lastUploadedLocalPlayer = { - position: this.localPlayer.position.clone(), - quaternion: this.localPlayer.quaternion.clone(), - chatMsg: this.localPlayer.chatMsg, - velocity: this.localPlayer.velocity.clone(), - name: this.localPlayer.name, - }; - - this.lastUploadTime = currentTime; - - if (currentTime - this.lastLatencyTestEmit > this.latencyTestWait) { - this.socket.emit('latencyTest'); - this.lastLatencyTestEmit = currentTime; - if (!this.lastLatencyTestGotResponse) { - this.localPlayer.latency = 999; - } - this.lastLatencyTestGotResponse = false; - } - } - - public processWorldItemData() { - // Implementation for processing world items - } - - public getServerInfo() { - return this.serverInfo; - } - - private processRemotePlayerData() { - this.messagesBeingTyped = []; - for (const remotePlayer of this.remotePlayers) { - if (remotePlayer.id === this.localPlayer.id) { - if (remotePlayer.forced) { - this.localPlayer.position.set(remotePlayer.position.x, remotePlayer.position.y, remotePlayer.position.z); - this.localPlayer.velocity.set(remotePlayer.velocity.x, remotePlayer.velocity.y, remotePlayer.velocity.z); - this.localPlayer.lookQuaternion.set(...remotePlayer.lookQuaternion); - //this.localPlayer.name = remotePlayer.name; - this.localPlayer.gravity = remotePlayer.gravity; - this.localPlayer.forcedAcknowledged = true; - } else { - this.localPlayer.forcedAcknowledged = false; - } - if (remotePlayer.health < this.localPlayer.health) this.damagedTimestamp = Date.now() / 1000; - this.localPlayer.health = remotePlayer.health; - 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; - continue; - } - if (remotePlayer.chatActive) - this.messagesBeingTyped.push(`${remotePlayer.name}: ${remotePlayer.chatMsg}`); - } - } - - private playersAreEqualEnough(player1: Player, player2: LastUploadedLocalPlayer | null) { - if (player1 === null || player2 === null) return false; - let out = true; - out = out && player1.position.equals(player2.position); - out = out && player1.quaternion.equals(player2.quaternion); - out = out && player1.chatMsg === player2.chatMsg; - out = out && player1.velocity.equals(player2.velocity); - out = out && player1.name === player2.name; - - return out; - } - - public getDamagedTimestamp() { - return this.damagedTimestamp; - } - - public getMessagesBeingTyped() { - return this.messagesBeingTyped; - } - - public getRemotePlayerData(): RemotePlayer[] { - return this.remotePlayers; - } - - public sendMessage(msg: string) { - const chatMessage = { - message: msg, - id: this.localPlayer.id, - name: this.localPlayer.name - }; - if (msg.length < 1) return; - if(chatMessage.message.startsWith('>')) chatMessage.message = '&2'+chatMessage.message; - if (msg.charAt(0) === '/'){ - this.socket.emit('chatMsg', chatMessage); - return; - } - chatMessage.message = '&f' + chatMessage.message; - this.chatOverlay.addChatMessage(chatMessage); - this.socket.emit('chatMsg', chatMessage); - } - - public applyDamage(id: number, damage: number) { - const player2 = this.remotePlayers.find(player => player.id === id); - const damageRequest = { - localPlayer: this.localPlayer, - targetPlayer: player2, - damage: damage - }; - this.socket.emit('applyDamage', damageRequest); - } - - public getWorldItemsData() { - return this.worldItems; - } + private socket: Socket; + private gameVersion: string = ''; + private remotePlayers: RemotePlayer[] = []; + private worldItems: WorldItem[] = []; + private lastUploadedLocalPlayer: LastUploadedLocalPlayer | null = null; + private lastUploadTime: number; + private uploadWait: number; + private lastLatencyTestEmit: number; + private lastLatencyTestGotResponse: boolean; + private latencyTestWait: number; + private messagesBeingTyped: string[] = []; + private localPlayer: Player; + private chatOverlay: ChatOverlay; + private damagedTimestamp: number = 0; + private serverInfo: ServerInfo; + + constructor(localPlayer: Player, chatOverlay: ChatOverlay) { + this.localPlayer = localPlayer; + this.chatOverlay = chatOverlay; + + this.socket = io(); + this.fetchVersion(); + + this.lastUploadTime = Date.now() / 1000; + this.uploadWait = 0.05; //gets replaced by server info + this.lastLatencyTestEmit = 0; + this.lastLatencyTestGotResponse = false; + this.latencyTestWait = 5; + + this.serverInfo = { + name: '', + maxPlayers: 0, + currentPlayers: 0, + mapName: '', + tickRate: 0, + version: '', + gameMode: '', + playerMaxHealth: 0, + }; + + this.setupSocketListeners(); + } + + private async fetchVersion() { + try { + const response = await fetch('gameVersion.json'); + const data = await response.json(); + this.gameVersion = data['version']; + } catch (e) { + console.error(e); + } + } + + private setupSocketListeners() { + this.socket.on('latencyTest', () => { + this.localPlayer.latency = (Date.now() / 1000 - this.lastLatencyTestEmit) * 1000; + this.lastLatencyTestGotResponse = true; + }); + + this.socket.on('remotePlayerData', (data: RemotePlayer[]) => { + this.remotePlayers = data; + this.processRemotePlayerData(); + }); + + this.socket.on('worldItemData', (data: WorldItem[]) => { + this.worldItems = data; + this.processWorldItemData(); + }); + + this.socket.on('chatMsg', (data: { id: number; name: string; message: string }) => { + if (data.id !== this.localPlayer.id) this.chatOverlay.addChatMessage(data); + }); + + this.socket.on('serverInfo', (data: ServerInfo) => { + this.serverInfo = data; + this.onServerInfo(); + }); + } + + private onServerInfo() { + this.uploadWait = 1 / this.serverInfo.tickRate; + } + public updatePlayerData() { + const currentTime = Date.now() / 1000; + this.localPlayer.gameVersion = this.gameVersion; + if (currentTime - this.lastUploadTime < this.uploadWait) return; + + if (this.localPlayer.gameVersion === '') return; + + if ( + this.playersAreEqualEnough(this.localPlayer, this.lastUploadedLocalPlayer) && + currentTime - this.lastUploadTime < 4 + ) { + return; + } + + this.socket.emit('playerData', this.localPlayer); + this.lastUploadedLocalPlayer = { + position: this.localPlayer.position.clone(), + quaternion: this.localPlayer.quaternion.clone(), + chatMsg: this.localPlayer.chatMsg, + velocity: this.localPlayer.velocity.clone(), + name: this.localPlayer.name, + }; + + this.lastUploadTime = currentTime; + + if (currentTime - this.lastLatencyTestEmit > this.latencyTestWait) { + this.socket.emit('latencyTest'); + this.lastLatencyTestEmit = currentTime; + if (!this.lastLatencyTestGotResponse) { + this.localPlayer.latency = 999; + } + this.lastLatencyTestGotResponse = false; + } + } + + public processWorldItemData() { + // Implementation for processing world items + } + + public getServerInfo() { + return this.serverInfo; + } + + private processRemotePlayerData() { + this.messagesBeingTyped = []; + for (const remotePlayer of this.remotePlayers) { + if (remotePlayer.id === this.localPlayer.id) { + if (remotePlayer.forced) { + this.localPlayer.position.set(remotePlayer.position.x, remotePlayer.position.y, remotePlayer.position.z); + this.localPlayer.velocity.set(remotePlayer.velocity.x, remotePlayer.velocity.y, remotePlayer.velocity.z); + this.localPlayer.lookQuaternion.set(...remotePlayer.lookQuaternion); + //this.localPlayer.name = remotePlayer.name; + this.localPlayer.gravity = remotePlayer.gravity; + this.localPlayer.forcedAcknowledged = true; + } else { + this.localPlayer.forcedAcknowledged = false; + } + if (remotePlayer.health < this.localPlayer.health) this.damagedTimestamp = Date.now() / 1000; + this.localPlayer.health = remotePlayer.health; + 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; + continue; + } + if (remotePlayer.chatActive) { + this.messagesBeingTyped.push(`${remotePlayer.name}: ${remotePlayer.chatMsg}`); + } + } + } + + private playersAreEqualEnough(player1: Player, player2: LastUploadedLocalPlayer | null) { + if (player1 === null || player2 === null) return false; + let out = true; + out = out && player1.position.equals(player2.position); + out = out && player1.quaternion.equals(player2.quaternion); + out = out && player1.chatMsg === player2.chatMsg; + out = out && player1.velocity.equals(player2.velocity); + out = out && player1.name === player2.name; + + return out; + } + + public getDamagedTimestamp() { + return this.damagedTimestamp; + } + + public getMessagesBeingTyped() { + return this.messagesBeingTyped; + } + + public getRemotePlayerData(): RemotePlayer[] { + return this.remotePlayers; + } + + public sendMessage(msg: string) { + const chatMessage = { + message: msg, + id: this.localPlayer.id, + name: this.localPlayer.name, + }; + if (msg.length < 1) return; + if (chatMessage.message.startsWith('>')) chatMessage.message = '&2' + chatMessage.message; + if (msg.charAt(0) === '/') { + this.socket.emit('chatMsg', chatMessage); + return; + } + chatMessage.message = '&f' + chatMessage.message; + this.chatOverlay.addChatMessage(chatMessage); + this.socket.emit('chatMsg', chatMessage); + } + + public applyDamage(id: number, damage: number) { + const player2 = this.remotePlayers.find((player) => player.id === id); + const damageRequest = { + localPlayer: this.localPlayer, + targetPlayer: player2, + damage: damage, + }; + this.socket.emit('applyDamage', damageRequest); + } + + public getWorldItemsData() { + return this.worldItems; + } } diff --git a/src/client/core/Player.ts b/src/client/core/Player.ts index a875deee..c88b55bf 100644 --- a/src/client/core/Player.ts +++ b/src/client/core/Player.ts @@ -1,55 +1,55 @@ import * as THREE from 'three'; -import {SettingsManager} from "./SettingsManager.ts"; +import { SettingsManager } from './SettingsManager.ts'; export class Player { - public position: THREE.Vector3; - public velocity: THREE.Vector3; - public inputVelocity: THREE.Vector3; - public gravity: number; - public lookQuaternion: THREE.Quaternion; - public quaternion: THREE.Quaternion; - public id: number; - public gameVersion: string; - public name: string; - public speed: number; - public acceleration: number; - public chatActive: boolean; - public chatMsg: string; - public latency: number; - public health: number; - public forced: boolean; - public forcedAcknowledged: boolean; - public inventory: number[]; - public idLastDamagedBy: number; - public playerSpectating: number; - public gameMsgs: string[]; - public gameMsgs2: string[]; + public position: THREE.Vector3; + public velocity: THREE.Vector3; + public inputVelocity: THREE.Vector3; + public gravity: number; + public lookQuaternion: THREE.Quaternion; + public quaternion: THREE.Quaternion; + public id: number; + public gameVersion: string; + public name: string; + public speed: number; + public acceleration: number; + public chatActive: boolean; + public chatMsg: string; + public latency: number; + public health: number; + public forced: boolean; + public forcedAcknowledged: boolean; + public inventory: number[]; + public idLastDamagedBy: number; + public playerSpectating: number; + public gameMsgs: string[]; + public gameMsgs2: string[]; - constructor() { - this.position = new THREE.Vector3(0,100,0); - this.velocity = new THREE.Vector3(0,0,0); - this.inputVelocity = new THREE.Vector3(); - this.gravity = 0; - this.lookQuaternion = new THREE.Quaternion(); - this.quaternion = new THREE.Quaternion(); - this.id = Math.floor(Math.random() * 1000000000); - this.gameVersion = ''; - this.name = ''; - this.speed = 5; - this.acceleration = 100; - this.chatActive = false; - this.chatMsg = ''; - this.latency = 1000; - this.health = 100; - this.forced = false; - this.forcedAcknowledged = false; - this.inventory = []; - this.idLastDamagedBy = -1; - this.playerSpectating = -1; - this.gameMsgs = []; - this.gameMsgs2 = []; + constructor() { + this.position = new THREE.Vector3(0, 100, 0); + this.velocity = new THREE.Vector3(0, 0, 0); + this.inputVelocity = new THREE.Vector3(); + this.gravity = 0; + this.lookQuaternion = new THREE.Quaternion(); + this.quaternion = new THREE.Quaternion(); + this.id = Math.floor(Math.random() * 1000000000); + this.gameVersion = ''; + this.name = ''; + this.speed = 5; + this.acceleration = 100; + this.chatActive = false; + this.chatMsg = ''; + this.latency = 1000; + this.health = 100; + this.forced = false; + this.forcedAcknowledged = false; + this.inventory = []; + this.idLastDamagedBy = -1; + this.playerSpectating = -1; + this.gameMsgs = []; + this.gameMsgs2 = []; - const storedName = SettingsManager.settings.name; - if (storedName) this.name = storedName; - } -} \ No newline at end of file + const storedName = SettingsManager.settings.name; + if (storedName) this.name = storedName; + } +} diff --git a/src/client/core/RemoteItemRenderer.ts b/src/client/core/RemoteItemRenderer.ts index b3e87ad3..ddff5e29 100644 --- a/src/client/core/RemoteItemRenderer.ts +++ b/src/client/core/RemoteItemRenderer.ts @@ -7,94 +7,103 @@ import { FishGun } from '../items/FishGun.ts'; // Custom types type Vector3Data = { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; }; type WorldItemData = { - id: number; - itemType: number; - vector: Vector3Data; + id: number; + itemType: number; + vector: Vector3Data; }; type ItemsToRenderEntry = { - id: number; - item: ItemBase; + id: number; + item: ItemBase; }; export class RemoteItemRenderer { - private networking: Networking; - private renderer: Renderer; - private itemsToRender: ItemsToRenderEntry[] = []; - private worldItemsData: WorldItemData[] = []; + private networking: Networking; + private renderer: Renderer; + private itemsToRender: ItemsToRenderEntry[] = []; + private worldItemsData: WorldItemData[] = []; - constructor(networking: Networking, renderer: Renderer) { - this.networking = networking; - this.renderer = renderer; - } + constructor(networking: Networking, renderer: Renderer) { + this.networking = networking; + this.renderer = renderer; + } - public update() { - // Get the latest world items data from networking - const newWorldItemsData: WorldItemData[] = this.networking.getWorldItemsData(); + public update() { + // Get the latest world items data from networking + const newWorldItemsData: WorldItemData[] = this.networking.getWorldItemsData(); - // Process the new data to update itemsToRender - this.updateWorldItems(newWorldItemsData); - } + // Process the new data to update itemsToRender + this.updateWorldItems(newWorldItemsData); + } - private updateWorldItems(newWorldItemsData: WorldItemData[]) { - // Update existing items and add new items - newWorldItemsData.forEach((worldItemData) => { - const existingItem = this.itemsToRender.find(item => item.id === worldItemData.id); - if (existingItem) { - // Update position - existingItem.item.setWorldPosition(new THREE.Vector3( - worldItemData.vector.x, - worldItemData.vector.y, - worldItemData.vector.z - )); - } else { - // Create new item - const item = this.createItemByType(worldItemData.itemType); - if (item) { - item.setWorldPosition(new THREE.Vector3( - worldItemData.vector.x, - worldItemData.vector.y, - worldItemData.vector.z - )); - this.itemsToRender.push({ id: worldItemData.id, item }); - } - } - }); + private updateWorldItems(newWorldItemsData: WorldItemData[]) { + // Update existing items and add new items + newWorldItemsData.forEach((worldItemData) => { + const existingItem = this.itemsToRender.find((item) => item.id === worldItemData.id); + if (existingItem) { + // Update position + existingItem.item.setWorldPosition( + new THREE.Vector3( + worldItemData.vector.x, + worldItemData.vector.y, + worldItemData.vector.z, + ), + ); + } else { + // Create new item + const item = this.createItemByType(worldItemData.itemType); + if (item) { + item.setWorldPosition( + new THREE.Vector3( + worldItemData.vector.x, + worldItemData.vector.y, + worldItemData.vector.z, + ), + ); + this.itemsToRender.push({ id: worldItemData.id, item }); + } + } + }); - // Remove items that are no longer in the newWorldItemsData - this.itemsToRender = this.itemsToRender.filter(item => { - const existsInNewData = newWorldItemsData.some(worldItemData => worldItemData.id === item.id); - if (!existsInNewData) { - // Remove item from scene - item.item.destroy(); - } - return existsInNewData; - }); - } + // Remove items that are no longer in the newWorldItemsData + this.itemsToRender = this.itemsToRender.filter((item) => { + const existsInNewData = newWorldItemsData.some((worldItemData) => worldItemData.id === item.id); + if (!existsInNewData) { + // Remove item from scene + item.item.destroy(); + } + return existsInNewData; + }); + } - private createItemByType(itemType: number): ItemBase | null { - // Create item based on itemType - switch (itemType) { - case 1: - return new BananaGun(this.renderer, this.networking, 0, ItemType.WorldItem); - case 2: - return new FishGun(this.renderer, this.networking, 0, ItemType.WorldItem); - default: - // Return a generic item - return new ItemBase(ItemType.WorldItem, this.renderer.getEntityScene(), this.renderer.getInventoryMenuScene(), 0); - } - } + private createItemByType(itemType: number): ItemBase | null { + // Create item based on itemType + switch (itemType) { + case 1: + return new BananaGun(this.renderer, this.networking, 0, ItemType.WorldItem); + case 2: + return new FishGun(this.renderer, this.networking, 0, ItemType.WorldItem); + default: + // Return a generic item + return new ItemBase( + ItemType.WorldItem, + this.renderer.getEntityScene(), + this.renderer.getInventoryMenuScene(), + 0, + ); + } + } - public onFrame() { - this.update(); - this.itemsToRender.forEach(itemEntry => { - itemEntry.item.onFrame(undefined, undefined); // Passing null for input and selectedIndex - }); - } + public onFrame() { + this.update(); + this.itemsToRender.forEach((itemEntry) => { + itemEntry.item.onFrame(undefined, undefined); // Passing null for input and selectedIndex + }); + } } diff --git a/src/client/core/RemotePlayerRenderer.ts b/src/client/core/RemotePlayerRenderer.ts index d1805869..437ffd0f 100644 --- a/src/client/core/RemotePlayerRenderer.ts +++ b/src/client/core/RemotePlayerRenderer.ts @@ -3,521 +3,531 @@ import { Networking } from './Networking.ts'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { Player } from './Player.ts'; -import {acceleratedRaycast, computeBoundsTree} from "three-mesh-bvh"; +import { acceleratedRaycast, computeBoundsTree } from 'three-mesh-bvh'; THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; THREE.Mesh.prototype.raycast = acceleratedRaycast; interface RemotePlayerData { - health: number; - id: number; - velocity: { x: number; y: number; z: number }; - position: { x: number; y: number; z: number }; - quaternion: [number, number, number, number]; // Add quaternion as required - forced: boolean; - name: string; - playerSpectating: number; + health: number; + id: number; + velocity: { x: number; y: number; z: number }; + position: { x: number; y: number; z: number }; + quaternion: [number, number, number, number]; // Add quaternion as required + forced: boolean; + name: string; + playerSpectating: number; } interface RemotePlayer extends Omit { - quaternion?: [number, number, number, number]; // Optional in case it's missing + quaternion?: [number, number, number, number]; // Optional in case it's missing } interface PlayerToRender { - id: number; - object: THREE.Object3D; - objectUUID: string; - sphere: THREE.Object3D; - nameLabel: THREE.Sprite; - name: string; + id: number; + object: THREE.Object3D; + objectUUID: string; + sphere: THREE.Object3D; + nameLabel: THREE.Sprite; + name: string; } export class RemotePlayerRenderer { - private entityScene: THREE.Scene; - private playersToRender: PlayerToRender[]; - private possumMesh: THREE.Mesh | undefined; - private loader: GLTFLoader; - private dracoLoader: DRACOLoader; - - private sphere: THREE.Mesh; - private sphereScene: THREE.Scene; - - private raycaster: THREE.Raycaster; - private camera: THREE.Camera; - private scene: THREE.Scene - - private isAnimating: { [id: number]: boolean }; - private animationPhase: { [id: number]: number }; - private previousVelocity: { [id: number]: number }; - private lastRunningYOffset: { [id: number]: number }; - - private groundTruthPositions: { [id: number]: THREE.Vector3 }; - - private networking: Networking; - private localPlayer: Player; - private deltaTime: number = 0; // Initialize deltaTime to avoid Deno error - private static minVelocityToAnimate = 0.1; - private static map: THREE.Mesh = new THREE.Mesh(); - - private crosshairVec = new THREE.Vector2(); - - constructor( - networking: Networking, - localPlayer: Player, - raycaster: THREE.Raycaster, - camera: THREE.Camera, - scene: THREE.Scene - ) { - this.networking = networking; - this.localPlayer = localPlayer; - this.raycaster = raycaster; - this.camera = camera; - this.scene = scene; - - this.entityScene = new THREE.Scene(); - - this.sphere = new THREE.Mesh(new THREE.SphereGeometry(.6), new THREE.MeshBasicMaterial({color: 0xffffff})); - this.sphere.geometry.computeBoundsTree(); - this.sphereScene = new THREE.Scene(); - - this.loader = new GLTFLoader(); - this.dracoLoader = new DRACOLoader(); - this.dracoLoader.setDecoderPath('/draco/'); - this.loader.setDRACOLoader(this.dracoLoader); - - this.possumMesh = undefined; - this.loader.load( - 'models/simplified_possum.glb', - (gltf) => { - console.time("Computing possum BVH"); - (gltf.scene.children[0]).geometry.computeBoundsTree(); - console.timeEnd("Computing possum BVH"); - this.possumMesh = (gltf.scene.children[0]); - }, - undefined, - () => { - console.log('possum loading error'); - } - ); - - this.playersToRender = []; - - this.isAnimating = {}; - this.animationPhase = {}; - this.previousVelocity = {}; - this.lastRunningYOffset = {}; - - this.groundTruthPositions = {}; - } - - public getEntityScene(): THREE.Scene { - return this.entityScene; - } - - public update(deltaTime: number): void { - this.deltaTime = deltaTime; - this.updateRemotePlayers(); - } - - private updateRemotePlayers(): void { - if (!this.possumMesh) return; - - const remotePlayerData: RemotePlayer[] = this.networking.getRemotePlayerData(); - const localPlayerId = this.localPlayer.id; - - // First, remove all players that should be hidden - this.playersToRender = this.playersToRender.filter((player) => { - const remotePlayer = remotePlayerData.find(rp => rp.id === player.id); - const shouldHide = - !remotePlayer || // Player no longer exists - remotePlayer.id === localPlayerId || // Is local player - remotePlayer.id === this.localPlayer.playerSpectating || // Is being spectated - remotePlayer.playerSpectating !== -1; // Is spectating someone - - if (shouldHide) { - // Remove the player's objects from scenes - this.entityScene.remove(player.object); - this.entityScene.remove(player.nameLabel); - this.sphereScene.remove(player.sphere); - - // Clean up associated data - delete this.groundTruthPositions[player.id]; - delete this.isAnimating[player.id]; - delete this.animationPhase[player.id]; - delete this.previousVelocity[player.id]; - delete this.lastRunningYOffset[player.id]; - } - - return !shouldHide; - }); - - // Then, update or add remaining valid players - remotePlayerData.forEach((remotePlayer) => { - // Skip if player should be hidden - if (remotePlayer.id === localPlayerId || - remotePlayer.id === this.localPlayer.playerSpectating || - remotePlayer.playerSpectating !== -1) { - return; - } - - const playerDataWithQuaternion: RemotePlayerData = { - ...remotePlayer, - quaternion: remotePlayer.quaternion || [0, 0, 0, 1], - }; - - const existingPlayer = this.playersToRender.find((player) => player.id === remotePlayer.id); - if (existingPlayer) { - this.updatePlayerPosition(existingPlayer.object, existingPlayer.sphere, playerDataWithQuaternion); - } else { - this.addNewPlayer(playerDataWithQuaternion); - } - }); - } - - - private updatePlayerPosition(playerObject: THREE.Object3D, playerSphere: THREE.Object3D, remotePlayerData: RemotePlayerData): void { - const velocity = Math.sqrt( - Math.pow(remotePlayerData.velocity.x, 2) + - Math.pow(remotePlayerData.velocity.y, 2) + - Math.pow(remotePlayerData.velocity.z, 2) - ); - - const playerId = remotePlayerData.id; - const prevVelocity = this.previousVelocity[playerId] || 0; - - if (prevVelocity <= RemotePlayerRenderer.minVelocityToAnimate && velocity > RemotePlayerRenderer.minVelocityToAnimate) { - this.isAnimating[playerId] = true; - this.animationPhase[playerId] = 0; - } - - this.previousVelocity[playerId] = velocity; - - // Get or initialize groundTruthPosition - if (!this.groundTruthPositions[playerId]) { - this.groundTruthPositions[playerId] = new THREE.Vector3( - remotePlayerData.position.x, - remotePlayerData.position.y, - remotePlayerData.position.z - ); - } - - const groundTruthPosition = this.groundTruthPositions[playerId]; - - // Apply velocity to groundTruthPosition - groundTruthPosition.x += remotePlayerData.velocity.x * this.deltaTime; - groundTruthPosition.y += remotePlayerData.velocity.y * this.deltaTime; - groundTruthPosition.z += remotePlayerData.velocity.z * this.deltaTime; - - // If forced, set groundTruthPosition to remotePlayerData.position - if (remotePlayerData.forced) { - groundTruthPosition.set( - remotePlayerData.position.x, - remotePlayerData.position.y, - remotePlayerData.position.z - ); - } - - // Lerp groundTruthPosition towards remotePlayerData.position - groundTruthPosition.lerp( - new THREE.Vector3( - remotePlayerData.position.x, - remotePlayerData.position.y, - remotePlayerData.position.z - ), - 0.1 * this.deltaTime * 60 - ); - - // Set playerObject.position to groundTruthPosition.clone() - playerObject.position.copy(groundTruthPosition); - playerSphere.position.copy(groundTruthPosition.clone()); - - // Apply animation offsets (e.g., yOffset) - if (this.isAnimating[playerId]) { - const frequency = 25; - this.animationPhase[playerId] += this.deltaTime * frequency; - - const amplitude = 0.08; - const yOffset = amplitude * (1 + Math.cos(this.animationPhase[playerId])); - - playerObject.position.y += yOffset; - playerSphere.position.y += yOffset; - this.lastRunningYOffset[playerId] = yOffset; - - if (velocity <= RemotePlayerRenderer.minVelocityToAnimate && Math.cos(this.animationPhase[playerId]) <= 0) { - this.isAnimating[playerId] = false; - this.lastRunningYOffset[playerId] = 0; - } - } else { - this.lastRunningYOffset[playerId] = 0; - } - - // Apply scared effect - const scaredLevel = 1 - Math.pow(remotePlayerData.health / this.networking.getServerInfo().playerMaxHealth, 2); // 0-1 - playerObject.position.x += (Math.random() - 0.5) * 0.05 * scaredLevel; - playerObject.position.y += (Math.random() - 0.5) * 0.05 * scaredLevel; - playerObject.position.z += (Math.random() - 0.5) * 0.05 * scaredLevel; - - // Apply quaternion slerp as before - const targetQuaternion = new THREE.Quaternion( - remotePlayerData.quaternion[0], - remotePlayerData.quaternion[1], - remotePlayerData.quaternion[2], - remotePlayerData.quaternion[3] - ); - const rotationQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); - targetQuaternion.multiply(rotationQuaternion); - - playerObject.quaternion.slerp(targetQuaternion, 0.5 * this.deltaTime * 60); - - // Update the position of the name label - const player = this.playersToRender.find(p => p.id === remotePlayerData.id); - if (player) { - player.nameLabel.position.set( - playerObject.position.x, - playerObject.position.y + 0.40, // Adjust the Y offset as needed - playerObject.position.z - ); - player.nameLabel.lookAt(this.camera.position); - - // Check if the name has changed - if (player.name !== remotePlayerData.name) { - player.name = remotePlayerData.name; // Update stored name - // Remove old label - this.entityScene.remove(player.nameLabel); - // Create and add new label - player.nameLabel = this.createTextSprite(remotePlayerData.name.toString()); - player.nameLabel.position.set( - playerObject.position.x, - playerObject.position.y + 0.40, - playerObject.position.z - ); - this.entityScene.add(player.nameLabel); - } - } - } - - private addNewPlayer(remotePlayerData: RemotePlayerData): void { - const object = this.possumMesh!.clone(); - const sphere = this.sphere.clone(); - - // Create a text sprite for the player's name - const nameLabel = this.createTextSprite(remotePlayerData.name.toString()); - - const newPlayer: PlayerToRender = { - id: remotePlayerData.id, - object: object, - objectUUID: object.uuid, - sphere: sphere, - nameLabel: nameLabel, - name: remotePlayerData.name, - }; - - this.playersToRender.push(newPlayer); - this.entityScene.add(newPlayer.object); - this.sphereScene.add(newPlayer.sphere); - this.entityScene.add(newPlayer.nameLabel); - - // Initialize groundTruthPosition for the new player - this.groundTruthPositions[remotePlayerData.id] = new THREE.Vector3( - remotePlayerData.position.x, - remotePlayerData.position.y, - remotePlayerData.position.z - ); - } - - private removeInactivePlayers(remotePlayerData: RemotePlayer[]): void { - this.playersToRender = this.playersToRender.filter((player) => { - const isActive = remotePlayerData.some((remotePlayer) => remotePlayer.id === player.id); - if (!isActive) { - this.entityScene.remove(player.object); - this.entityScene.remove(player.nameLabel); - this.sphereScene.remove(player.sphere); - // Remove associated data for the player - delete this.groundTruthPositions[player.id]; - delete this.isAnimating[player.id]; - delete this.animationPhase[player.id]; - delete this.previousVelocity[player.id]; - delete this.lastRunningYOffset[player.id]; - } - return isActive; - }); - } - - private createTextSprite(text: string): THREE.Sprite { - text = text.replace(/&[0123456789abcdef]/g, ''); // Remove color codes - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d')!; - const fontSize = 64; - context.font = `${fontSize}px Comic Sans MS`; - - // Measure the text width and set canvas size accordingly - const textWidth = context.measureText(text).width; - canvas.width = textWidth * 2; // Increase resolution - canvas.height = fontSize * 2; // Increase resolution - - // Redraw the text on the canvas - context.font = `${fontSize}px Comic Sans MS`; - context.fillStyle = 'rgba(255,255,255,1)'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillText(text, canvas.width / 2, canvas.height / 2); - - const texture = new THREE.CanvasTexture(canvas); - const spriteMaterial = new THREE.SpriteMaterial({ map: texture }); - const sprite = new THREE.Sprite(spriteMaterial); - - // Adjust the sprite scale to match the canvas aspect ratio - sprite.scale.set((textWidth / fontSize ) * 0.4 , 0.4, 0.4); - - return sprite; - } - - - public getRemotePlayerIDsInCrosshair(): number[] { - const shotVectors = this.getShotVectorsToPlayersInCrosshair(); - const playerIDs = shotVectors.map(shot => shot.playerID); - return playerIDs; - } - - public getShotVectorsToPlayersInCrosshair(): { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] { - const shotVectors: { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] = []; - const objectsInCrosshair = this.getPlayersInCrosshairWithWalls(); - - for (const object of objectsInCrosshair) { - for (const player of this.playersToRender) { - if (player.objectUUID === object.uuid) { - // Find the intersection point on the player - const intersection = this.findIntersectionOnPlayer(object); - if (intersection) { - const vector = new THREE.Vector3().subVectors(intersection.point, this.camera.position); - const hitPoint = intersection.point.clone(); // World coordinates of the hit - shotVectors.push({ playerID: player.id, vector, hitPoint }); - } - break; - } - } - } - - return shotVectors; - } - - private findIntersectionOnPlayer(playerObject: THREE.Object3D): THREE.Intersection | null { - this.raycaster.setFromCamera(this.crosshairVec, this.camera); - - const intersects = this.raycaster.intersectObject(playerObject, true); - if (intersects.length > 0) { - return intersects[0]; // Return the first intersection point - } - return null; - } - - private getPlayersInCrosshairWithWalls(): THREE.Object3D[] { - this.raycaster.setFromCamera(this.crosshairVec, this.camera); - - const playerIntersects = this.raycaster.intersectObjects(this.entityScene.children); - this.raycaster.firstHitOnly = true; - const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); - this.raycaster.firstHitOnly = false; - - const filteredIntersects = playerIntersects.filter((playerIntersect) => { - for (const wallIntersect of wallIntersects) { - if (wallIntersect.distance < playerIntersect.distance) { - return false; - } - } - return true; - }); - - return filteredIntersects.map((intersect) => intersect.object); - } - - public getPlayerSpheresInCrosshairWithWalls(): THREE.Object3D[] { - this.raycaster.setFromCamera(this.crosshairVec, this.camera); - - this.sphereScene.updateMatrixWorld(); - - this.raycaster.firstHitOnly = true; - const playerIntersects = this.raycaster.intersectObjects(this.sphereScene.children); - const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); - this.raycaster.firstHitOnly = false; - - const filteredIntersects = playerIntersects.filter((playerIntersect) => { - for (const wallIntersect of wallIntersects) { - if (wallIntersect.distance < playerIntersect.distance) { - return false; - } - } - return true; - }); - - return filteredIntersects.map((intersect) => intersect.object); - } - - public getShotVectorsToPlayersWithOffset(yawOffset: number, pitchOffset: number): { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] { - const shotVectors: { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] = []; - const offsetDirection = this.calculateOffsetDirection(yawOffset, pitchOffset); - - // Set the raycaster with the offset direction - this.raycaster.set(this.camera.position, offsetDirection); - - // Intersect with all potential targets (players and walls) - const playerIntersects = this.raycaster.intersectObjects(this.playersToRender.map(p => p.object), true); - this.raycaster.firstHitOnly = true; - const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); - this.raycaster.firstHitOnly = false; - - // Filter player intersections based on wall intersections - const filteredPlayerIntersects = playerIntersects.filter((playerIntersect) => { - for (const wallIntersect of wallIntersects) { - if (wallIntersect.distance < playerIntersect.distance) { - return false; // A wall is blocking the player - } - } - return true; // No wall is blocking the player - }); - - // Process the filtered player intersections - for (const intersect of filteredPlayerIntersects) { - const player = this.playersToRender.find(p => p.object === intersect.object); - if (player) { - const vector = new THREE.Vector3().subVectors(intersect.point, this.camera.position); - const hitPoint = intersect.point.clone(); // World coordinates of the hit - shotVectors.push({ playerID: player.id, vector, hitPoint }); - } - } - - return shotVectors; - } - - - private calculateOffsetDirection(yawOffset: number, pitchOffset: number): THREE.Vector3 { - // Get the camera's current direction - const direction = new THREE.Vector3(); - this.camera.getWorldDirection(direction); - - // Create a quaternion for the yaw and pitch offsets - const yawQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset); - const pitchQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset); - - // Apply the rotations - direction.applyQuaternion(yawQuaternion); - direction.applyQuaternion(pitchQuaternion); - - return direction.normalize(); - } - - private findIntersectionOnPlayerWithOffset(playerObject: THREE.Object3D, offsetDirection: THREE.Vector3): THREE.Intersection | null { - // Set the raycaster with the offset direction - this.raycaster.set(this.camera.position, offsetDirection); - - const intersects = this.raycaster.intersectObject(playerObject, true); - if (intersects.length > 0) { - return intersects[0]; // Return the first intersection point - } - return null; - } - - public static setMap(map: THREE.Mesh) { - this.map = map; - } + private entityScene: THREE.Scene; + private playersToRender: PlayerToRender[]; + private possumMesh: THREE.Mesh | undefined; + private loader: GLTFLoader; + private dracoLoader: DRACOLoader; + + private sphere: THREE.Mesh; + private sphereScene: THREE.Scene; + + private raycaster: THREE.Raycaster; + private camera: THREE.Camera; + private scene: THREE.Scene; + + private isAnimating: { [id: number]: boolean }; + private animationPhase: { [id: number]: number }; + private previousVelocity: { [id: number]: number }; + private lastRunningYOffset: { [id: number]: number }; + + private groundTruthPositions: { [id: number]: THREE.Vector3 }; + + private networking: Networking; + private localPlayer: Player; + private deltaTime: number = 0; // Initialize deltaTime to avoid Deno error + private static minVelocityToAnimate = 0.1; + private static map: THREE.Mesh = new THREE.Mesh(); + + private crosshairVec = new THREE.Vector2(); + + constructor( + networking: Networking, + localPlayer: Player, + raycaster: THREE.Raycaster, + camera: THREE.Camera, + scene: THREE.Scene, + ) { + this.networking = networking; + this.localPlayer = localPlayer; + this.raycaster = raycaster; + this.camera = camera; + this.scene = scene; + + this.entityScene = new THREE.Scene(); + + this.sphere = new THREE.Mesh(new THREE.SphereGeometry(.6), new THREE.MeshBasicMaterial({ color: 0xffffff })); + this.sphere.geometry.computeBoundsTree(); + this.sphereScene = new THREE.Scene(); + + this.loader = new GLTFLoader(); + this.dracoLoader = new DRACOLoader(); + this.dracoLoader.setDecoderPath('/draco/'); + this.loader.setDRACOLoader(this.dracoLoader); + + this.possumMesh = undefined; + this.loader.load( + 'models/simplified_possum.glb', + (gltf) => { + console.time('Computing possum BVH'); + ( gltf.scene.children[0]).geometry.computeBoundsTree(); + console.timeEnd('Computing possum BVH'); + this.possumMesh = gltf.scene.children[0]; + }, + undefined, + () => { + console.log('possum loading error'); + }, + ); + + this.playersToRender = []; + + this.isAnimating = {}; + this.animationPhase = {}; + this.previousVelocity = {}; + this.lastRunningYOffset = {}; + + this.groundTruthPositions = {}; + } + + public getEntityScene(): THREE.Scene { + return this.entityScene; + } + + public update(deltaTime: number): void { + this.deltaTime = deltaTime; + this.updateRemotePlayers(); + } + + private updateRemotePlayers(): void { + if (!this.possumMesh) return; + + const remotePlayerData: RemotePlayer[] = this.networking.getRemotePlayerData(); + const localPlayerId = this.localPlayer.id; + + // First, remove all players that should be hidden + this.playersToRender = this.playersToRender.filter((player) => { + const remotePlayer = remotePlayerData.find((rp) => rp.id === player.id); + const shouldHide = !remotePlayer || // Player no longer exists + remotePlayer.id === localPlayerId || // Is local player + remotePlayer.id === this.localPlayer.playerSpectating || // Is being spectated + remotePlayer.playerSpectating !== -1; // Is spectating someone + + if (shouldHide) { + // Remove the player's objects from scenes + this.entityScene.remove(player.object); + this.entityScene.remove(player.nameLabel); + this.sphereScene.remove(player.sphere); + + // Clean up associated data + delete this.groundTruthPositions[player.id]; + delete this.isAnimating[player.id]; + delete this.animationPhase[player.id]; + delete this.previousVelocity[player.id]; + delete this.lastRunningYOffset[player.id]; + } + + return !shouldHide; + }); + + // Then, update or add remaining valid players + remotePlayerData.forEach((remotePlayer) => { + // Skip if player should be hidden + if ( + remotePlayer.id === localPlayerId || + remotePlayer.id === this.localPlayer.playerSpectating || + remotePlayer.playerSpectating !== -1 + ) { + return; + } + + const playerDataWithQuaternion: RemotePlayerData = { + ...remotePlayer, + quaternion: remotePlayer.quaternion || [0, 0, 0, 1], + }; + + const existingPlayer = this.playersToRender.find((player) => player.id === remotePlayer.id); + if (existingPlayer) { + this.updatePlayerPosition(existingPlayer.object, existingPlayer.sphere, playerDataWithQuaternion); + } else { + this.addNewPlayer(playerDataWithQuaternion); + } + }); + } + + private updatePlayerPosition( + playerObject: THREE.Object3D, + playerSphere: THREE.Object3D, + remotePlayerData: RemotePlayerData, + ): void { + const velocity = Math.sqrt( + Math.pow(remotePlayerData.velocity.x, 2) + + Math.pow(remotePlayerData.velocity.y, 2) + + Math.pow(remotePlayerData.velocity.z, 2), + ); + + const playerId = remotePlayerData.id; + const prevVelocity = this.previousVelocity[playerId] || 0; + + if ( + prevVelocity <= RemotePlayerRenderer.minVelocityToAnimate && velocity > RemotePlayerRenderer.minVelocityToAnimate + ) { + this.isAnimating[playerId] = true; + this.animationPhase[playerId] = 0; + } + + this.previousVelocity[playerId] = velocity; + + // Get or initialize groundTruthPosition + if (!this.groundTruthPositions[playerId]) { + this.groundTruthPositions[playerId] = new THREE.Vector3( + remotePlayerData.position.x, + remotePlayerData.position.y, + remotePlayerData.position.z, + ); + } + + const groundTruthPosition = this.groundTruthPositions[playerId]; + + // Apply velocity to groundTruthPosition + groundTruthPosition.x += remotePlayerData.velocity.x * this.deltaTime; + groundTruthPosition.y += remotePlayerData.velocity.y * this.deltaTime; + groundTruthPosition.z += remotePlayerData.velocity.z * this.deltaTime; + + // If forced, set groundTruthPosition to remotePlayerData.position + if (remotePlayerData.forced) { + groundTruthPosition.set( + remotePlayerData.position.x, + remotePlayerData.position.y, + remotePlayerData.position.z, + ); + } + + // Lerp groundTruthPosition towards remotePlayerData.position + groundTruthPosition.lerp( + new THREE.Vector3( + remotePlayerData.position.x, + remotePlayerData.position.y, + remotePlayerData.position.z, + ), + 0.1 * this.deltaTime * 60, + ); + + // Set playerObject.position to groundTruthPosition.clone() + playerObject.position.copy(groundTruthPosition); + playerSphere.position.copy(groundTruthPosition.clone()); + + // Apply animation offsets (e.g., yOffset) + if (this.isAnimating[playerId]) { + const frequency = 25; + this.animationPhase[playerId] += this.deltaTime * frequency; + + const amplitude = 0.08; + const yOffset = amplitude * (1 + Math.cos(this.animationPhase[playerId])); + + playerObject.position.y += yOffset; + playerSphere.position.y += yOffset; + this.lastRunningYOffset[playerId] = yOffset; + + if (velocity <= RemotePlayerRenderer.minVelocityToAnimate && Math.cos(this.animationPhase[playerId]) <= 0) { + this.isAnimating[playerId] = false; + this.lastRunningYOffset[playerId] = 0; + } + } else { + this.lastRunningYOffset[playerId] = 0; + } + + // Apply scared effect + const scaredLevel = 1 - Math.pow(remotePlayerData.health / this.networking.getServerInfo().playerMaxHealth, 2); // 0-1 + playerObject.position.x += (Math.random() - 0.5) * 0.05 * scaredLevel; + playerObject.position.y += (Math.random() - 0.5) * 0.05 * scaredLevel; + playerObject.position.z += (Math.random() - 0.5) * 0.05 * scaredLevel; + + // Apply quaternion slerp as before + const targetQuaternion = new THREE.Quaternion( + remotePlayerData.quaternion[0], + remotePlayerData.quaternion[1], + remotePlayerData.quaternion[2], + remotePlayerData.quaternion[3], + ); + const rotationQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); + targetQuaternion.multiply(rotationQuaternion); + + playerObject.quaternion.slerp(targetQuaternion, 0.5 * this.deltaTime * 60); + + // Update the position of the name label + const player = this.playersToRender.find((p) => p.id === remotePlayerData.id); + if (player) { + player.nameLabel.position.set( + playerObject.position.x, + playerObject.position.y + 0.40, // Adjust the Y offset as needed + playerObject.position.z, + ); + player.nameLabel.lookAt(this.camera.position); + + // Check if the name has changed + if (player.name !== remotePlayerData.name) { + player.name = remotePlayerData.name; // Update stored name + // Remove old label + this.entityScene.remove(player.nameLabel); + // Create and add new label + player.nameLabel = this.createTextSprite(remotePlayerData.name.toString()); + player.nameLabel.position.set( + playerObject.position.x, + playerObject.position.y + 0.40, + playerObject.position.z, + ); + this.entityScene.add(player.nameLabel); + } + } + } + + private addNewPlayer(remotePlayerData: RemotePlayerData): void { + const object = this.possumMesh!.clone(); + const sphere = this.sphere.clone(); + + // Create a text sprite for the player's name + const nameLabel = this.createTextSprite(remotePlayerData.name.toString()); + + const newPlayer: PlayerToRender = { + id: remotePlayerData.id, + object: object, + objectUUID: object.uuid, + sphere: sphere, + nameLabel: nameLabel, + name: remotePlayerData.name, + }; + + this.playersToRender.push(newPlayer); + this.entityScene.add(newPlayer.object); + this.sphereScene.add(newPlayer.sphere); + this.entityScene.add(newPlayer.nameLabel); + + // Initialize groundTruthPosition for the new player + this.groundTruthPositions[remotePlayerData.id] = new THREE.Vector3( + remotePlayerData.position.x, + remotePlayerData.position.y, + remotePlayerData.position.z, + ); + } + + private removeInactivePlayers(remotePlayerData: RemotePlayer[]): void { + this.playersToRender = this.playersToRender.filter((player) => { + const isActive = remotePlayerData.some((remotePlayer) => remotePlayer.id === player.id); + if (!isActive) { + this.entityScene.remove(player.object); + this.entityScene.remove(player.nameLabel); + this.sphereScene.remove(player.sphere); + // Remove associated data for the player + delete this.groundTruthPositions[player.id]; + delete this.isAnimating[player.id]; + delete this.animationPhase[player.id]; + delete this.previousVelocity[player.id]; + delete this.lastRunningYOffset[player.id]; + } + return isActive; + }); + } + + private createTextSprite(text: string): THREE.Sprite { + text = text.replace(/&[0123456789abcdef]/g, ''); // Remove color codes + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d')!; + const fontSize = 64; + context.font = `${fontSize}px Comic Sans MS`; + + // Measure the text width and set canvas size accordingly + const textWidth = context.measureText(text).width; + canvas.width = textWidth * 2; // Increase resolution + canvas.height = fontSize * 2; // Increase resolution + + // Redraw the text on the canvas + context.font = `${fontSize}px Comic Sans MS`; + context.fillStyle = 'rgba(255,255,255,1)'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(text, canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + const spriteMaterial = new THREE.SpriteMaterial({ map: texture }); + const sprite = new THREE.Sprite(spriteMaterial); + + // Adjust the sprite scale to match the canvas aspect ratio + sprite.scale.set((textWidth / fontSize) * 0.4, 0.4, 0.4); + + return sprite; + } + + public getRemotePlayerIDsInCrosshair(): number[] { + const shotVectors = this.getShotVectorsToPlayersInCrosshair(); + const playerIDs = shotVectors.map((shot) => shot.playerID); + return playerIDs; + } + + public getShotVectorsToPlayersInCrosshair(): { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] { + const shotVectors: { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] = []; + const objectsInCrosshair = this.getPlayersInCrosshairWithWalls(); + + for (const object of objectsInCrosshair) { + for (const player of this.playersToRender) { + if (player.objectUUID === object.uuid) { + // Find the intersection point on the player + const intersection = this.findIntersectionOnPlayer(object); + if (intersection) { + const vector = new THREE.Vector3().subVectors(intersection.point, this.camera.position); + const hitPoint = intersection.point.clone(); // World coordinates of the hit + shotVectors.push({ playerID: player.id, vector, hitPoint }); + } + break; + } + } + } + + return shotVectors; + } + + private findIntersectionOnPlayer(playerObject: THREE.Object3D): THREE.Intersection | null { + this.raycaster.setFromCamera(this.crosshairVec, this.camera); + + const intersects = this.raycaster.intersectObject(playerObject, true); + if (intersects.length > 0) { + return intersects[0]; // Return the first intersection point + } + return null; + } + + private getPlayersInCrosshairWithWalls(): THREE.Object3D[] { + this.raycaster.setFromCamera(this.crosshairVec, this.camera); + + const playerIntersects = this.raycaster.intersectObjects(this.entityScene.children); + this.raycaster.firstHitOnly = true; + const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); + this.raycaster.firstHitOnly = false; + + const filteredIntersects = playerIntersects.filter((playerIntersect) => { + for (const wallIntersect of wallIntersects) { + if (wallIntersect.distance < playerIntersect.distance) { + return false; + } + } + return true; + }); + + return filteredIntersects.map((intersect) => intersect.object); + } + + public getPlayerSpheresInCrosshairWithWalls(): THREE.Object3D[] { + this.raycaster.setFromCamera(this.crosshairVec, this.camera); + + this.sphereScene.updateMatrixWorld(); + + this.raycaster.firstHitOnly = true; + const playerIntersects = this.raycaster.intersectObjects(this.sphereScene.children); + const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); + this.raycaster.firstHitOnly = false; + + const filteredIntersects = playerIntersects.filter((playerIntersect) => { + for (const wallIntersect of wallIntersects) { + if (wallIntersect.distance < playerIntersect.distance) { + return false; + } + } + return true; + }); + + return filteredIntersects.map((intersect) => intersect.object); + } + + public getShotVectorsToPlayersWithOffset( + yawOffset: number, + pitchOffset: number, + ): { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] { + const shotVectors: { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] = []; + const offsetDirection = this.calculateOffsetDirection(yawOffset, pitchOffset); + + // Set the raycaster with the offset direction + this.raycaster.set(this.camera.position, offsetDirection); + + // Intersect with all potential targets (players and walls) + const playerIntersects = this.raycaster.intersectObjects(this.playersToRender.map((p) => p.object), true); + this.raycaster.firstHitOnly = true; + const wallIntersects = this.raycaster.intersectObjects([RemotePlayerRenderer.map]); + this.raycaster.firstHitOnly = false; + + // Filter player intersections based on wall intersections + const filteredPlayerIntersects = playerIntersects.filter((playerIntersect) => { + for (const wallIntersect of wallIntersects) { + if (wallIntersect.distance < playerIntersect.distance) { + return false; // A wall is blocking the player + } + } + return true; // No wall is blocking the player + }); + + // Process the filtered player intersections + for (const intersect of filteredPlayerIntersects) { + const player = this.playersToRender.find((p) => p.object === intersect.object); + if (player) { + const vector = new THREE.Vector3().subVectors(intersect.point, this.camera.position); + const hitPoint = intersect.point.clone(); // World coordinates of the hit + shotVectors.push({ playerID: player.id, vector, hitPoint }); + } + } + + return shotVectors; + } + + private calculateOffsetDirection(yawOffset: number, pitchOffset: number): THREE.Vector3 { + // Get the camera's current direction + const direction = new THREE.Vector3(); + this.camera.getWorldDirection(direction); + + // Create a quaternion for the yaw and pitch offsets + const yawQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset); + const pitchQuaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset); + + // Apply the rotations + direction.applyQuaternion(yawQuaternion); + direction.applyQuaternion(pitchQuaternion); + + return direction.normalize(); + } + + private findIntersectionOnPlayerWithOffset( + playerObject: THREE.Object3D, + offsetDirection: THREE.Vector3, + ): THREE.Intersection | null { + // Set the raycaster with the offset direction + this.raycaster.set(this.camera.position, offsetDirection); + + const intersects = this.raycaster.intersectObject(playerObject, true); + if (intersects.length > 0) { + return intersects[0]; // Return the first intersection point + } + return null; + } + + public static setMap(map: THREE.Mesh) { + this.map = map; + } } diff --git a/src/client/core/Renderer.ts b/src/client/core/Renderer.ts index 58fd3b8d..6a46c166 100644 --- a/src/client/core/Renderer.ts +++ b/src/client/core/Renderer.ts @@ -1,401 +1,415 @@ import * as THREE from 'three'; import { Networking, type RemotePlayer } from './Networking.ts'; import { Player } from './Player.ts'; -import { ChatOverlay } from "../ui/ChatOverlay.ts"; +import { ChatOverlay } from '../ui/ChatOverlay.ts'; import { RemotePlayerRenderer } from './RemotePlayerRenderer.ts'; -import {InputHandler} from "../input/InputHandler.ts"; -import {SettingsManager} from "./SettingsManager.ts"; -import {CollisionManager} from "../input/CollisionManager.ts"; +import { InputHandler } from '../input/InputHandler.ts'; +import { SettingsManager } from './SettingsManager.ts'; +import { CollisionManager } from '../input/CollisionManager.ts'; export class Renderer { - private clock: THREE.Clock; - private deltaTime: number = 0; - private chatOverlay: ChatOverlay; - private scene: THREE.Scene; - private camera: THREE.PerspectiveCamera; - private renderer: THREE.WebGLRenderer; - private heldItemScene: THREE.Scene; - private heldItemCamera: THREE.PerspectiveCamera; - private ambientLight: THREE.AmbientLight; - private framerate: number; - private framesInFramerateSample: number; - private sampleOn: number; - private lastFramerateCalculation: number; - private networking: Networking; - private localPlayer: Player; - private raycaster: THREE.Raycaster; - public scaredLevel: number = 0; - private lastPlayerHealth: number = 100; - private knockbackVector: THREE.Vector3 = new THREE.Vector3(); - private bobCycle: number; - private lastCameraRoll: number - - 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; - private remotePlayerRenderer: RemotePlayerRenderer; - private inputHandler!: InputHandler; - private collisionManager!: CollisionManager; - - private spectateGroundTruthPosition: THREE.Vector3 | null = null; - - - constructor(container: HTMLElement, networking: Networking, localPlayer: Player, chatOverlay: ChatOverlay) { - this.networking = networking; - this.localPlayer = localPlayer; - this.chatOverlay = chatOverlay; - - this.clock = new THREE.Clock(); - 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); - - // Create a new scene and camera for the held item - this.heldItemScene = new THREE.Scene(); - this.heldItemCamera = new THREE.PerspectiveCamera(90, globalThis.innerWidth / globalThis.innerHeight, 0.01, 1000); - 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); - this.inventoryMenuCamera.lookAt(0, 0, 0); - this.inventoryMenuScene.add(this.inventoryMenuCamera); - this.inventoryMenuScene.add(new THREE.AmbientLight(0xffffff, 0.5)); - - // 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 - - this.scene.add(this.ambientLight); - this.heldItemScene.add(ambientLight2); - - // 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; - this.sampleOn = 0; - this.lastFramerateCalculation = 0; - - this.bobCycle = 0; - this.lastCameraRoll = 0; - - this.raycaster = new THREE.Raycaster(); - - // Initialize remotePlayerRenderer - this.remotePlayerRenderer = new RemotePlayerRenderer( - this.networking, - this.localPlayer, - this.raycaster, - 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.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); - } - - public onFrame(localPlayer: Player) { - this.deltaTime = this.clock.getDelta(); - - // Ensure the renderer clears the buffers before the first render - this.renderer.autoClear = true; - - // Render the main scene - this.renderer.render(this.scene, this.camera); - - // Prevent clearing the buffers in subsequent renders - this.renderer.autoClear = false; - - // Update and render remote players - this.remotePlayerRenderer.update(this.deltaTime); - this.renderer.render(this.remotePlayerRenderer.getEntityScene(), this.camera); - - // 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 - 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); - this.renderer.setScissor( - screenWidth - (inventoryWidth + 4) * this.screenPixelsInGamePixel, - screenHeight / 2 - (inventoryHeight / 2) * this.screenPixelsInGamePixel, - inventoryWidth * this.screenPixelsInGamePixel, - inventoryHeight * this.screenPixelsInGamePixel - ); - this.renderer.setViewport( - screenWidth - (inventoryWidth + 4) * this.screenPixelsInGamePixel, - screenHeight / 2 - (inventoryHeight / 2) * this.screenPixelsInGamePixel, - inventoryWidth * this.screenPixelsInGamePixel, - inventoryHeight * this.screenPixelsInGamePixel - ); - this.renderer.render(this.inventoryMenuScene, this.inventoryMenuCamera); - - // Reset scissor test and viewport after rendering the health indicator - this.renderer.setScissorTest(false); - this.renderer.setViewport(0, 0, screenWidth, screenHeight); - - - // Restore autoClear to true if necessary - this.renderer.autoClear = true; - - if(localPlayer.playerSpectating !== -1) { - const remotePlayer = this.networking.getRemotePlayerData().find((player) => 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, - remotePlayer.position.y, - remotePlayer.position.z - ); - } - - // 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, - remotePlayer.position.y, - remotePlayer.position.z - ); - } - - // Lerp ground truth position towards actual position - this.spectateGroundTruthPosition.lerp( - new THREE.Vector3( - remotePlayer.position.x, - remotePlayer.position.y, - remotePlayer.position.z - ), - 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[0], - remotePlayer.lookQuaternion[1], - remotePlayer.lookQuaternion[2], - remotePlayer.lookQuaternion[3] - ), 0.3 * this.deltaTime * 60); - } - } else { - // Reset spectate position when not spectating - this.spectateGroundTruthPosition = null; - this.camera.position.copy(localPlayer.position); - this.camera.setRotationFromQuaternion(this.localPlayer.lookQuaternion); - } - - - - - - this.camera.position.add(this.knockbackVector); - this.knockbackVector.lerp(new THREE.Vector3(), 0.05 * this.deltaTime * 60); - - - if(this.localPlayer.health < this.lastPlayerHealth) { - const remotePlayer: RemotePlayer | undefined = this.networking.getRemotePlayerData().find((player) => 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)); - } - } - - - const shakeAmount = 0.08 * Math.pow(this.scaredLevel,5); - this.camera.position.add(new THREE.Vector3((Math.random()-0.5) * shakeAmount, (Math.random()-0.5) *shakeAmount, (Math.random()-0.5) * shakeAmount)); - this.camera.rotation.x += (Math.random()-0.5) * shakeAmount * 0.12; - this.camera.rotation.y += (Math.random()-0.5) * shakeAmount * 0.12; - this.camera.rotation.z += (Math.random()-0.5) * shakeAmount * 0.12; - - this.heldItemCamera.rotation.set((Math.random()-0.5) * shakeAmount, (Math.random()-0.5) * shakeAmount, (Math.random()-0.5) * shakeAmount ); - - this.lastPlayerHealth = this.localPlayer.health; - - const vel = Math.sqrt(Math.pow(this.localPlayer.inputVelocity.x,2) + Math.pow(this.localPlayer.inputVelocity.z,2)) - - if(vel == 0 || this.collisionManager.isPlayerInAir() || this.localPlayer.playerSpectating !== -1) { - this.bobCycle = 0; - } else { - 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 - - //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; - newHandZ -= -playerVelocity.z * 0.02 * SettingsManager.settings.viewBobbingStrength; - - if(this.inputHandler.getAim()) { - newHandX *= 0.25; - newHandY *= 0.25; - newHandZ *= 0.25; - } - - this.heldItemCamera.position.lerp(new THREE.Vector3(newHandX, newHandY, 5 + newHandZ),0.15 * this.deltaTime * 60); - - const maxRollAmount = this.inputHandler.getInputX() * -.007 * SettingsManager.settings.viewBobbingStrength; - const maxRollSpeed = this.deltaTime * .4; - let roll: number = this.lastCameraRoll; - roll = Renderer.approachNumber(roll, maxRollSpeed, maxRollAmount); - const euler = new THREE.Euler().setFromQuaternion(this.camera.quaternion, 'YXZ'); - euler.z += roll; - this.lastCameraRoll = roll; - - this.camera.quaternion.setFromEuler(euler); - - this.updateFramerate(); - } - - private updateFramerate() { - this.sampleOn++; - if (this.sampleOn >= this.framesInFramerateSample) { - this.framerate = this.framesInFramerateSample / (Date.now() / 1000 - this.lastFramerateCalculation); - this.sampleOn = 0; - this.lastFramerateCalculation = Date.now() / 1000; - } - } - - public getFramerate(): number { - return this.framerate; - } - - public getScene(): THREE.Scene { - return this.scene; - } - - public getCamera(): THREE.PerspectiveCamera { - return this.camera; - } - - public getHeldItemScene(): THREE.Scene { - return this.heldItemScene; - } - - public getHealthIndicatorScene(): THREE.Scene { - return this.healthIndicatorScene; - } - - public getInventoryMenuScene(): THREE.Scene { - return this.inventoryMenuScene; - } - - public getInventoryMenuCamera(): THREE.OrthographicCamera { - 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); - - this.screenPixelsInGamePixel = globalThis.innerHeight / 200; - // Update held item camera aspect ratio - this.heldItemCamera.aspect = globalThis.innerWidth / globalThis.innerHeight; - this.heldItemCamera.updateProjectionMatrix(); - } - - - public getShotVectorsToPlayersInCrosshair(): { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] { - return this.remotePlayerRenderer.getShotVectorsToPlayersInCrosshair(); - } - - public getPlayerSpheresInCrosshairWithWalls() { - return this.remotePlayerRenderer.getPlayerSpheresInCrosshairWithWalls(); - } - - public getShotVectorsToPlayersWithOffset(yawOffset: number, pitchOffset: number): { playerID: number, vector: THREE.Vector3, hitPoint: THREE.Vector3 }[] { - return this.remotePlayerRenderer.getShotVectorsToPlayersWithOffset(yawOffset, pitchOffset); - } - - public getEntityScene(): THREE.Scene { - return this.remotePlayerRenderer.getEntityScene(); - } - - public setInputHandler(inputHandler: InputHandler) { - this.inputHandler = inputHandler; - } - - public setCollisionManager(collisionManager: CollisionManager) { - this.collisionManager = collisionManager; - } - - private static approachNumber(input: number, step: number, approach: number): number { - if (input == approach) {return approach;} - let output: number; - if (input > approach) { - output = input - step; - if (output <= approach) {return approach;} - } else { - output = input + step; - if (output >= approach) {return approach;} - } - return output; - } + private clock: THREE.Clock; + private deltaTime: number = 0; + private chatOverlay: ChatOverlay; + private scene: THREE.Scene; + private camera: THREE.PerspectiveCamera; + private renderer: THREE.WebGLRenderer; + private heldItemScene: THREE.Scene; + private heldItemCamera: THREE.PerspectiveCamera; + private ambientLight: THREE.AmbientLight; + private framerate: number; + private framesInFramerateSample: number; + private sampleOn: number; + private lastFramerateCalculation: number; + private networking: Networking; + private localPlayer: Player; + private raycaster: THREE.Raycaster; + public scaredLevel: number = 0; + private lastPlayerHealth: number = 100; + private knockbackVector: THREE.Vector3 = new THREE.Vector3(); + private bobCycle: number; + private lastCameraRoll: number; + + 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; + private remotePlayerRenderer: RemotePlayerRenderer; + private inputHandler!: InputHandler; + private collisionManager!: CollisionManager; + + private spectateGroundTruthPosition: THREE.Vector3 | null = null; + + constructor(container: HTMLElement, networking: Networking, localPlayer: Player, chatOverlay: ChatOverlay) { + this.networking = networking; + this.localPlayer = localPlayer; + this.chatOverlay = chatOverlay; + + this.clock = new THREE.Clock(); + 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); + + // Create a new scene and camera for the held item + this.heldItemScene = new THREE.Scene(); + this.heldItemCamera = new THREE.PerspectiveCamera(90, globalThis.innerWidth / globalThis.innerHeight, 0.01, 1000); + 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); + this.inventoryMenuCamera.lookAt(0, 0, 0); + this.inventoryMenuScene.add(this.inventoryMenuCamera); + this.inventoryMenuScene.add(new THREE.AmbientLight(0xffffff, 0.5)); + + // 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 + + this.scene.add(this.ambientLight); + this.heldItemScene.add(ambientLight2); + + // 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; + this.sampleOn = 0; + this.lastFramerateCalculation = 0; + + this.bobCycle = 0; + this.lastCameraRoll = 0; + + this.raycaster = new THREE.Raycaster(); + + // Initialize remotePlayerRenderer + this.remotePlayerRenderer = new RemotePlayerRenderer( + this.networking, + this.localPlayer, + this.raycaster, + 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.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); + } + + public onFrame(localPlayer: Player) { + this.deltaTime = this.clock.getDelta(); + + // Ensure the renderer clears the buffers before the first render + this.renderer.autoClear = true; + + // Render the main scene + this.renderer.render(this.scene, this.camera); + + // Prevent clearing the buffers in subsequent renders + this.renderer.autoClear = false; + + // Update and render remote players + this.remotePlayerRenderer.update(this.deltaTime); + this.renderer.render(this.remotePlayerRenderer.getEntityScene(), this.camera); + + // 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 + 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); + this.renderer.setScissor( + screenWidth - (inventoryWidth + 4) * this.screenPixelsInGamePixel, + screenHeight / 2 - (inventoryHeight / 2) * this.screenPixelsInGamePixel, + inventoryWidth * this.screenPixelsInGamePixel, + inventoryHeight * this.screenPixelsInGamePixel, + ); + this.renderer.setViewport( + screenWidth - (inventoryWidth + 4) * this.screenPixelsInGamePixel, + screenHeight / 2 - (inventoryHeight / 2) * this.screenPixelsInGamePixel, + inventoryWidth * this.screenPixelsInGamePixel, + inventoryHeight * this.screenPixelsInGamePixel, + ); + this.renderer.render(this.inventoryMenuScene, this.inventoryMenuCamera); + + // Reset scissor test and viewport after rendering the health indicator + this.renderer.setScissorTest(false); + this.renderer.setViewport(0, 0, screenWidth, screenHeight); + + // Restore autoClear to true if necessary + this.renderer.autoClear = true; + + if (localPlayer.playerSpectating !== -1) { + const remotePlayer = this.networking.getRemotePlayerData().find((player) => + 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, + remotePlayer.position.y, + remotePlayer.position.z, + ); + } + + // 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, + remotePlayer.position.y, + remotePlayer.position.z, + ); + } + + // Lerp ground truth position towards actual position + this.spectateGroundTruthPosition.lerp( + new THREE.Vector3( + remotePlayer.position.x, + remotePlayer.position.y, + remotePlayer.position.z, + ), + 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[0], + remotePlayer.lookQuaternion[1], + remotePlayer.lookQuaternion[2], + remotePlayer.lookQuaternion[3], + ), + 0.3 * this.deltaTime * 60, + ); + } + } else { + // Reset spectate position when not spectating + this.spectateGroundTruthPosition = null; + this.camera.position.copy(localPlayer.position); + this.camera.setRotationFromQuaternion(this.localPlayer.lookQuaternion); + } + + this.camera.position.add(this.knockbackVector); + this.knockbackVector.lerp(new THREE.Vector3(), 0.05 * this.deltaTime * 60); + + if (this.localPlayer.health < this.lastPlayerHealth) { + const remotePlayer: RemotePlayer | undefined = this.networking.getRemotePlayerData().find((player) => + 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)); + } + } + + const shakeAmount = 0.08 * Math.pow(this.scaredLevel, 5); + this.camera.position.add( + new THREE.Vector3( + (Math.random() - 0.5) * shakeAmount, + (Math.random() - 0.5) * shakeAmount, + (Math.random() - 0.5) * shakeAmount, + ), + ); + this.camera.rotation.x += (Math.random() - 0.5) * shakeAmount * 0.12; + this.camera.rotation.y += (Math.random() - 0.5) * shakeAmount * 0.12; + this.camera.rotation.z += (Math.random() - 0.5) * shakeAmount * 0.12; + + this.heldItemCamera.rotation.set( + (Math.random() - 0.5) * shakeAmount, + (Math.random() - 0.5) * shakeAmount, + (Math.random() - 0.5) * shakeAmount, + ); + + this.lastPlayerHealth = this.localPlayer.health; + + const vel = Math.sqrt( + Math.pow(this.localPlayer.inputVelocity.x, 2) + Math.pow(this.localPlayer.inputVelocity.z, 2), + ); + + if (vel == 0 || this.collisionManager.isPlayerInAir() || this.localPlayer.playerSpectating !== -1) { + this.bobCycle = 0; + } else { + 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 + + //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; + newHandZ -= -playerVelocity.z * 0.02 * SettingsManager.settings.viewBobbingStrength; + + if (this.inputHandler.getAim()) { + newHandX *= 0.25; + newHandY *= 0.25; + newHandZ *= 0.25; + } + + this.heldItemCamera.position.lerp(new THREE.Vector3(newHandX, newHandY, 5 + newHandZ), 0.15 * this.deltaTime * 60); + + const maxRollAmount = this.inputHandler.getInputX() * -.007 * SettingsManager.settings.viewBobbingStrength; + const maxRollSpeed = this.deltaTime * .4; + let roll: number = this.lastCameraRoll; + roll = Renderer.approachNumber(roll, maxRollSpeed, maxRollAmount); + const euler = new THREE.Euler().setFromQuaternion(this.camera.quaternion, 'YXZ'); + euler.z += roll; + this.lastCameraRoll = roll; + + this.camera.quaternion.setFromEuler(euler); + + this.updateFramerate(); + } + + private updateFramerate() { + this.sampleOn++; + if (this.sampleOn >= this.framesInFramerateSample) { + this.framerate = this.framesInFramerateSample / (Date.now() / 1000 - this.lastFramerateCalculation); + this.sampleOn = 0; + this.lastFramerateCalculation = Date.now() / 1000; + } + } + + public getFramerate(): number { + return this.framerate; + } + + public getScene(): THREE.Scene { + return this.scene; + } + + public getCamera(): THREE.PerspectiveCamera { + return this.camera; + } + + public getHeldItemScene(): THREE.Scene { + return this.heldItemScene; + } + + public getHealthIndicatorScene(): THREE.Scene { + return this.healthIndicatorScene; + } + + public getInventoryMenuScene(): THREE.Scene { + return this.inventoryMenuScene; + } + + public getInventoryMenuCamera(): THREE.OrthographicCamera { + 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); + + this.screenPixelsInGamePixel = globalThis.innerHeight / 200; + // Update held item camera aspect ratio + this.heldItemCamera.aspect = globalThis.innerWidth / globalThis.innerHeight; + this.heldItemCamera.updateProjectionMatrix(); + } + + public getShotVectorsToPlayersInCrosshair(): { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] { + return this.remotePlayerRenderer.getShotVectorsToPlayersInCrosshair(); + } + + public getPlayerSpheresInCrosshairWithWalls() { + return this.remotePlayerRenderer.getPlayerSpheresInCrosshairWithWalls(); + } + + public getShotVectorsToPlayersWithOffset( + yawOffset: number, + pitchOffset: number, + ): { playerID: number; vector: THREE.Vector3; hitPoint: THREE.Vector3 }[] { + return this.remotePlayerRenderer.getShotVectorsToPlayersWithOffset(yawOffset, pitchOffset); + } + + public getEntityScene(): THREE.Scene { + return this.remotePlayerRenderer.getEntityScene(); + } + + public setInputHandler(inputHandler: InputHandler) { + this.inputHandler = inputHandler; + } + + public setCollisionManager(collisionManager: CollisionManager) { + this.collisionManager = collisionManager; + } + + private static approachNumber(input: number, step: number, approach: number): number { + if (input == approach) return approach; + let output: number; + if (input > approach) { + output = input - step; + if (output <= approach) return approach; + } else { + output = input + step; + if (output >= approach) return approach; + } + return output; + } } diff --git a/src/client/core/SettingsManager.ts b/src/client/core/SettingsManager.ts index 6a326dd5..0e028ef7 100644 --- a/src/client/core/SettingsManager.ts +++ b/src/client/core/SettingsManager.ts @@ -1,39 +1,39 @@ export class SettingsManager { - static settings: Settings; + static settings: Settings; - static { - SettingsManager.reset() - const settingsJson = localStorage.getItem('settings'); - if (settingsJson) SettingsManager.settings = JSON.parse(settingsJson); - } + static { + SettingsManager.reset(); + const settingsJson = localStorage.getItem('settings'); + if (settingsJson) SettingsManager.settings = JSON.parse(settingsJson); + } - public static write(): void { - localStorage.setItem('settings', JSON.stringify(SettingsManager.settings)); - } + public static write(): void { + localStorage.setItem('settings', JSON.stringify(SettingsManager.settings)); + } - public static reset() { - SettingsManager.settings = { - sense: 1, - controllerSense: 1, - name: null, - crosshairColor: 'rgb(0,255,255)', - crosshairType: 1, - viewBobbingStrength: 1, - doPrettyText: false, - }; - } + public static reset() { + SettingsManager.settings = { + sense: 1, + controllerSense: 1, + name: null, + crosshairColor: 'rgb(0,255,255)', + crosshairType: 1, + viewBobbingStrength: 1, + doPrettyText: false, + }; + } - constructor() { - throw Error('Settings class is static.'); - } + constructor() { + throw Error('Settings class is static.'); + } } interface Settings { - sense: number; - controllerSense: number; - name: null | string; - crosshairColor: string; - crosshairType: number; - viewBobbingStrength: number; - doPrettyText: boolean; -} \ No newline at end of file + sense: number; + controllerSense: number; + name: null | string; + crosshairColor: string; + crosshairType: number; + viewBobbingStrength: number; + doPrettyText: boolean; +} diff --git a/src/client/input/CollisionManager.ts b/src/client/input/CollisionManager.ts index 4cd8e336..fe855d13 100644 --- a/src/client/input/CollisionManager.ts +++ b/src/client/input/CollisionManager.ts @@ -1,142 +1,150 @@ import * as THREE from 'three'; import { Player } from '../core/Player.ts'; -import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree, StaticGeometryGenerator, MeshBVH } from 'three-mesh-bvh'; -import { InputHandler } from "./InputHandler.ts"; -import { RemotePlayerRenderer} from "../core/RemotePlayerRenderer.ts"; +import { + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, + MeshBVH, + StaticGeometryGenerator, +} from 'three-mesh-bvh'; +import { InputHandler } from './InputHandler.ts'; +import { RemotePlayerRenderer } from '../core/RemotePlayerRenderer.ts'; THREE.Mesh.prototype.raycast = acceleratedRaycast; THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; export class CollisionManager { - private clock: THREE.Clock; - private readonly colliderSphere: THREE.Sphere; - private readonly deltaVec: THREE.Vector3; - private readonly prevPosition: THREE.Vector3; - public static mapLoaded: boolean = false; - private static colliderGeom?: THREE.BufferGeometry; // Mark as possibly undefined - private inputHandler: InputHandler; - private static readonly maxAngle: number = Math.cos(45 * Math.PI / 180); - private readonly triNormal: THREE.Vector3; - private readonly upVector: THREE.Vector3; - private coyoteTime: number; - private jumped: boolean; - private collided: boolean; - - constructor(inputHandler: InputHandler) { - this.inputHandler = inputHandler; - this.clock = new THREE.Clock(); - this.colliderSphere = new THREE.Sphere(new THREE.Vector3(), .2); - this.deltaVec = new THREE.Vector3(); - this.prevPosition = new THREE.Vector3(); - this.triNormal = new THREE.Vector3(); - this.upVector = new THREE.Vector3(0, 1, 0) - this.coyoteTime = 0; - this.jumped = false; - this.collided = false; - } - - public collisionPeriodic(localPlayer: Player) { - if (!CollisionManager.mapLoaded || !CollisionManager.colliderGeom || !CollisionManager.colliderGeom.boundsTree) return; // Add checks - let deltaTime: number = this.clock.getDelta(); - let steps: number = 1; - while (deltaTime >= 1 / 120) { - deltaTime = deltaTime / 2 - steps = steps * 2; - } - for (let i = 0; i < steps; i++) { - this.physics(localPlayer, deltaTime); - } - } - - private physics(localPlayer: Player, deltaTime: number) { - this.prevPosition.copy(localPlayer.position); - const jump: boolean = this.inputHandler.jump; - - 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)); - - const bvh: MeshBVH | undefined = CollisionManager.colliderGeom?.boundsTree; - if (!bvh) return; // Ensure bvh exists - - this.colliderSphere.center = localPlayer.position.clone(); - - 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); - } - } - - this.colliderSphere.center = localPlayer.position.clone(); - return false; - }, - - boundsTraverseOrder: (box: THREE.Box3) => { - return box.distanceToPoint(this.colliderSphere.center) - this.colliderSphere.radius; - } - }); - - if (!this.collided) { - this.coyoteTime += deltaTime; - if (jump && this.coyoteTime < 6 / 60 && !this.jumped) { - localPlayer.gravity = 8; - this.jumped = true; - } - } else { - if (jump) { - localPlayer.gravity = 8; - } else { - this.jumped = false; - } - } - if (!(deltaTime == 0)) { - localPlayer.velocity.copy(localPlayer.position.clone().sub(this.prevPosition).divideScalar(deltaTime)); - } - } - - public static staticGeometry(group: THREE.Group) { - //if (!this.mapLoaded) { - console.time("Building static geometry BVH"); - const staticGenerator = new StaticGeometryGenerator(group); - staticGenerator.attributes = ['position']; - this.colliderGeom = staticGenerator.generate(); - this.colliderGeom.computeBoundsTree({maxDepth: 1000000, maxLeafTris: 4}); - RemotePlayerRenderer.setMap(new THREE.Mesh(this.colliderGeom)); - this.mapLoaded = true; - console.timeEnd("Building static geometry BVH"); - // } - } - - public isPlayerInAir(): boolean { - return !this.collided; - } -} \ No newline at end of file + private clock: THREE.Clock; + private readonly colliderSphere: THREE.Sphere; + private readonly deltaVec: THREE.Vector3; + private readonly prevPosition: THREE.Vector3; + public static mapLoaded: boolean = false; + private static colliderGeom?: THREE.BufferGeometry; // Mark as possibly undefined + private inputHandler: InputHandler; + private static readonly maxAngle: number = Math.cos(45 * Math.PI / 180); + private readonly triNormal: THREE.Vector3; + private readonly upVector: THREE.Vector3; + private coyoteTime: number; + private jumped: boolean; + private collided: boolean; + + constructor(inputHandler: InputHandler) { + this.inputHandler = inputHandler; + this.clock = new THREE.Clock(); + this.colliderSphere = new THREE.Sphere(new THREE.Vector3(), .2); + this.deltaVec = new THREE.Vector3(); + this.prevPosition = new THREE.Vector3(); + this.triNormal = new THREE.Vector3(); + this.upVector = new THREE.Vector3(0, 1, 0); + this.coyoteTime = 0; + this.jumped = false; + this.collided = false; + } + + public collisionPeriodic(localPlayer: Player) { + if (!CollisionManager.mapLoaded || !CollisionManager.colliderGeom || !CollisionManager.colliderGeom.boundsTree) { + return; // Add checks + } + let deltaTime: number = this.clock.getDelta(); + let steps: number = 1; + while (deltaTime >= 1 / 120) { + deltaTime = deltaTime / 2; + steps = steps * 2; + } + for (let i = 0; i < steps; i++) { + this.physics(localPlayer, deltaTime); + } + } + + private physics(localPlayer: Player, deltaTime: number) { + this.prevPosition.copy(localPlayer.position); + const jump: boolean = this.inputHandler.jump; + + 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)); + + const bvh: MeshBVH | undefined = CollisionManager.colliderGeom?.boundsTree; + if (!bvh) return; // Ensure bvh exists + + this.colliderSphere.center = localPlayer.position.clone(); + + 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); + } + } + + this.colliderSphere.center = localPlayer.position.clone(); + return false; + }, + + boundsTraverseOrder: (box: THREE.Box3) => { + return box.distanceToPoint(this.colliderSphere.center) - this.colliderSphere.radius; + }, + }); + + if (!this.collided) { + this.coyoteTime += deltaTime; + if (jump && this.coyoteTime < 6 / 60 && !this.jumped) { + localPlayer.gravity = 8; + this.jumped = true; + } + } else { + if (jump) { + localPlayer.gravity = 8; + } else { + this.jumped = false; + } + } + if (!(deltaTime == 0)) { + localPlayer.velocity.copy(localPlayer.position.clone().sub(this.prevPosition).divideScalar(deltaTime)); + } + } + + public static staticGeometry(group: THREE.Group) { + //if (!this.mapLoaded) { + console.time('Building static geometry BVH'); + const staticGenerator = new StaticGeometryGenerator(group); + staticGenerator.attributes = ['position']; + this.colliderGeom = staticGenerator.generate(); + this.colliderGeom.computeBoundsTree({ maxDepth: 1000000, maxLeafTris: 4 }); + RemotePlayerRenderer.setMap(new THREE.Mesh(this.colliderGeom)); + this.mapLoaded = true; + console.timeEnd('Building static geometry BVH'); + // } + } + + public isPlayerInAir(): boolean { + return !this.collided; + } +} diff --git a/src/client/input/HeldItemInput.ts b/src/client/input/HeldItemInput.ts index 916b4869..062b2aa1 100644 --- a/src/client/input/HeldItemInput.ts +++ b/src/client/input/HeldItemInput.ts @@ -1,11 +1,11 @@ export class HeldItemInput { - public leftClick: boolean = false; - public rightClick: boolean = false; - public shiftKey: boolean = false; + public leftClick: boolean = false; + public rightClick: boolean = false; + public shiftKey: boolean = false; - constructor(leftClick: boolean = false, rightClick: boolean = false, shiftKey: boolean = false) { - this.leftClick = leftClick; - this.rightClick = rightClick; - this.shiftKey = shiftKey; - } -} \ No newline at end of file + constructor(leftClick: boolean = false, rightClick: boolean = false, shiftKey: boolean = false) { + this.leftClick = leftClick; + this.rightClick = rightClick; + this.shiftKey = shiftKey; + } +} diff --git a/src/client/input/InputHandler.ts b/src/client/input/InputHandler.ts index b50c4c3b..f3fbba1f 100644 --- a/src/client/input/InputHandler.ts +++ b/src/client/input/InputHandler.ts @@ -2,342 +2,359 @@ import * as THREE from 'three'; import { PointerLockControls } from './PointerLockControl.ts'; import { Renderer } from '../core/Renderer.ts'; import { Player } from '../core/Player.ts'; -import {SettingsManager} from "../core/SettingsManager.ts"; +import { SettingsManager } from '../core/SettingsManager.ts'; export class InputHandler { - private readonly gameIndex: number; - private mouse: PointerLockControls; - private gamepad: (Gamepad | null) = null; - private readonly gamepadEuler ; - private clock: THREE.Clock; - private keys: { [key: string]: boolean } = {}; - private leftMouseDown: boolean = false; - private rightMouseDown: boolean = false; - private renderer: Renderer; - private readonly localPlayer: Player; - private inputX: number = 0; - private inputZ: number = 0; - public jump = false; - public prevInputVelocity: THREE.Vector3; - private scrollClicksSinceLastCheck: number = 0; - private readonly gamepadInputs: GamepadInputs; - private shoot: boolean = false; - private aim: boolean = false; - public nameSettingActive: boolean = false; - private touchJoyX: number = 0; - private touchJoyY: number = 0; - private touchLookX: number = 0; - private touchLookY: number = 0; - private inventoryIterationTouched: boolean = false; - private touchButtons: number[] = []; - - constructor(renderer: Renderer, localPlayer: Player, nextGameIndex: number) { - this.renderer = renderer; - this.localPlayer = localPlayer; - this.prevInputVelocity = new THREE.Vector3(); - this.gamepadEuler = new THREE.Euler(0, 0, 0, 'YXZ'); - - this.clock = new THREE.Clock(); - this.mouse = new PointerLockControls(this.localPlayer, document.body); - - this.gamepadInputs = new GamepadInputs(); - - this.gameIndex = nextGameIndex; - - if(!navigator.getGamepads()) { - console.log("Browser does not support Gamepad API.") - } - - this.setupEventListeners(); - } - - private setupEventListeners() { - document.addEventListener('keydown', this.onKeyDown.bind(this)); - document.addEventListener('keyup', this.onKeyUp.bind(this)); - document.addEventListener('mousedown', this.onMouseDown.bind(this)); - document.addEventListener('mouseup', this.onMouseUp.bind(this)); - document.addEventListener('mouseleave', this.onMouseUp.bind(this)); - document.addEventListener('blur', this.deregisterAllKeys.bind(this), false); - document.addEventListener('pointerlockchange', this.deregisterAllKeys.bind(this), false); - document.addEventListener('visibilitychange', this.deregisterAllKeys.bind(this), false); - - document.addEventListener('click', () => { - this.mouse.lock(); - }); - - document.addEventListener('contextmenu', (event) => {event.preventDefault();}); - - document.addEventListener('wheel', this.processScroll.bind(this)); - } - - private processScroll(e :WheelEvent) { - if(e.deltaY >= 4) - this.scrollClicksSinceLastCheck++; - if(e.deltaY <= -4) - this.scrollClicksSinceLastCheck--; - } - - public getScrollClicks() { - const clicks = this.scrollClicksSinceLastCheck; - this.scrollClicksSinceLastCheck = 0; - return clicks; - - } - - public handleInputs() { - const deltaTime: number = this.clock.getDelta(); - const deltaTimeAcceleration = this.localPlayer.acceleration * deltaTime; - - let dist = 1; - let speedMultiplier: number = 1; - this.jump = false; - this.aim = false; - this.shoot = false; - - const oldInputZ = this.inputZ; - const oldInputX = this.inputX; - - this.gamepad = navigator.getGamepads()[this.gameIndex]; - if(this.gamepad) { - if(this.gamepad.connected) { - this.updateGamepadInputArray(this.gamepad); - this.gamepadEuler.setFromQuaternion(this.localPlayer.lookQuaternion); - if (Math.abs(this.gamepadInputs.leftJoyX) >= .1) this.inputX += deltaTimeAcceleration * this.gamepadInputs.leftJoyX; - if (Math.abs(this.gamepadInputs.leftJoyY) >= .1) this.inputZ += deltaTimeAcceleration * this.gamepadInputs.leftJoyY; - const vectorLength = Math.sqrt((this.gamepadInputs.leftJoyX * this.gamepadInputs.leftJoyX) + (this.gamepadInputs.leftJoyY * this.gamepadInputs.leftJoyY)); - if (vectorLength >= .1) speedMultiplier = Math.min(Math.max(vectorLength, 0), 1); - if (this.gamepadInputs.A) this.jump = true; - if (this.gamepadInputs.leftTrigger > .5) this.aim = true; - if (this.gamepadInputs.rightTrigger > .5) this.shoot = true; - const aimAdjust = this.calculateAimAssist(); - this.gamepadEuler.y -= this.gamepadInputs.rightJoyX * SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - this.gamepadEuler.x -= this.gamepadInputs.rightJoyY * SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - this.gamepadEuler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.gamepadEuler.x)); - this.localPlayer.lookQuaternion.setFromEuler(this.gamepadEuler); - } - } - - //touch joystick controls - if(Math.hypot(this.touchJoyY, this.touchJoyX) > 0.1) { - const touchVectorLength = Math.hypot(this.touchJoyX, this.touchJoyY); - speedMultiplier = Math.min(Math.max(touchVectorLength, 0), 1); - this.inputX += deltaTimeAcceleration * this.touchJoyX; - this.inputZ += deltaTimeAcceleration * this.touchJoyY; - } - - - - - const touchSensitivity = 0.03; // Adjust sensitivity as needed - this.gamepadEuler.setFromQuaternion(this.localPlayer.lookQuaternion); - - if (!this.localPlayer.chatActive && !this.nameSettingActive) { - if (this.getKey('w')) this.inputZ -= deltaTimeAcceleration; - if (this.getKey('s')) this.inputZ += deltaTimeAcceleration; - if (this.getKey('a')) this.inputX -= deltaTimeAcceleration; - if (this.getKey('d')) this.inputX += deltaTimeAcceleration; - const aimAdjust = this.calculateAimAssist(); - if (this.getKey('arrowright')) this.gamepadEuler.y -= SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - if (this.getKey('arrowleft')) this.gamepadEuler.y += SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - if (this.getKey('arrowup')) this.gamepadEuler.x += SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - if (this.getKey('arrowdown')) this.gamepadEuler.x -= SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; - if (this.getKey(' ')) this.jump = true; - - } - - this.gamepadEuler.y -= this.touchLookX * touchSensitivity; - this.gamepadEuler.x -= this.touchLookY * touchSensitivity; - this.gamepadEuler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.gamepadEuler.x)); - this.localPlayer.lookQuaternion.setFromEuler(this.gamepadEuler); - - switch (this.inputZ - oldInputZ) { - case 0: - this.inputZ = InputHandler.approachZero(this.inputZ, deltaTimeAcceleration); - } - - switch (this.inputX - oldInputX) { - case 0: - this.inputX = InputHandler.approachZero(this.inputX, deltaTimeAcceleration); - } - - if(this.localPlayer.health <= 0) dist = 0; //don't allow movement when health = 0 - - this.prevInputVelocity.copy(this.localPlayer.inputVelocity); - - this.localPlayer.inputVelocity.z = dist * this.inputZ; - this.localPlayer.inputVelocity.x = dist * this.inputX; - this.localPlayer.inputVelocity.y = 0; - this.localPlayer.inputVelocity.clampLength(0, this.localPlayer.speed * speedMultiplier); - this.localPlayer.inputVelocity.y = this.prevInputVelocity.y; - this.inputZ = this.localPlayer.inputVelocity.z; - this.inputX = this.localPlayer.inputVelocity.x; - - const euler = new THREE.Euler().setFromQuaternion(this.localPlayer.lookQuaternion, 'YXZ'); - euler.x = 0; - euler.z = 0; - this.localPlayer.quaternion.setFromEuler(euler); - this.localPlayer.inputVelocity.applyQuaternion(this.localPlayer.quaternion); - - if (this.leftMouseDown || this.touchButtons.includes(0)) this.shoot = true; - if (this.rightMouseDown) this.aim = true; - - if(this.localPlayer.playerSpectating !== -1) { - this.inputX = 0; - this.inputZ = 0; - this.jump = false; - this.shoot = false; - this.aim = false; - } - - } - - public getKey(key: string):boolean { - return this.keys[key]; - } - - public setTouchJoyInput(x: number, y: number) { - this.touchJoyX = x; - this.touchJoyY = y; - } - - public setLastTouchLookDelta(x: number, y: number) { - this.touchLookX = x; - this.touchLookY = y; - } - public setButtonsHeld(buttons: number[]) { - this.touchButtons = buttons; - this.jump = this.jump || buttons.includes(-1); - this.inventoryIterationTouched = buttons.includes(1); - - } - - public getInventoryIterationTouched() { - return this.inventoryIterationTouched; - } - - private onKeyDown(event: KeyboardEvent) { - //event.preventDefault(); - if(event.key === 'Tab' || event.key === "'"|| event.key === '/') event.preventDefault(); - const key = event.key.toLowerCase(); - this.keys[key] = true; - - if (!this.localPlayer.chatActive && !this.nameSettingActive) { - if (key === 'c') { - this.leftMouseDown = true; - } else if (key === 'z') { - this.rightMouseDown = true; - } - } - } - - private onKeyUp(event: KeyboardEvent) { - const key = event.key.toLowerCase(); - this.keys[key] = false; - - if (!this.localPlayer.chatActive && !this.nameSettingActive) { - if (key === 'c') { - this.leftMouseDown = false; - } else if (key === 'z') { - this.rightMouseDown = false; - } - } - } - - private onMouseDown(event: MouseEvent) { - if (event.button === 0) { - this.leftMouseDown = true; - } else if (event.button === 2) { - this.rightMouseDown = true; - } - } - - private onMouseUp(event: MouseEvent) { - if (event.button === 0) { - this.leftMouseDown = false; - } else if (event.button === 2) { - this.rightMouseDown = false; - } - } - - public getShoot() { - return this.shoot; - } - - public getAim() { - return this.aim; - } - - public getGamepadInputs(): GamepadInputs { - return this.gamepadInputs; - } - - public deregisterAllKeys(){ - const locked = document.pointerLockElement === document.body; - if(!locked) - this.keys = {}; - } - - public getInputX() { - return this.inputX; - } - - public getInputZ() { - return this.inputZ; - } - - private static approachZero(input: number, step: number): number { - if (input == 0) {return 0;} - let sign: number = 1; - if (input < 0) {sign = -1;} - const output: number = Math.abs(input) - step; - if (output <= 0) {return 0;} - return sign * output; - } - - private updateGamepadInputArray(gamepad: Gamepad) { - if (gamepad.axes[4]) { - this.gamepadInputs.leftTrigger = gamepad.axes[4]; - this.gamepadInputs.rightTrigger = gamepad.axes[5]; - } else { - this.gamepadInputs.leftTrigger = gamepad.buttons[6].value; - this.gamepadInputs.rightTrigger = gamepad.buttons[7].value; - } - this.gamepadInputs.leftJoyX = gamepad.axes[0]; - this.gamepadInputs.leftJoyY = gamepad.axes[1]; - this.gamepadInputs.A = gamepad.buttons[0].pressed; - this.gamepadInputs.rightJoyX = gamepad.axes[2]; - this.gamepadInputs.rightJoyY = gamepad.axes[3]; - this.gamepadInputs.leftShoulder = gamepad.buttons[4].pressed - this.gamepadInputs.rightShoulder= gamepad.buttons[5].pressed - } - - private calculateAimAssist(): number { - if(this.gamepad) { - if ((Math.abs(this.gamepadInputs.rightJoyX) >= .1 || Math.abs(this.gamepadInputs.rightJoyY) >= .1)) { - if (this.renderer.getPlayerSpheresInCrosshairWithWalls().length > 0) { - return .5; - } - } - } else if (this.getKey('arrowup') || this.getKey('arrowdown') || this.getKey('arrowleft') || this.getKey('arrowright')) { - if (this.renderer.getPlayerSpheresInCrosshairWithWalls().length > 0) { - return .5; - } - } - return 1; - } + private readonly gameIndex: number; + private mouse: PointerLockControls; + private gamepad: Gamepad | null = null; + private readonly gamepadEuler; + private clock: THREE.Clock; + private keys: { [key: string]: boolean } = {}; + private leftMouseDown: boolean = false; + private rightMouseDown: boolean = false; + private renderer: Renderer; + private readonly localPlayer: Player; + private inputX: number = 0; + private inputZ: number = 0; + public jump = false; + public prevInputVelocity: THREE.Vector3; + private scrollClicksSinceLastCheck: number = 0; + private readonly gamepadInputs: GamepadInputs; + private shoot: boolean = false; + private aim: boolean = false; + public nameSettingActive: boolean = false; + private touchJoyX: number = 0; + private touchJoyY: number = 0; + private touchLookX: number = 0; + private touchLookY: number = 0; + private inventoryIterationTouched: boolean = false; + private touchButtons: number[] = []; + + constructor(renderer: Renderer, localPlayer: Player, nextGameIndex: number) { + this.renderer = renderer; + this.localPlayer = localPlayer; + this.prevInputVelocity = new THREE.Vector3(); + this.gamepadEuler = new THREE.Euler(0, 0, 0, 'YXZ'); + + this.clock = new THREE.Clock(); + this.mouse = new PointerLockControls(this.localPlayer, document.body); + + this.gamepadInputs = new GamepadInputs(); + + this.gameIndex = nextGameIndex; + + if (!navigator.getGamepads()) { + console.log('Browser does not support Gamepad API.'); + } + + this.setupEventListeners(); + } + + private setupEventListeners() { + document.addEventListener('keydown', this.onKeyDown.bind(this)); + document.addEventListener('keyup', this.onKeyUp.bind(this)); + document.addEventListener('mousedown', this.onMouseDown.bind(this)); + document.addEventListener('mouseup', this.onMouseUp.bind(this)); + document.addEventListener('mouseleave', this.onMouseUp.bind(this)); + document.addEventListener('blur', this.deregisterAllKeys.bind(this), false); + document.addEventListener('pointerlockchange', this.deregisterAllKeys.bind(this), false); + document.addEventListener('visibilitychange', this.deregisterAllKeys.bind(this), false); + + document.addEventListener('click', () => { + this.mouse.lock(); + }); + + document.addEventListener('contextmenu', (event) => { + event.preventDefault(); + }); + + document.addEventListener('wheel', this.processScroll.bind(this)); + } + + private processScroll(e: WheelEvent) { + if (e.deltaY >= 4) { + this.scrollClicksSinceLastCheck++; + } + if (e.deltaY <= -4) { + this.scrollClicksSinceLastCheck--; + } + } + + public getScrollClicks() { + const clicks = this.scrollClicksSinceLastCheck; + this.scrollClicksSinceLastCheck = 0; + return clicks; + } + + public handleInputs() { + const deltaTime: number = this.clock.getDelta(); + const deltaTimeAcceleration = this.localPlayer.acceleration * deltaTime; + + let dist = 1; + let speedMultiplier: number = 1; + this.jump = false; + this.aim = false; + this.shoot = false; + + const oldInputZ = this.inputZ; + const oldInputX = this.inputX; + + this.gamepad = navigator.getGamepads()[this.gameIndex]; + if (this.gamepad) { + if (this.gamepad.connected) { + this.updateGamepadInputArray(this.gamepad); + this.gamepadEuler.setFromQuaternion(this.localPlayer.lookQuaternion); + if (Math.abs(this.gamepadInputs.leftJoyX) >= .1) { + this.inputX += deltaTimeAcceleration * this.gamepadInputs.leftJoyX; + } + if (Math.abs(this.gamepadInputs.leftJoyY) >= .1) { + this.inputZ += deltaTimeAcceleration * this.gamepadInputs.leftJoyY; + } + const vectorLength = Math.sqrt( + (this.gamepadInputs.leftJoyX * this.gamepadInputs.leftJoyX) + + (this.gamepadInputs.leftJoyY * this.gamepadInputs.leftJoyY), + ); + if (vectorLength >= .1) speedMultiplier = Math.min(Math.max(vectorLength, 0), 1); + if (this.gamepadInputs.A) this.jump = true; + if (this.gamepadInputs.leftTrigger > .5) this.aim = true; + if (this.gamepadInputs.rightTrigger > .5) this.shoot = true; + const aimAdjust = this.calculateAimAssist(); + this.gamepadEuler.y -= this.gamepadInputs.rightJoyX * SettingsManager.settings.controllerSense * deltaTime * + aimAdjust * 4; + this.gamepadEuler.x -= this.gamepadInputs.rightJoyY * SettingsManager.settings.controllerSense * deltaTime * + aimAdjust * 4; + this.gamepadEuler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.gamepadEuler.x)); + this.localPlayer.lookQuaternion.setFromEuler(this.gamepadEuler); + } + } + + //touch joystick controls + if (Math.hypot(this.touchJoyY, this.touchJoyX) > 0.1) { + const touchVectorLength = Math.hypot(this.touchJoyX, this.touchJoyY); + speedMultiplier = Math.min(Math.max(touchVectorLength, 0), 1); + this.inputX += deltaTimeAcceleration * this.touchJoyX; + this.inputZ += deltaTimeAcceleration * this.touchJoyY; + } + + const touchSensitivity = 0.03; // Adjust sensitivity as needed + this.gamepadEuler.setFromQuaternion(this.localPlayer.lookQuaternion); + + if (!this.localPlayer.chatActive && !this.nameSettingActive) { + if (this.getKey('w')) this.inputZ -= deltaTimeAcceleration; + if (this.getKey('s')) this.inputZ += deltaTimeAcceleration; + if (this.getKey('a')) this.inputX -= deltaTimeAcceleration; + if (this.getKey('d')) this.inputX += deltaTimeAcceleration; + const aimAdjust = this.calculateAimAssist(); + if (this.getKey('arrowright')) { + this.gamepadEuler.y -= SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; + } + if (this.getKey('arrowleft')) { + this.gamepadEuler.y += SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; + } + if (this.getKey('arrowup')) { + this.gamepadEuler.x += SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; + } + if (this.getKey('arrowdown')) { + this.gamepadEuler.x -= SettingsManager.settings.controllerSense * deltaTime * aimAdjust * 4; + } + if (this.getKey(' ')) this.jump = true; + } + + this.gamepadEuler.y -= this.touchLookX * touchSensitivity; + this.gamepadEuler.x -= this.touchLookY * touchSensitivity; + this.gamepadEuler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.gamepadEuler.x)); + this.localPlayer.lookQuaternion.setFromEuler(this.gamepadEuler); + + switch (this.inputZ - oldInputZ) { + case 0: + this.inputZ = InputHandler.approachZero(this.inputZ, deltaTimeAcceleration); + } + + switch (this.inputX - oldInputX) { + case 0: + this.inputX = InputHandler.approachZero(this.inputX, deltaTimeAcceleration); + } + + if (this.localPlayer.health <= 0) dist = 0; //don't allow movement when health = 0 + + this.prevInputVelocity.copy(this.localPlayer.inputVelocity); + + this.localPlayer.inputVelocity.z = dist * this.inputZ; + this.localPlayer.inputVelocity.x = dist * this.inputX; + this.localPlayer.inputVelocity.y = 0; + this.localPlayer.inputVelocity.clampLength(0, this.localPlayer.speed * speedMultiplier); + this.localPlayer.inputVelocity.y = this.prevInputVelocity.y; + this.inputZ = this.localPlayer.inputVelocity.z; + this.inputX = this.localPlayer.inputVelocity.x; + + const euler = new THREE.Euler().setFromQuaternion(this.localPlayer.lookQuaternion, 'YXZ'); + euler.x = 0; + euler.z = 0; + this.localPlayer.quaternion.setFromEuler(euler); + this.localPlayer.inputVelocity.applyQuaternion(this.localPlayer.quaternion); + + if (this.leftMouseDown || this.touchButtons.includes(0)) this.shoot = true; + if (this.rightMouseDown) this.aim = true; + + if (this.localPlayer.playerSpectating !== -1) { + this.inputX = 0; + this.inputZ = 0; + this.jump = false; + this.shoot = false; + this.aim = false; + } + } + + public getKey(key: string): boolean { + return this.keys[key]; + } + + public setTouchJoyInput(x: number, y: number) { + this.touchJoyX = x; + this.touchJoyY = y; + } + + public setLastTouchLookDelta(x: number, y: number) { + this.touchLookX = x; + this.touchLookY = y; + } + public setButtonsHeld(buttons: number[]) { + this.touchButtons = buttons; + this.jump = this.jump || buttons.includes(-1); + this.inventoryIterationTouched = buttons.includes(1); + } + + public getInventoryIterationTouched() { + return this.inventoryIterationTouched; + } + + private onKeyDown(event: KeyboardEvent) { + //event.preventDefault(); + if (event.key === 'Tab' || event.key === "'" || event.key === '/') event.preventDefault(); + const key = event.key.toLowerCase(); + this.keys[key] = true; + + if (!this.localPlayer.chatActive && !this.nameSettingActive) { + if (key === 'c') { + this.leftMouseDown = true; + } else if (key === 'z') { + this.rightMouseDown = true; + } + } + } + + private onKeyUp(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + this.keys[key] = false; + + if (!this.localPlayer.chatActive && !this.nameSettingActive) { + if (key === 'c') { + this.leftMouseDown = false; + } else if (key === 'z') { + this.rightMouseDown = false; + } + } + } + + private onMouseDown(event: MouseEvent) { + if (event.button === 0) { + this.leftMouseDown = true; + } else if (event.button === 2) { + this.rightMouseDown = true; + } + } + + private onMouseUp(event: MouseEvent) { + if (event.button === 0) { + this.leftMouseDown = false; + } else if (event.button === 2) { + this.rightMouseDown = false; + } + } + + public getShoot() { + return this.shoot; + } + + public getAim() { + return this.aim; + } + + public getGamepadInputs(): GamepadInputs { + return this.gamepadInputs; + } + + public deregisterAllKeys() { + const locked = document.pointerLockElement === document.body; + if (!locked) { + this.keys = {}; + } + } + + public getInputX() { + return this.inputX; + } + + public getInputZ() { + return this.inputZ; + } + + private static approachZero(input: number, step: number): number { + if (input == 0) return 0; + let sign: number = 1; + if (input < 0) sign = -1; + const output: number = Math.abs(input) - step; + if (output <= 0) return 0; + return sign * output; + } + + private updateGamepadInputArray(gamepad: Gamepad) { + if (gamepad.axes[4]) { + this.gamepadInputs.leftTrigger = gamepad.axes[4]; + this.gamepadInputs.rightTrigger = gamepad.axes[5]; + } else { + this.gamepadInputs.leftTrigger = gamepad.buttons[6].value; + this.gamepadInputs.rightTrigger = gamepad.buttons[7].value; + } + this.gamepadInputs.leftJoyX = gamepad.axes[0]; + this.gamepadInputs.leftJoyY = gamepad.axes[1]; + this.gamepadInputs.A = gamepad.buttons[0].pressed; + this.gamepadInputs.rightJoyX = gamepad.axes[2]; + this.gamepadInputs.rightJoyY = gamepad.axes[3]; + this.gamepadInputs.leftShoulder = gamepad.buttons[4].pressed; + this.gamepadInputs.rightShoulder = gamepad.buttons[5].pressed; + } + + private calculateAimAssist(): number { + if (this.gamepad) { + if ((Math.abs(this.gamepadInputs.rightJoyX) >= .1 || Math.abs(this.gamepadInputs.rightJoyY) >= .1)) { + if (this.renderer.getPlayerSpheresInCrosshairWithWalls().length > 0) { + return .5; + } + } + } else if ( + this.getKey('arrowup') || this.getKey('arrowdown') || this.getKey('arrowleft') || this.getKey('arrowright') + ) { + if (this.renderer.getPlayerSpheresInCrosshairWithWalls().length > 0) { + return .5; + } + } + return 1; + } } class GamepadInputs { - leftJoyX: number = 0; - leftJoyY: number = 0; - rightJoyX: number = 0; - rightJoyY: number = 0; - leftTrigger: number= 0; - rightTrigger: number = 0; - leftShoulder: boolean = false; - rightShoulder: boolean = false; - A: boolean = false; - B: boolean = false; - X: boolean = false; - Y: boolean = false; + leftJoyX: number = 0; + leftJoyY: number = 0; + rightJoyX: number = 0; + rightJoyY: number = 0; + leftTrigger: number = 0; + rightTrigger: number = 0; + leftShoulder: boolean = false; + rightShoulder: boolean = false; + A: boolean = false; + B: boolean = false; + X: boolean = false; + Y: boolean = false; } diff --git a/src/client/input/PointerLockControl.ts b/src/client/input/PointerLockControl.ts index 718ef384..b761c2bc 100644 --- a/src/client/input/PointerLockControl.ts +++ b/src/client/input/PointerLockControl.ts @@ -1,92 +1,92 @@ import * as THREE from 'three'; -import { Player } from "../core/Player.ts"; -import {SettingsManager} from "../core/SettingsManager.ts"; +import { Player } from '../core/Player.ts'; +import { SettingsManager } from '../core/SettingsManager.ts'; // Define a custom event map interface interface PointerLockControlEventMap { - change: Event; - lock: Event; - unlock: Event; + change: Event; + lock: Event; + unlock: Event; } // Extend the EventDispatcher class to use our custom event map export class PointerLockControls extends THREE.EventDispatcher { - public localPlayer: Player; - public domElement: Element; - public isLocked: boolean = false; + public localPlayer: Player; + public domElement: Element; + public isLocked: boolean = false; - constructor(localPlayer: Player, domElement: Element) { - super(); + constructor(localPlayer: Player, domElement: Element) { + super(); - if (domElement === undefined) { - console.warn('THREE.PointerLockControls: The second parameter "domElement" is now mandatory.'); - domElement = document.body; - } + if (domElement === undefined) { + console.warn('THREE.PointerLockControls: The second parameter "domElement" is now mandatory.'); + domElement = document.body; + } - this.localPlayer = localPlayer; - this.domElement = domElement; + this.localPlayer = localPlayer; + this.domElement = domElement; - this.connect(); - } + this.connect(); + } - public connect(): void { - document.addEventListener('mousemove', this.onMouseMove, false); - document.addEventListener('pointerlockchange', this.onPointerLockChange, false); - document.addEventListener('pointerlockerror', this.onPointerLockError, false); - } + public connect(): void { + document.addEventListener('mousemove', this.onMouseMove, false); + document.addEventListener('pointerlockchange', this.onPointerLockChange, false); + document.addEventListener('pointerlockerror', this.onPointerLockError, false); + } - public disconnect(): void { - document.removeEventListener('mousemove', this.onMouseMove, false); - document.removeEventListener('pointerlockchange', this.onPointerLockChange, false); - document.removeEventListener('pointerlockerror', this.onPointerLockError, false); - } + public disconnect(): void { + document.removeEventListener('mousemove', this.onMouseMove, false); + document.removeEventListener('pointerlockchange', this.onPointerLockChange, false); + document.removeEventListener('pointerlockerror', this.onPointerLockError, false); + } - public dispose(): void { - this.disconnect(); - } + public dispose(): void { + this.disconnect(); + } - public getObject(): Player { - return this.localPlayer; - } + public getObject(): Player { + return this.localPlayer; + } - public getDirection = (v: THREE.Vector3): THREE.Vector3 => { - return v.copy(new THREE.Vector3(0, 0, -1)).applyQuaternion(this.localPlayer.lookQuaternion); - }; + public getDirection = (v: THREE.Vector3): THREE.Vector3 => { + return v.copy(new THREE.Vector3(0, 0, -1)).applyQuaternion(this.localPlayer.lookQuaternion); + }; - public lock(): void { - this.domElement.requestPointerLock(); - } + public lock(): void { + this.domElement.requestPointerLock(); + } - public unlock(): void { - document.exitPointerLock(); - } + public unlock(): void { + document.exitPointerLock(); + } - private onMouseMove = (event: MouseEvent): void => { - if (!this.isLocked) return; + private onMouseMove = (event: MouseEvent): void => { + if (!this.isLocked) return; - // deno-lint-ignore no-explicit-any - const movementX = event.movementX || (event as any).mozMovementX || (event as any).webkitMovementX || 0; - // deno-lint-ignore no-explicit-any - const movementY = event.movementY || (event as any).mozMovementY || (event as any).webkitMovementY || 0; + // deno-lint-ignore no-explicit-any + const movementX = event.movementX || (event as any).mozMovementX || (event as any).webkitMovementX || 0; + // deno-lint-ignore no-explicit-any + const movementY = event.movementY || (event as any).mozMovementY || (event as any).webkitMovementY || 0; - const euler = new THREE.Euler(0, 0, 0, 'YXZ'); - euler.setFromQuaternion(this.localPlayer.lookQuaternion); + const euler = new THREE.Euler(0, 0, 0, 'YXZ'); + euler.setFromQuaternion(this.localPlayer.lookQuaternion); - euler.y -= movementX * SettingsManager.settings.sense * .002; - euler.x -= movementY * SettingsManager.settings.sense * .002; + euler.y -= movementX * SettingsManager.settings.sense * .002; + euler.x -= movementY * SettingsManager.settings.sense * .002; - euler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, euler.x)); + euler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, euler.x)); - this.localPlayer.lookQuaternion.setFromEuler(euler); + this.localPlayer.lookQuaternion.setFromEuler(euler); - // this.dispatchEvent({ type: 'change' }); - }; + // this.dispatchEvent({ type: 'change' }); + }; - private onPointerLockChange = (): void => { - this.isLocked = document.pointerLockElement === this.domElement; - }; + private onPointerLockChange = (): void => { + this.isLocked = document.pointerLockElement === this.domElement; + }; - private onPointerLockError = (): void => { - console.error('THREE.PointerLockControls: Unable to use Pointer Lock API'); - }; + private onPointerLockError = (): void => { + console.error('THREE.PointerLockControls: Unable to use Pointer Lock API'); + }; } diff --git a/src/client/input/TouchInputHandler.ts b/src/client/input/TouchInputHandler.ts index ffb6bbf8..36f1103c 100644 --- a/src/client/input/TouchInputHandler.ts +++ b/src/client/input/TouchInputHandler.ts @@ -1,190 +1,174 @@ -import {InputHandler} from "./InputHandler.ts"; -import {ChatOverlay} from "../ui/ChatOverlay.ts"; +import { InputHandler } from './InputHandler.ts'; +import { ChatOverlay } from '../ui/ChatOverlay.ts'; export class TouchInputHandler { - public static joystickRadius = 30; - - private lastTouchTimestamp: number = 0; - private inputHandler: InputHandler; - private chatOverlay: ChatOverlay; - - private joystickX: number = 0; - private joystickY: number = 0; - - private joystickInputX: number = 0; - private joystickInputY: number = 0; - - private joystickFingerId: number = -1; - private lookFingerId: number = -1; - - private lastLookX: number = 0; - private lastLookY: number = 0; - - private lastLookChangeX: number = 0; - private lastLookChangeY: number = 0; - - private buttonsHeld: {button:number, fingerId: number}[] = []; - - - constructor(inputHandler: InputHandler, chatOverlay: ChatOverlay) { - this.inputHandler = inputHandler; - this.chatOverlay = chatOverlay - this.setupEventListeners(); - - - } - - public onFrame() { - this.chatOverlay.setLastTouchTimestamp(this.lastTouchTimestamp); - this.chatOverlay.setTouchJoystickEngaged(this.joystickFingerId !== -1); - this.chatOverlay.setJoystickPosition(this.joystickX, this.joystickY); - this.chatOverlay.setJoystickInput(this.joystickInputX, this.joystickInputY); - this.chatOverlay.setButtonsHeld(this.buttonsHeld.map((button) => button.button)); - - this.inputHandler.setTouchJoyInput(this.joystickInputX, this.joystickInputY); - this.inputHandler.setLastTouchLookDelta(this.lastLookChangeX, this.lastLookChangeY); - this.inputHandler.setButtonsHeld(this.buttonsHeld.map((button) => button.button)); - this.lastLookChangeX = 0; this.lastLookChangeY = 0; - if(this.joystickFingerId == -1) { - this.joystickInputX = 0; - this.joystickInputY = 0; - } - } - - private setupEventListeners() { - document.addEventListener('touchstart', this.onTouchStart.bind(this), false); - document.addEventListener('touchmove', this.onTouchMove.bind(this), false); - document.addEventListener('touchend', this.onTouchEnd.bind(this), false); - } - - private onTouchStart(event: TouchEvent) { - this.lastTouchTimestamp = Date.now()/1000; - - for (let i = 0; i < event.touches.length; i++) { - - const pixelRatio = this.getPixelRatio(); - const touchX = event.touches[i].clientX * pixelRatio; - const touchY = event.touches[i].clientY * pixelRatio; - // console.log(touchX, touchY); - - if(this.joystickFingerId == -1){ - if (touchX < globalThis.innerWidth * pixelRatio / 2.5) { - - this.joystickFingerId = event.touches[i].identifier; - this.joystickX = touchX; - this.joystickY = touchY; - continue; - } - } - if(this.lookFingerId == -1){ - if (touchX > globalThis.innerWidth * pixelRatio / 2.5 && touchX < globalThis.innerWidth * pixelRatio - 30) { - this.lookFingerId = event.touches[i].identifier; - this.lastLookX = touchX; - this.lastLookY = touchY; - continue; - } - } - - - } - this.onTouchMove(event); - } - - private onTouchMove(event: TouchEvent) { - this.lastTouchTimestamp = Date.now()/1000; - - for (let i = 0; i < event.touches.length; i++) { - - const pixelRatio = this.getPixelRatio(); - const touchX = event.touches[i].clientX * pixelRatio; - const touchY = event.touches[i].clientY * pixelRatio; - - if (event.touches[i].identifier === this.joystickFingerId) { - this.joystickInputX = (touchX - this.joystickX) / TouchInputHandler.joystickRadius; - this.joystickInputY = (touchY - this.joystickY) / TouchInputHandler.joystickRadius; - - const mag = Math.sqrt(this.joystickInputX * this.joystickInputX + this.joystickInputY * this.joystickInputY); - const dir = Math.atan2(this.joystickInputY, this.joystickInputX); - if(mag > 1) { - this.joystickInputX = Math.cos(dir); - this.joystickInputY = Math.sin(dir); - } - continue; - } - - if (event.touches[i].identifier === this.lookFingerId) { - - this.lastLookChangeX = touchX - this.lastLookX; - this.lastLookChangeY = touchY - this.lastLookY; - - this.lastLookX = touchX; - this.lastLookY = touchY; - continue; - } - - if(touchX > globalThis.innerWidth * pixelRatio - 30) { - const buttonClosestTo = Math.round((touchY - 100) / 30); - - let found = false; - for(let j = 0; j < this.buttonsHeld.length; j++) - if(this.buttonsHeld[j].button === buttonClosestTo){ - this.buttonsHeld[j].fingerId = event.touches[i].identifier; - found = true; - } - if(!found) - this.buttonsHeld.push({button: buttonClosestTo, fingerId: event.touches[i].identifier}); - - - - } - - - - - } - - } - - private onTouchEnd(event: TouchEvent) { - if(event.touches.length === 0) { - this.joystickFingerId = -1; - this.lookFingerId = -1; - } - for (let i = 0; i < event.changedTouches.length; i++) { - if (event.changedTouches[i].identifier === this.joystickFingerId) { - this.joystickFingerId = -1; - } - if (event.changedTouches[i].identifier === this.lookFingerId) { - this.lookFingerId = -1; - } - - const touchY = event.changedTouches[i].clientY * this.getPixelRatio(); - const buttonClosestTo = Math.round((touchY - 100) / 30); - - // Iterate backwards to safely remove elements - for (let j = this.buttonsHeld.length - 1; j >= 0; j--) { - if (this.buttonsHeld[j].fingerId === event.changedTouches[i].identifier || this.buttonsHeld[j].button === buttonClosestTo) { - this.buttonsHeld.splice(j, 1); - } - } - } - - - } - - public getButtonState(button: number): boolean { - for(let i = 0; i < this.buttonsHeld.length; i++) - if(this.buttonsHeld[i].button === button && this.buttonsHeld[i].fingerId !== -1) - return true; - return false; - - } - - private getPixelRatio(): number { - return 200 / globalThis.innerHeight; - } - - - - + public static joystickRadius = 30; + + private lastTouchTimestamp: number = 0; + private inputHandler: InputHandler; + private chatOverlay: ChatOverlay; + + private joystickX: number = 0; + private joystickY: number = 0; + + private joystickInputX: number = 0; + private joystickInputY: number = 0; + + private joystickFingerId: number = -1; + private lookFingerId: number = -1; + + private lastLookX: number = 0; + private lastLookY: number = 0; + + private lastLookChangeX: number = 0; + private lastLookChangeY: number = 0; + + private buttonsHeld: { button: number; fingerId: number }[] = []; + + constructor(inputHandler: InputHandler, chatOverlay: ChatOverlay) { + this.inputHandler = inputHandler; + this.chatOverlay = chatOverlay; + this.setupEventListeners(); + } + + public onFrame() { + this.chatOverlay.setLastTouchTimestamp(this.lastTouchTimestamp); + this.chatOverlay.setTouchJoystickEngaged(this.joystickFingerId !== -1); + this.chatOverlay.setJoystickPosition(this.joystickX, this.joystickY); + this.chatOverlay.setJoystickInput(this.joystickInputX, this.joystickInputY); + this.chatOverlay.setButtonsHeld(this.buttonsHeld.map((button) => button.button)); + + this.inputHandler.setTouchJoyInput(this.joystickInputX, this.joystickInputY); + this.inputHandler.setLastTouchLookDelta(this.lastLookChangeX, this.lastLookChangeY); + this.inputHandler.setButtonsHeld(this.buttonsHeld.map((button) => button.button)); + this.lastLookChangeX = 0; + this.lastLookChangeY = 0; + if (this.joystickFingerId == -1) { + this.joystickInputX = 0; + this.joystickInputY = 0; + } + } + + private setupEventListeners() { + document.addEventListener('touchstart', this.onTouchStart.bind(this), false); + document.addEventListener('touchmove', this.onTouchMove.bind(this), false); + document.addEventListener('touchend', this.onTouchEnd.bind(this), false); + } + + private onTouchStart(event: TouchEvent) { + this.lastTouchTimestamp = Date.now() / 1000; + + for (let i = 0; i < event.touches.length; i++) { + const pixelRatio = this.getPixelRatio(); + const touchX = event.touches[i].clientX * pixelRatio; + const touchY = event.touches[i].clientY * pixelRatio; + // console.log(touchX, touchY); + + if (this.joystickFingerId == -1) { + if (touchX < globalThis.innerWidth * pixelRatio / 2.5) { + this.joystickFingerId = event.touches[i].identifier; + this.joystickX = touchX; + this.joystickY = touchY; + continue; + } + } + if (this.lookFingerId == -1) { + if (touchX > globalThis.innerWidth * pixelRatio / 2.5 && touchX < globalThis.innerWidth * pixelRatio - 30) { + this.lookFingerId = event.touches[i].identifier; + this.lastLookX = touchX; + this.lastLookY = touchY; + continue; + } + } + } + this.onTouchMove(event); + } + + private onTouchMove(event: TouchEvent) { + this.lastTouchTimestamp = Date.now() / 1000; + + for (let i = 0; i < event.touches.length; i++) { + const pixelRatio = this.getPixelRatio(); + const touchX = event.touches[i].clientX * pixelRatio; + const touchY = event.touches[i].clientY * pixelRatio; + + if (event.touches[i].identifier === this.joystickFingerId) { + this.joystickInputX = (touchX - this.joystickX) / TouchInputHandler.joystickRadius; + this.joystickInputY = (touchY - this.joystickY) / TouchInputHandler.joystickRadius; + + const mag = Math.sqrt(this.joystickInputX * this.joystickInputX + this.joystickInputY * this.joystickInputY); + const dir = Math.atan2(this.joystickInputY, this.joystickInputX); + if (mag > 1) { + this.joystickInputX = Math.cos(dir); + this.joystickInputY = Math.sin(dir); + } + continue; + } + + if (event.touches[i].identifier === this.lookFingerId) { + this.lastLookChangeX = touchX - this.lastLookX; + this.lastLookChangeY = touchY - this.lastLookY; + + this.lastLookX = touchX; + this.lastLookY = touchY; + continue; + } + + if (touchX > globalThis.innerWidth * pixelRatio - 30) { + const buttonClosestTo = Math.round((touchY - 100) / 30); + + let found = false; + for (let j = 0; j < this.buttonsHeld.length; j++) { + if (this.buttonsHeld[j].button === buttonClosestTo) { + this.buttonsHeld[j].fingerId = event.touches[i].identifier; + found = true; + } + } + if (!found) { + this.buttonsHeld.push({ button: buttonClosestTo, fingerId: event.touches[i].identifier }); + } + } + } + } + + private onTouchEnd(event: TouchEvent) { + if (event.touches.length === 0) { + this.joystickFingerId = -1; + this.lookFingerId = -1; + } + for (let i = 0; i < event.changedTouches.length; i++) { + if (event.changedTouches[i].identifier === this.joystickFingerId) { + this.joystickFingerId = -1; + } + if (event.changedTouches[i].identifier === this.lookFingerId) { + this.lookFingerId = -1; + } + + const touchY = event.changedTouches[i].clientY * this.getPixelRatio(); + const buttonClosestTo = Math.round((touchY - 100) / 30); + + // Iterate backwards to safely remove elements + for (let j = this.buttonsHeld.length - 1; j >= 0; j--) { + if ( + this.buttonsHeld[j].fingerId === event.changedTouches[i].identifier || + this.buttonsHeld[j].button === buttonClosestTo + ) { + this.buttonsHeld.splice(j, 1); + } + } + } + } + + public getButtonState(button: number): boolean { + for (let i = 0; i < this.buttonsHeld.length; i++) { + if (this.buttonsHeld[i].button === button && this.buttonsHeld[i].fingerId !== -1) { + return true; + } + } + return false; + } + + private getPixelRatio(): number { + return 200 / globalThis.innerHeight; + } } diff --git a/src/client/items/BananaGun.ts b/src/client/items/BananaGun.ts index 9fba619e..81a54ada 100644 --- a/src/client/items/BananaGun.ts +++ b/src/client/items/BananaGun.ts @@ -1,9 +1,9 @@ -import {ItemBase, ItemType} from './ItemBase.ts'; -import {HeldItemInput} from '../input/HeldItemInput.ts'; +import { ItemBase, ItemType } from './ItemBase.ts'; +import { HeldItemInput } from '../input/HeldItemInput.ts'; import * as THREE from 'three'; -import {Renderer} from '../core/Renderer.ts'; -import {Networking} from '../core/Networking.ts'; -import { AssetManager } from "../core/AssetManager.ts"; +import { Renderer } from '../core/Renderer.ts'; +import { Networking } from '../core/Networking.ts'; +import { AssetManager } from '../core/AssetManager.ts'; const firingDelay = 0.12; const firingDelayHeld = 0.225; //longer firing delay when mouse is held down @@ -16,196 +16,194 @@ const scopedQuaternion = new THREE.Quaternion(0.64, 0.22, -0.69, -0.22); const inventoryQuaternionBase = new THREE.Quaternion(0, 0, 0, 1); export class BananaGun extends ItemBase { - private renderer!: Renderer; - private networking!: Networking; - private lastInput: HeldItemInput; - private lastFired: number; - private addedToHandScene: boolean; - - // deno-lint-ignore constructor-super - constructor(renderer: Renderer, networking: Networking, 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.networking = networking; - this.lastInput = new HeldItemInput(); - this.addedToHandScene = false; - this.lastFired = 0; - - - } - - public override init() { - AssetManager.getInstance().loadAsset('models/simplified_banana_1.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); - - if(this.itemType === ItemType.WorldItem) - this.object.scale.set(0.45, 0.45, 0.45); - }); - } - - - 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); - - if (input.leftClick && (!this.lastInput.leftClick || Date.now() / 1000 - this.lastFired > firingDelayHeld)) { - if (Date.now() / 1000 - this.lastFired > firingDelay) { - this.lastFired = Date.now() / 1000; - this.shootBanana(); - this.handPosition.add(new THREE.Vector3(0, 0, 0.6)); - rotateAroundWorldAxis(this.object.quaternion, new THREE.Vector3(1, 0, 0), Math.PI / 16); - } - } - - this.lastInput = input; - } - - 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; - } - public itemDepleted(): boolean { - return false; - } - - private shootBanana() { - const processShots = () => { - const shotVectors = this.renderer.getShotVectorsToPlayersInCrosshair(); - if (shotVectors.length > 0) { - for (const shot of shotVectors) { - const { playerID, hitPoint } = shot; - this.networking.applyDamage(playerID, 17); - this.renderer.playerHitMarkers.push({hitPoint: hitPoint, shotVector: shot.vector, timestamp: -1}); - } - this.renderer.lastShotSomeoneTimestamp = Date.now() / 1000; - } - }; - - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(processShots, { timeout: 150 }); - } else { - setTimeout(processShots, 0); - } - } - - - - - // Method to set world position when used as WorldItem - public override setWorldPosition(vector: THREE.Vector3) { - super.setWorldPosition(vector); - } + private renderer!: Renderer; + private networking!: Networking; + private lastInput: HeldItemInput; + private lastFired: number; + private addedToHandScene: boolean; + + // deno-lint-ignore constructor-super + constructor(renderer: Renderer, networking: Networking, 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.networking = networking; + this.lastInput = new HeldItemInput(); + this.addedToHandScene = false; + this.lastFired = 0; + } + + public override init() { + AssetManager.getInstance().loadAsset('models/simplified_banana_1.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); + + if (this.itemType === ItemType.WorldItem) { + this.object.scale.set(0.45, 0.45, 0.45); + } + }); + } + + 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); + + if (input.leftClick && (!this.lastInput.leftClick || Date.now() / 1000 - this.lastFired > firingDelayHeld)) { + if (Date.now() / 1000 - this.lastFired > firingDelay) { + this.lastFired = Date.now() / 1000; + this.shootBanana(); + this.handPosition.add(new THREE.Vector3(0, 0, 0.6)); + rotateAroundWorldAxis(this.object.quaternion, new THREE.Vector3(1, 0, 0), Math.PI / 16); + } + } + + this.lastInput = input; + } + + 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; + } + public itemDepleted(): boolean { + return false; + } + + private shootBanana() { + const processShots = () => { + const shotVectors = this.renderer.getShotVectorsToPlayersInCrosshair(); + if (shotVectors.length > 0) { + for (const shot of shotVectors) { + const { playerID, hitPoint } = shot; + this.networking.applyDamage(playerID, 17); + this.renderer.playerHitMarkers.push({ hitPoint: hitPoint, shotVector: shot.vector, timestamp: -1 }); + } + this.renderer.lastShotSomeoneTimestamp = Date.now() / 1000; + } + }; + + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(processShots, { timeout: 150 }); + } else { + setTimeout(processShots, 0); + } + } + + // 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); + 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); + source.lerp(target, frac); } function moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { - source.slerp(target, frac); + source.slerp(target, frac); } diff --git a/src/client/items/FishGun.ts b/src/client/items/FishGun.ts index 010bc07b..42d5ab23 100644 --- a/src/client/items/FishGun.ts +++ b/src/client/items/FishGun.ts @@ -1,10 +1,10 @@ -import {ItemBase, ItemType} from './ItemBase.ts'; -import {HeldItemInput} from '../input/HeldItemInput.ts'; +import { ItemBase, ItemType } from './ItemBase.ts'; +import { HeldItemInput } from '../input/HeldItemInput.ts'; import * as THREE from 'three'; -import {Renderer} from '../core/Renderer.ts'; -import {Networking} from '../core/Networking.ts'; -import { AssetManager } from "../core/AssetManager.ts"; +import { Renderer } from '../core/Renderer.ts'; +import { Networking } from '../core/Networking.ts'; +import { AssetManager } from '../core/AssetManager.ts'; const firingDelay = 0.45; const firingDelayHeld = 0.45; //longer firing delay when mouse is held down @@ -13,241 +13,235 @@ const showInHandDelay = 0.1; const scopedPosition = new THREE.Vector3(0, -0.6, 3.5); const unscopedPosition = new THREE.Vector3(0.75, -0.9, 3.2); const hiddenPosition = new THREE.Vector3(0.85, -2.7, 3.2); -const scopedQuaternion = new THREE.Quaternion(0,0.707,0,0.707); +const scopedQuaternion = new THREE.Quaternion(0, 0.707, 0, 0.707); const inventoryQuaternionBase = new THREE.Quaternion(0, 0, 0, 1); export class FishGun extends ItemBase { - private renderer!: Renderer; - private networking!: Networking; - private lastInput: HeldItemInput; - private lastFired: number; - private addedToHandScene: boolean; - - // deno-lint-ignore constructor-super - constructor(renderer: Renderer, networking: Networking, 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.networking = networking; - this.lastInput = new HeldItemInput(); - this.addedToHandScene = false; - this.lastFired = 0; - } - - public override init() { - AssetManager.getInstance().loadAsset('models/simplified_fish.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.InventoryItem) - this.object.scale.set(1.5, 1.5, 1.5); - } - - this.inventoryMenuObject = this.object.clone(); - this.inventoryMenuObject.scale.set(0.8, 0.8, 0.8); - - if(this.itemType === ItemType.WorldItem) - this.object.scale.set(0.45, 0.45, 0.45); - }); - } - - - - 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); - - if (input.leftClick && (!this.lastInput.leftClick || Date.now() / 1000 - this.lastFired > firingDelayHeld)) { - if (Date.now() / 1000 - this.lastFired > firingDelay) { - this.lastFired = Date.now() / 1000; - this.shootFish(); - this.handPosition.add(new THREE.Vector3(0, 0, 2)); - rotateAroundWorldAxis(this.object.quaternion, new THREE.Vector3(1, 0, 0), Math.PI / 16); - } - } - - this.lastInput = input; - } - - 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; - } - public itemDepleted(): boolean { - return false; - } - - private shootFish() { - const totalShots = 25; - let processedShots = 0; - const TIMEOUT = 150; - - const processShots = (deadline?: IdleDeadline) => { - const timeRemaining = deadline ? deadline.timeRemaining() : 16; - - while (processedShots < totalShots && timeRemaining > 0) { - const shotVectors = this.renderer.getShotVectorsToPlayersWithOffset( - (Math.random() - 0.5) * 0.30, - (Math.random() - 0.5) * 0.30 - ); - if (shotVectors.length > 0) { - for (const shot of shotVectors) { - const { playerID, hitPoint } = shot; - this.networking.applyDamage(playerID, 3); - this.renderer.playerHitMarkers.push({ - hitPoint: hitPoint, - shotVector: shot.vector, - timestamp: -1, - }); - } - this.renderer.lastShotSomeoneTimestamp = Date.now() / 1000; - } - processedShots++; - } - - // If we still have shots to process, schedule the next batch - if (processedShots < totalShots) { - if (typeof requestIdleCallback === 'function') { - const idleCallbackId = requestIdleCallback(processShots, { timeout: TIMEOUT }); - - // Ensure completion within timeout - setTimeout(() => { - cancelIdleCallback(idleCallbackId); - processShots(); - }, TIMEOUT); - } else { - setTimeout(() => processShots(), 0); - } - } - }; - - // Initial call - if (typeof requestIdleCallback === 'function') { - const idleCallbackId = requestIdleCallback(processShots, { timeout: TIMEOUT }); - - // Ensure first batch starts within timeout - setTimeout(() => { - cancelIdleCallback(idleCallbackId); - processShots(); - }, TIMEOUT); - } else { - setTimeout(() => processShots(), 0); - } - } - - - - - - - - - - // Method to set world position when used as WorldItem - public override setWorldPosition(vector: THREE.Vector3) { - super.setWorldPosition(vector); - } + private renderer!: Renderer; + private networking!: Networking; + private lastInput: HeldItemInput; + private lastFired: number; + private addedToHandScene: boolean; + + // deno-lint-ignore constructor-super + constructor(renderer: Renderer, networking: Networking, 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.networking = networking; + this.lastInput = new HeldItemInput(); + this.addedToHandScene = false; + this.lastFired = 0; + } + + public override init() { + AssetManager.getInstance().loadAsset('models/simplified_fish.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.InventoryItem) { + this.object.scale.set(1.5, 1.5, 1.5); + } + } + + this.inventoryMenuObject = this.object.clone(); + this.inventoryMenuObject.scale.set(0.8, 0.8, 0.8); + + if (this.itemType === ItemType.WorldItem) { + this.object.scale.set(0.45, 0.45, 0.45); + } + }); + } + + 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); + + if (input.leftClick && (!this.lastInput.leftClick || Date.now() / 1000 - this.lastFired > firingDelayHeld)) { + if (Date.now() / 1000 - this.lastFired > firingDelay) { + this.lastFired = Date.now() / 1000; + this.shootFish(); + this.handPosition.add(new THREE.Vector3(0, 0, 2)); + rotateAroundWorldAxis(this.object.quaternion, new THREE.Vector3(1, 0, 0), Math.PI / 16); + } + } + + this.lastInput = input; + } + + 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; + } + public itemDepleted(): boolean { + return false; + } + + private shootFish() { + const totalShots = 25; + let processedShots = 0; + const TIMEOUT = 150; + + const processShots = (deadline?: IdleDeadline) => { + const timeRemaining = deadline ? deadline.timeRemaining() : 16; + + while (processedShots < totalShots && timeRemaining > 0) { + const shotVectors = this.renderer.getShotVectorsToPlayersWithOffset( + (Math.random() - 0.5) * 0.30, + (Math.random() - 0.5) * 0.30, + ); + if (shotVectors.length > 0) { + for (const shot of shotVectors) { + const { playerID, hitPoint } = shot; + this.networking.applyDamage(playerID, 3); + this.renderer.playerHitMarkers.push({ + hitPoint: hitPoint, + shotVector: shot.vector, + timestamp: -1, + }); + } + this.renderer.lastShotSomeoneTimestamp = Date.now() / 1000; + } + processedShots++; + } + + // If we still have shots to process, schedule the next batch + if (processedShots < totalShots) { + if (typeof requestIdleCallback === 'function') { + const idleCallbackId = requestIdleCallback(processShots, { timeout: TIMEOUT }); + + // Ensure completion within timeout + setTimeout(() => { + cancelIdleCallback(idleCallbackId); + processShots(); + }, TIMEOUT); + } else { + setTimeout(() => processShots(), 0); + } + } + }; + + // Initial call + if (typeof requestIdleCallback === 'function') { + const idleCallbackId = requestIdleCallback(processShots, { timeout: TIMEOUT }); + + // Ensure first batch starts within timeout + setTimeout(() => { + cancelIdleCallback(idleCallbackId); + processShots(); + }, TIMEOUT); + } else { + setTimeout(() => processShots(), 0); + } + } + + // 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); + 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); + source.lerp(target, frac); } function moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { - source.slerp(target, frac); + source.slerp(target, frac); } diff --git a/src/client/items/ItemBase.ts b/src/client/items/ItemBase.ts index 49ff6b0b..54f00704 100644 --- a/src/client/items/ItemBase.ts +++ b/src/client/items/ItemBase.ts @@ -4,158 +4,152 @@ import * as THREE from 'three'; const showInHandDelay = 0.1; export class ItemBase { - protected timeAccum:number = 0; - protected clock:THREE.Clock = new THREE.Clock(); - - protected object!: THREE.Object3D; - protected itemType: ItemType; - - protected scene: THREE.Scene; // The scene to put the item in - - protected inventoryMenuScene: THREE.Scene; //Inventory menu scene - protected inventoryMenuObject!:THREE.Object3D; //The object shown in the inventory menu (he do spin) - protected index:number; //The index of the item in the inventory - protected shownInHand:boolean = false; - protected angleAccum: number = 0; - protected handPosition:THREE.Vector3 = new THREE.Vector3(0.85, -0.8, 3.2); - protected shownInHandTimestamp:number = 0; - - - constructor(itemType:ItemType, scene:THREE.Scene, inventoryMenuScene:THREE.Scene, index:number){ - this.itemType = itemType; - this.scene = scene; - this.inventoryMenuScene = inventoryMenuScene; - this.index = index; - - this.init(); - } - - protected init() { - // Init should be responsible for creating object and inventoryMenuObject - // For this class, we'll just create a simple cube - const geometry = new THREE.BoxGeometry(0.5,0.5,0.5); - const material = new THREE.MeshBasicMaterial({color: 0x00ff00}); - this.object = new THREE.Mesh(geometry, material); - this.inventoryMenuObject = this.object.clone(); - - if(this.itemType === ItemType.InventoryItem) - this.object.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); - } - }); - } - - onFrame(input: HeldItemInput | undefined, selectedIndex: number | undefined) { - if(!this.object) return; //return if object hasn't loaded - const deltaTime = this.clock.getDelta(); - this.timeAccum += deltaTime; - - if(this.itemType === ItemType.WorldItem) - this.worldOnFrame(deltaTime); - if(this.itemType === ItemType.InventoryItem && selectedIndex !== undefined && input !== undefined){ - this.inventoryOnFrame(deltaTime, selectedIndex); - this.handOnFrame(deltaTime, input); - } - } - - - /** -- World Items -- */ - protected addedToWorldScene:boolean = false; - protected worldPosition:THREE.Vector3 = new THREE.Vector3(); - - - protected worldOnFrame(deltaTime:number){ // This function is called every frame for world items - if(!this.addedToWorldScene){ - this.scene.add(this.object); - this.addedToWorldScene = true; - - } - this.object.position.copy(this.worldPosition); - this.object.position.add(new THREE.Vector3(0, Math.sin(this.timeAccum*2) * 0.1, 0)); - this.object.rotation.y += deltaTime * 2; - - } - - setWorldPosition(vector:THREE.Vector3){ - this.worldPosition = vector; - } - - /** -- Inventory Items -- */ - protected addedToInventoryItemScenes:boolean = false; - - protected inventoryOnFrame(deltaTime:number, selectedIndex:number){ - if(!this.addedToInventoryItemScenes){ - this.scene.add(this.object); - this.inventoryMenuScene.add(this.inventoryMenuObject); - } - this.angleAccum+=deltaTime; - this.inventoryMenuObject.position.set(0, this.index, 0); - - const targetQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0,0,0)); - if(this.index === selectedIndex){ - rotateAroundWorldAxis(targetQuaternion, new THREE.Vector3(0,1,0), this.angleAccum * 6); - this.showInHand(); - }else{ - this.hideInHand(); - } - rotateAroundWorldAxis(targetQuaternion, new THREE.Vector3(1,0,0), Math.PI / 4); - this.inventoryMenuObject.quaternion.slerp(targetQuaternion, 0.1 * 60 * deltaTime); - } - - protected handOnFrame(deltaTime:number, input:HeldItemInput){ - if(this.shownInHand && Date.now() / 1000 - this.shownInHandTimestamp > showInHandDelay) - this.handPosition.lerp(heldPosition, 0.1 * 60 *deltaTime); - else - this.handPosition.lerp(hiddenPosition, 0.1 * 60 *deltaTime); - - this.object.position.copy(this.handPosition); - if(this.shownInHand && input.leftClick){ - this.object.position.add(new THREE.Vector3(Math.random()*0.2, Math.random()*0.2, Math.random()*0.2)); - this.object.quaternion.slerp(new THREE.Quaternion().random(),0.1); - } - } - - protected showInHand(){ - if(this.shownInHand) return; - this.shownInHand = true; - this.shownInHandTimestamp = Date.now() / 1000; - - } - - protected hideInHand(){ - if(!this.shownInHand) return; - this.shownInHand = false; - } - - - public destroy(){ - this.scene.remove(this.object); - this.inventoryMenuScene.remove(this.inventoryMenuObject); - } - - - + protected timeAccum: number = 0; + protected clock: THREE.Clock = new THREE.Clock(); + + protected object!: THREE.Object3D; + protected itemType: ItemType; + + protected scene: THREE.Scene; // The scene to put the item in + + protected inventoryMenuScene: THREE.Scene; //Inventory menu scene + protected inventoryMenuObject!: THREE.Object3D; //The object shown in the inventory menu (he do spin) + protected index: number; //The index of the item in the inventory + protected shownInHand: boolean = false; + protected angleAccum: number = 0; + protected handPosition: THREE.Vector3 = new THREE.Vector3(0.85, -0.8, 3.2); + protected shownInHandTimestamp: number = 0; + + constructor(itemType: ItemType, scene: THREE.Scene, inventoryMenuScene: THREE.Scene, index: number) { + this.itemType = itemType; + this.scene = scene; + this.inventoryMenuScene = inventoryMenuScene; + this.index = index; + + this.init(); + } + + protected init() { + // Init should be responsible for creating object and inventoryMenuObject + // For this class, we'll just create a simple cube + const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5); + const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); + this.object = new THREE.Mesh(geometry, material); + this.inventoryMenuObject = this.object.clone(); + + if (this.itemType === ItemType.InventoryItem) { + this.object.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); + } + }); + } + } + + onFrame(input: HeldItemInput | undefined, selectedIndex: number | undefined) { + if (!this.object) return; //return if object hasn't loaded + const deltaTime = this.clock.getDelta(); + this.timeAccum += deltaTime; + + if (this.itemType === ItemType.WorldItem) { + this.worldOnFrame(deltaTime); + } + if (this.itemType === ItemType.InventoryItem && selectedIndex !== undefined && input !== undefined) { + this.inventoryOnFrame(deltaTime, selectedIndex); + this.handOnFrame(deltaTime, input); + } + } + + /** -- World Items -- */ + protected addedToWorldScene: boolean = false; + protected worldPosition: THREE.Vector3 = new THREE.Vector3(); + + protected worldOnFrame(deltaTime: number) { // This function is called every frame for world items + if (!this.addedToWorldScene) { + this.scene.add(this.object); + this.addedToWorldScene = true; + } + this.object.position.copy(this.worldPosition); + this.object.position.add(new THREE.Vector3(0, Math.sin(this.timeAccum * 2) * 0.1, 0)); + this.object.rotation.y += deltaTime * 2; + } + + setWorldPosition(vector: THREE.Vector3) { + this.worldPosition = vector; + } + + /** -- Inventory Items -- */ + protected addedToInventoryItemScenes: boolean = false; + + protected inventoryOnFrame(deltaTime: number, selectedIndex: number) { + if (!this.addedToInventoryItemScenes) { + this.scene.add(this.object); + this.inventoryMenuScene.add(this.inventoryMenuObject); + } + this.angleAccum += deltaTime; + this.inventoryMenuObject.position.set(0, this.index, 0); + + const targetQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0)); + if (this.index === selectedIndex) { + rotateAroundWorldAxis(targetQuaternion, new THREE.Vector3(0, 1, 0), this.angleAccum * 6); + this.showInHand(); + } else { + this.hideInHand(); + } + rotateAroundWorldAxis(targetQuaternion, new THREE.Vector3(1, 0, 0), Math.PI / 4); + this.inventoryMenuObject.quaternion.slerp(targetQuaternion, 0.1 * 60 * deltaTime); + } + + protected handOnFrame(deltaTime: number, input: HeldItemInput) { + if (this.shownInHand && Date.now() / 1000 - this.shownInHandTimestamp > showInHandDelay) { + this.handPosition.lerp(heldPosition, 0.1 * 60 * deltaTime); + } else { + this.handPosition.lerp(hiddenPosition, 0.1 * 60 * deltaTime); + } + + this.object.position.copy(this.handPosition); + if (this.shownInHand && input.leftClick) { + this.object.position.add(new THREE.Vector3(Math.random() * 0.2, Math.random() * 0.2, Math.random() * 0.2)); + this.object.quaternion.slerp(new THREE.Quaternion().random(), 0.1); + } + } + + protected showInHand() { + if (this.shownInHand) return; + this.shownInHand = true; + this.shownInHandTimestamp = Date.now() / 1000; + } + + protected hideInHand() { + if (!this.shownInHand) return; + this.shownInHand = false; + } + + public destroy() { + this.scene.remove(this.object); + this.inventoryMenuScene.remove(this.inventoryMenuObject); + } } const heldPosition = new THREE.Vector3(0.85, -0.8, 3.2); const hiddenPosition = new THREE.Vector3(0.85, -2.5, 3.2); export enum ItemType { - //TODO diagnose lint being weird here? - // eslint-disable-next-line no-unused-vars - WorldItem = 1, - // eslint-disable-next-line no-unused-vars - InventoryItem = 2, + //TODO diagnose lint being weird here? + // eslint-disable-next-line no-unused-vars + WorldItem = 1, + // eslint-disable-next-line no-unused-vars + InventoryItem = 2, } function rotateAroundWorldAxis(source: THREE.Quaternion, axis: THREE.Vector3, angle: number) { - const rotationQuat = new THREE.Quaternion().setFromAxisAngle(axis, angle); - source.multiplyQuaternions(rotationQuat, source); -} \ No newline at end of file + const rotationQuat = new THREE.Quaternion().setFromAxisAngle(axis, angle); + source.multiplyQuaternions(rotationQuat, source); +} diff --git a/src/client/main.ts b/src/client/main.ts index 65f1a9ed..164a464e 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,4 +1,4 @@ // import {Game} from "./core/Game.ts"; // // const game = new Game(); -// game.start(); \ No newline at end of file +// game.start(); diff --git a/src/client/ui/ChatOverlay.ts b/src/client/ui/ChatOverlay.ts index d9c00925..560c4644 100644 --- a/src/client/ui/ChatOverlay.ts +++ b/src/client/ui/ChatOverlay.ts @@ -1,901 +1,948 @@ -import {Player} from '../core/Player.ts'; -import {Renderer} from '../core/Renderer.ts'; -import {Networking} from '../core/Networking.ts'; -import {InputHandler} from '../input/InputHandler.ts'; -import {CommandManager} from "../core/CommandManager.ts"; -import {SettingsManager} from "../core/SettingsManager.ts"; -import {TouchInputHandler} from "../input/TouchInputHandler.ts"; +import { Player } from '../core/Player.ts'; +import { Renderer } from '../core/Renderer.ts'; +import { Networking } from '../core/Networking.ts'; +import { InputHandler } from '../input/InputHandler.ts'; +import { CommandManager } from '../core/CommandManager.ts'; +import { SettingsManager } from '../core/SettingsManager.ts'; +import { TouchInputHandler } from '../input/TouchInputHandler.ts'; interface ChatMessage { - id: number; - message: string; - name: string; - timestamp: number; + id: number; + message: string; + name: string; + timestamp: number; } interface AnimatedGameMessage { - id: string; // Unique identifier - message: string; - state: 'animatingIn' | 'animatingOut' | 'idle'; - animationProgress: number; // Ranges from 0 to 1 - timestamp: number; // Time when the current animation state started + id: string; // Unique identifier + message: string; + state: 'animatingIn' | 'animatingOut' | 'idle'; + animationProgress: number; // Ranges from 0 to 1 + timestamp: number; // Time when the current animation state started } interface LineMessage { - currentMessage: AnimatedGameMessage | null; - pendingMessage: string | null; + currentMessage: AnimatedGameMessage | null; + pendingMessage: string | null; } const hitMarkerLifetime = 0.3; export class ChatOverlay { - private chatCanvas: HTMLCanvasElement; - private chatCtx: CanvasRenderingContext2D; - private chatMessages: ChatMessage[]; // Typed as ChatMessage[] - private chatMessageLifespan: number; - private charsToRemovePerSecond: number; - private maxMessagesOnScreen: number; - private nameSettingActive: boolean; - private localPlayer: Player; - private renderer!: Renderer; - private networking!: Networking; - private screenWidth: number; - private inputHandler!: InputHandler; - private debugTextHeight!: number; - private oldScreenWidth: number = 0; - private readonly commandManager: CommandManager; - private lastTouchTimestamp: number = 0; - private touchJoystickEngaged: boolean = false; - private joystickX: number = 0; - private joystickY: number = 0; - private joystickInputX: number = 0; - private joystickInputY: number = 0; - private buttonsHeld: number[] = []; - private lastRoutineMs = 0; - - private offscreenCanvas: HTMLCanvasElement; - private offscreenCtx: CanvasRenderingContext2D; - - public gameMessages: string[] = []; - private previousGameMessages: string[] = []; - - // Removed animatedGameMessages in favor of per-line management - private lines: LineMessage[] = []; - private animationDuration: number = 1; // Adjusted for smoother animation - - // Color code mapping - COLOR_CODES: { [key: string]: string } = { - '0': '#000000', // Black - '1': '#0000AA', // Dark Blue - '2': '#00AA00', // Dark Green - '3': '#00AAAA', // Dark Aqua - '4': '#AA0000', // Dark Red - '5': '#AA00AA', // Dark Purple - '6': '#FFAA00', // Gold - '7': '#AAAAAA', // Gray - '8': '#555555', // Dark Gray - '9': '#5555FF', // Blue - 'a': '#55FF55', // Green - 'b': '#55FFFF', // Aqua - 'c': '#FF5555', // Red - 'd': '#FF55FF', // Light Purple - 'e': '#FFFF55', // Yellow - 'f': '#FFFFFF', // White - 'g': this.getRainbowColor() - }; - - private getColorCode(code: string): string | false { - if (code === 'g') { - return this.getRainbowColor(); - } - return this.COLOR_CODES[code] || false; - } - - - private getRainbowColor(): string { - const hue = (Date.now() / 20) % 360; - return `hsl(${hue}, 100%, 50%)`; - } - - - constructor(container: HTMLElement, localPlayer: Player) { - this.localPlayer = localPlayer; - this.chatCanvas = document.createElement('canvas'); - this.chatCtx = this.chatCanvas.getContext('2d') as CanvasRenderingContext2D; - this.chatCtx.imageSmoothingEnabled = false; - - - this.chatCanvas.width = 400; - this.chatCanvas.height = 200; - - this.chatMessages = []; - this.chatMessageLifespan = 40; // 40 seconds - this.charsToRemovePerSecond = 30; - this.maxMessagesOnScreen = 12; - - this.nameSettingActive = false; - this.screenWidth = 100; - - this.commandManager = new CommandManager(this.localPlayer, this); - - this.setupEventListeners(); - - this.chatCanvas.style.position = 'absolute'; - this.chatCanvas.style.display = 'block'; - this.chatCanvas.style.zIndex = '100'; - this.chatCanvas.style.top = '0'; - this.chatCanvas.style.left = '0'; - - this.chatCanvas.style.height = '100vh'; - document.body.style.margin = '0'; - this.chatCanvas.style.imageRendering = 'pixelated'; - this.chatCanvas.style.textRendering = 'pixelated'; - - this.chatCanvas.style.touchAction = 'none'; - - this.offscreenCanvas = document.createElement('canvas'); - this.offscreenCtx = this.offscreenCanvas.getContext('2d') as CanvasRenderingContext2D; - - // Initialize lines for per-line message management - this.lines = Array(this.maxMessagesOnScreen).fill(null).map(() => ({ - currentMessage: null, - pendingMessage: null - })); - - //document.body.appendChild(this.chatCanvas); - container.appendChild(this.chatCanvas); - - globalThis.addEventListener('resize', this.onWindowResize.bind(this)); - globalThis.addEventListener('orientationchange', this.onWindowResize.bind(this)); - } - - public setRenderer(renderer: Renderer) { - this.renderer = renderer; - } - - public setNetworking(networking: Networking) { - this.networking = networking; - } - - public setInputHandler(inputHandler: InputHandler) { - this.inputHandler = inputHandler; - } - - private setupEventListeners() { - document.addEventListener('keydown', this.onKeyDown.bind(this)); - } - - public onFrame() { - const startTime = Date.now(); - const now = Date.now() / 1000; - - this.gameMessages = this.localPlayer.gameMsgs; - this.detectGameMessagesChanges(now); - this.updateAnimatedGameMessages(now); - - this.clearOldMessages(); - this.chatCtx.clearRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); - this.renderHitMarkers(); - this.renderChatMessages(); - this.renderGameText(); - this.renderDebugText(); - if (this.inputHandler.getKey('tab')) - this.renderPlayerList(); - this.renderEvil(); - this.renderCrosshair(); - this.renderTouchControls(); - - this.screenWidth = Math.floor(this.renderer.getCamera().aspect * 200); - - if (this.oldScreenWidth !== this.screenWidth) { - //if(this.chatCanvas.width < this.screenWidth) - this.chatCanvas.width = this.screenWidth; - this.oldScreenWidth = this.screenWidth; - } - - // this.chatCanvas.width = this.screenWidth; - // this.chatCtx.fillRect(0,0,10,10); - - this.onWindowResize(); - - this.inputHandler.nameSettingActive = this.nameSettingActive; - if (Math.random() < 0.03) - this.lastRoutineMs = Date.now() - startTime; - } - - private onWindowResize() { - this.chatCanvas.style.width = globalThis.innerWidth + 'px'; - this.chatCanvas.style.height = globalThis.innerHeight + 'px'; - } - - private renderChatMessages() { - const ctx = this.chatCtx; - - this.offscreenCtx.font = '8px Tiny5'; - this.offscreenCtx.fillStyle = 'white'; - - const usermsg = this.localPlayer.chatMsg; - let cursor = ''; - if ((Date.now() / 1000) % 0.7 < 0.35) cursor = '|'; - - const linesToRender: string[] = []; - const pixOffsets: number[] = []; - const messagesBeingTyped = this.networking.getMessagesBeingTyped(); - - for (let i = 0; i < this.chatMessages.length; i++) { - let msg = this.chatMessages[i].message; - const name = this.chatMessages[i].name; - if (name.length > 0) msg = `${name}: ${msg}`; - - const duplicateFromPlayerData = messagesBeingTyped.includes(msg); - - let charsToRemove = Date.now() / 1000 - this.chatMessages[i].timestamp - this.chatMessageLifespan; - charsToRemove = Math.max(0, charsToRemove * this.charsToRemovePerSecond); - charsToRemove = Math.floor(charsToRemove); - - let removedSubstring = ''; - let remainingMsg = msg; - if (charsToRemove > 0) { - let charsRemoved = 0; - while (charsRemoved < charsToRemove && remainingMsg.length > 0) { - const char = remainingMsg.charAt(0); - removedSubstring += char; - remainingMsg = remainingMsg.substring(1); - charsRemoved++; - } - } - - if (!duplicateFromPlayerData) { - linesToRender.push(remainingMsg); - pixOffsets.push(this.offscreenCtx.measureText(removedSubstring).width); - } - } - - for (const msg of messagesBeingTyped) { - linesToRender.push(msg + cursor); - pixOffsets.push(0); - } - - if (this.localPlayer.chatActive) { - if (this.localPlayer.chatMsg.startsWith('>')) - linesToRender.push('&2' + usermsg + cursor); - else - linesToRender.push(usermsg + cursor); - pixOffsets.push(0); - } - - if (this.nameSettingActive) { - linesToRender.push('Enter your name: ' + usermsg + cursor); - pixOffsets.push(0); - this.localPlayer.name = usermsg + cursor; - if (this.localPlayer.name.length == 0) this.localPlayer.name = ' '; - } - - const wrappedLines: string[] = []; - const lineOrigins: number[] = []; - const isFirstWrappedLine: boolean[] = []; - - for (let i = 0; i < linesToRender.length; i++) { - const wrapped = this.doTextWrapping(this.offscreenCtx, [linesToRender[i]], this.screenWidth - 10); - for (let j = 0; j < wrapped.length; j++) { - wrappedLines.push(wrapped[j]); - lineOrigins.push(i); - isFirstWrappedLine.push(j === 0); - } - } - - const totalLines = wrappedLines.length; - for (let i = 0; i < totalLines; i++) { - const lineIndex = totalLines - i - 1; - const text = wrappedLines[lineIndex]; - const originIndex = lineOrigins[lineIndex]; - const pixOffset = isFirstWrappedLine[lineIndex] ? pixOffsets[originIndex] : 0; - - this.renderPixelText(text, 3 + pixOffset, 200 - 20 - 8 * i, 'white'); - } - - if ((usermsg !== '' && this.localPlayer.chatActive) || this.nameSettingActive) { - ctx.fillStyle = 'rgba(145,142,118,0.3)'; - let width = ctx.measureText(usermsg).width; - if (this.nameSettingActive) { - width = ctx.measureText('Enter your name: ' + usermsg).width; - } - ctx.fillRect(2, 200 - 20 - 7, width + 1, 9); - } - } - - private renderPrettyText(text: string, x: number, y: number, defaultColor: string) { - let currentX = x; - const segments: { text: string, color: string }[] = []; - let currentColor = defaultColor; - let currentSegment = ''; - - // Parse color codes and split into segments - for (let i = 0; i < text.length; i++) { - if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { - if (currentSegment) { - segments.push({ text: currentSegment, color: currentColor }); - } - currentColor = this.getColorCode(text[i + 1]); - currentSegment = ''; - i++; // Skip the color code character - } else { - currentSegment += text[i]; - } - } - - if (currentSegment) { - segments.push({ text: currentSegment, color: currentColor }); - } - - // Render each segment - for (const segment of segments) { - this.offscreenCtx.font = '8px Tiny5'; - const textMetrics = this.offscreenCtx.measureText(segment.text); - const textWidth = Math.max(Math.ceil(textMetrics.width), 1); - const textHeight = 8; - - if (this.offscreenCanvas.width !== textWidth || this.offscreenCanvas.height !== textHeight) { - this.offscreenCanvas.width = textWidth; - this.offscreenCanvas.height = textHeight; - } - - this.offscreenCtx.clearRect(0, 0, textWidth, textHeight); - this.offscreenCtx.font = '8px Tiny5'; - this.offscreenCtx.fillStyle = segment.color; - this.offscreenCtx.fillText(segment.text, 0, textHeight - 1); - - const imageData = this.offscreenCtx.getImageData(0, 0, textWidth, textHeight); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - data[i + 3] = data[i + 3] > 170 ? 255 : 0; - } - - this.offscreenCtx.putImageData(imageData, 0, 0); - this.chatCtx.drawImage(this.offscreenCanvas, currentX, y - textHeight + 1); - currentX += textWidth; - } - } - - private renderUglyText(text: string, x: number, y: number, defaultColor: string) { - let currentX = x; - let currentColor = defaultColor; - let currentSegment = ''; - - for (let i = 0; i < text.length; i++) { - if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { - if (currentSegment) { - this.chatCtx.font = '8px Tiny5'; - this.chatCtx.fillStyle = currentColor; - this.chatCtx.fillText(currentSegment, currentX, y); - currentX += this.chatCtx.measureText(currentSegment).width; - } - currentColor = this.getColorCode(text[i + 1]); - currentSegment = ''; - i++; // Skip the color code character - } else { - currentSegment += text[i]; - } - } - - if (currentSegment) { - this.chatCtx.font = '8px Tiny5'; - this.chatCtx.fillStyle = currentColor; - this.chatCtx.fillText(currentSegment, currentX, y); - } - } - - private renderPixelText(text: string, x: number, y: number, color: string) { - if (SettingsManager.settings.doPrettyText) - this.renderPrettyText(text, x, y, color); - else - this.renderUglyText(text, x, y, color); - } - - private renderDebugText() { - const ctx = this.chatCtx; - ctx.font = '8px Tiny5'; - ctx.fillStyle = 'teal'; - - const linesToRender = []; - const framerate = this.renderer.getFramerate(); - - if (this.localPlayer.latency >= 999) - linesToRender.push('disconnected :('); - - //const playerX = Math.round(this.localPlayer.position.x); - - linesToRender.push('candiru ' + this.localPlayer.gameVersion + ' @ ' + Math.round(framerate) + 'fps, ' + Math.round(this.localPlayer.latency) + 'ms'); - //linesToRender.push('connected to: ' + this.networking.getServerInfo().name); - //linesToRender.push('players: ' + this.networking.getServerInfo().currentPlayers + '/' + this.networking.getServerInfo().maxPlayers); - //linesToRender.push('map: ' + this.networking.getServerInfo().mapName); - //linesToRender.push('mode: ' + this.networking.getServerInfo().gameMode); - //linesToRender.push('serverVersion: ' + this.networking.getServerInfo().version); - //linesToRender.push('tickRate: ' + this.networking.getServerInfo().tickRate); - //linesToRender.push('playerMaxHealth: ' + this.networking.getServerInfo().playerMaxHealth); - //linesToRender.push('health: ' + this.localPlayer.health); - - for(const msg of this.localPlayer.gameMsgs2) - linesToRender.push(msg) - - //linesToRender.push('routineTime: ' + this.lastRoutineMs + 'ms'); - - for (let i = 0; i < linesToRender.length; i++) { - this.renderPixelText(linesToRender[i], 2, 7 + 7 * i, 'teal'); - } - - this.debugTextHeight = 7 * linesToRender.length; - } - - private detectGameMessagesChanges(now: number) { - const current = this.gameMessages; - - for (let i = 0; i < this.maxMessagesOnScreen; i++) { - const line = this.lines[i]; - const currentMessage = current[i] || null; - - if (!line.currentMessage) { - if (currentMessage) { - line.currentMessage = { - id: this.generateUniqueId(), - message: currentMessage, - state: 'animatingIn', - animationProgress: 0, - timestamp: now, - }; - } - continue; - } - - if (currentMessage !== line.currentMessage.message) { - if (line.currentMessage.state === 'idle') { - line.currentMessage.state = 'animatingOut'; - line.currentMessage.timestamp = now; - line.pendingMessage = currentMessage; - } else { - line.pendingMessage = currentMessage; - } - } - } - - this.previousGameMessages = [...current]; - } - - - private updateAnimatedGameMessages(now: number) { - for (let i = 0; i < this.maxMessagesOnScreen; i++) { - const line = this.lines[i]; - if (!line.currentMessage) continue; // Early return if null - - const elapsed = now - line.currentMessage.timestamp; - let progress = Math.min(elapsed / this.animationDuration, 1); - progress = this.easeOut(progress); - - line.currentMessage.animationProgress = progress; - - if (line.currentMessage.state === 'animatingOut' && progress >= 1) { - // Remove the message after fade-out - if (line.pendingMessage) { - line.currentMessage = { - id: this.generateUniqueId(), - message: line.pendingMessage, - state: 'animatingIn', - animationProgress: 0, - timestamp: now, - }; - line.pendingMessage = null; - } else { - line.currentMessage = null; - } - continue; - } - - if (line.currentMessage.state === 'animatingIn' && progress >= 1) { - line.currentMessage.state = 'idle'; - line.currentMessage.animationProgress = 1; - } - } - } - - - private renderGameText() { - const ctx = this.chatCtx; - ctx.font = '8px Tiny5'; - const centerY = this.chatCanvas.height / 2 + 48; - - for (let i = 0; i < this.maxMessagesOnScreen; i++) { - const line = this.lines[i]; - if (!line.currentMessage) continue; - - let visibleText = line.currentMessage.message; - - // Check if we should skip animation - const shouldSkipAnimation = - line.currentMessage.state === 'animatingOut' && - line.pendingMessage !== null && - line.currentMessage.message.includes('seconds') && - line.pendingMessage.includes('seconds'); - - if (shouldSkipAnimation && line.pendingMessage) { - // Directly update to the pending message - line.currentMessage = { - id: this.generateUniqueId(), - message: line.pendingMessage, - state: 'idle', - animationProgress: 1, - timestamp: Date.now() / 1000, - }; - line.pendingMessage = null; - visibleText = line.currentMessage.message; - } else if (line.currentMessage.state === 'animatingIn' || line.currentMessage.state === 'animatingOut') { - visibleText = this.getVisibleText(line.currentMessage.message, line.currentMessage.state, line.currentMessage.animationProgress); - } - - // Calculate the actual width of the rendered text, including color codes - const textWidth = this.getRenderedTextWidth(visibleText); - const x = Math.floor((this.screenWidth - textWidth) / 2); - const y = Math.floor(centerY + (i * 10)); - - this.renderPixelText(visibleText, x, y, 'white'); - } - } - - - private getRenderedTextWidth(text: string): number { - let totalWidth = 0; - let currentSegment = ''; - const ctx = this.chatCtx; - - for (let i = 0; i < text.length; i++) { - if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { - // Measure the current segment before switching color - if (currentSegment) { - totalWidth += ctx.measureText(currentSegment).width; - currentSegment = ''; - } - i++; // Skip the color code character - } else { - currentSegment += text[i]; - } - } - - // Measure the last segment - if (currentSegment) { - totalWidth += ctx.measureText(currentSegment).width; - } - - return totalWidth; - } - - - - - private getVisibleText(message: string, state: 'animatingIn' | 'animatingOut' | 'idle', progress: number): string { - if (state === 'idle') { - return message; - } - - const length = message.length; - if (length === 0) return ''; - - let charsToShow = 0; - - if (state === 'animatingIn') { - charsToShow = Math.floor(progress * length); - charsToShow = Math.min(charsToShow, length); // Ensure it doesn't exceed the message length - return message.substring(0, charsToShow); - } else if (state === 'animatingOut') { - charsToShow = Math.floor((1 - progress) * length); - charsToShow = Math.max(charsToShow, 0); // Ensure it doesn't go below zero - return message.substring(0, charsToShow); - } - - return message; - } - - private easeOut(progress: number): number { - return 1 - Math.pow(1 - progress, 1.5); - } - - public renderTouchControls() { - if (Date.now() / 1000 - this.lastTouchTimestamp > 10) return; - if (this.touchJoystickEngaged) { - // Draw circle for movement - this.chatCtx.fillStyle = 'rgba(255,255,255,0.25)'; - this.chatCtx.beginPath(); - this.chatCtx.arc(this.joystickX, this.joystickY, TouchInputHandler.joystickRadius, 0, 2 * Math.PI); - this.chatCtx.fill(); - - // Smaller circle for joystick-- offset from center - this.chatCtx.fillStyle = 'rgba(255,255,255,0.5)'; - this.chatCtx.beginPath(); - this.chatCtx.arc( - this.joystickX + this.joystickInputX * TouchInputHandler.joystickRadius, - this.joystickY + this.joystickInputY * TouchInputHandler.joystickRadius, - 10, - 0, - 2 * Math.PI - ); - this.chatCtx.fill(); - } - - // Draw rounded square center right for jumping - const squareWidth = 24; - const squareHeight = 24; - const cornerRadius = 6; - const x = this.chatCanvas.width - squareWidth - 12; // 12px from the right edge - let y = (this.chatCanvas.height - squareHeight) / 2; // Center vertically - - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '●', 1, 0); - y -= squareHeight + 4; - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '↑', 1, -1); - y += squareHeight + 4; - y += squareHeight + 4; - this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '[]', 1, 1); - } - - public setButtonsHeld(buttons: number[]) { - this.buttonsHeld = buttons; - } - - private drawButton(x: number, y: number, width: number, height: number, cornerRadius: number, text: string, textOffset: number, index: number) { - if (this.buttonsHeld.includes(index)) - this.chatCtx.fillStyle = 'rgba(100,100,100,0.3)'; - else - this.chatCtx.fillStyle = 'rgba(255,255,255,0.15)'; - - this.drawRoundedSquare(x, y, width, height, cornerRadius); - // Draw character inside square - this.chatCtx.fillStyle = 'rgba(0,0,0,0.5)'; - this.chatCtx.font = '16px Tiny5'; - const textWidth = this.chatCtx.measureText(text).width; - this.chatCtx.fillText(text, Math.floor(x + width / 2 - textWidth / 2 + textOffset), Math.floor(y + height / 2 + 16 / 2 - 2)); - } - - private drawRoundedSquare(x: number, y: number, width: number, height: number, cornerRadius: number) { - this.chatCtx.beginPath(); - this.chatCtx.moveTo(x + cornerRadius, y); - this.chatCtx.lineTo(x + width - cornerRadius, y); - this.chatCtx.quadraticCurveTo(x + width, y, x + width, y + cornerRadius); - this.chatCtx.lineTo(x + width, y + height - cornerRadius); - this.chatCtx.quadraticCurveTo(x + width, y + height, x + width - cornerRadius, y + height); - this.chatCtx.lineTo(x + cornerRadius, y + height); - this.chatCtx.quadraticCurveTo(x, y + height, x, y + height - cornerRadius); - this.chatCtx.lineTo(x, y + cornerRadius); - this.chatCtx.quadraticCurveTo(x, y, x + cornerRadius, y); - this.chatCtx.closePath(); - this.chatCtx.fill(); - } - - public setLastTouchTimestamp(timestamp: number) { - this.lastTouchTimestamp = timestamp; - } - - public setTouchJoystickEngaged(value: boolean) { - this.touchJoystickEngaged = value; - } - - public setJoystickPosition(x: number, y: number) { - this.joystickX = x; - this.joystickY = y; - } - - public setJoystickInput(x: number, y: number) { - this.joystickInputX = x; - this.joystickInputY = y; - } - - public renderHitMarkers() { - const numDots = 10; // Number of dots to render around each hit point - - 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 - - const timeSinceHit = Date.now() / 1000 - this.renderer.playerHitMarkers[i].timestamp; - const lifePercent = timeSinceHit / hitMarkerLifetime; - - if (timeSinceHit > hitMarkerLifetime) { - this.renderer.playerHitMarkers.splice(i, 1); - continue; - } - - 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); - - if (projected.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(); - - // 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 - } - } - } - } - - public getDebugTextHeight(): number { - return this.debugTextHeight; - } - - private renderPlayerList() { - const ctx = this.chatCtx; - const linesToRender: string[] = []; - const colorsToRender: string[] = []; - const playerData = this.networking.getRemotePlayerData(); - - linesToRender.push(playerData.length + ' online - ' + Math.round(this.localPlayer.latency) + 'ms'); - colorsToRender.push('white'); - for (let i = 0; i < playerData.length; i++) { - linesToRender.push(playerData[i].name); - if (playerData[i].latency > 200) - colorsToRender.push('red'); - else if (playerData[i].latency > 50) - colorsToRender.push('orange'); - else - colorsToRender.push('green'); - } - - ctx.font = '8px Tiny5'; - - let longestLinePix = 0; - for (let i = 0; i < linesToRender.length; i++) - longestLinePix = Math.max(longestLinePix, ctx.measureText(linesToRender[i]).width); - - ctx.fillStyle = 'rgba(0,0,0,0.5)'; - ctx.fillRect(Math.floor(this.screenWidth / 2 - longestLinePix / 2), 4, longestLinePix + 3, linesToRender.length * 7 + 2); - - for (let i = 0; i < linesToRender.length; i++) { - this.renderPixelText(linesToRender[i], Math.floor(this.screenWidth / 2 - longestLinePix / 2 + 2), 11 + 7 * i, colorsToRender[i]); - } - } - - private renderEvil() { - const ctx = this.chatCtx; - if (Date.now() / 1000 - this.networking.getDamagedTimestamp() < 0.05) { - ctx.fillStyle = 'rgba(255,0,0,0.1)'; - ctx.fillRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); - } - } - - private renderCrosshair() { - const ctx = this.chatCtx; - ctx.fillStyle = SettingsManager.settings.crosshairColor; - if (this.renderer.crosshairIsFlashing) - ctx.fillStyle = '#FF0000'; - switch (SettingsManager.settings.crosshairType) { - case 0: - ctx.fillRect(Math.floor(this.screenWidth / 2), 100 - 3, 1, 7); - ctx.fillRect(Math.floor(this.screenWidth / 2 - 3), 100, 7, 1); - break; - case 1: - ctx.fillRect(Math.floor(this.screenWidth / 2), 100, 1, 1); - break; - } - } - - private onKeyDown(e: KeyboardEvent) { - if (e.key === 'Backspace' && (this.localPlayer.chatActive || this.nameSettingActive)) { - this.localPlayer.chatMsg = this.localPlayer.chatMsg.slice(0, -1); - return; - } - - if (e.key === 'Enter') { - if (this.localPlayer.chatActive) { - if (!this.commandManager.runCmd(this.localPlayer.chatMsg)) this.networking.sendMessage(this.localPlayer.chatMsg); - } - if (this.nameSettingActive) { - this.localPlayer.name = this.localPlayer.chatMsg.toString(); - SettingsManager.settings.name = this.localPlayer.chatMsg.toString(); - SettingsManager.write(); - } - this.localPlayer.chatMsg = ''; - this.localPlayer.chatActive = false; - this.nameSettingActive = false; - } - - if (e.key === 'Escape' || e.key === 'Enter') { - this.localPlayer.chatMsg = ''; - this.localPlayer.chatActive = false; - this.nameSettingActive = false; - } - - if ((this.localPlayer.chatActive) && e.key.length === 1 && this.localPlayer.chatMsg.length < 300) - this.localPlayer.chatMsg += e.key; - - if ((this.nameSettingActive) && e.key.length === 1 && this.localPlayer.chatMsg.length < 42) - this.localPlayer.chatMsg += e.key; - - if (e.key.toLowerCase() === 't' && !this.nameSettingActive) { - 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 (e.key.toLowerCase() === 'n' && !this.localPlayer.chatActive) - this.nameSettingActive = true; - } - - public addChatMessage(msg: { id: number; name: string; message: string; }) { - const chatMessage: ChatMessage = { - id: msg.id, - name: msg.name, - message: msg.message, - timestamp: Date.now() / 1000, - }; - this.chatMessages.push(chatMessage); - } - - private clearOldMessages() { - for (let i = 0; i < this.chatMessages.length; i++) - if (Date.now() / 1000 - this.chatMessages[i].timestamp > this.chatMessageLifespan + 5) - this.chatMessages.splice(i, 1); - - for (let i = this.chatMessages.length - 1; i >= 0; i--) { - if (i < this.chatMessages.length - this.maxMessagesOnScreen) - this.chatMessages[i].timestamp = Math.min(Date.now() / 1000 - this.chatMessageLifespan, this.chatMessages[i].timestamp); - } - } - - private doTextWrapping(ctx: CanvasRenderingContext2D, text: string[], maxWidth: number, initialOffset: number = 0): string[] { - ctx.font = '8px Tiny5'; - const resultLines: string[] = []; - - for (const line of text) { - if (line === '' || ctx.measureText(line).width <= maxWidth) { - resultLines.push(line); - continue; - } - - const words = line.split(' '); - let currentLine = ''; - let isFirstLine = true; - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const testWidth = ctx.measureText(testLine).width; - - const availableWidth = isFirstLine ? maxWidth - initialOffset : maxWidth; - - if (testWidth <= availableWidth) { - currentLine = testLine; - } else { - if (currentLine) { - resultLines.push(currentLine); - } - currentLine = word; - isFirstLine = false; - } - } - - if (currentLine) { - resultLines.push(currentLine); - } - } - - return resultLines; - } - - private generateUniqueId(): string { - return Math.random().toString(36).substr(2, 9); - } -} \ No newline at end of file + private chatCanvas: HTMLCanvasElement; + private chatCtx: CanvasRenderingContext2D; + private chatMessages: ChatMessage[]; // Typed as ChatMessage[] + private chatMessageLifespan: number; + private charsToRemovePerSecond: number; + private maxMessagesOnScreen: number; + private nameSettingActive: boolean; + private localPlayer: Player; + private renderer!: Renderer; + private networking!: Networking; + private screenWidth: number; + private inputHandler!: InputHandler; + private debugTextHeight!: number; + private oldScreenWidth: number = 0; + private readonly commandManager: CommandManager; + private lastTouchTimestamp: number = 0; + private touchJoystickEngaged: boolean = false; + private joystickX: number = 0; + private joystickY: number = 0; + private joystickInputX: number = 0; + private joystickInputY: number = 0; + private buttonsHeld: number[] = []; + private lastRoutineMs = 0; + + private offscreenCanvas: HTMLCanvasElement; + private offscreenCtx: CanvasRenderingContext2D; + + public gameMessages: string[] = []; + private previousGameMessages: string[] = []; + + // Removed animatedGameMessages in favor of per-line management + private lines: LineMessage[] = []; + private animationDuration: number = 1; // Adjusted for smoother animation + + // Color code mapping + COLOR_CODES: { [key: string]: string } = { + '0': '#000000', // Black + '1': '#0000AA', // Dark Blue + '2': '#00AA00', // Dark Green + '3': '#00AAAA', // Dark Aqua + '4': '#AA0000', // Dark Red + '5': '#AA00AA', // Dark Purple + '6': '#FFAA00', // Gold + '7': '#AAAAAA', // Gray + '8': '#555555', // Dark Gray + '9': '#5555FF', // Blue + 'a': '#55FF55', // Green + 'b': '#55FFFF', // Aqua + 'c': '#FF5555', // Red + 'd': '#FF55FF', // Light Purple + 'e': '#FFFF55', // Yellow + 'f': '#FFFFFF', // White + 'g': this.getRainbowColor(), + }; + + private getColorCode(code: string): string | false { + if (code === 'g') { + return this.getRainbowColor(); + } + return this.COLOR_CODES[code] || false; + } + + private getRainbowColor(): string { + const hue = (Date.now() / 20) % 360; + return `hsl(${hue}, 100%, 50%)`; + } + + constructor(container: HTMLElement, localPlayer: Player) { + this.localPlayer = localPlayer; + this.chatCanvas = document.createElement('canvas'); + this.chatCtx = this.chatCanvas.getContext('2d') as CanvasRenderingContext2D; + this.chatCtx.imageSmoothingEnabled = false; + + this.chatCanvas.width = 400; + this.chatCanvas.height = 200; + + this.chatMessages = []; + this.chatMessageLifespan = 40; // 40 seconds + this.charsToRemovePerSecond = 30; + this.maxMessagesOnScreen = 12; + + this.nameSettingActive = false; + this.screenWidth = 100; + + this.commandManager = new CommandManager(this.localPlayer, this); + + this.setupEventListeners(); + + this.chatCanvas.style.position = 'absolute'; + this.chatCanvas.style.display = 'block'; + this.chatCanvas.style.zIndex = '100'; + this.chatCanvas.style.top = '0'; + this.chatCanvas.style.left = '0'; + + this.chatCanvas.style.height = '100vh'; + document.body.style.margin = '0'; + this.chatCanvas.style.imageRendering = 'pixelated'; + this.chatCanvas.style.textRendering = 'pixelated'; + + this.chatCanvas.style.touchAction = 'none'; + + this.offscreenCanvas = document.createElement('canvas'); + this.offscreenCtx = this.offscreenCanvas.getContext('2d') as CanvasRenderingContext2D; + + // Initialize lines for per-line message management + this.lines = Array(this.maxMessagesOnScreen).fill(null).map(() => ({ + currentMessage: null, + pendingMessage: null, + })); + + //document.body.appendChild(this.chatCanvas); + container.appendChild(this.chatCanvas); + + globalThis.addEventListener('resize', this.onWindowResize.bind(this)); + globalThis.addEventListener('orientationchange', this.onWindowResize.bind(this)); + } + + public setRenderer(renderer: Renderer) { + this.renderer = renderer; + } + + public setNetworking(networking: Networking) { + this.networking = networking; + } + + public setInputHandler(inputHandler: InputHandler) { + this.inputHandler = inputHandler; + } + + private setupEventListeners() { + document.addEventListener('keydown', this.onKeyDown.bind(this)); + } + + public onFrame() { + const startTime = Date.now(); + const now = Date.now() / 1000; + + this.gameMessages = this.localPlayer.gameMsgs; + this.detectGameMessagesChanges(now); + this.updateAnimatedGameMessages(now); + + this.clearOldMessages(); + this.chatCtx.clearRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); + this.renderHitMarkers(); + this.renderChatMessages(); + this.renderGameText(); + this.renderDebugText(); + if (this.inputHandler.getKey('tab')) { + this.renderPlayerList(); + } + this.renderEvil(); + this.renderCrosshair(); + this.renderTouchControls(); + + this.screenWidth = Math.floor(this.renderer.getCamera().aspect * 200); + + if (this.oldScreenWidth !== this.screenWidth) { + //if(this.chatCanvas.width < this.screenWidth) + this.chatCanvas.width = this.screenWidth; + this.oldScreenWidth = this.screenWidth; + } + + // this.chatCanvas.width = this.screenWidth; + // this.chatCtx.fillRect(0,0,10,10); + + this.onWindowResize(); + + this.inputHandler.nameSettingActive = this.nameSettingActive; + if (Math.random() < 0.03) { + this.lastRoutineMs = Date.now() - startTime; + } + } + + private onWindowResize() { + this.chatCanvas.style.width = globalThis.innerWidth + 'px'; + this.chatCanvas.style.height = globalThis.innerHeight + 'px'; + } + + private renderChatMessages() { + const ctx = this.chatCtx; + + this.offscreenCtx.font = '8px Tiny5'; + this.offscreenCtx.fillStyle = 'white'; + + const usermsg = this.localPlayer.chatMsg; + let cursor = ''; + if ((Date.now() / 1000) % 0.7 < 0.35) cursor = '|'; + + const linesToRender: string[] = []; + const pixOffsets: number[] = []; + const messagesBeingTyped = this.networking.getMessagesBeingTyped(); + + for (let i = 0; i < this.chatMessages.length; i++) { + let msg = this.chatMessages[i].message; + const name = this.chatMessages[i].name; + if (name.length > 0) msg = `${name}: ${msg}`; + + const duplicateFromPlayerData = messagesBeingTyped.includes(msg); + + let charsToRemove = Date.now() / 1000 - this.chatMessages[i].timestamp - this.chatMessageLifespan; + charsToRemove = Math.max(0, charsToRemove * this.charsToRemovePerSecond); + charsToRemove = Math.floor(charsToRemove); + + let removedSubstring = ''; + let remainingMsg = msg; + if (charsToRemove > 0) { + let charsRemoved = 0; + while (charsRemoved < charsToRemove && remainingMsg.length > 0) { + const char = remainingMsg.charAt(0); + removedSubstring += char; + remainingMsg = remainingMsg.substring(1); + charsRemoved++; + } + } + + if (!duplicateFromPlayerData) { + linesToRender.push(remainingMsg); + pixOffsets.push(this.offscreenCtx.measureText(removedSubstring).width); + } + } + + for (const msg of messagesBeingTyped) { + linesToRender.push(msg + cursor); + pixOffsets.push(0); + } + + if (this.localPlayer.chatActive) { + if (this.localPlayer.chatMsg.startsWith('>')) { + linesToRender.push('&2' + usermsg + cursor); + } else { + linesToRender.push(usermsg + cursor); + } + pixOffsets.push(0); + } + + if (this.nameSettingActive) { + linesToRender.push('Enter your name: ' + usermsg + cursor); + pixOffsets.push(0); + this.localPlayer.name = usermsg + cursor; + if (this.localPlayer.name.length == 0) this.localPlayer.name = ' '; + } + + const wrappedLines: string[] = []; + const lineOrigins: number[] = []; + const isFirstWrappedLine: boolean[] = []; + + for (let i = 0; i < linesToRender.length; i++) { + const wrapped = this.doTextWrapping(this.offscreenCtx, [linesToRender[i]], this.screenWidth - 10); + for (let j = 0; j < wrapped.length; j++) { + wrappedLines.push(wrapped[j]); + lineOrigins.push(i); + isFirstWrappedLine.push(j === 0); + } + } + + const totalLines = wrappedLines.length; + for (let i = 0; i < totalLines; i++) { + const lineIndex = totalLines - i - 1; + const text = wrappedLines[lineIndex]; + const originIndex = lineOrigins[lineIndex]; + const pixOffset = isFirstWrappedLine[lineIndex] ? pixOffsets[originIndex] : 0; + + this.renderPixelText(text, 3 + pixOffset, 200 - 20 - 8 * i, 'white'); + } + + if ((usermsg !== '' && this.localPlayer.chatActive) || this.nameSettingActive) { + ctx.fillStyle = 'rgba(145,142,118,0.3)'; + let width = ctx.measureText(usermsg).width; + if (this.nameSettingActive) { + width = ctx.measureText('Enter your name: ' + usermsg).width; + } + ctx.fillRect(2, 200 - 20 - 7, width + 1, 9); + } + } + + private renderPrettyText(text: string, x: number, y: number, defaultColor: string) { + let currentX = x; + const segments: { text: string; color: string }[] = []; + let currentColor = defaultColor; + let currentSegment = ''; + + // Parse color codes and split into segments + for (let i = 0; i < text.length; i++) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { + if (currentSegment) { + segments.push({ text: currentSegment, color: currentColor }); + } + currentColor = this.getColorCode(text[i + 1]); + currentSegment = ''; + i++; // Skip the color code character + } else { + currentSegment += text[i]; + } + } + + if (currentSegment) { + segments.push({ text: currentSegment, color: currentColor }); + } + + // Render each segment + for (const segment of segments) { + this.offscreenCtx.font = '8px Tiny5'; + const textMetrics = this.offscreenCtx.measureText(segment.text); + const textWidth = Math.max(Math.ceil(textMetrics.width), 1); + const textHeight = 8; + + if (this.offscreenCanvas.width !== textWidth || this.offscreenCanvas.height !== textHeight) { + this.offscreenCanvas.width = textWidth; + this.offscreenCanvas.height = textHeight; + } + + this.offscreenCtx.clearRect(0, 0, textWidth, textHeight); + this.offscreenCtx.font = '8px Tiny5'; + this.offscreenCtx.fillStyle = segment.color; + this.offscreenCtx.fillText(segment.text, 0, textHeight - 1); + + const imageData = this.offscreenCtx.getImageData(0, 0, textWidth, textHeight); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + data[i + 3] = data[i + 3] > 170 ? 255 : 0; + } + + this.offscreenCtx.putImageData(imageData, 0, 0); + this.chatCtx.drawImage(this.offscreenCanvas, currentX, y - textHeight + 1); + currentX += textWidth; + } + } + + private renderUglyText(text: string, x: number, y: number, defaultColor: string) { + let currentX = x; + let currentColor = defaultColor; + let currentSegment = ''; + + for (let i = 0; i < text.length; i++) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { + if (currentSegment) { + this.chatCtx.font = '8px Tiny5'; + this.chatCtx.fillStyle = currentColor; + this.chatCtx.fillText(currentSegment, currentX, y); + currentX += this.chatCtx.measureText(currentSegment).width; + } + currentColor = this.getColorCode(text[i + 1]); + currentSegment = ''; + i++; // Skip the color code character + } else { + currentSegment += text[i]; + } + } + + if (currentSegment) { + this.chatCtx.font = '8px Tiny5'; + this.chatCtx.fillStyle = currentColor; + this.chatCtx.fillText(currentSegment, currentX, y); + } + } + + private renderPixelText(text: string, x: number, y: number, color: string) { + if (SettingsManager.settings.doPrettyText) { + this.renderPrettyText(text, x, y, color); + } else { + this.renderUglyText(text, x, y, color); + } + } + + private renderDebugText() { + const ctx = this.chatCtx; + ctx.font = '8px Tiny5'; + ctx.fillStyle = 'teal'; + + const linesToRender = []; + const framerate = this.renderer.getFramerate(); + + if (this.localPlayer.latency >= 999) { + linesToRender.push('disconnected :('); + } + + //const playerX = Math.round(this.localPlayer.position.x); + + linesToRender.push( + 'candiru ' + this.localPlayer.gameVersion + ' @ ' + Math.round(framerate) + 'fps, ' + + Math.round(this.localPlayer.latency) + 'ms', + ); + //linesToRender.push('connected to: ' + this.networking.getServerInfo().name); + //linesToRender.push('players: ' + this.networking.getServerInfo().currentPlayers + '/' + this.networking.getServerInfo().maxPlayers); + //linesToRender.push('map: ' + this.networking.getServerInfo().mapName); + //linesToRender.push('mode: ' + this.networking.getServerInfo().gameMode); + //linesToRender.push('serverVersion: ' + this.networking.getServerInfo().version); + //linesToRender.push('tickRate: ' + this.networking.getServerInfo().tickRate); + //linesToRender.push('playerMaxHealth: ' + this.networking.getServerInfo().playerMaxHealth); + //linesToRender.push('health: ' + this.localPlayer.health); + + for (const msg of this.localPlayer.gameMsgs2) { + linesToRender.push(msg); + } + + //linesToRender.push('routineTime: ' + this.lastRoutineMs + 'ms'); + + for (let i = 0; i < linesToRender.length; i++) { + this.renderPixelText(linesToRender[i], 2, 7 + 7 * i, 'teal'); + } + + this.debugTextHeight = 7 * linesToRender.length; + } + + private detectGameMessagesChanges(now: number) { + const current = this.gameMessages; + + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + const currentMessage = current[i] || null; + + if (!line.currentMessage) { + if (currentMessage) { + line.currentMessage = { + id: this.generateUniqueId(), + message: currentMessage, + state: 'animatingIn', + animationProgress: 0, + timestamp: now, + }; + } + continue; + } + + if (currentMessage !== line.currentMessage.message) { + if (line.currentMessage.state === 'idle') { + line.currentMessage.state = 'animatingOut'; + line.currentMessage.timestamp = now; + line.pendingMessage = currentMessage; + } else { + line.pendingMessage = currentMessage; + } + } + } + + this.previousGameMessages = [...current]; + } + + private updateAnimatedGameMessages(now: number) { + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + if (!line.currentMessage) continue; // Early return if null + + const elapsed = now - line.currentMessage.timestamp; + let progress = Math.min(elapsed / this.animationDuration, 1); + progress = this.easeOut(progress); + + line.currentMessage.animationProgress = progress; + + if (line.currentMessage.state === 'animatingOut' && progress >= 1) { + // Remove the message after fade-out + if (line.pendingMessage) { + line.currentMessage = { + id: this.generateUniqueId(), + message: line.pendingMessage, + state: 'animatingIn', + animationProgress: 0, + timestamp: now, + }; + line.pendingMessage = null; + } else { + line.currentMessage = null; + } + continue; + } + + if (line.currentMessage.state === 'animatingIn' && progress >= 1) { + line.currentMessage.state = 'idle'; + line.currentMessage.animationProgress = 1; + } + } + } + + private renderGameText() { + const ctx = this.chatCtx; + ctx.font = '8px Tiny5'; + const centerY = this.chatCanvas.height / 2 + 48; + + for (let i = 0; i < this.maxMessagesOnScreen; i++) { + const line = this.lines[i]; + if (!line.currentMessage) continue; + + let visibleText = line.currentMessage.message; + + // Check if we should skip animation + const shouldSkipAnimation = line.currentMessage.state === 'animatingOut' && + line.pendingMessage !== null && + line.currentMessage.message.includes('seconds') && + line.pendingMessage.includes('seconds'); + + if (shouldSkipAnimation && line.pendingMessage) { + // Directly update to the pending message + line.currentMessage = { + id: this.generateUniqueId(), + message: line.pendingMessage, + state: 'idle', + animationProgress: 1, + timestamp: Date.now() / 1000, + }; + line.pendingMessage = null; + visibleText = line.currentMessage.message; + } else if (line.currentMessage.state === 'animatingIn' || line.currentMessage.state === 'animatingOut') { + visibleText = this.getVisibleText( + line.currentMessage.message, + line.currentMessage.state, + line.currentMessage.animationProgress, + ); + } + + // Calculate the actual width of the rendered text, including color codes + const textWidth = this.getRenderedTextWidth(visibleText); + const x = Math.floor((this.screenWidth - textWidth) / 2); + const y = Math.floor(centerY + (i * 10)); + + this.renderPixelText(visibleText, x, y, 'white'); + } + } + + private getRenderedTextWidth(text: string): number { + let totalWidth = 0; + let currentSegment = ''; + const ctx = this.chatCtx; + + for (let i = 0; i < text.length; i++) { + if (text[i] === '&' && i + 1 < text.length && this.getColorCode(text[i + 1])) { + // Measure the current segment before switching color + if (currentSegment) { + totalWidth += ctx.measureText(currentSegment).width; + currentSegment = ''; + } + i++; // Skip the color code character + } else { + currentSegment += text[i]; + } + } + + // Measure the last segment + if (currentSegment) { + totalWidth += ctx.measureText(currentSegment).width; + } + + return totalWidth; + } + + private getVisibleText(message: string, state: 'animatingIn' | 'animatingOut' | 'idle', progress: number): string { + if (state === 'idle') { + return message; + } + + const length = message.length; + if (length === 0) return ''; + + let charsToShow = 0; + + if (state === 'animatingIn') { + charsToShow = Math.floor(progress * length); + charsToShow = Math.min(charsToShow, length); // Ensure it doesn't exceed the message length + return message.substring(0, charsToShow); + } else if (state === 'animatingOut') { + charsToShow = Math.floor((1 - progress) * length); + charsToShow = Math.max(charsToShow, 0); // Ensure it doesn't go below zero + return message.substring(0, charsToShow); + } + + return message; + } + + private easeOut(progress: number): number { + return 1 - Math.pow(1 - progress, 1.5); + } + + public renderTouchControls() { + if (Date.now() / 1000 - this.lastTouchTimestamp > 10) return; + if (this.touchJoystickEngaged) { + // Draw circle for movement + this.chatCtx.fillStyle = 'rgba(255,255,255,0.25)'; + this.chatCtx.beginPath(); + this.chatCtx.arc(this.joystickX, this.joystickY, TouchInputHandler.joystickRadius, 0, 2 * Math.PI); + this.chatCtx.fill(); + + // Smaller circle for joystick-- offset from center + this.chatCtx.fillStyle = 'rgba(255,255,255,0.5)'; + this.chatCtx.beginPath(); + this.chatCtx.arc( + this.joystickX + this.joystickInputX * TouchInputHandler.joystickRadius, + this.joystickY + this.joystickInputY * TouchInputHandler.joystickRadius, + 10, + 0, + 2 * Math.PI, + ); + this.chatCtx.fill(); + } + + // Draw rounded square center right for jumping + const squareWidth = 24; + const squareHeight = 24; + const cornerRadius = 6; + const x = this.chatCanvas.width - squareWidth - 12; // 12px from the right edge + let y = (this.chatCanvas.height - squareHeight) / 2; // Center vertically + + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '●', 1, 0); + y -= squareHeight + 4; + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '↑', 1, -1); + y += squareHeight + 4; + y += squareHeight + 4; + this.drawButton(x, y, squareWidth, squareHeight, cornerRadius, '[]', 1, 1); + } + + public setButtonsHeld(buttons: number[]) { + this.buttonsHeld = buttons; + } + + private drawButton( + x: number, + y: number, + width: number, + height: number, + cornerRadius: number, + text: string, + textOffset: number, + index: number, + ) { + if (this.buttonsHeld.includes(index)) { + this.chatCtx.fillStyle = 'rgba(100,100,100,0.3)'; + } else { + this.chatCtx.fillStyle = 'rgba(255,255,255,0.15)'; + } + + this.drawRoundedSquare(x, y, width, height, cornerRadius); + // Draw character inside square + this.chatCtx.fillStyle = 'rgba(0,0,0,0.5)'; + this.chatCtx.font = '16px Tiny5'; + const textWidth = this.chatCtx.measureText(text).width; + this.chatCtx.fillText( + text, + Math.floor(x + width / 2 - textWidth / 2 + textOffset), + Math.floor(y + height / 2 + 16 / 2 - 2), + ); + } + + private drawRoundedSquare(x: number, y: number, width: number, height: number, cornerRadius: number) { + this.chatCtx.beginPath(); + this.chatCtx.moveTo(x + cornerRadius, y); + this.chatCtx.lineTo(x + width - cornerRadius, y); + this.chatCtx.quadraticCurveTo(x + width, y, x + width, y + cornerRadius); + this.chatCtx.lineTo(x + width, y + height - cornerRadius); + this.chatCtx.quadraticCurveTo(x + width, y + height, x + width - cornerRadius, y + height); + this.chatCtx.lineTo(x + cornerRadius, y + height); + this.chatCtx.quadraticCurveTo(x, y + height, x, y + height - cornerRadius); + this.chatCtx.lineTo(x, y + cornerRadius); + this.chatCtx.quadraticCurveTo(x, y, x + cornerRadius, y); + this.chatCtx.closePath(); + this.chatCtx.fill(); + } + + public setLastTouchTimestamp(timestamp: number) { + this.lastTouchTimestamp = timestamp; + } + + public setTouchJoystickEngaged(value: boolean) { + this.touchJoystickEngaged = value; + } + + public setJoystickPosition(x: number, y: number) { + this.joystickX = x; + this.joystickY = y; + } + + public setJoystickInput(x: number, y: number) { + this.joystickInputX = x; + this.joystickInputY = y; + } + + public renderHitMarkers() { + const numDots = 10; // Number of dots to render around each hit point + + 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 + } + + const timeSinceHit = Date.now() / 1000 - this.renderer.playerHitMarkers[i].timestamp; + const lifePercent = timeSinceHit / hitMarkerLifetime; + + if (timeSinceHit > hitMarkerLifetime) { + this.renderer.playerHitMarkers.splice(i, 1); + continue; + } + + 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); + + if (projected.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(); + + // 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 + } + } + } + } + + public getDebugTextHeight(): number { + return this.debugTextHeight; + } + + private renderPlayerList() { + const ctx = this.chatCtx; + const linesToRender: string[] = []; + const colorsToRender: string[] = []; + const playerData = this.networking.getRemotePlayerData(); + + linesToRender.push(playerData.length + ' online - ' + Math.round(this.localPlayer.latency) + 'ms'); + colorsToRender.push('white'); + for (let i = 0; i < playerData.length; i++) { + linesToRender.push(playerData[i].name); + if (playerData[i].latency > 200) { + colorsToRender.push('red'); + } else if (playerData[i].latency > 50) { + colorsToRender.push('orange'); + } else { + colorsToRender.push('green'); + } + } + + ctx.font = '8px Tiny5'; + + let longestLinePix = 0; + for (let i = 0; i < linesToRender.length; i++) { + longestLinePix = Math.max(longestLinePix, ctx.measureText(linesToRender[i]).width); + } + + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect( + Math.floor(this.screenWidth / 2 - longestLinePix / 2), + 4, + longestLinePix + 3, + linesToRender.length * 7 + 2, + ); + + for (let i = 0; i < linesToRender.length; i++) { + this.renderPixelText( + linesToRender[i], + Math.floor(this.screenWidth / 2 - longestLinePix / 2 + 2), + 11 + 7 * i, + colorsToRender[i], + ); + } + } + + private renderEvil() { + const ctx = this.chatCtx; + if (Date.now() / 1000 - this.networking.getDamagedTimestamp() < 0.05) { + ctx.fillStyle = 'rgba(255,0,0,0.1)'; + ctx.fillRect(0, 0, this.chatCanvas.width, this.chatCanvas.height); + } + } + + private renderCrosshair() { + const ctx = this.chatCtx; + ctx.fillStyle = SettingsManager.settings.crosshairColor; + if (this.renderer.crosshairIsFlashing) { + ctx.fillStyle = '#FF0000'; + } + switch (SettingsManager.settings.crosshairType) { + case 0: + ctx.fillRect(Math.floor(this.screenWidth / 2), 100 - 3, 1, 7); + ctx.fillRect(Math.floor(this.screenWidth / 2 - 3), 100, 7, 1); + break; + case 1: + ctx.fillRect(Math.floor(this.screenWidth / 2), 100, 1, 1); + break; + } + } + + private onKeyDown(e: KeyboardEvent) { + if (e.key === 'Backspace' && (this.localPlayer.chatActive || this.nameSettingActive)) { + this.localPlayer.chatMsg = this.localPlayer.chatMsg.slice(0, -1); + return; + } + + if (e.key === 'Enter') { + if (this.localPlayer.chatActive) { + if (!this.commandManager.runCmd(this.localPlayer.chatMsg)) { + this.networking.sendMessage(this.localPlayer.chatMsg); + } + } + if (this.nameSettingActive) { + this.localPlayer.name = this.localPlayer.chatMsg.toString(); + SettingsManager.settings.name = this.localPlayer.chatMsg.toString(); + SettingsManager.write(); + } + this.localPlayer.chatMsg = ''; + this.localPlayer.chatActive = false; + this.nameSettingActive = false; + } + + if (e.key === 'Escape' || e.key === 'Enter') { + this.localPlayer.chatMsg = ''; + this.localPlayer.chatActive = false; + this.nameSettingActive = false; + } + + if ((this.localPlayer.chatActive) && e.key.length === 1 && this.localPlayer.chatMsg.length < 300) { + this.localPlayer.chatMsg += e.key; + } + + if ((this.nameSettingActive) && e.key.length === 1 && this.localPlayer.chatMsg.length < 42) { + this.localPlayer.chatMsg += e.key; + } + + if (e.key.toLowerCase() === 't' && !this.nameSettingActive) { + 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 (e.key.toLowerCase() === 'n' && !this.localPlayer.chatActive) { + this.nameSettingActive = true; + } + } + + public addChatMessage(msg: { id: number; name: string; message: string }) { + const chatMessage: ChatMessage = { + id: msg.id, + name: msg.name, + message: msg.message, + timestamp: Date.now() / 1000, + }; + this.chatMessages.push(chatMessage); + } + + private clearOldMessages() { + for (let i = 0; i < this.chatMessages.length; i++) { + if (Date.now() / 1000 - this.chatMessages[i].timestamp > this.chatMessageLifespan + 5) { + this.chatMessages.splice(i, 1); + } + } + + for (let i = this.chatMessages.length - 1; i >= 0; i--) { + if (i < this.chatMessages.length - this.maxMessagesOnScreen) { + this.chatMessages[i].timestamp = Math.min( + Date.now() / 1000 - this.chatMessageLifespan, + this.chatMessages[i].timestamp, + ); + } + } + } + + private doTextWrapping( + ctx: CanvasRenderingContext2D, + text: string[], + maxWidth: number, + initialOffset: number = 0, + ): string[] { + ctx.font = '8px Tiny5'; + const resultLines: string[] = []; + + for (const line of text) { + if (line === '' || ctx.measureText(line).width <= maxWidth) { + resultLines.push(line); + continue; + } + + const words = line.split(' '); + let currentLine = ''; + let isFirstLine = true; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const testWidth = ctx.measureText(testLine).width; + + const availableWidth = isFirstLine ? maxWidth - initialOffset : maxWidth; + + if (testWidth <= availableWidth) { + currentLine = testLine; + } else { + if (currentLine) { + resultLines.push(currentLine); + } + currentLine = word; + isFirstLine = false; + } + } + + if (currentLine) { + resultLines.push(currentLine); + } + } + + return resultLines; + } + + private generateUniqueId(): string { + return Math.random().toString(36).substr(2, 9); + } +} diff --git a/src/client/ui/HealthIndicator.ts b/src/client/ui/HealthIndicator.ts index 6883a783..ecc18856 100644 --- a/src/client/ui/HealthIndicator.ts +++ b/src/client/ui/HealthIndicator.ts @@ -1,132 +1,134 @@ 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 {Player} from "../core/Player.ts"; -import {Networking} from "../core/Networking.ts"; - +import { Renderer } from '../core/Renderer.ts'; +import { Player } from '../core/Player.ts'; +import { Networking } from '../core/Networking.ts'; const clock = new THREE.Clock(); export class HealthIndicator { - private scene: THREE.Scene; - 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); - } - - 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.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); - } - }); - }, - undefined, - () => { - console.log('overlay possum loading error'); - } - ); - } - - public onFrame() { - if (!this.possumObject) return; - if (!this.sceneAdded) { - this.scene.add(this.possumObject); - this.sceneAdded = true; - } - - 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; - - this.targetPosition.copy(basePosition); - this.targetPosition.y += scaredLevel * 0.5 * Math.sin(1.1 * Math.PI * this.rotatedAngle); - this.targetPosition.y += (Math.random() - 0.5 ) * 0.2 * scaredLevel; - this.targetPosition.x += (Math.random() - 0.5 ) * 0.2 * scaredLevel; - this.targetPosition.z += (Math.random() - 0.5 ) * 0.2 * scaredLevel; - - this.targetQuaternion.copy(baseQuaternion); - rotateAroundWorldAxis(this.targetQuaternion, new THREE.Vector3(0, 0, 1), 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); - - moveTowardsPos(this.possumObject.position, this.targetPosition, 0.8 * deltaTime * 60); - moveTowardsRot(this.possumObject.quaternion, this.targetQuaternion, 0.5 * deltaTime * 60); - - let targetRGBI: number[]; - - if(!this.lastHealthChangeWasDamage && this.localPlayer.health < maxHealth && this.rotatedAngle % 2 > 1) - targetRGBI = [125,255,125,1.2]; - else - targetRGBI = [255,255,255,0.5]; - - - for(let i = 0; i < 4; i++) - this.lightRGBI[i] = 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])); - - if(this.lastHealththis.localPlayer.health) - this.lastHealthChangeWasDamage = true; - this.lastHealth = this.localPlayer.health; - } - - - + private scene: THREE.Scene; + 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); + } + + 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.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); + } + }); + }, + undefined, + () => { + console.log('overlay possum loading error'); + }, + ); + } + + public onFrame() { + if (!this.possumObject) return; + if (!this.sceneAdded) { + this.scene.add(this.possumObject); + this.sceneAdded = true; + } + + 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; + + this.targetPosition.copy(basePosition); + this.targetPosition.y += scaredLevel * 0.5 * Math.sin(1.1 * Math.PI * this.rotatedAngle); + this.targetPosition.y += (Math.random() - 0.5) * 0.2 * scaredLevel; + this.targetPosition.x += (Math.random() - 0.5) * 0.2 * scaredLevel; + this.targetPosition.z += (Math.random() - 0.5) * 0.2 * scaredLevel; + + this.targetQuaternion.copy(baseQuaternion); + rotateAroundWorldAxis( + this.targetQuaternion, + new THREE.Vector3(0, 0, 1), + 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); + + moveTowardsPos(this.possumObject.position, this.targetPosition, 0.8 * deltaTime * 60); + moveTowardsRot(this.possumObject.quaternion, this.targetQuaternion, 0.5 * deltaTime * 60); + + let targetRGBI: number[]; + + if (!this.lastHealthChangeWasDamage && this.localPlayer.health < maxHealth && this.rotatedAngle % 2 > 1) { + targetRGBI = [125, 255, 125, 1.2]; + } else { + targetRGBI = [255, 255, 255, 0.5]; + } + + for (let i = 0; i < 4; i++) { + this.lightRGBI[i] = 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]), + ); + + if (this.lastHealth < this.localPlayer.health) { + this.lastHealthChangeWasDamage = false; + } else if (this.lastHealth > this.localPlayer.health) { + this.lastHealthChangeWasDamage = true; + } + 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); + 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); + source.lerp(target, frac); } function moveTowardsRot(source: THREE.Quaternion, target: THREE.Quaternion, frac: number) { - source.slerp(target, frac); + source.slerp(target, frac); } -function rgbToHex(r:number, g:number, b:number) { - return (r << 16) + (g << 8) + b; +function rgbToHex(r: number, g: number, b: number) { + return (r << 16) + (g << 8) + b; } const basePosition = new THREE.Vector3(0, 0, 1.2); -const baseQuaternion = new THREE.Quaternion(0,0,0,1); +const baseQuaternion = new THREE.Quaternion(0, 0, 0, 1); diff --git a/src/main.server.ts b/src/main.server.ts index 6a60a09e..a73e6628 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -6,27 +6,27 @@ import { renderApplication } from '@angular/platform-server'; import { provideServerContext } from '@analogjs/router/server'; import { ServerContext } from '@analogjs/router/tokens'; -import { AppComponent } from "./app/app.component.ts"; -import { config } from "./app/app.config.server.ts"; +import { AppComponent } from './app/app.component.ts'; +import { config } from './app/app.config.server.ts'; if (Deno.env.has('PRODUCTION')) { - enableProdMode(); + enableProdMode(); } export function bootstrap() { - return bootstrapApplication(AppComponent, config); + return bootstrapApplication(AppComponent, config); } export default async function render( - url: string, - document: string, - serverContext: ServerContext + url: string, + document: string, + serverContext: ServerContext, ) { - const html = await renderApplication(bootstrap, { - document, - url, - platformProviders: [provideServerContext(serverContext)], - }); + const html = await renderApplication(bootstrap, { + document, + url, + platformProviders: [provideServerContext(serverContext)], + }); - return html; + return html; } diff --git a/src/main.ts b/src/main.ts index af4e47ab..98f615be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import 'zone.js'; import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from "./app/app.component.ts"; -import { appConfig } from "./app/app.config.ts"; +import { AppComponent } from './app/app.component.ts'; +import { appConfig } from './app/app.config.ts'; bootstrapApplication(AppComponent, appConfig); diff --git a/src/server/DataValidator.ts b/src/server/DataValidator.ts index 153bd05f..62191261 100644 --- a/src/server/DataValidator.ts +++ b/src/server/DataValidator.ts @@ -1,81 +1,78 @@ -import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; -import { Player } from "./models/Player.ts"; -import { ChatMessage } from "./models/ChatMessage.ts"; -import { DamageRequest } from "./models/DamageRequest.ts"; - - +import { z } from 'https://deno.land/x/zod@v3.23.8/mod.ts'; +import { Player } from './models/Player.ts'; +import { ChatMessage } from './models/ChatMessage.ts'; +import { DamageRequest } from './models/DamageRequest.ts'; export class DataValidator { - private static SERVER_VERSION = ''; - - public static async updateServerVersion() { - const versionFile = await Deno.readTextFile("public/gameVersion.json"); - const versionData = JSON.parse(versionFile); - this.SERVER_VERSION = versionData.version; - return this.SERVER_VERSION; - } + private static SERVER_VERSION = ''; - public static getServerVersion() { - return this.SERVER_VERSION; - } + public static async updateServerVersion() { + const versionFile = await Deno.readTextFile('public/gameVersion.json'); + const versionData = JSON.parse(versionFile); + this.SERVER_VERSION = versionData.version; + return this.SERVER_VERSION; + } + public static getServerVersion() { + return this.SERVER_VERSION; + } - private static vector3Schema = z.object({ - x: z.number(), - y: z.number(), - z: z.number(), - }).strict(); + private static vector3Schema = z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).strict(); - private static playerDataSchema = z.object({ - id: z.number(), - speed: z.number(), - acceleration: z.number(), - name: z.string().max(42), - gameVersion: z.string().refine(val => val === this.SERVER_VERSION, { - message: `Game version must be ${this.SERVER_VERSION}`, - }), - position: DataValidator.vector3Schema, - velocity: DataValidator.vector3Schema, - inputVelocity: DataValidator.vector3Schema, - gravity: z.number(), - lookQuaternion: z.array(z.number()).length(4), - quaternion: z.array(z.number()).length(4), - chatActive: z.boolean(), - chatMsg: z.string().max(300), - latency: z.number(), - health: z.number(), - forced: z.boolean(), - forcedAcknowledged: z.boolean(), - updateTimestamp: z.number().optional(), - lastDamageTime: z.number().optional(), - inventory: z.array(z.number()), - idLastDamagedBy: z.number().optional(), - playerSpectating: z.number(), - gameMsgs: z.array(z.string()), - gameMsgs2: z.array(z.string()), - }).strict(); + private static playerDataSchema = z.object({ + id: z.number(), + speed: z.number(), + acceleration: z.number(), + name: z.string().max(42), + gameVersion: z.string().refine((val) => val === this.SERVER_VERSION, { + message: `Game version must be ${this.SERVER_VERSION}`, + }), + position: DataValidator.vector3Schema, + velocity: DataValidator.vector3Schema, + inputVelocity: DataValidator.vector3Schema, + gravity: z.number(), + lookQuaternion: z.array(z.number()).length(4), + quaternion: z.array(z.number()).length(4), + chatActive: z.boolean(), + chatMsg: z.string().max(300), + latency: z.number(), + health: z.number(), + forced: z.boolean(), + forcedAcknowledged: z.boolean(), + updateTimestamp: z.number().optional(), + lastDamageTime: z.number().optional(), + inventory: z.array(z.number()), + idLastDamagedBy: z.number().optional(), + playerSpectating: z.number(), + gameMsgs: z.array(z.string()), + gameMsgs2: z.array(z.string()), + }).strict(); - private static chatMsgSchema = z.object({ - id: z.number(), - name: z.string().max(42), - message: z.string().max(300), - }).strict(); + private static chatMsgSchema = z.object({ + id: z.number(), + name: z.string().max(42), + message: z.string().max(300), + }).strict(); - private static damageRequestSchema = z.object({ - localPlayer: DataValidator.playerDataSchema, - targetPlayer: DataValidator.playerDataSchema, - damage: z.number(), - }).strict(); + private static damageRequestSchema = z.object({ + localPlayer: DataValidator.playerDataSchema, + targetPlayer: DataValidator.playerDataSchema, + damage: z.number(), + }).strict(); - static validatePlayerData(data: Player) { - return DataValidator.playerDataSchema.safeParse(data); - } + static validatePlayerData(data: Player) { + return DataValidator.playerDataSchema.safeParse(data); + } - static validateChatMessage(data: ChatMessage) { - return DataValidator.chatMsgSchema.safeParse(data); - } + static validateChatMessage(data: ChatMessage) { + return DataValidator.chatMsgSchema.safeParse(data); + } - static validateDamageRequest(data: DamageRequest) { - return DataValidator.damageRequestSchema.safeParse(data); - } + static validateDamageRequest(data: DamageRequest) { + return DataValidator.damageRequestSchema.safeParse(data); + } } diff --git a/src/server/GameEngine.ts b/src/server/GameEngine.ts index 486e9666..c5cd63c7 100644 --- a/src/server/GameEngine.ts +++ b/src/server/GameEngine.ts @@ -1,162 +1,155 @@ -import { ChatManager } from "./managers/ChatManager.ts"; -import { DamageSystem } from "./managers/DamageSystem.ts"; -import { ItemManager } from "./managers/ItemManager.ts"; -import { PlayerManager } from "./managers/PlayerManager.ts"; -import { Server } from "https://deno.land/x/socket_io@0.2.0/mod.ts"; -import config from "./config.ts"; -import { Vector3 } from "./models/Vector3.ts"; -import { ServerInfo } from "./models/ServerInfo.ts"; -import {DataValidator} from "./DataValidator.ts"; -import {Player} from "./models/Player.ts"; -import {Gamemode} from "./gamemodes/Gamemode.ts"; -import {FFAGamemode} from "./gamemodes/FFAGamemode.ts"; +import { ChatManager } from './managers/ChatManager.ts'; +import { DamageSystem } from './managers/DamageSystem.ts'; +import { ItemManager } from './managers/ItemManager.ts'; +import { PlayerManager } from './managers/PlayerManager.ts'; +import { Server } from 'https://deno.land/x/socket_io@0.2.0/mod.ts'; +import config from './config.ts'; +import { Vector3 } from './models/Vector3.ts'; +import { ServerInfo } from './models/ServerInfo.ts'; +import { DataValidator } from './DataValidator.ts'; +import { Player } from './models/Player.ts'; +import { Gamemode } from './gamemodes/Gamemode.ts'; +import { FFAGamemode } from './gamemodes/FFAGamemode.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 gamemode: Gamemode | false = false; - - constructor( - public playerManager: PlayerManager, - private itemManager: ItemManager, - public chatManager: ChatManager, - private damageSystem: DamageSystem, - private io: Server - ) {} - - start() { - setInterval(() => this.serverTick(), 1000 / config.server.tickRate); - setInterval(() => this.periodicCleanup(), config.server.cleanupInterval); - setInterval(() => this.emitServerInfo(), config.server.cleanupInterval); - this.initGamemode(); - } - - private serverTick() { - try { - const currentTime = Date.now() / 1000; - this.playerManager.regenerateHealth(); - this.itemManager.tick(currentTime); - if(this.gamemode) this.gamemode.tick(); - - // Emit player data if there are updates or enough time has passed - if (this.playerUpdateSinceLastEmit || currentTime - this.lastPlayerTickTimestamp > 1 / config.server.tickRate) { - try { - this.io.emit('remotePlayerData', this.playerManager.getAllPlayers()); - this.playerUpdateSinceLastEmit = false; - this.lastPlayerTickTimestamp = currentTime; - } catch (err) { - console.error('⚠ error emitting player data:', err); - } - } - - // Emit item data if there are updates - if (this.itemUpdateSinceLastEmit || this.itemManager.hasUpdates()) { - try { - this.io.emit('worldItemData', this.itemManager.getAllItems()); - this.itemUpdateSinceLastEmit = false; - } catch (err) { - console.error('⚠ error emitting item data:', err); - } - } - } catch (error) { - console.error('⚠ error in serverTick:', error); - } - } - - public periodicCleanup() { - // for(const player of this.playerManager.getAllPlayers()) - // console.log(player.gameMsgs) - - try { - const currentTime = Date.now() / 1000; - const players = this.playerManager.getAllPlayers(); - - players.forEach(player => { - if (player.position.y < -150) { - player.health = 0; - player.velocity = new Vector3(0, 0, 0); - this.chatManager.broadcastChat(`${player.name} fell off :'(`); - console.log(`💔 ${player.name}(${player.id}) fell off the map`); - } - - if (player.health <= 0) { - if(this.gamemode) this.gamemode.onPlayerDeath(player); //gamemode now handles - else this.playerManager.respawnPlayer(player); - } - - if ((player.updateTimestamp || 0) + config.player.disconnectTime < currentTime) { - if(this.gamemode) this.gamemode.onPlayerDisconnect(player); - console.log(`🟠 ${player.name}(${player.id}) left`); - this.chatManager.broadcastChat(`${player.name} left`); - this.playerManager.removePlayer(player.id); - } - }); - - const playerData = this.playerManager.getAllPlayerData(); - playerData.forEach(playerData => { - for(let i = 0; i playerData.extras.gameMsgsTimeouts[i] && playerData.extras.gameMsgsTimeouts[i] !== -1){ - - playerData.player.gameMsgs[i] = ''; - playerData.extras.gameMsgsTimeouts[i] = -1; - this.playerUpdateSinceLastEmit = true; - - } - } - }); - - - - const items = this.itemManager.getAllItems(); - items.forEach(item => { - if (item.vector.y < -5) { - this.itemManager.removeItem(item.id); - this.itemUpdateSinceLastEmit = true; - } - }); - - if(this.gamemode) this.gamemode.onPeriodicCleanup(); - - } catch (error) { - console.error('⚠ error in periodicCleanup:', error); - } - } - - // Method to emit server info to all clients - public emitServerInfo() { - this.serverInfo.version = DataValidator.getServerVersion(); - this.serverInfo.currentPlayers = this.playerManager.getAllPlayers().length; - this.io.emit('serverInfo', this.serverInfo); - } - - - - public setGameMessage(player:Player, message:string, index:number, timeout?:number){ - player.gameMsgs[index] = message; - const extras = this.playerManager.getPlayerExtrasById(player.id); - if(timeout && timeout > 0 && extras){ - extras.gameMsgsTimeouts[index] = Date.now()/1000 + timeout; - } - } - - - - private initGamemode(){ - try { - switch(config.game.mode){ - case 'ffa': - this.gamemode = new FFAGamemode(this); - break; - default: - console.log('⚠️ invalid gamemode supplied (check your config!)', config.game.mode); - break; - } - } catch (error) { - console.error('⚠ error initializing gamemode:', error); - } - } + 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 gamemode: Gamemode | false = false; + + constructor( + public playerManager: PlayerManager, + private itemManager: ItemManager, + public chatManager: ChatManager, + private damageSystem: DamageSystem, + private io: Server, + ) {} + + start() { + setInterval(() => this.serverTick(), 1000 / config.server.tickRate); + setInterval(() => this.periodicCleanup(), config.server.cleanupInterval); + setInterval(() => this.emitServerInfo(), config.server.cleanupInterval); + this.initGamemode(); + } + + private serverTick() { + try { + const currentTime = Date.now() / 1000; + this.playerManager.regenerateHealth(); + this.itemManager.tick(currentTime); + if (this.gamemode) this.gamemode.tick(); + + // Emit player data if there are updates or enough time has passed + if (this.playerUpdateSinceLastEmit || currentTime - this.lastPlayerTickTimestamp > 1 / config.server.tickRate) { + try { + this.io.emit('remotePlayerData', this.playerManager.getAllPlayers()); + this.playerUpdateSinceLastEmit = false; + this.lastPlayerTickTimestamp = currentTime; + } catch (err) { + console.error('⚠ error emitting player data:', err); + } + } + + // Emit item data if there are updates + if (this.itemUpdateSinceLastEmit || this.itemManager.hasUpdates()) { + try { + this.io.emit('worldItemData', this.itemManager.getAllItems()); + this.itemUpdateSinceLastEmit = false; + } catch (err) { + console.error('⚠ error emitting item data:', err); + } + } + } catch (error) { + console.error('⚠ error in serverTick:', error); + } + } + + public periodicCleanup() { + // for(const player of this.playerManager.getAllPlayers()) + // console.log(player.gameMsgs) + + try { + const currentTime = Date.now() / 1000; + const players = this.playerManager.getAllPlayers(); + + players.forEach((player) => { + if (player.position.y < -150) { + player.health = 0; + player.velocity = new Vector3(0, 0, 0); + this.chatManager.broadcastChat(`${player.name} fell off :'(`); + console.log(`💔 ${player.name}(${player.id}) fell off the map`); + } + + if (player.health <= 0) { + if (this.gamemode) this.gamemode.onPlayerDeath(player); //gamemode now handles + else this.playerManager.respawnPlayer(player); + } + + if ((player.updateTimestamp || 0) + config.player.disconnectTime < currentTime) { + if (this.gamemode) this.gamemode.onPlayerDisconnect(player); + console.log(`🟠 ${player.name}(${player.id}) left`); + this.chatManager.broadcastChat(`${player.name} left`); + this.playerManager.removePlayer(player.id); + } + }); + + const playerData = this.playerManager.getAllPlayerData(); + playerData.forEach((playerData) => { + for (let i = 0; i < playerData.extras.gameMsgsTimeouts.length; i++) { + if ( + playerData.extras.gameMsgsTimeouts[i] && + currentTime > playerData.extras.gameMsgsTimeouts[i] && playerData.extras.gameMsgsTimeouts[i] !== -1 + ) { + playerData.player.gameMsgs[i] = ''; + playerData.extras.gameMsgsTimeouts[i] = -1; + this.playerUpdateSinceLastEmit = true; + } + } + }); + + const items = this.itemManager.getAllItems(); + items.forEach((item) => { + if (item.vector.y < -5) { + this.itemManager.removeItem(item.id); + this.itemUpdateSinceLastEmit = true; + } + }); + + if (this.gamemode) this.gamemode.onPeriodicCleanup(); + } catch (error) { + console.error('⚠ error in periodicCleanup:', error); + } + } + + // Method to emit server info to all clients + public emitServerInfo() { + this.serverInfo.version = DataValidator.getServerVersion(); + this.serverInfo.currentPlayers = this.playerManager.getAllPlayers().length; + this.io.emit('serverInfo', this.serverInfo); + } + + public setGameMessage(player: Player, message: string, index: number, timeout?: number) { + player.gameMsgs[index] = message; + const extras = this.playerManager.getPlayerExtrasById(player.id); + if (timeout && timeout > 0 && extras) { + extras.gameMsgsTimeouts[index] = Date.now() / 1000 + timeout; + } + } + + private initGamemode() { + try { + switch (config.game.mode) { + case 'ffa': + this.gamemode = new FFAGamemode(this); + break; + default: + console.log('⚠️ invalid gamemode supplied (check your config!)', config.game.mode); + break; + } + } catch (error) { + console.error('⚠ error initializing gamemode:', error); + } + } } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index f597bc4c..674639e7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,7 +1,7 @@ -import { Application, Router, send } from "@oak/oak"; -import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts"; +import { Application, Router, send } from '@oak/oak'; +import { Server, Socket } from 'https://deno.land/x/socket_io@0.2.0/mod.ts'; import config from './config.ts'; -import { serve } from "https://deno.land/std@0.150.0/http/server.ts"; +import { serve } from 'https://deno.land/std@0.150.0/http/server.ts'; import { GameEngine } from './GameEngine.ts'; import { PlayerManager } from './managers/PlayerManager.ts'; @@ -9,154 +9,153 @@ import { ItemManager } from './managers/ItemManager.ts'; import { ChatManager } from './managers/ChatManager.ts'; import { DamageSystem } from './managers/DamageSystem.ts'; import { MapData } from './models/MapData.ts'; -import { DataValidator } from "./DataValidator.ts"; +import { DataValidator } from './DataValidator.ts'; export class GameServer { - router: Router = new Router(); - app: Application = new Application(); - io: Server = new Server(); - - gameEngine: GameEngine; - playerManager: PlayerManager; - itemManager: ItemManager; - chatManager: ChatManager; - damageSystem: DamageSystem; - mapData: MapData; - - constructor() { - this.mapData = this.loadMapData(); - this.playerManager = new PlayerManager(this.mapData); - this.chatManager = new ChatManager(this.io, this.playerManager); - this.itemManager = new ItemManager(this.mapData, this.playerManager, this.chatManager); - this.damageSystem = new DamageSystem(this.playerManager, this.chatManager); - - this.playerManager.setItemManager(this.itemManager); - - this.setupSocketIO(); - this.setupRoutes(); - - this.gameEngine = new GameEngine( - this.playerManager, - this.itemManager, - this.chatManager, - this.damageSystem, - this.io - ); - this.itemManager.setGamemode(this.gameEngine.gamemode); - this.damageSystem.setGameEngine(this.gameEngine); - this.gameEngine.start(); - - DataValidator.updateServerVersion(); - this.start(); - } - - private setupSocketIO() { - this.io.on("connection", (socket: Socket) => { - if (socket.connected) { - - socket.on("error", (error) => { - console.error(`Socket error for ${socket.id}:`, error); - }); - - // deno-lint-ignore require-await - socket.on("playerData", async (data) => { - try { - const result = this.playerManager.addOrUpdatePlayer(data); - if (result.isNew && result.player) { - if(this.gameEngine.gamemode) this.gameEngine.gamemode.onPlayerConnect(result.player); - this.chatManager.broadcastChat(`${result.player.name} joined`); - console.log(`🟢 ${result.player.name}(${result.player.id}) joined`); - this.gameEngine.emitServerInfo(); - } - } catch (err) { - console.error(`Error handling playerData:`, err); - } - }); - - // deno-lint-ignore require-await - socket.on("chatMsg", async (data) => { - try { - this.chatManager.handleChatMessage(data, socket); - } catch (err) { - console.error(`Error handling chat message:`, err); - } - }); - - socket.on("applyDamage", (data) => { - try { - this.damageSystem.handleDamageRequest(data); - } catch (err) { - console.error(`Error handling damage request:`, err); - } - }); - - socket.on('latencyTest', () => { - try { - socket.emit('latencyTest', 'response :)'); - } catch (err) { - console.error(`Error handling latency test:`, err); - } - }); - - socket.on("disconnect", () => { - //console.log(`Socket disconnected: ${socket.id}, reason: ${reason}`); //reason is passed - }); - } - }); - } - - private setupRoutes() { - this.router.get("/(.*)", async (context) => { - try { - await send(context, context.params[0], { - root: `${Deno.cwd()}/dist`, - index: "index.html", - }); - } catch { - try { - await send(context, "index.html", { - root: `${Deno.cwd()}/dist`, - }); - } catch (err) { - console.error('Error serving files:', err); - context.response.status = 500; - context.response.body = "Internal Server Error"; - } - } - }); - - this.app.use(this.router.routes()); - this.app.use(this.router.allowedMethods()); - } - - private async start() { - try { - const handler = this.io.handler(async (req: Request) => { - try { - return await this.app.handle(req) || new Response(null, { status: 404 }); - } catch (error) { - console.error('Request handler error:', error); - return new Response('Internal Server Error', { status: 500 }); - } - }); - - await serve(handler, { - port: config.server.port - }); - } catch (error) { - console.error('Failed to start server:', error); - Deno.exit(1); - } - } - - private loadMapData(): MapData { - try { - const mapJson = Deno.readTextFileSync(`./dist/maps/${config.server.defaultMap}/map.json`); - const mapObj = JSON.parse(mapJson); - return MapData.fromJSON(mapObj); - } catch (error) { - console.error("Failed to load map data:", error); - return new MapData('default_map', [], []); - } - } + router: Router = new Router(); + app: Application = new Application(); + io: Server = new Server(); + + gameEngine: GameEngine; + playerManager: PlayerManager; + itemManager: ItemManager; + chatManager: ChatManager; + damageSystem: DamageSystem; + mapData: MapData; + + constructor() { + this.mapData = this.loadMapData(); + this.playerManager = new PlayerManager(this.mapData); + this.chatManager = new ChatManager(this.io, this.playerManager); + this.itemManager = new ItemManager(this.mapData, this.playerManager, this.chatManager); + this.damageSystem = new DamageSystem(this.playerManager, this.chatManager); + + this.playerManager.setItemManager(this.itemManager); + + this.setupSocketIO(); + this.setupRoutes(); + + this.gameEngine = new GameEngine( + this.playerManager, + this.itemManager, + this.chatManager, + this.damageSystem, + this.io, + ); + this.itemManager.setGamemode(this.gameEngine.gamemode); + this.damageSystem.setGameEngine(this.gameEngine); + this.gameEngine.start(); + + DataValidator.updateServerVersion(); + this.start(); + } + + private setupSocketIO() { + this.io.on('connection', (socket: Socket) => { + if (socket.connected) { + socket.on('error', (error) => { + console.error(`Socket error for ${socket.id}:`, error); + }); + + // deno-lint-ignore require-await + socket.on('playerData', async (data) => { + try { + const result = this.playerManager.addOrUpdatePlayer(data); + if (result.isNew && result.player) { + if (this.gameEngine.gamemode) this.gameEngine.gamemode.onPlayerConnect(result.player); + this.chatManager.broadcastChat(`${result.player.name} joined`); + console.log(`🟢 ${result.player.name}(${result.player.id}) joined`); + this.gameEngine.emitServerInfo(); + } + } catch (err) { + console.error(`Error handling playerData:`, err); + } + }); + + // deno-lint-ignore require-await + socket.on('chatMsg', async (data) => { + try { + this.chatManager.handleChatMessage(data, socket); + } catch (err) { + console.error(`Error handling chat message:`, err); + } + }); + + socket.on('applyDamage', (data) => { + try { + this.damageSystem.handleDamageRequest(data); + } catch (err) { + console.error(`Error handling damage request:`, err); + } + }); + + socket.on('latencyTest', () => { + try { + socket.emit('latencyTest', 'response :)'); + } catch (err) { + console.error(`Error handling latency test:`, err); + } + }); + + socket.on('disconnect', () => { + //console.log(`Socket disconnected: ${socket.id}, reason: ${reason}`); //reason is passed + }); + } + }); + } + + private setupRoutes() { + this.router.get('/(.*)', async (context) => { + try { + await send(context, context.params[0], { + root: `${Deno.cwd()}/dist`, + index: 'index.html', + }); + } catch { + try { + await send(context, 'index.html', { + root: `${Deno.cwd()}/dist`, + }); + } catch (err) { + console.error('Error serving files:', err); + context.response.status = 500; + context.response.body = 'Internal Server Error'; + } + } + }); + + this.app.use(this.router.routes()); + this.app.use(this.router.allowedMethods()); + } + + private async start() { + try { + const handler = this.io.handler(async (req: Request) => { + try { + return await this.app.handle(req) || new Response(null, { status: 404 }); + } catch (error) { + console.error('Request handler error:', error); + return new Response('Internal Server Error', { status: 500 }); + } + }); + + await serve(handler, { + port: config.server.port, + }); + } catch (error) { + console.error('Failed to start server:', error); + Deno.exit(1); + } + } + + private loadMapData(): MapData { + try { + const mapJson = Deno.readTextFileSync(`./dist/maps/${config.server.defaultMap}/map.json`); + const mapObj = JSON.parse(mapJson); + return MapData.fromJSON(mapObj); + } catch (error) { + console.error('Failed to load map data:', error); + return new MapData('default_map', [], []); + } + } } diff --git a/src/server/config.ts b/src/server/config.ts index 98a9d726..f040b7cb 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,91 +1,90 @@ const defaults = { - // Server settings - SERVER_PORT: '3000', - SERVER_NAME: 'my-server', - SERVER_URL: 'https://example.com', - SERVER_DEFAULT_MAP: 'crackhouse_1', - SERVER_TICK_RATE: '15', - SERVER_CLEANUP_INTERVAL: '2000', + // Server settings + SERVER_PORT: '3000', + SERVER_NAME: 'my-server', + SERVER_URL: 'https://example.com', + SERVER_DEFAULT_MAP: 'crackhouse_1', + SERVER_TICK_RATE: '15', + SERVER_CLEANUP_INTERVAL: '2000', - // Player settings - PLAYER_DISCONNECT_TIME: '8', - PLAYER_AFK_KICK_TIME: '600', - PLAYER_MAX_HEALTH: '100', - PLAYER_BASE_INVENTORY: '[]', + // Player settings + PLAYER_DISCONNECT_TIME: '8', + PLAYER_AFK_KICK_TIME: '600', + PLAYER_MAX_HEALTH: '100', + PLAYER_BASE_INVENTORY: '[]', - //Game settings - GAME_MODE: 'ffa', - GAME_MAX_PLAYERS: '20', + //Game settings + GAME_MODE: 'ffa', + GAME_MAX_PLAYERS: '20', - // Health settings - HEALTH_REGEN_DELAY: '5', - HEALTH_REGEN_RATE: '3', + // Health settings + HEALTH_REGEN_DELAY: '5', + HEALTH_REGEN_RATE: '3', - //Item settings - MAX_ITEMS_IN_WORLD: '7', - ITEM_RESPAWN_TIME: '10', + //Item settings + MAX_ITEMS_IN_WORLD: '7', + ITEM_RESPAWN_TIME: '10', }; async function updateEnvFile(defaults: Record) { - const envPath = '.env'; - const envExists = await Deno.stat(envPath).catch(() => false); - let currentEnv: Record = {}; + const envPath = '.env'; + const envExists = await Deno.stat(envPath).catch(() => false); + let currentEnv: Record = {}; - if (envExists) { - const content = await Deno.readTextFile(envPath); - currentEnv = content.split('\n') - .filter(line => line && !line.startsWith('#')) - .reduce((acc, line) => { - const [key, value] = line.split('=').map(s => s.trim()); - acc[key] = value.replace(/["']/g, ''); - return acc; - }, {} as Record); - } + if (envExists) { + const content = await Deno.readTextFile(envPath); + currentEnv = content.split('\n') + .filter((line) => line && !line.startsWith('#')) + .reduce((acc, line) => { + const [key, value] = line.split('=').map((s) => s.trim()); + acc[key] = value.replace(/["']/g, ''); + return acc; + }, {} as Record); + } - const finalEnv = { - ...defaults, - ...currentEnv - }; + const finalEnv = { + ...defaults, + ...currentEnv, + }; + const envContent = Object.entries(finalEnv) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); - const envContent = Object.entries(finalEnv) - .map(([key, value]) => `${key}=${value}`) - .join('\n'); - - await Deno.writeTextFile(envPath, envContent); - return finalEnv; + await Deno.writeTextFile(envPath, envContent); + return finalEnv; } // Parse specific types from string values function parseConfig(env: Record) { - return { - server: { - port: parseInt(env.SERVER_PORT), - name: env.SERVER_NAME, - url: env.SERVER_URL, - defaultMap: env.SERVER_DEFAULT_MAP, - tickRate: parseInt(env.SERVER_TICK_RATE), - cleanupInterval: parseInt(env.SERVER_CLEANUP_INTERVAL) - }, - game: { - mode: env.GAME_MODE, - maxPlayers: parseInt(env.GAME_MAX_PLAYERS) - }, - player: { - disconnectTime: parseInt(env.PLAYER_DISCONNECT_TIME), - afkKickTime: parseInt(env.PLAYER_AFK_KICK_TIME), - maxHealth: parseInt(env.PLAYER_MAX_HEALTH), - baseInventory: JSON.parse(env.PLAYER_BASE_INVENTORY) as number[] - }, - health: { - regenDelay: parseInt(env.HEALTH_REGEN_DELAY), - regenRate: parseInt(env.HEALTH_REGEN_RATE) - }, - items: { - maxItemsInWorld: parseInt(env.MAX_ITEMS_IN_WORLD), - respawnTime: parseInt(env.ITEM_RESPAWN_TIME) - } - }; + return { + server: { + port: parseInt(env.SERVER_PORT), + name: env.SERVER_NAME, + url: env.SERVER_URL, + defaultMap: env.SERVER_DEFAULT_MAP, + tickRate: parseInt(env.SERVER_TICK_RATE), + cleanupInterval: parseInt(env.SERVER_CLEANUP_INTERVAL), + }, + game: { + mode: env.GAME_MODE, + maxPlayers: parseInt(env.GAME_MAX_PLAYERS), + }, + player: { + disconnectTime: parseInt(env.PLAYER_DISCONNECT_TIME), + afkKickTime: parseInt(env.PLAYER_AFK_KICK_TIME), + maxHealth: parseInt(env.PLAYER_MAX_HEALTH), + baseInventory: JSON.parse(env.PLAYER_BASE_INVENTORY) as number[], + }, + health: { + regenDelay: parseInt(env.HEALTH_REGEN_DELAY), + regenRate: parseInt(env.HEALTH_REGEN_RATE), + }, + items: { + maxItemsInWorld: parseInt(env.MAX_ITEMS_IN_WORLD), + respawnTime: parseInt(env.ITEM_RESPAWN_TIME), + }, + }; } const rawConfig = await updateEnvFile(defaults); diff --git a/src/server/gamemodes/FFAGamemode.ts b/src/server/gamemodes/FFAGamemode.ts index 70f61183..aff8ff67 100644 --- a/src/server/gamemodes/FFAGamemode.ts +++ b/src/server/gamemodes/FFAGamemode.ts @@ -1,124 +1,120 @@ -import {GameEngine} from "../GameEngine.ts"; -import {Player} from "../models/Player.ts"; -import {Gamemode} from "./Gamemode.ts"; -import config from "../config.ts"; - +import { GameEngine } from '../GameEngine.ts'; +import { Player } from '../models/Player.ts'; +import { Gamemode } from './Gamemode.ts'; +import config from '../config.ts'; export class FFAGamemode extends Gamemode { - - private spectateTimeouts: Map = new Map(); - constructor(gameEngine: GameEngine) { - super(gameEngine); - this.init(); - } - - init(): void { - console.log('🐙 FFA Gamemode initialized'); - } - - tick(): void { - const currentTime = Date.now() / 1000; - for (const [player, timestamp] of this.spectateTimeouts) { - if (currentTime - timestamp > 10) { - this.gameEngine.playerManager.respawnPlayer(player); - player.playerSpectating = -1; - this.spectateTimeouts.delete(player); - this.gameEngine.setGameMessage(player, '', 0); - }else{ - this.gameEngine.setGameMessage(player, '&crespawn in ' + Math.floor(10 + timestamp - currentTime)+ ' seconds', 1, 0.5); - } - player.health = config.player.maxHealth; - - - } - } - - onPeriodicCleanup(): void { - // 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']; - } - - - } - - onPlayerConnect(_player: Player): void { - } - - onPlayerDisconnect(_player: Player): void { - } - - onPlayerDeath(player: Player): void { - const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); - if(extras) {extras.deaths++; extras.killStreak = 0;} - - if (player.lastDamageTime && player.idLastDamagedBy && - Date.now() / 1000 - player.lastDamageTime < 5) { - const killer = this.gameEngine.playerManager.getPlayerById(player.idLastDamagedBy); - if (killer) { - // Redirect spectators of the dead player to the killer - 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); - } - } - - - // Set the dead player to spectate the killer - player.playerSpectating = player.idLastDamagedBy; - 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(killer, '&akilled ' + player.name, 0, 5); - - // Add the dead player to the spectate timeout list - this.spectateTimeouts.set(player, Date.now() / 1000); - this.gameEngine.playerUpdateSinceLastEmit = true; - - this.onPlayerKill(killer); - - - } else { - // Respawn the player if no killer is found - this.gameEngine.playerManager.respawnPlayer(player); - } - } else { - // Respawn the player if no valid killer is found - this.gameEngine.playerManager.respawnPlayer(player); - } - - - } - - onPlayerKill(player:Player){ - const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); - if(extras) { - extras.kills++; - extras.killStreak++; - - let colorCode = '&a'; - if(extras.killStreak >= 5) colorCode = '&b'; - if(extras.killStreak >= 10) colorCode = '&6'; - if(extras.killStreak >= 15) colorCode = '&g'; - - if(extras.killStreak >= 3) - this.gameEngine.setGameMessage(player, colorCode+extras.killStreak+' kill streak', 1, 5); - if(extras.killStreak >= 5) - this.gameEngine.chatManager.broadcastChat(colorCode+player.name+' is on a '+extras.killStreak+' kill streak'); - - - } - } - - - - - onItemPickup(_player: Player): void { - } - - + private spectateTimeouts: Map = new Map(); + constructor(gameEngine: GameEngine) { + super(gameEngine); + this.init(); + } + + init(): void { + console.log('🐙 FFA Gamemode initialized'); + } + + tick(): void { + const currentTime = Date.now() / 1000; + for (const [player, timestamp] of this.spectateTimeouts) { + if (currentTime - timestamp > 10) { + this.gameEngine.playerManager.respawnPlayer(player); + player.playerSpectating = -1; + this.spectateTimeouts.delete(player); + this.gameEngine.setGameMessage(player, '', 0); + } else { + this.gameEngine.setGameMessage( + player, + '&crespawn in ' + Math.floor(10 + timestamp - currentTime) + ' seconds', + 1, + 0.5, + ); + } + player.health = config.player.maxHealth; + } + } + + onPeriodicCleanup(): void { + // 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']; + } + } + + onPlayerConnect(_player: Player): void { + } + + onPlayerDisconnect(_player: Player): void { + } + + onPlayerDeath(player: Player): void { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (extras) { + extras.deaths++; + extras.killStreak = 0; + } + + if ( + player.lastDamageTime && player.idLastDamagedBy && + Date.now() / 1000 - player.lastDamageTime < 5 + ) { + const killer = this.gameEngine.playerManager.getPlayerById(player.idLastDamagedBy); + if (killer) { + // Redirect spectators of the dead player to the killer + 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); + } + } + + // Set the dead player to spectate the killer + player.playerSpectating = player.idLastDamagedBy; + 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(killer, '&akilled ' + player.name, 0, 5); + + // Add the dead player to the spectate timeout list + this.spectateTimeouts.set(player, Date.now() / 1000); + this.gameEngine.playerUpdateSinceLastEmit = true; + + this.onPlayerKill(killer); + } else { + // Respawn the player if no killer is found + this.gameEngine.playerManager.respawnPlayer(player); + } + } else { + // Respawn the player if no valid killer is found + this.gameEngine.playerManager.respawnPlayer(player); + } + } + + onPlayerKill(player: Player) { + const extras = this.gameEngine.playerManager.getPlayerExtrasById(player.id); + if (extras) { + extras.kills++; + extras.killStreak++; + + let colorCode = '&a'; + if (extras.killStreak >= 5) colorCode = '&b'; + if (extras.killStreak >= 10) colorCode = '&6'; + if (extras.killStreak >= 15) colorCode = '&g'; + + if (extras.killStreak >= 3) { + this.gameEngine.setGameMessage(player, colorCode + extras.killStreak + ' kill streak', 1, 5); + } + if (extras.killStreak >= 5) { + this.gameEngine.chatManager.broadcastChat( + colorCode + player.name + ' is on a ' + extras.killStreak + ' kill streak', + ); + } + } + } + + onItemPickup(_player: Player): void { + } } diff --git a/src/server/gamemodes/Gamemode.ts b/src/server/gamemodes/Gamemode.ts index 9df17da8..10326525 100644 --- a/src/server/gamemodes/Gamemode.ts +++ b/src/server/gamemodes/Gamemode.ts @@ -1,24 +1,20 @@ -import {GameEngine} from "../GameEngine.ts"; -import {Player} from "../models/Player.ts"; +import { GameEngine } from '../GameEngine.ts'; +import { Player } from '../models/Player.ts'; export abstract class Gamemode { + constructor(protected gameEngine: GameEngine) {} - constructor(protected gameEngine: GameEngine) {} + abstract init(): void; - abstract init(): void; + abstract tick(): void; - abstract tick(): void; + abstract onPeriodicCleanup(): void; - abstract onPeriodicCleanup(): void; + abstract onPlayerConnect(player: Player): void; - abstract onPlayerConnect(player:Player): void; + abstract onPlayerDisconnect(player: Player): void; - abstract onPlayerDisconnect(player:Player): void; + abstract onPlayerDeath(player: Player): void; - abstract onPlayerDeath(player:Player): void; - - abstract onItemPickup(player:Player): void; - - - -} \ No newline at end of file + abstract onItemPickup(player: Player): void; +} diff --git a/src/server/managers/ChatManager.ts b/src/server/managers/ChatManager.ts index 329cd149..825a80e6 100644 --- a/src/server/managers/ChatManager.ts +++ b/src/server/managers/ChatManager.ts @@ -1,86 +1,87 @@ -import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts"; -import { DataValidator } from "../DataValidator.ts"; +import { Server, Socket } from 'https://deno.land/x/socket_io@0.2.0/mod.ts'; +import { DataValidator } from '../DataValidator.ts'; import { ChatMessage } from '../models/ChatMessage.ts'; -import {PlayerManager} from "./PlayerManager.ts"; - +import { PlayerManager } from './PlayerManager.ts'; export class ChatManager { - constructor(private io: Server, private playerManager:PlayerManager) {} + constructor(private io: Server, private playerManager: PlayerManager) {} - handleChatMessage(data: ChatMessage, socket: Socket) { - const { error } = DataValidator.validateChatMessage(data); - if (error) { - console.warn(`Invalid chat message: ${error.message}`); - return; - } + handleChatMessage(data: ChatMessage, socket: Socket) { + const { error } = DataValidator.validateChatMessage(data); + if (error) { + console.warn(`Invalid chat message: ${error.message}`); + return; + } - const isCommand = this.parseCommand(data.message, socket, data.id); - if (!isCommand) { - if(data.message.startsWith('>')) data.message = '&2'+data.message; - console.log(`💬 ${data.name}: ${data.message}`); - this.io.emit('chatMsg', data); - } - } + const isCommand = this.parseCommand(data.message, socket, data.id); + if (!isCommand) { + if (data.message.startsWith('>')) data.message = '&2' + data.message; + console.log(`💬 ${data.name}: ${data.message}`); + this.io.emit('chatMsg', data); + } + } - private parseCommand(message: string, socket: Socket, playerId: number): boolean { - if (message.charAt(0) !== '/') return false; + private parseCommand(message: string, socket: Socket, playerId: number): boolean { + if (message.charAt(0) !== '/') return false; - const args = message.slice(1).split(' '); - const command = args.shift()?.toLowerCase(); + const args = message.slice(1).split(' '); + const command = args.shift()?.toLowerCase(); - switch (command) { - case 'help': - this.whisperChatMessage(message + ` -> nah i'm good`, socket); - break; - case 'kill':{ - const player = this.playerManager.getPlayerById(playerId); - if(player) - this.playerManager.respawnPlayer(player); - this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name} killed himself`); - break; - } - case 'thumbsup': - this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 👍`); - break; - case 'thumbsdown': - this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 👎`); - break; - case 'octopus': - this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 🐙`); - break; - case 'ping': - this.whisperChatMessage(message + ' -> pong!', socket); - break; - case 'version': - this.whisperChatMessage(message + ` -> candiru ${DataValidator.getServerVersion()}`, socket); - break; - case 'clear': - for(let i = 0; i < 25; i++) - this.whisperChatMessage(' ', socket); - this.whisperChatMessage(message + ' -> cleared chat', socket); - break; - default: - this.whisperChatMessage(message +' -> unknown command', socket); - } + switch (command) { + case 'help': + this.whisperChatMessage(message + ` -> nah i'm good`, socket); + break; + case 'kill': { + const player = this.playerManager.getPlayerById(playerId); + if (player) { + this.playerManager.respawnPlayer(player); + } + this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name} killed himself`); + break; + } + case 'thumbsup': + this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 👍`); + break; + case 'thumbsdown': + this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 👎`); + break; + case 'octopus': + this.broadcastChat(`${this.playerManager.getPlayerById(playerId)?.name}: 🐙`); + break; + case 'ping': + this.whisperChatMessage(message + ' -> pong!', socket); + break; + case 'version': + this.whisperChatMessage(message + ` -> candiru ${DataValidator.getServerVersion()}`, socket); + break; + case 'clear': + for (let i = 0; i < 25; i++) { + this.whisperChatMessage(' ', socket); + } + this.whisperChatMessage(message + ' -> cleared chat', socket); + break; + default: + this.whisperChatMessage(message + ' -> unknown command', socket); + } - return true; - } + return true; + } - broadcastChat(message: string) { - const chatMessage: ChatMessage = { - id: -1, - name: '', - message, - }; - this.io.emit('chatMsg', chatMessage); - } + broadcastChat(message: string) { + const chatMessage: ChatMessage = { + id: -1, + name: '', + message, + }; + this.io.emit('chatMsg', chatMessage); + } - whisperChatMessage(message: string, socket: Socket) { - const chatMessage: ChatMessage = { - id: -1, - name: '', - message, - }; - socket.emit('chatMsg', chatMessage); - } -} \ No newline at end of file + whisperChatMessage(message: string, socket: Socket) { + const chatMessage: ChatMessage = { + id: -1, + name: '', + message, + }; + socket.emit('chatMsg', chatMessage); + } +} diff --git a/src/server/managers/DamageSystem.ts b/src/server/managers/DamageSystem.ts index d079ac1f..4f77d0b0 100644 --- a/src/server/managers/DamageSystem.ts +++ b/src/server/managers/DamageSystem.ts @@ -1,68 +1,68 @@ -import { PlayerManager } from "./PlayerManager.ts"; -import { ChatManager } from "./ChatManager.ts"; -import { DamageRequest } from "../models/DamageRequest.ts"; -import { DataValidator } from "../DataValidator.ts"; -import { Vector3 } from "../models/Vector3.ts"; -import {GameEngine} from "../GameEngine.ts"; +import { PlayerManager } from './PlayerManager.ts'; +import { ChatManager } from './ChatManager.ts'; +import { DamageRequest } from '../models/DamageRequest.ts'; +import { DataValidator } from '../DataValidator.ts'; +import { Vector3 } from '../models/Vector3.ts'; +import { GameEngine } from '../GameEngine.ts'; export class DamageSystem { - constructor( - private playerManager: PlayerManager, - private chatManager: ChatManager, - ) {} - private gameEngine!:GameEngine; - public setGameEngine(gameEngine:GameEngine){ - this.gameEngine = gameEngine; - } + constructor( + private playerManager: PlayerManager, + private chatManager: ChatManager, + ) {} + private gameEngine!: GameEngine; + public setGameEngine(gameEngine: GameEngine) { + this.gameEngine = gameEngine; + } - handleDamageRequest(data: DamageRequest) { - const validationResult = DataValidator.validateDamageRequest(data); - if (!validationResult.success) { - console.warn(`Invalid damage request: ${validationResult.error?.message}`); - return; - } + handleDamageRequest(data: DamageRequest) { + const validationResult = DataValidator.validateDamageRequest(data); + if (!validationResult.success) { + console.warn(`Invalid damage request: ${validationResult.error?.message}`); + return; + } - const targetPlayer = this.playerManager.getPlayerById(data.targetPlayer.id); - const localPlayer = this.playerManager.getPlayerById(data.localPlayer.id); + const targetPlayer = this.playerManager.getPlayerById(data.targetPlayer.id); + const localPlayer = this.playerManager.getPlayerById(data.localPlayer.id); - if (!targetPlayer || !localPlayer) { - console.warn('Target or local player not found.'); - return; - } + if (!targetPlayer || !localPlayer) { + console.warn('Target or local player not found.'); + return; + } - // Validate positions - const localPlayerSentPosition = data.localPlayer.position; - const localPlayerServerPosition = localPlayer.position; - const localDistance = Vector3.distanceTo(localPlayerSentPosition, localPlayerServerPosition); + // Validate positions + const localPlayerSentPosition = data.localPlayer.position; + const localPlayerServerPosition = localPlayer.position; + const localDistance = Vector3.distanceTo(localPlayerSentPosition, localPlayerServerPosition); - const targetPlayerSentPosition = data.targetPlayer.position; - const targetPlayerServerPosition = targetPlayer.position; - const targetDistance = Vector3.distanceTo(targetPlayerSentPosition, targetPlayerServerPosition); + const targetPlayerSentPosition = data.targetPlayer.position; + const targetPlayerServerPosition = targetPlayer.position; + const targetDistance = Vector3.distanceTo(targetPlayerSentPosition, targetPlayerServerPosition); - const MAX_DESYNC_DISTANCE = 1; // Threshold for considering positions in sync + const MAX_DESYNC_DISTANCE = 1; // Threshold for considering positions in sync - if (localDistance > MAX_DESYNC_DISTANCE || targetDistance > MAX_DESYNC_DISTANCE) { - //console.warn(`⚠️ Client out of sync - localDistance: ${localDistance}, targetDistance: ${targetDistance}`); - // Optionally, send a message back to the client - // this.chatManager.whisperChatMessage('⚠️ Shot not registered (client out of sync)', localPlayer.socket); - return; - } + if (localDistance > MAX_DESYNC_DISTANCE || targetDistance > MAX_DESYNC_DISTANCE) { + //console.warn(`⚠️ Client out of sync - localDistance: ${localDistance}, targetDistance: ${targetDistance}`); + // Optionally, send a message back to the client + // this.chatManager.whisperChatMessage('⚠️ Shot not registered (client out of sync)', localPlayer.socket); + return; + } - // Apply damage - targetPlayer.health -= data.damage; - targetPlayer.lastDamageTime = Date.now() / 1000; - targetPlayer.idLastDamagedBy = localPlayer.id; + // Apply damage + targetPlayer.health -= data.damage; + targetPlayer.lastDamageTime = Date.now() / 1000; + targetPlayer.idLastDamagedBy = localPlayer.id; - if (targetPlayer.health <= 0) { - const killerName = localPlayer.name; - const killedName = targetPlayer.name; - //this.chatManager.broadcastChat(`${killerName} &fkilled ${killedName}`); - console.log(`💔 ${killerName} killed ${killedName}`); - //this.playerManager.respawnPlayer(targetPlayer); - this.gameEngine.periodicCleanup(); - } + if (targetPlayer.health <= 0) { + const killerName = localPlayer.name; + const killedName = targetPlayer.name; + //this.chatManager.broadcastChat(`${killerName} &fkilled ${killedName}`); + console.log(`💔 ${killerName} killed ${killedName}`); + //this.playerManager.respawnPlayer(targetPlayer); + this.gameEngine.periodicCleanup(); + } - // Update player data - this.playerManager.addOrUpdatePlayer(targetPlayer); - } -} \ No newline at end of file + // Update player data + this.playerManager.addOrUpdatePlayer(targetPlayer); + } +} diff --git a/src/server/managers/GameMsgManager.ts b/src/server/managers/GameMsgManager.ts index 96ab7fa3..7c8dd390 100644 --- a/src/server/managers/GameMsgManager.ts +++ b/src/server/managers/GameMsgManager.ts @@ -1,4 +1,4 @@ export class GameMsgManager { - constructor() { - } -} \ No newline at end of file + constructor() { + } +} diff --git a/src/server/managers/ItemManager.ts b/src/server/managers/ItemManager.ts index 3b75d9bb..78fcd2a2 100644 --- a/src/server/managers/ItemManager.ts +++ b/src/server/managers/ItemManager.ts @@ -1,124 +1,122 @@ import { WorldItem } from '../models/WorldItem.ts'; import { MapData } from '../models/MapData.ts'; import { Vector3 } from '../models/Vector3.ts'; -import config from "../config.ts"; -import {PlayerManager} from "./PlayerManager.ts"; -import {ChatManager} from "./ChatManager.ts"; -import {Gamemode} from "../gamemodes/Gamemode.ts"; - +import config from '../config.ts'; +import { PlayerManager } from './PlayerManager.ts'; +import { ChatManager } from './ChatManager.ts'; +import { Gamemode } from '../gamemodes/Gamemode.ts'; export class ItemManager { - private worldItems: WorldItem[] = []; - private lastItemCreationTimestamp: number = Date.now() / 1000; - private itemUpdateFlag: boolean = false; - private gamemode: Gamemode | false = false; - - constructor(private mapData: MapData, public playerManager:PlayerManager, private chatManager:ChatManager) {} - - public setGamemode(gamemode: Gamemode | false) {this.gamemode = gamemode;} - - tick(currentTime: number) { - this.checkForPickups(); - if (currentTime - this.lastItemCreationTimestamp > config.items.respawnTime) { - this.createItem(); - this.lastItemCreationTimestamp = currentTime; - } - // Additional item-related logic can be added here - } - - createItem() { - if (!this.mapData) return; - - const randomIndex = Math.floor(Math.random() * this.mapData.itemRespawnPoints.length); - const respawnPoint = this.mapData.itemRespawnPoints[randomIndex]; - const newItem = new WorldItem( - new Vector3(respawnPoint.position.x, respawnPoint.position.y, respawnPoint.position.z), - respawnPoint.itemId - ); - - if (this.isItemCloseToPoint(newItem.vector, 1)) return; // Another item is too close - if (this.worldItems.length >= config.items.maxItemsInWorld) return; // Max items reached - - this.worldItems.push(newItem); - this.itemUpdateFlag = true; - } - - pushItem(item: WorldItem) { - this.worldItems.push(item); - this.itemUpdateFlag = true; - } - - 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 => - Vector3.distanceTo(player.position, 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); - 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; - } - } - } - - - isItemCloseToPoint(vector: Vector3, distance: number): boolean { - return this.worldItems.some(item => Vector3.distanceTo(item.vector,vector) < distance); - } - - getAllItems(): WorldItem[] { - return this.worldItems; - } - - removeItem(itemId: number) { - this.worldItems = this.worldItems.filter(item => item.id !== itemId); - this.itemUpdateFlag = true; - } - - hasUpdates(): boolean { - if (this.itemUpdateFlag) { - this.itemUpdateFlag = false; - return true; - } - return false; - } - - triggerUpdateFlag(){ - this.itemUpdateFlag = true; - } -} \ No newline at end of file + private worldItems: WorldItem[] = []; + private lastItemCreationTimestamp: number = Date.now() / 1000; + private itemUpdateFlag: boolean = false; + private gamemode: Gamemode | false = false; + + constructor(private mapData: MapData, public playerManager: PlayerManager, private chatManager: ChatManager) {} + + public setGamemode(gamemode: Gamemode | false) { + this.gamemode = gamemode; + } + + tick(currentTime: number) { + this.checkForPickups(); + if (currentTime - this.lastItemCreationTimestamp > config.items.respawnTime) { + this.createItem(); + this.lastItemCreationTimestamp = currentTime; + } + // Additional item-related logic can be added here + } + + createItem() { + if (!this.mapData) return; + + const randomIndex = Math.floor(Math.random() * this.mapData.itemRespawnPoints.length); + const respawnPoint = this.mapData.itemRespawnPoints[randomIndex]; + const newItem = new WorldItem( + new Vector3(respawnPoint.position.x, respawnPoint.position.y, respawnPoint.position.z), + respawnPoint.itemId, + ); + + if (this.isItemCloseToPoint(newItem.vector, 1)) return; // Another item is too close + if (this.worldItems.length >= config.items.maxItemsInWorld) return; // Max items reached + + this.worldItems.push(newItem); + this.itemUpdateFlag = true; + } + + pushItem(item: WorldItem) { + this.worldItems.push(item); + this.itemUpdateFlag = true; + } + + 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) => Vector3.distanceTo(player.position, 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); + 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; + } + } + } + + isItemCloseToPoint(vector: Vector3, distance: number): boolean { + return this.worldItems.some((item) => Vector3.distanceTo(item.vector, vector) < distance); + } + + getAllItems(): WorldItem[] { + return this.worldItems; + } + + removeItem(itemId: number) { + this.worldItems = this.worldItems.filter((item) => item.id !== itemId); + this.itemUpdateFlag = true; + } + + hasUpdates(): boolean { + if (this.itemUpdateFlag) { + this.itemUpdateFlag = false; + return true; + } + return false; + } + + triggerUpdateFlag() { + this.itemUpdateFlag = true; + } +} diff --git a/src/server/managers/PlayerManager.ts b/src/server/managers/PlayerManager.ts index 5e041515..86b4fa37 100644 --- a/src/server/managers/PlayerManager.ts +++ b/src/server/managers/PlayerManager.ts @@ -1,162 +1,171 @@ -import { Player } from "../models/Player.ts"; +import { Player } from '../models/Player.ts'; import { Vector3 } from '../models/Vector3.ts'; import { Quaternion } from '../models/Quaternion.ts'; import { DataValidator } from '../DataValidator.ts'; -import { MapData } from "../models/MapData.ts"; -import config from "../config.ts"; -import { WorldItem } from "../models/WorldItem.ts"; -import { ItemManager } from "./ItemManager.ts"; -import {PlayerExtras} from "../models/PlayerExtras.ts"; +import { MapData } from '../models/MapData.ts'; +import config from '../config.ts'; +import { WorldItem } from '../models/WorldItem.ts'; +import { ItemManager } from './ItemManager.ts'; +import { PlayerExtras } from '../models/PlayerExtras.ts'; interface PlayerData { - player: Player; - extras: PlayerExtras; + player: Player; + extras: PlayerExtras; } export class PlayerManager { - private players: Map = new Map(); - private mapData: MapData; - private itemManager!: ItemManager; - - constructor(mapData: MapData) { - this.mapData = mapData; - } - - setItemManager(itemManager: ItemManager) { - this.itemManager = itemManager - } - - addOrUpdatePlayer(data: Player): { isNew: boolean; player?: Player } { - const { error } = DataValidator.validatePlayerData(data); - if (error) { - throw new Error(`⚠️ invalid player data `); - } - - const existingPlayerData = this.players.get(data.id); - if (data.name.length < 1) data.name = 'possum' + data.id.toString().substring(0,3); - if(data.chatMsg.startsWith('/admin ')) data.chatMsg = '/admin ' + data.chatMsg.substring(7).replace(/./g, '*'); - if(data.chatMsg.startsWith('>')) data.chatMsg = '&2'+data.chatMsg; - if(!data.chatMsg.startsWith('&f')) data.chatMsg = '&f'+data.chatMsg; - - if (existingPlayerData) { - // Handle forced acknowledgment - if (existingPlayerData.player.forced && !data.forcedAcknowledged) { - return { isNew: false }; - } - if (existingPlayerData.player.forced && data.forcedAcknowledged) { - existingPlayerData.player.forced = false; - } - - // Update existing player, preserving certain fields - data.health = existingPlayerData.player.health; - data.inventory = existingPlayerData.player.inventory; - data.lastDamageTime = existingPlayerData.player.lastDamageTime; - data.gameMsgs = existingPlayerData.player.gameMsgs; - data.gameMsgs2 = existingPlayerData.player.gameMsgs2; - data.playerSpectating = existingPlayerData.player.playerSpectating; - data.updateTimestamp = Date.now() / 1000; - - const updatedData: PlayerData = { - player: data, - extras: existingPlayerData.extras - }; - this.players.set(data.id, updatedData); - return { isNew: false }; - } else { - // New player - data.inventory = [...config.player.baseInventory]; - const spawnPoint = this.getRandomSpawnPoint(); - data.position = spawnPoint.vec; - data.health = config.player.maxHealth; - data.gameMsgs = []; - data.gameMsgs2 = []; - data.playerSpectating = -1; - data.lookQuaternion = [spawnPoint.quaternion.x, spawnPoint.quaternion.y, spawnPoint.quaternion.z, spawnPoint.quaternion.w]; - data.forced = true; - - const newPlayerData: PlayerData = { - player: data, - extras: new PlayerExtras() - }; - this.players.set(data.id, newPlayerData); - this.itemManager.triggerUpdateFlag(); - - return { isNew: true, player: data }; - } - } - - removePlayer(playerId: number) { - this.players.delete(playerId); - } - - getAllPlayers(): Player[] { - return Array.from(this.players.values()).map(playerData => playerData.player); - } - - getPlayerById(playerId: number): Player | undefined { - const playerData = this.players.get(playerId); - return playerData?.player; - } - - getPlayerDataById(playerId: number): PlayerData | undefined { - return this.players.get(playerId); - } - - getPlayerExtrasById(playerId: number): PlayerExtras | undefined { - const playerData = this.players.get(playerId); - return playerData?.extras; - } - - getAllPlayerData(): PlayerData[] { - return Array.from(this.players.values()); - } - - public dropAllItems(player:Player){ - for(let i = 0; i < player.inventory.length; i++){ - this.itemManager.pushItem(new WorldItem(player.position, player.inventory[i])); - } - player.inventory = []; - - } - - respawnPlayer(player: Player) { - const playerData = this.players.get(player.id); - if (!playerData) return; - - const spawnPoint = this.getRandomSpawnPoint(); - player.position = spawnPoint.vec; - player.lookQuaternion = [spawnPoint.quaternion.x, spawnPoint.quaternion.y, spawnPoint.quaternion.z, spawnPoint.quaternion.w]; - player.health = config.player.maxHealth; - player.gravity = 0; - player.velocity = new Vector3(0, 0, 0); - player.forced = true; - - const updatedPlayerData: PlayerData = { - player: player, - extras: playerData.extras - }; - this.players.set(player.id, updatedPlayerData); - } - - regenerateHealth() { - const currentTime = Date.now() / 1000; - for (const playerData of this.players.values()) { - const player = playerData.player; - const lastDamage = player.lastDamageTime ?? 0; - if (player.health < config.player.maxHealth && (lastDamage + config.health.regenDelay < currentTime)) { - player.health += config.health.regenRate / config.server.tickRate; - if (player.health > config.player.maxHealth) player.health = config.player.maxHealth; - } - } - } - - private getRandomSpawnPoint(): { vec: Vector3; quaternion: Quaternion } { - if (!this.mapData) { - return { vec: new Vector3(2, 1, 0), quaternion: new Quaternion(0, 0, 0, 1) }; - } - - const randomIndex = Math.floor(Math.random() * this.mapData.respawnPoints.length); - const respawnPoint = this.mapData.respawnPoints[randomIndex]; - return { vec: respawnPoint.position, quaternion: respawnPoint.quaternion }; - } + private players: Map = new Map(); + private mapData: MapData; + private itemManager!: ItemManager; + + constructor(mapData: MapData) { + this.mapData = mapData; + } + + setItemManager(itemManager: ItemManager) { + this.itemManager = itemManager; + } + + addOrUpdatePlayer(data: Player): { isNew: boolean; player?: Player } { + const { error } = DataValidator.validatePlayerData(data); + if (error) { + throw new Error(`⚠️ invalid player data `); + } + + const existingPlayerData = this.players.get(data.id); + if (data.name.length < 1) data.name = 'possum' + data.id.toString().substring(0, 3); + if (data.chatMsg.startsWith('/admin ')) data.chatMsg = '/admin ' + data.chatMsg.substring(7).replace(/./g, '*'); + if (data.chatMsg.startsWith('>')) data.chatMsg = '&2' + data.chatMsg; + if (!data.chatMsg.startsWith('&f')) data.chatMsg = '&f' + data.chatMsg; + + if (existingPlayerData) { + // Handle forced acknowledgment + if (existingPlayerData.player.forced && !data.forcedAcknowledged) { + return { isNew: false }; + } + if (existingPlayerData.player.forced && data.forcedAcknowledged) { + existingPlayerData.player.forced = false; + } + + // Update existing player, preserving certain fields + data.health = existingPlayerData.player.health; + data.inventory = existingPlayerData.player.inventory; + data.lastDamageTime = existingPlayerData.player.lastDamageTime; + data.gameMsgs = existingPlayerData.player.gameMsgs; + data.gameMsgs2 = existingPlayerData.player.gameMsgs2; + data.playerSpectating = existingPlayerData.player.playerSpectating; + data.updateTimestamp = Date.now() / 1000; + + const updatedData: PlayerData = { + player: data, + extras: existingPlayerData.extras, + }; + this.players.set(data.id, updatedData); + return { isNew: false }; + } else { + // New player + data.inventory = [...config.player.baseInventory]; + const spawnPoint = this.getRandomSpawnPoint(); + data.position = spawnPoint.vec; + data.health = config.player.maxHealth; + data.gameMsgs = []; + data.gameMsgs2 = []; + data.playerSpectating = -1; + data.lookQuaternion = [ + spawnPoint.quaternion.x, + spawnPoint.quaternion.y, + spawnPoint.quaternion.z, + spawnPoint.quaternion.w, + ]; + data.forced = true; + + const newPlayerData: PlayerData = { + player: data, + extras: new PlayerExtras(), + }; + this.players.set(data.id, newPlayerData); + this.itemManager.triggerUpdateFlag(); + + return { isNew: true, player: data }; + } + } + + removePlayer(playerId: number) { + this.players.delete(playerId); + } + + getAllPlayers(): Player[] { + return Array.from(this.players.values()).map((playerData) => playerData.player); + } + + getPlayerById(playerId: number): Player | undefined { + const playerData = this.players.get(playerId); + return playerData?.player; + } + + getPlayerDataById(playerId: number): PlayerData | undefined { + return this.players.get(playerId); + } + + getPlayerExtrasById(playerId: number): PlayerExtras | undefined { + const playerData = this.players.get(playerId); + return playerData?.extras; + } + + getAllPlayerData(): PlayerData[] { + return Array.from(this.players.values()); + } + + public dropAllItems(player: Player) { + for (let i = 0; i < player.inventory.length; i++) { + this.itemManager.pushItem(new WorldItem(player.position, player.inventory[i])); + } + player.inventory = []; + } + + respawnPlayer(player: Player) { + const playerData = this.players.get(player.id); + if (!playerData) return; + + const spawnPoint = this.getRandomSpawnPoint(); + player.position = spawnPoint.vec; + player.lookQuaternion = [ + spawnPoint.quaternion.x, + spawnPoint.quaternion.y, + spawnPoint.quaternion.z, + spawnPoint.quaternion.w, + ]; + player.health = config.player.maxHealth; + player.gravity = 0; + player.velocity = new Vector3(0, 0, 0); + player.forced = true; + + const updatedPlayerData: PlayerData = { + player: player, + extras: playerData.extras, + }; + this.players.set(player.id, updatedPlayerData); + } + + regenerateHealth() { + const currentTime = Date.now() / 1000; + for (const playerData of this.players.values()) { + const player = playerData.player; + const lastDamage = player.lastDamageTime ?? 0; + if (player.health < config.player.maxHealth && (lastDamage + config.health.regenDelay < currentTime)) { + player.health += config.health.regenRate / config.server.tickRate; + if (player.health > config.player.maxHealth) player.health = config.player.maxHealth; + } + } + } + + private getRandomSpawnPoint(): { vec: Vector3; quaternion: Quaternion } { + if (!this.mapData) { + return { vec: new Vector3(2, 1, 0), quaternion: new Quaternion(0, 0, 0, 1) }; + } + + const randomIndex = Math.floor(Math.random() * this.mapData.respawnPoints.length); + const respawnPoint = this.mapData.respawnPoints[randomIndex]; + return { vec: respawnPoint.position, quaternion: respawnPoint.quaternion }; + } } diff --git a/src/server/models/ChatMessage.ts b/src/server/models/ChatMessage.ts index c3e0ede1..f2fe9379 100644 --- a/src/server/models/ChatMessage.ts +++ b/src/server/models/ChatMessage.ts @@ -1,5 +1,5 @@ export interface ChatMessage { - id: number; - name: string; - message: string; -} \ No newline at end of file + id: number; + name: string; + message: string; +} diff --git a/src/server/models/DamageRequest.ts b/src/server/models/DamageRequest.ts index 40b94aa1..421b991f 100644 --- a/src/server/models/DamageRequest.ts +++ b/src/server/models/DamageRequest.ts @@ -1,7 +1,7 @@ -import { Player } from "./Player.ts"; +import { Player } from './Player.ts'; export interface DamageRequest { - localPlayer: Player; - targetPlayer: Player; - damage: number; -} \ No newline at end of file + localPlayer: Player; + targetPlayer: Player; + damage: number; +} diff --git a/src/server/models/ItemRespawnPoint.ts b/src/server/models/ItemRespawnPoint.ts index d77b7c53..811fc3c2 100644 --- a/src/server/models/ItemRespawnPoint.ts +++ b/src/server/models/ItemRespawnPoint.ts @@ -1,9 +1,9 @@ import { Vector3 } from './Vector3.ts'; export class ItemRespawnPoint { - constructor( - public position: Vector3, - public itemId: number, - public spawnChancePerTick: number - ) {} -} \ No newline at end of file + constructor( + public position: Vector3, + public itemId: number, + public spawnChancePerTick: number, + ) {} +} diff --git a/src/server/models/MapData.ts b/src/server/models/MapData.ts index 5ea4bb83..ed2b665c 100644 --- a/src/server/models/MapData.ts +++ b/src/server/models/MapData.ts @@ -1,33 +1,35 @@ import { RespawnPoint } from './RespawnPoint.ts'; import { ItemRespawnPoint } from './ItemRespawnPoint.ts'; -import { Vector3 } from "./Vector3.ts"; -import { Quaternion } from "./Quaternion.ts"; +import { Vector3 } from './Vector3.ts'; +import { Quaternion } from './Quaternion.ts'; export class MapData { - constructor( - public name: string, - public respawnPoints: RespawnPoint[], - public itemRespawnPoints: ItemRespawnPoint[] - ) {} + constructor( + public name: string, + public respawnPoints: RespawnPoint[], + public itemRespawnPoints: ItemRespawnPoint[], + ) {} - static fromJSON(json: { respawnPoints: RespawnPoint[]; itemRespawnPoints: ItemRespawnPoint[]; name: string; }): MapData { - const respawnPoints = json.respawnPoints.map( - (rp) => - new RespawnPoint( - new Vector3(rp.position.x, rp.position.y, rp.position.z), - new Quaternion(rp.quaternion.x, rp.quaternion.y, rp.quaternion.z, rp.quaternion.w) - ) - ); + static fromJSON( + json: { respawnPoints: RespawnPoint[]; itemRespawnPoints: ItemRespawnPoint[]; name: string }, + ): MapData { + const respawnPoints = json.respawnPoints.map( + (rp) => + new RespawnPoint( + new Vector3(rp.position.x, rp.position.y, rp.position.z), + new Quaternion(rp.quaternion.x, rp.quaternion.y, rp.quaternion.z, rp.quaternion.w), + ), + ); - const itemRespawnPoints = json.itemRespawnPoints.map( - (irp) => - new ItemRespawnPoint( - new Vector3(irp.position.x, irp.position.y, irp.position.z), - irp.itemId, - irp.spawnChancePerTick - ) - ); + const itemRespawnPoints = json.itemRespawnPoints.map( + (irp) => + new ItemRespawnPoint( + new Vector3(irp.position.x, irp.position.y, irp.position.z), + irp.itemId, + irp.spawnChancePerTick, + ), + ); - return new MapData(json.name, respawnPoints, itemRespawnPoints); - } -} \ No newline at end of file + return new MapData(json.name, respawnPoints, itemRespawnPoints); + } +} diff --git a/src/server/models/Player.ts b/src/server/models/Player.ts index 3f63639f..de4ab1f3 100644 --- a/src/server/models/Player.ts +++ b/src/server/models/Player.ts @@ -1,29 +1,28 @@ -import { Vector3 } from "./Vector3.ts"; +import { Vector3 } from './Vector3.ts'; export interface Player { - id: number; - speed: number; - acceleration: number; - name: string; - gameVersion: string; - position: Vector3; - velocity: Vector3; - inputVelocity: Vector3; - gravity: number; - lookQuaternion: number[]; - quaternion: number[]; - chatActive: boolean; - chatMsg: string; - latency: number; - health: number; - forced: boolean; - forcedAcknowledged: boolean; - updateTimestamp?: number; - lastDamageTime?: number; - inventory: number[]; - idLastDamagedBy?: number; - playerSpectating:number; - gameMsgs:string[]; - gameMsgs2:string[]; - -} \ No newline at end of file + id: number; + speed: number; + acceleration: number; + name: string; + gameVersion: string; + position: Vector3; + velocity: Vector3; + inputVelocity: Vector3; + gravity: number; + lookQuaternion: number[]; + quaternion: number[]; + chatActive: boolean; + chatMsg: string; + latency: number; + health: number; + forced: boolean; + forcedAcknowledged: boolean; + updateTimestamp?: number; + lastDamageTime?: number; + inventory: number[]; + idLastDamagedBy?: number; + playerSpectating: number; + gameMsgs: string[]; + gameMsgs2: string[]; +} diff --git a/src/server/models/PlayerExtras.ts b/src/server/models/PlayerExtras.ts index 610cf4e6..f16eff18 100644 --- a/src/server/models/PlayerExtras.ts +++ b/src/server/models/PlayerExtras.ts @@ -1,8 +1,7 @@ export class PlayerExtras { -gameMsgsTimeouts: number[] = []; -kills: number = 0; -deaths: number = 0; -killStreak: number = 0; -points: number = 0; - -} \ No newline at end of file + gameMsgsTimeouts: number[] = []; + kills: number = 0; + deaths: number = 0; + killStreak: number = 0; + points: number = 0; +} diff --git a/src/server/models/Quaternion.ts b/src/server/models/Quaternion.ts index 97216d34..dd755166 100644 --- a/src/server/models/Quaternion.ts +++ b/src/server/models/Quaternion.ts @@ -1,3 +1,3 @@ export class Quaternion { - constructor(public x: number, public y: number, public z: number, public w: number) {} -} \ No newline at end of file + constructor(public x: number, public y: number, public z: number, public w: number) {} +} diff --git a/src/server/models/RespawnPoint.ts b/src/server/models/RespawnPoint.ts index e96e5e6e..c0ad5eae 100644 --- a/src/server/models/RespawnPoint.ts +++ b/src/server/models/RespawnPoint.ts @@ -2,5 +2,5 @@ import { Vector3 } from './Vector3.ts'; import { Quaternion } from './Quaternion.ts'; export class RespawnPoint { - constructor(public position: Vector3, public quaternion: Quaternion) {} -} \ No newline at end of file + constructor(public position: Vector3, public quaternion: Quaternion) {} +} diff --git a/src/server/models/ServerInfo.ts b/src/server/models/ServerInfo.ts index a628942d..6dc1e906 100644 --- a/src/server/models/ServerInfo.ts +++ b/src/server/models/ServerInfo.ts @@ -1,22 +1,22 @@ -import config from "../config.ts"; +import config from '../config.ts'; export class ServerInfo { - public name: string; - public maxPlayers: number; - public currentPlayers: number; - public mapName: string; - public tickRate: number; - public version: string; - public gameMode: string; - public playerMaxHealth: number; - 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; - } -} \ No newline at end of file + public name: string; + public maxPlayers: number; + public currentPlayers: number; + public mapName: string; + public tickRate: number; + public version: string; + public gameMode: string; + public playerMaxHealth: number; + 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; + } +} diff --git a/src/server/models/Vector3.ts b/src/server/models/Vector3.ts index a8f44c1c..253af654 100644 --- a/src/server/models/Vector3.ts +++ b/src/server/models/Vector3.ts @@ -1,18 +1,18 @@ export class Vector3 { - constructor(public x: number, public y: number, public z: number) {} - public distanceTo(other: Vector3): number { - return Math.sqrt( - Math.pow(this.x - other.x, 2) + - Math.pow(this.y - other.y, 2) + - Math.pow(this.z - other.z, 2) - ); - } + constructor(public x: number, public y: number, public z: number) {} + public distanceTo(other: Vector3): number { + return Math.sqrt( + Math.pow(this.x - other.x, 2) + + Math.pow(this.y - other.y, 2) + + Math.pow(this.z - other.z, 2), + ); + } - public static distanceTo(v1: Vector3, v2: Vector3): number { - return Math.sqrt( - Math.pow(v1.x - v2.x, 2) + - Math.pow(v1.y - v2.y, 2) + - Math.pow(v1.z - v2.z, 2) - ); - } -} \ No newline at end of file + public static distanceTo(v1: Vector3, v2: Vector3): number { + return Math.sqrt( + Math.pow(v1.x - v2.x, 2) + + Math.pow(v1.y - v2.y, 2) + + Math.pow(v1.z - v2.z, 2), + ); + } +} diff --git a/src/server/models/WorldItem.ts b/src/server/models/WorldItem.ts index 629e6b32..43c3bf32 100644 --- a/src/server/models/WorldItem.ts +++ b/src/server/models/WorldItem.ts @@ -1,9 +1,9 @@ -import { Vector3 } from "./Vector3.ts"; +import { Vector3 } from './Vector3.ts'; export class WorldItem { - public id: number; + public id: number; - constructor(public vector: Vector3, public itemType: number) { - this.id = Math.floor(Math.random() * 100000) + 1; - } -} \ No newline at end of file + constructor(public vector: Vector3, public itemType: number) { + this.id = Math.floor(Math.random() * 100000) + 1; + } +} diff --git a/src/styles.css b/src/styles.css index 3852c971..6d69c386 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,85 +5,85 @@ /* You can add global styles to this file, and also import other style files */ :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } a:hover { - color: #535bf2; + color: #535bf2; } body { - margin: 0; - /*display: flex;*/ - /*place-items: center;*/ - min-width: 320px; - min-height: 100vh; + margin: 0; + /*display: flex;*/ + /*place-items: center;*/ + min-width: 320px; + min-height: 100vh; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #646cff; } button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } .card { - padding: 2em; + padding: 2em; } .logo { - @apply box-content; + @apply box-content; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } + :root { + color: #213547; + background-color: #ffffff; + } - a:hover { - color: #747bff; - } + a:hover { + color: #747bff; + } - button { - background-color: #f9f9f9; - } + button { + background-color: #f9f9f9; + } }