Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Primitive Punch-Up!</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <style> | |
| /* Basic styling for the body to remove default margins and overflows */ | |
| body { | |
| margin: 0; | |
| overflow: hidden; /* Hide scrollbars */ | |
| font-family: 'Inter', sans-serif; /* Use Inter font */ | |
| background-color: #1a1a1a; /* Dark background for the page */ | |
| } | |
| /* Container for the game canvas and UI elements */ | |
| #game-container { | |
| position: relative; | |
| width: 100vw; /* Full viewport width */ | |
| height: 100vh; /* Full viewport height */ | |
| display: flex; /* Use flexbox for centering */ | |
| justify-content: center; /* Center horizontally */ | |
| align-items: center; /* Center vertically */ | |
| overflow: hidden; /* Ensure no overflow */ | |
| } | |
| /* Styling for the score displays */ | |
| #score-left, #score-right { | |
| position: absolute; | |
| top: 20px; /* Distance from the top */ | |
| color: white; /* White text color */ | |
| font-size: 2.2em; /* Larger font size */ | |
| font-weight: bold; /* Bold text */ | |
| text-shadow: 3px 3px 6px rgba(0,0,0,0.8); /* Stronger text shadow for readability */ | |
| z-index: 10; /* Ensure it's above the canvas */ | |
| padding: 10px 15px; /* Padding around text */ | |
| background-color: rgba(0, 0, 0, 0.4); /* Semi-transparent background */ | |
| border-radius: 12px; /* Rounded corners */ | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.5); /* Subtle box shadow */ | |
| } | |
| #score-left { | |
| left: 30px; /* Position on the left */ | |
| } | |
| #score-right { | |
| right: 30px; /* Position on the right */ | |
| } | |
| /* Styling for the health bars */ | |
| #health-bar-left, #health-bar-right { | |
| position: absolute; | |
| top: 90px; /* Position below scores */ | |
| height: 35px; /* Height of the health bar */ | |
| background-color: green; /* Default healthy color */ | |
| border: 3px solid #ffffff; /* White border */ | |
| border-radius: 10px; /* Rounded corners */ | |
| transition: width 0.3s ease-out, background-color 0.3s ease-out; /* Smooth transitions for width and color */ | |
| z-index: 10; /* Ensure it's above the canvas */ | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.5); /* Subtle box shadow */ | |
| } | |
| #health-bar-left { | |
| left: 50%; /* Start from the center */ | |
| transform: translateX(-105%); /* Shift left to align to the left of center */ | |
| width: 250px; /* Max width for health bar */ | |
| max-width: 250px; /* Ensure it doesn't exceed this width */ | |
| } | |
| #health-bar-right { | |
| right: 50%; /* Start from the center */ | |
| transform: translateX(5%); /* Shift right to align to the right of center */ | |
| width: 250px; /* Max width for health bar */ | |
| max-width: 250px; /* Ensure it doesn't exceed this width */ | |
| } | |
| /* Styling for the reset button */ | |
| #reset-button { | |
| position: absolute; | |
| bottom: 40px; /* Distance from the bottom */ | |
| left: 50%; /* Center horizontally */ | |
| transform: translateX(-50%); /* Adjust for true centering */ | |
| padding: 18px 35px; /* Generous padding */ | |
| font-size: 1.8em; /* Larger font size */ | |
| background: linear-gradient(145deg, #ff6b6b, #ee4444); /* Gradient background */ | |
| color: white; /* White text */ | |
| border: none; /* No border */ | |
| border-radius: 15px; /* More rounded corners */ | |
| cursor: pointer; /* Pointer cursor on hover */ | |
| box-shadow: 0 8px 20px rgba(0,0,0,0.6); /* Stronger shadow */ | |
| transition: background 0.3s ease, transform 0.1s ease, box-shadow 0.3s ease; /* Smooth transitions */ | |
| z-index: 10; /* Ensure it's above the canvas */ | |
| font-weight: bold; /* Bold text */ | |
| letter-spacing: 1px; /* Slight letter spacing */ | |
| text-transform: uppercase; /* Uppercase text */ | |
| } | |
| #reset-button:hover { | |
| background: linear-gradient(145deg, #ff4d4d, #cc3333); /* Darker gradient on hover */ | |
| transform: translateX(-50%) scale(1.05); /* Slightly enlarge on hover */ | |
| box-shadow: 0 10px 25px rgba(0,0,0,0.8); /* Deeper shadow on hover */ | |
| } | |
| #reset-button:active { | |
| transform: translateX(-50%) scale(0.98); /* Shrink slightly on click */ | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.4); /* Recessed shadow on click */ | |
| } | |
| /* Styling for the Three.js canvas */ | |
| canvas { | |
| display: block; /* Remove extra space below canvas */ | |
| width: 100%; /* Make canvas fill its container */ | |
| height: 100%; /* Make canvas fill its container */ | |
| border-radius: 15px; /* Rounded corners for the canvas itself */ | |
| box-shadow: 0 0 25px rgba(0,0,0,0.7); /* Shadow around the canvas */ | |
| } | |
| /* Controls display */ | |
| #controls { | |
| position: absolute; | |
| bottom: 120px; /* Position above the reset button */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(0, 0, 0, 0.6); | |
| color: white; | |
| padding: 15px 25px; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.7); | |
| font-size: 1.1em; | |
| text-align: center; | |
| z-index: 10; | |
| display: flex; | |
| gap: 40px; /* Space between player control sections */ | |
| } | |
| #controls h3 { | |
| margin-top: 0; | |
| color: #ffd700; /* Gold color for headings */ | |
| font-size: 1.3em; | |
| margin-bottom: 10px; | |
| } | |
| #controls ul { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| text-align: left; | |
| } | |
| #controls li { | |
| margin-bottom: 5px; | |
| } | |
| #controls span { | |
| font-weight: bold; | |
| color: #aaffaa; /* Light green for keys */ | |
| display: inline-block; | |
| width: 40px; /* Fixed width for key display */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game-container"> | |
| <div id="score-left">P1 Score: 0</div> | |
| <div id="score-right">P2 Score: 0</div> | |
| <div id="health-bar-left"></div> | |
| <div id="health-bar-right"></div> | |
| <button id="reset-button">Primitive Reset!</button> | |
| <div id="controls"> | |
| <div> | |
| <h3>Player 1 Controls</h3> | |
| <ul> | |
| <li><span>WASD:</span> Move</li> | |
| <li><span>E:</span> Attack</li> | |
| <li><span>Q:</span> Block</li> | |
| <li><span>C:</span> Change Gear</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h3>Player 2 Controls</h3> | |
| <ul> | |
| <li><span>IJKL:</span> Move</li> | |
| <li><span>U:</span> Attack</li> | |
| <li><span>O:</span> Block</li> | |
| <li><span>M:</span> Change Gear</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| // Import Three.js library from a reliable CDN | |
| import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.module.js'; | |
| // Declare global variables for Three.js scene, camera, renderer, and game state | |
| let scene, camera, renderer; | |
| let player1, player2; // Our primitive-assembled characters | |
| let player1Health = 100; | |
| let player2Health = 100; | |
| let player1Score = 0; | |
| let player2Score = 0; | |
| const maxHealth = 100; // Define max health for players | |
| const movementSpeed = 0.3; // Adjust movement speed for players | |
| const baseAttackDamage = 10; // Base damage dealt by an attack | |
| const blockReduction = 0.5; // Percentage of damage reduced when blocking (e.g., 0.5 means 50% less damage) | |
| const playerScaleFactor = 1.5; // Scale factor for players | |
| const attackRange = 2 * (1.5 * playerScaleFactor); // About 2 player widths | |
| // Projectile parameters | |
| const projectileSpeed = 0.8; | |
| const projectileLife = 120; // frames (2 seconds at 60fps) | |
| const projectileRadius = 0.3; | |
| // Object to keep track of pressed keys for smooth movement | |
| const keys = { | |
| w: false, a: false, s: false, d: false, | |
| i: false, j: false, k: false, l: false, | |
| p1_attack: false, p1_block: false, p1_change_gear: false, | |
| p2_attack: false, p2_block: false, p2_change_gear: false | |
| }; | |
| // Weapon and Shield Definitions for DnD-like classes | |
| const gearCombinations = [ | |
| { name: "Fighter (Sword & Kite Shield)", weapon: "sword", shield: "kite_shield" }, | |
| { name: "Barbarian (Great Axe)", weapon: "great_axe", shield: null }, | |
| { name: "Rogue (Daggers & Buckler)", weapon: "dagger", shield: "buckler" }, | |
| { name: "Paladin (Mace & Tower Shield)", weapon: "mace", shield: "tower_shield" }, | |
| { name: "Ranger (Longbow)", weapon: "longbow", shield: null } | |
| ]; | |
| let player1GearIndex = 0; | |
| let player2GearIndex = 0; | |
| const activeProjectiles = []; | |
| const impactParticlesGroup = new THREE.Group(); | |
| const weaponRangeParticlesGroup = new THREE.Group(); // New group for weapon range particles | |
| /** | |
| * Generates a random integer within a specified range (inclusive). | |
| * @param {number} min - The minimum value. | |
| * @param {number} max - The maximum value. | |
| * @returns {number} A random integer. | |
| */ | |
| function getRandomInt(min, max) { | |
| min = Math.ceil(min); | |
| max = Math.floor(max); | |
| return Math.floor(Math.random() * (max - min + 1)) + min; | |
| } | |
| /** | |
| * Initializes the game environment, including the Three.js scene, camera, renderer, | |
| * 3D objects (players, ground), UI elements, and event listeners. | |
| */ | |
| function init() { | |
| // Scene Setup: The container for all 3D objects, lights, and cameras | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x333333); // Dark grey background for the 3D scene | |
| // Camera Setup: Orthographic camera for a top-down semi-isometric view | |
| const aspectRatio = window.innerWidth / window.innerHeight; | |
| const frustumSize = 30; // Adjusted frustum size for a slightly more zoomed in view | |
| camera = new THREE.OrthographicCamera( | |
| frustumSize * aspectRatio / - 2, | |
| frustumSize * aspectRatio / 2, | |
| frustumSize / 2, | |
| frustumSize / - 2, | |
| 1, 1000 | |
| ); | |
| // Position camera for a top-down semi-isometric view | |
| camera.position.set(10, 30, 10); // Higher Y, and equal X/Z for a balanced angle | |
| camera.lookAt(0, 0, 0); // Make the camera look directly at the origin | |
| // Renderer Setup: Renders the 3D scene onto a 2D canvas | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| document.getElementById('game-container').appendChild(renderer.domElement); | |
| // Lighting: Essential for seeing 3D objects | |
| const ambientLight = new THREE.AmbientLight(0x404040); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(5, 10, 7); | |
| scene.add(directionalLight); | |
| // Ground/Arena: A simple plane representing the fighting stage | |
| const groundGeometry = new THREE.PlaneGeometry(50, 50); // Larger ground for larger players | |
| const groundMaterial = new THREE.MeshPhongMaterial({ color: 0x666666, side: THREE.DoubleSide }); | |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
| ground.rotation.x = -Math.PI / 2; | |
| scene.add(ground); | |
| // Player 1 Character: Created using primitive shapes | |
| player1 = createPrimitiveCharacter(0xff4500); // Red-orange color | |
| player1.position.set(-10, 0.5 * playerScaleFactor, 0); // Start on the left side of the arena, adjusted for size | |
| scene.add(player1); | |
| // Player 2 Character: Created using primitive shapes | |
| player2 = createPrimitiveCharacter(0x00aaff); // Blue color | |
| player2.position.set(10, 0.5 * playerScaleFactor, 0); // Start on the right side of the arena, adjusted for size | |
| scene.add(player2); | |
| // Initialize players with their starting gear | |
| updatePlayerGear(player1, player1GearIndex); | |
| updatePlayerGear(player2, player2GearIndex); | |
| // Add particle groups to the scene | |
| scene.add(impactParticlesGroup); | |
| scene.add(weaponRangeParticlesGroup); | |
| // UI Elements Initialization and Event Listeners | |
| document.getElementById('reset-button').addEventListener('click', resetGame); | |
| updateHealthBars(); | |
| updateScores(); | |
| // Event Listeners for Keyboard Input | |
| window.addEventListener('keydown', onKeyDown); | |
| window.addEventListener('keyup', onKeyUp); | |
| window.addEventListener('resize', onWindowResize); | |
| // Start the animation loop | |
| animate(); | |
| } | |
| /** | |
| * Creates a "primitive assembled" character with body, head, arms, and legs using Three.js primitives. | |
| * Each part is given a name and damage value for collision detection and health reduction. | |
| * @param {number} color - Hex color for the character. | |
| * @returns {THREE.Group} A group representing the player character. | |
| */ | |
| function createPrimitiveCharacter(color) { | |
| const character = new THREE.Group(); | |
| character.parts = []; // Array to hold all individual body parts | |
| character.missingParts = []; // Array to track removed parts | |
| character.playerColor = color; // Store the player's main color | |
| // Body: A central box | |
| const bodyGeometry = new THREE.BoxGeometry(1.5 * playerScaleFactor, 2.5 * playerScaleFactor, 1.5 * playerScaleFactor); | |
| const bodyMaterial = new THREE.MeshPhongMaterial({ color: color }); | |
| const body = new THREE.Mesh(bodyGeometry, bodyMaterial); | |
| body.position.y = 1.25 * playerScaleFactor; | |
| body.name = 'body'; | |
| body.damageValue = baseAttackDamage * 1.5; // Body hits deal more damage | |
| character.add(body); | |
| character.parts.push(body); | |
| // Head: A sphere on top of the body | |
| const headGeometry = new THREE.SphereGeometry(0.8 * playerScaleFactor, 24, 24); | |
| const headMaterial = new THREE.MeshPhongMaterial({ color: color }); | |
| const head = new THREE.Mesh(headGeometry, headMaterial); | |
| head.position.y = 3.2 * playerScaleFactor; | |
| head.name = 'head'; | |
| head.damageValue = baseAttackDamage * 3; // Headshots deal critical damage | |
| character.add(head); | |
| character.parts.push(head); | |
| const visorGeometry = new THREE.BoxGeometry(0.6 * playerScaleFactor, 0.2 * playerScaleFactor, 0.1 * playerScaleFactor); | |
| const visorMaterial = new THREE.MeshPhongMaterial({ color: 0x333333 }); | |
| const visor = new THREE.Mesh(visorGeometry, visorMaterial); | |
| visor.position.set(0, 1.8 * playerScaleFactor, 0.5 * playerScaleFactor); | |
| visor.name = 'visor'; // Not a critical part, no damageValue | |
| character.add(visor); | |
| character.parts.push(visor); | |
| // Arms: Two cylinders for arms | |
| const upperArmGeometry = new THREE.CylinderGeometry(0.3 * playerScaleFactor, 0.3 * playerScaleFactor, 0.7 * playerScaleFactor, 8); | |
| const lowerArmGeometry = new THREE.CylinderGeometry(0.28 * playerScaleFactor, 0.28 * playerScaleFactor, 0.7 * playerScaleFactor, 8); | |
| const armMaterial = new THREE.MeshPhongMaterial({ color: color }); | |
| // Player's right arm (for weapon) | |
| const rightArm = new THREE.Mesh(upperArmGeometry, armMaterial); | |
| rightArm.position.set(-1.2 * playerScaleFactor, 2.2 * playerScaleFactor, 0); | |
| rightArm.rotation.z = (color === 0xff4500) ? Math.PI / 6 : -Math.PI / 6; | |
| rightArm.rotation.x = 0; | |
| rightArm.name = 'rightArm'; | |
| rightArm.damageValue = baseAttackDamage * 0.8; | |
| character.add(rightArm); | |
| character.parts.push(rightArm); | |
| character.rightArm = rightArm; // Store reference for animation | |
| character.rightArmInitialRotZ = rightArm.rotation.z; | |
| character.rightArmInitialRotX = rightArm.rotation.x; | |
| const rightLowerArm = new THREE.Mesh(lowerArmGeometry, armMaterial); | |
| rightLowerArm.position.set(-1.7 * playerScaleFactor, 1.8 * playerScaleFactor, 0); | |
| rightLowerArm.rotation.z = Math.PI / 8; | |
| rightLowerArm.name = 'rightLowerArm'; | |
| rightLowerArm.damageValue = baseAttackDamage * 0.8; | |
| character.add(rightLowerArm); | |
| character.parts.push(rightLowerArm); | |
| // Player's left arm (for shield) | |
| const leftArm = new THREE.Mesh(upperArmGeometry, armMaterial); | |
| leftArm.position.set(1.2 * playerScaleFactor, 2.2 * playerScaleFactor, 0); | |
| leftArm.rotation.z = (color === 0xff4500) ? -Math.PI / 6 : Math.PI / 6; | |
| leftArm.rotation.x = 0; | |
| leftArm.rotation.y = 0; | |
| leftArm.name = 'leftArm'; | |
| leftArm.damageValue = baseAttackDamage * 0.8; | |
| character.add(leftArm); | |
| character.parts.push(leftArm); | |
| character.leftArm = leftArm; // Store reference for animation | |
| character.leftArmInitialRotZ = leftArm.rotation.z; | |
| character.leftArmInitialRotX = leftArm.rotation.x; | |
| character.leftArmInitialRotY = leftArm.rotation.y; | |
| const leftLowerArm = new THREE.Mesh(lowerArmGeometry, armMaterial); | |
| leftLowerArm.position.set(1.7 * playerScaleFactor, 1.8 * playerScaleFactor, 0); | |
| leftLowerArm.rotation.z = -Math.PI / 8; | |
| leftLowerArm.name = 'leftLowerArm'; | |
| leftLowerArm.damageValue = baseAttackDamage * 0.8; | |
| character.add(leftLowerArm); | |
| character.parts.push(leftLowerArm); | |
| // Legs: Two cylinders for legs | |
| const upperLegGeometry = new THREE.CylinderGeometry(0.25 * playerScaleFactor, 0.25 * playerScaleFactor, 0.8 * playerScaleFactor, 8); | |
| const lowerLegGeometry = new THREE.CylinderGeometry(0.2 * playerScaleFactor, 0.2 * playerScaleFactor, 0.8 * playerScaleFactor, 8); | |
| const legMaterial = new THREE.MeshPhongMaterial({ color: 0x777777 }); | |
| const leg1 = new THREE.Mesh(upperLegGeometry, legMaterial); | |
| leg1.position.set(-0.3 * playerScaleFactor, 0.4 * playerScaleFactor, 0); | |
| leg1.name = 'leftUpperLeg'; | |
| leg1.damageValue = baseAttackDamage * 0.5; | |
| character.add(leg1); | |
| character.parts.push(leg1); | |
| const leg2 = new THREE.Mesh(lowerLegGeometry, legMaterial); | |
| leg2.position.set(-0.3 * playerScaleFactor, -0.4 * playerScaleFactor, 0); | |
| leg2.name = 'leftLowerLeg'; | |
| leg2.damageValue = baseAttackDamage * 0.5; | |
| character.add(leg2); | |
| character.parts.push(leg2); | |
| const leg3 = new THREE.Mesh(upperLegGeometry, legMaterial); | |
| leg3.position.set(0.3 * playerScaleFactor, 0.4 * playerScaleFactor, 0); | |
| leg3.name = 'rightUpperLeg'; | |
| leg3.damageValue = baseAttackDamage * 0.5; | |
| character.add(leg3); | |
| character.parts.push(leg3); | |
| const leg4 = new THREE.Mesh(lowerLegGeometry, legMaterial); | |
| leg4.position.set(0.3 * playerScaleFactor, -0.4 * playerScaleFactor, 0); | |
| leg4.name = 'rightLowerLeg'; | |
| leg4.damageValue = baseAttackDamage * 0.5; | |
| character.add(leg4); | |
| character.parts.push(leg4); | |
| // Backpack (Box) | |
| const backpackGeometry = new THREE.BoxGeometry(0.8 * playerScaleFactor, 1 * playerScaleFactor, 0.4 * playerScaleFactor); | |
| const backpackMaterial = new THREE.MeshPhongMaterial({ color: 0x444444 }); | |
| const backpack = new THREE.Mesh(backpackGeometry, backpackMaterial); | |
| backpack.position.set(0, 0.75 * playerScaleFactor, -0.7 * playerScaleFactor); | |
| backpack.name = 'backpack'; // Not a critical part | |
| character.add(backpack); | |
| character.parts.push(backpack); | |
| // Store initial positions and rotations for resetting | |
| character.initialState = { | |
| position: character.position.clone(), | |
| parts: character.parts.map(part => ({ | |
| name: part.name, | |
| position: part.position.clone(), | |
| rotation: part.rotation.clone(), | |
| material: part.material // Store material reference | |
| })) | |
| }; | |
| // Animation properties | |
| character.isAttacking = false; | |
| character.attackAnimationProgress = 0; | |
| character.attackDuration = 15; // frames | |
| character.isBlocking = false; | |
| character.blockAnimationProgress = 0; | |
| character.blockDuration = 10; // frames | |
| // Store current weapon type for animation logic | |
| character.currentWeaponType = null; | |
| character.weaponMesh = null; // Reference to the actual weapon mesh | |
| character.shieldMesh = null; // Reference to the actual shield mesh | |
| return character; | |
| } | |
| /** | |
| * Creates a weapon mesh based on the specified type. | |
| * Includes a collision sphere for the weapon. | |
| * @param {string} type - The type of weapon (e.g., "sword", "longbow"). | |
| * @param {number} [color=0xcccccc] - Hex color for the weapon. | |
| * @returns {THREE.Group} A group representing the weapon. | |
| */ | |
| function createWeapon(type, color = 0xcccccc) { | |
| const weaponGroup = new THREE.Group(); | |
| const material = new THREE.MeshPhongMaterial({ color: color }); | |
| const handleMaterial = new THREE.MeshPhongMaterial({ color: 0x663300 }); | |
| const bladeMaterial = new THREE.MeshPhongMaterial({ color: 0x999999 }); | |
| // Collision sphere for the weapon (relative to its local origin) | |
| weaponGroup.collisionSphere = new THREE.Sphere(new THREE.Vector3(), 1.0); | |
| switch (type) { | |
| case "sword": | |
| weaponGroup.add(new THREE.Mesh(new THREE.BoxGeometry(0.2, 2.0, 0.1), bladeMaterial)); // Blade | |
| const hiltMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.5), handleMaterial); // Hilt | |
| hiltMesh.position.y = -1.25; | |
| weaponGroup.add(hiltMesh); | |
| weaponGroup.position.set(0.5, -0.5, 0); | |
| weaponGroup.rotation.z = -Math.PI / 2; | |
| weaponGroup.collisionSphere.radius = 1.2; // Adjust collision sphere size | |
| break; | |
| case "great_axe": | |
| weaponGroup.add(new THREE.Mesh(new THREE.BoxGeometry(0.2, 3.0, 0.2), handleMaterial)); // Handle | |
| const axeHead = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.5, 1.0), bladeMaterial); // Axe blade | |
| axeHead.position.set(0, 1.5, 0.5); | |
| weaponGroup.add(axeHead); | |
| weaponGroup.position.set(0.5, -1.0, 0); | |
| weaponGroup.rotation.z = -Math.PI / 2; | |
| weaponGroup.collisionSphere.radius = 1.8; | |
| break; | |
| case "dagger": | |
| weaponGroup.add(new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.8, 0.05), bladeMaterial)); // Blade | |
| const daggerHiltMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.3), handleMaterial); // Hilt | |
| daggerHiltMesh.position.y = -0.55; | |
| weaponGroup.add(daggerHiltMesh); | |
| weaponGroup.position.set(0.3, -0.2, 0); | |
| weaponGroup.rotation.z = -Math.PI / 2; | |
| weaponGroup.collisionSphere.radius = 0.8; | |
| break; | |
| case "mace": | |
| weaponGroup.add(new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 1.5), handleMaterial)); // Handle | |
| const maceHead = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 16), bladeMaterial); // Spiked head | |
| maceHead.position.y = 0.75; | |
| weaponGroup.add(maceHead); | |
| weaponGroup.position.set(0.5, -0.75, 0); | |
| weaponGroup.rotation.z = -Math.PI / 2; | |
| weaponGroup.collisionSphere.radius = 1.0; | |
| break; | |
| case "longbow": | |
| const bowString = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 2.0), new THREE.MeshPhongMaterial({ color: 0x333333 })); | |
| const bowBody = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2.0, 0.1), new THREE.MeshPhongMaterial({ color: 0x8B4513 })); | |
| bowString.position.x = 0.5; | |
| bowBody.rotation.y = Math.PI / 2; | |
| weaponGroup.add(bowBody); | |
| weaponGroup.add(bowString); | |
| weaponGroup.position.set(0.5, -0.5, 0); | |
| weaponGroup.rotation.z = -Math.PI / 2; | |
| weaponGroup.collisionSphere.radius = 1.5; | |
| break; | |
| default: | |
| break; | |
| } | |
| weaponGroup.name = `weapon_${type}`; | |
| weaponGroup.visible = false; | |
| return weaponGroup; | |
| } | |
| /** | |
| * Creates a shield mesh based on the specified type. | |
| * Includes a collision sphere for the shield. | |
| * @param {string} type - The type of shield (e.g., "kite_shield", "buckler"). | |
| * @param {number} [color=0x888888] - Hex color for the shield. | |
| * @returns {THREE.Group} A group representing the shield. | |
| */ | |
| function createShield(type, color = 0x888888) { | |
| const shieldGroup = new THREE.Group(); | |
| const material = new THREE.MeshPhongMaterial({ color: color }); | |
| shieldGroup.collisionSphere = new THREE.Sphere(new THREE.Vector3(), 1.5); // Default collision sphere | |
| switch (type) { | |
| case "kite_shield": | |
| const pointsKite = [ | |
| new THREE.Vector2(0, 1.5), new THREE.Vector2(0.8, 1.0), | |
| new THREE.Vector2(0.8, -1.5), new THREE.Vector2(-0.8, -1.5), | |
| new THREE.Vector2(-0.8, 1.0) | |
| ]; | |
| const kiteShape = new THREE.Shape(pointsKite); | |
| const shieldShapeKite = new THREE.ExtrudeGeometry(kiteShape, { depth: 0.1, bevelEnabled: false }); | |
| shieldGroup.add(new THREE.Mesh(shieldShapeKite, material)); | |
| shieldGroup.position.set(-0.5, -0.5, 0); | |
| shieldGroup.rotation.y = Math.PI / 2; | |
| shieldGroup.collisionSphere.radius = 1.8; // Adjust collision sphere size | |
| break; | |
| case "buckler": | |
| const shieldShapeBuckler = new THREE.CylinderGeometry(0.7, 0.7, 0.1, 16); | |
| shieldGroup.add(new THREE.Mesh(shieldShapeBuckler, material)); | |
| shieldGroup.position.set(-0.5, -0.2, 0); | |
| shieldGroup.rotation.y = Math.PI / 2; | |
| shieldGroup.collisionSphere.radius = 0.9; | |
| break; | |
| case "tower_shield": | |
| const shieldShapeTower = new THREE.BoxGeometry(1.2, 2.5, 0.1); | |
| shieldGroup.add(new THREE.Mesh(shieldShapeTower, material)); | |
| shieldGroup.position.set(-0.5, -1.0, 0); | |
| shieldGroup.rotation.y = Math.PI / 2; | |
| shieldGroup.collisionSphere.radius = 2.0; | |
| break; | |
| default: | |
| break; | |
| } | |
| shieldGroup.name = `shield_${type}`; | |
| shieldGroup.visible = false; | |
| return shieldGroup; | |
| } | |
| /** | |
| * Creates a projectile (a small sphere) to be fired. | |
| * @param {THREE.Group} firingPlayer - The player who fired the projectile. | |
| * @param {THREE.Group} targetPlayer - The intended target of the projectile. | |
| */ | |
| function createProjectile(firingPlayer, targetPlayer) { | |
| const projectileGeometry = new THREE.SphereGeometry(projectileRadius, 8, 8); | |
| const projectileMaterial = new THREE.MeshPhongMaterial({ color: 0xffd700 }); // Gold color | |
| const projectileMesh = new THREE.Mesh(projectileGeometry, projectileMaterial); | |
| // Set projectile starting position from player's weapon tip or chest height | |
| const startPosition = new THREE.Vector3(); | |
| if (firingPlayer.weaponMesh) { | |
| // Get weapon's world position and move it slightly forward | |
| firingPlayer.weaponMesh.getWorldPosition(startPosition); | |
| const forwardDir = new THREE.Vector3(); | |
| firingPlayer.getWorldDirection(forwardDir); | |
| startPosition.add(forwardDir.multiplyScalar(firingPlayer.weaponMesh.collisionSphere.radius * 0.5)); | |
| } else { | |
| // If no weapon, shoot from player's chest height | |
| startPosition.copy(firingPlayer.position); | |
| startPosition.y += 1.5 * playerScaleFactor; | |
| } | |
| projectileMesh.position.copy(startPosition); | |
| // Calculate direction towards target (at target's chest height) | |
| const targetPosition = targetPlayer.position.clone(); | |
| targetPosition.y += 1.5 * playerScaleFactor; // Aim for chest height | |
| const direction = new THREE.Vector3(); | |
| direction.subVectors(targetPosition, projectileMesh.position).normalize(); | |
| projectileMesh.velocity = direction.multiplyScalar(projectileSpeed); | |
| projectileMesh.life = projectileLife; | |
| projectileMesh.damage = baseAttackDamage; // Projectiles deal base damage | |
| projectileMesh.originPlayer = firingPlayer; // Store who fired it | |
| scene.add(projectileMesh); | |
| activeProjectiles.push(projectileMesh); | |
| } | |
| /** | |
| * Updates a player's equipped weapon and shield based on the gear index. | |
| * @param {THREE.Group} player - The player character to update. | |
| * @param {number} gearIndex - The index of the gear combination to equip. | |
| */ | |
| function updatePlayerGear(player, gearIndex) { | |
| // Remove existing weapon and shield meshes from the player's arms | |
| if (player.weaponMesh && player.weaponMesh.parent) { | |
| player.weaponMesh.parent.remove(player.weaponMesh); | |
| } | |
| if (player.shieldMesh && player.shieldMesh.parent) { | |
| player.shieldMesh.parent.remove(player.shieldMesh); | |
| } | |
| const gear = gearCombinations[gearIndex]; | |
| player.currentWeaponType = gear.weapon; // Store the current weapon type for animation logic | |
| // Add new weapon if applicable | |
| if (gear.weapon) { | |
| const newWeapon = createWeapon(gear.weapon); | |
| player.rightArm.add(newWeapon); // Attach to the player's right arm | |
| player.weaponMesh = newWeapon; // Store reference | |
| newWeapon.visible = true; | |
| } else { | |
| player.weaponMesh = null; | |
| } | |
| // Add new shield if applicable | |
| if (gear.shield) { | |
| const newShield = createShield(gear.shield); | |
| player.leftArm.add(newShield); // Attach to the player's left arm | |
| player.shieldMesh = newShield; // Store reference | |
| newShield.visible = true; | |
| } else { | |
| player.shieldMesh = null; | |
| } | |
| } | |
| /** | |
| * Handles keydown events to update player movement and action states. | |
| * @param {KeyboardEvent} event - The keyboard event. | |
| */ | |
| function onKeyDown(event) { | |
| switch (event.code) { | |
| // Player 1 Movement (WASD) | |
| case 'KeyW': keys.w = true; break; | |
| case 'KeyA': keys.a = true; break; | |
| case 'KeyS': keys.s = true; break; | |
| case 'KeyD': keys.d = true; break; | |
| // Player 1 Actions | |
| case 'KeyE': // Attack | |
| // Only allow attack if not already attacking or blocking | |
| if (!player1.isAttacking && !player1.isBlocking) { | |
| startAttackAnimation(player1); | |
| createProjectile(player1, player2); // All attacks now fire projectiles | |
| } | |
| break; | |
| case 'KeyQ': // Block | |
| // Only allow block if not already blocking or attacking | |
| if (!player1.isBlocking && !player1.isAttacking) { | |
| startBlockAnimation(player1); | |
| } | |
| break; | |
| case 'KeyC': // Change Gear | |
| if (!keys.p1_change_gear) { // Prevent rapid gear changes | |
| player1GearIndex = (player1GearIndex + 1) % gearCombinations.length; | |
| updatePlayerGear(player1, player1GearIndex); | |
| keys.p1_change_gear = true; // Set flag to prevent continuous change | |
| } | |
| break; | |
| // Player 2 Movement (IJKL) | |
| case 'KeyI': keys.i = true; break; | |
| case 'KeyJ': keys.j = true; break; | |
| case 'KeyK': keys.k = true; break; | |
| case 'KeyL': keys.l = true; break; | |
| // Player 2 Actions | |
| case 'KeyU': // Attack | |
| // Only allow attack if not already attacking or blocking | |
| if (!player2.isAttacking && !player2.isBlocking) { | |
| startAttackAnimation(player2); | |
| createProjectile(player2, player1); // All attacks now fire projectiles | |
| } | |
| break; | |
| case 'KeyO': // Block | |
| // Only allow block if not already blocking or attacking | |
| if (!player2.isBlocking && !player2.isAttacking) { | |
| startBlockAnimation(player2); | |
| } | |
| break; | |
| case 'KeyM': // Change Gear | |
| if (!keys.p2_change_gear) { // Prevent rapid gear changes | |
| player2GearIndex = (player2GearIndex + 1) % gearCombinations.length; | |
| updatePlayerGear(player2, player2GearIndex); | |
| keys.p2_change_gear = true; // Set flag to prevent continuous change | |
| } | |
| break; | |
| } | |
| } | |
| /** | |
| * Handles keyup events to reset player movement and action states. | |
| * @param {KeyboardEvent} event - The keyboard event. | |
| */ | |
| function onKeyUp(event) { | |
| switch (event.code) { | |
| // Player 1 Movement | |
| case 'KeyW': keys.w = false; break; | |
| case 'KeyA': keys.a = false; break; | |
| case 'KeyS': keys.s = false; break; | |
| case 'KeyD': keys.d = false; break; | |
| // Player 1 Actions (resetting the "pressed" state for single-trigger actions) | |
| case 'KeyE': /* Attack action is handled by animation state */ break; | |
| case 'KeyQ': /* Block action is handled by animation state */ break; | |
| case 'KeyC': keys.p1_change_gear = false; break; // Release for next change | |
| // Player 2 Movement | |
| case 'KeyI': keys.i = false; break; | |
| case 'KeyJ': keys.j = false; break; | |
| case 'KeyK': keys.k = false; break; | |
| case 'KeyL': keys.l = false; break; | |
| // Player 2 Actions | |
| case 'KeyU': /* Attack action is handled by animation state */ break; | |
| case 'KeyO': /* Block action is handled by animation state */ break; | |
| case 'KeyM': keys.p2_change_gear = false; break; // Release for next change | |
| } | |
| } | |
| /** | |
| * Initiates the attack animation for a given player. | |
| * @param {THREE.Group} player - The player initiating the attack. | |
| */ | |
| function startAttackAnimation(player) { | |
| player.isAttacking = true; | |
| player.attackAnimationProgress = 0; // Reset animation progress | |
| // Store initial arm rotation for smooth return | |
| player.rightArm.initialRotationZ = player.rightArm.rotation.z; | |
| player.rightArm.initialRotationX = player.rightArm.rotation.x; | |
| // Create weapon range particles using the player's stored color | |
| createWeaponRangeParticles(player.weaponMesh ? player.weaponMesh : player.position, player.playerColor); | |
| } | |
| /** | |
| * Initiates the block animation for a given player. | |
| * @param {THREE.Group} player - The player initiating the block. | |
| */ | |
| function startBlockAnimation(player) { | |
| player.isBlocking = true; | |
| player.blockAnimationProgress = 0; // Reset animation progress | |
| // Store initial arm rotation for smooth return | |
| player.leftArm.initialRotationZ = player.leftArm.rotation.z; | |
| player.leftArm.initialRotationX = player.leftArm.rotation.x; | |
| player.leftArm.initialRotationY = player.leftArm.rotation.y; | |
| } | |
| /** | |
| * Creates a burst of small particles at a given position and color. | |
| * Used for visual feedback on hits/blocks. | |
| * @param {THREE.Vector3} position - The world position for particles. | |
| * @param {number} color - Hex color for the particles. | |
| * @param {string} type - Type of particles ('impact' or 'blood'). | |
| */ | |
| function createParticles(position, color, type = 'impact') { | |
| const particleCount = (type === 'blood') ? 20 : 10; | |
| const particleColor = (type === 'blood') ? 0x8B0000 : color; // Dark red for blood | |
| const particleMaterial = new THREE.MeshBasicMaterial({ color: particleColor, transparent: true, opacity: 1 }); | |
| const particleGeometry = new THREE.SphereGeometry(0.1, 8, 8); // Smaller for blood | |
| for (let i = 0; i < particleCount; i++) { | |
| const particle = new THREE.Mesh(particleGeometry, particleMaterial.clone()); | |
| particle.position.copy(position); | |
| particle.velocity = new THREE.Vector3( | |
| (Math.random() - 0.5) * 0.8, // Random X velocity | |
| Math.random() * 0.8 + 0.2, // Upward Y velocity | |
| (Math.random() - 0.5) * 0.8 // Random Z velocity | |
| ); | |
| particle.life = (type === 'blood') ? 60 : 40; // Longer life for blood | |
| particle.initialLife = particle.life; // Store initial life for opacity calculation | |
| impactParticlesGroup.add(particle); | |
| } | |
| } | |
| /** | |
| * Creates a temporary particle system to visualize weapon range/attack. | |
| * @param {THREE.Object3D} originObject - The object from which particles emanate (weapon or player). | |
| * @param {number} color - The hex color of the particles. | |
| */ | |
| function createWeaponRangeParticles(originObject, color) { | |
| const particleCount = 15; | |
| const particleMaterial = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.8 }); | |
| const particleGeometry = new THREE.SphereGeometry(0.15, 8, 8); | |
| const originPosition = new THREE.Vector3(); | |
| if (originObject instanceof THREE.Mesh || originObject instanceof THREE.Group) { // Check if it's a Three.js object | |
| originObject.getWorldPosition(originPosition); | |
| } else { // Assume it's a Vector3 if not a mesh/group | |
| originPosition.copy(originObject); | |
| } | |
| for (let i = 0; i < particleCount; i++) { | |
| const particle = new THREE.Mesh(particleGeometry, particleMaterial.clone()); | |
| particle.position.copy(originPosition); | |
| // Randomize initial direction slightly | |
| const angle = Math.random() * Math.PI * 2; | |
| const speed = 0.1 + Math.random() * 0.1; | |
| particle.velocity = new THREE.Vector3( | |
| Math.cos(angle) * speed, | |
| 0.1 + Math.random() * 0.1, // Slight upward motion | |
| Math.sin(angle) * speed | |
| ); | |
| particle.life = 30; // Short life for range visualization | |
| particle.initialLife = particle.life; // Store initial life for opacity calculation | |
| weaponRangeParticlesGroup.add(particle); | |
| } | |
| } | |
| /** | |
| * The main animation loop of the game. | |
| * Updates player movement, animations, projectile physics, and renders the scene. | |
| */ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Player 1 Movement | |
| if (keys.w) player1.position.z -= movementSpeed; | |
| if (keys.s) player1.position.z += movementSpeed; | |
| if (keys.a) player1.position.x -= movementSpeed; | |
| if (keys.d) player1.position.x += movementSpeed; | |
| // Player 2 Movement | |
| if (keys.i) player2.position.z -= movementSpeed; | |
| if (keys.k) player2.position.z += movementSpeed; | |
| if (keys.j) player2.position.x -= movementSpeed; | |
| if (keys.l) player2.position.x += movementSpeed; | |
| // Auto-rotate players to face each other (only if not attacking/blocking to prevent jitter) | |
| if (!player1.isAttacking && !player1.isBlocking) { | |
| player1.lookAt(player2.position.x, player1.position.y, player2.position.z); | |
| } | |
| if (!player2.isAttacking && !player2.isBlocking) { | |
| player2.lookAt(player1.position.x, player2.position.y, player1.position.z); | |
| } | |
| // Attack Animation Logic for both players | |
| [player1, player2].forEach(player => { | |
| if (player.isAttacking) { | |
| player.attackAnimationProgress++; | |
| const progress = player.attackAnimationProgress / player.attackDuration; | |
| const swingDirection = (player === player1) ? 1 : -1; // Adjust swing direction for each player | |
| // Animate weapon arm based on weapon type | |
| switch (player.currentWeaponType) { | |
| case "sword": | |
| case "great_axe": | |
| case "mace": | |
| // Sweeping motion: Z-axis for horizontal swing, X-axis for vertical tilt | |
| player.rightArm.rotation.z = player.rightArmInitialRotZ + swingDirection * Math.sin(progress * Math.PI * 1.5) * Math.PI / 2; | |
| player.rightArm.rotation.x = player.rightArmInitialRotX + Math.sin(progress * Math.PI * 2) * Math.PI / 12; | |
| break; | |
| case "dagger": | |
| // Forward jab: X-axis for forward thrust | |
| player.rightArm.rotation.x = player.rightArmInitialRotX + Math.sin(progress * Math.PI * 2) * Math.PI / 6; | |
| player.rightArm.rotation.z = player.rightArmInitialRotZ + swingDirection * Math.sin(progress * Math.PI * 2) * Math.PI / 12; // Slight side movement | |
| break; | |
| case "longbow": | |
| // Draw back and release: X-axis for pulling back, then forward | |
| player.rightArm.rotation.x = player.rightArmInitialRotX + Math.sin(progress * Math.PI * 2) * Math.PI / 4; | |
| player.rightArm.rotation.z = player.rightArmInitialRotZ; // Keep Z stable | |
| break; | |
| } | |
| // End attack animation | |
| if (player.attackAnimationProgress >= player.attackDuration) { | |
| player.isAttacking = false; | |
| // Reset arm rotations to initial state | |
| player.rightArm.rotation.z = player.rightArmInitialRotZ; | |
| player.rightArm.rotation.x = player.rightArmInitialRotX; | |
| } | |
| } | |
| // Block Animation Logic | |
| if (player.isBlocking) { | |
| player.blockAnimationProgress++; | |
| const progress = player.blockAnimationProgress / player.blockDuration; | |
| const blockDirection = (player === player1) ? 1 : -1; // Adjust block direction | |
| // Move shield arm to a defensive position | |
| player.leftArm.rotation.z = player.leftArmInitialRotZ + blockDirection * Math.sin(progress * Math.PI) * Math.PI / 8; // Arm moves up/down slightly | |
| player.leftArm.rotation.y = player.leftArmInitialRotY + blockDirection * Math.sin(progress * Math.PI) * Math.PI / 6; // Shield turns towards opponent | |
| // End block animation | |
| if (player.blockAnimationProgress >= player.blockDuration) { | |
| player.isBlocking = false; | |
| // Reset arm rotations to initial state | |
| player.leftArm.rotation.z = player.leftArmInitialRotZ; | |
| player.leftArm.rotation.x = player.leftArmInitialRotX; | |
| player.leftArm.rotation.y = player.leftArmInitialRotY; | |
| } | |
| } | |
| // Health decay from missing parts | |
| const healthDecayRate = player.missingParts.length * 0.1; // 0.1 health per frame per missing part | |
| if (healthDecayRate > 0) { | |
| if (player === player1) { | |
| player1Health = Math.max(0, player1Health - healthDecayRate); | |
| if (player1Health === 0) { | |
| player2Score++; | |
| updateScores(); | |
| resetGame(); | |
| } | |
| } else { | |
| player2Health = Math.max(0, player2Health - healthDecayRate); | |
| if (player2Health === 0) { | |
| player1Score++; | |
| updateScores(); | |
| resetGame(); | |
| } | |
| } | |
| updateHealthBars(); | |
| } | |
| }); | |
| // Projectile Movement and Collision | |
| for (let i = activeProjectiles.length - 1; i >= 0; i--) { | |
| const projectile = activeProjectiles[i]; | |
| projectile.position.add(projectile.velocity); | |
| projectile.life--; | |
| const targetPlayer = (projectile.originPlayer === player1) ? player2 : player1; | |
| // Check for collision with target player's *individual* body parts | |
| const projectileSphere = new THREE.Sphere(projectile.position, projectileRadius); | |
| let hitOccurred = false; | |
| let hitPart = null; | |
| // Loop through each part of the target player | |
| for (let j = targetPlayer.parts.length - 1; j >= 0; j--) { | |
| const part = targetPlayer.parts[j]; | |
| // Calculate world position and collision sphere for the part | |
| const partWorldPosition = new THREE.Vector3(); | |
| part.getWorldPosition(partWorldPosition); | |
| const partCollisionSphere = new THREE.Sphere(partWorldPosition, part.geometry.parameters.radius || part.geometry.parameters.width / 2 || 0.5); // Estimate radius from geometry | |
| if (projectileSphere.intersectsSphere(partCollisionSphere)) { | |
| // Check for shield block first | |
| if (targetPlayer.isBlocking && targetPlayer.shieldMesh) { | |
| const targetShield = targetPlayer.shieldMesh; | |
| const shieldWorldPosition = new THREE.Vector3(); | |
| targetShield.getWorldPosition(shieldWorldPosition); | |
| targetShield.collisionSphere.center.copy(shieldWorldPosition); | |
| if (projectileSphere.intersectsSphere(targetShield.collisionSphere)) { | |
| // Block successful! | |
| applyDamage(targetPlayer, projectile.damage * (1 - blockReduction), projectile.originPlayer); | |
| createParticles(shieldWorldPosition, 0x00ff00, 'impact'); // Green for block | |
| hitOccurred = true; | |
| break; // Shield blocked, no need to check other parts | |
| } | |
| } | |
| // If not blocked by shield, apply damage to the part | |
| if (!hitOccurred) { | |
| hitPart = part; | |
| applyDamage(targetPlayer, part.damageValue || projectile.damage, projectile.originPlayer); // Use part's damage value if available | |
| createParticles(partWorldPosition, 0xffa500, 'impact'); // Orange for hit | |
| createParticles(partWorldPosition, 0xff0000, 'blood'); // Red blood particles | |
| hitOccurred = true; | |
| // Remove the hit part from the player model | |
| targetPlayer.remove(part); | |
| targetPlayer.parts.splice(j, 1); // Remove from active parts array | |
| targetPlayer.missingParts.push(part.name); // Add to missing parts list | |
| // Dispose of the removed part's geometry and material | |
| if (part.geometry) part.geometry.dispose(); | |
| if (part.material) part.material.dispose(); | |
| break; // Only one part hit per projectile | |
| } | |
| } | |
| } | |
| // Remove projectile on impact or if it goes too far | |
| if (hitOccurred || projectile.life <= 0) { | |
| scene.remove(projectile); | |
| projectile.geometry.dispose(); | |
| projectile.material.dispose(); | |
| activeProjectiles.splice(i, 1); | |
| } | |
| } | |
| // Update impact particles (move and fade out) | |
| for (let i = impactParticlesGroup.children.length - 1; i >= 0; i--) { | |
| const particle = impactParticlesGroup.children[i]; | |
| particle.position.add(particle.velocity); | |
| particle.velocity.y -= 0.02; // Simple gravity | |
| particle.life--; | |
| particle.material.opacity = particle.life / particle.initialLife; // Fade out over lifetime (assuming initialLife was stored) | |
| if (particle.life <= 0) { | |
| impactParticlesGroup.remove(particle); | |
| particle.geometry.dispose(); | |
| particle.material.dispose(); | |
| } | |
| } | |
| // Update weapon range particles (expand and fade out) | |
| for (let i = weaponRangeParticlesGroup.children.length - 1; i >= 0; i--) { | |
| const particle = weaponRangeParticlesGroup.children[i]; | |
| particle.position.add(particle.velocity); | |
| particle.scale.setScalar(particle.scale.x + 0.05); // Expand | |
| particle.material.opacity -= 0.03; // Fade out | |
| if (particle.material.opacity <= 0) { | |
| weaponRangeParticlesGroup.remove(particle); | |
| particle.geometry.dispose(); | |
| particle.material.dispose(); | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| /** | |
| * Applies damage to a target player, considering if they are blocking. | |
| * If a player's health drops to 0, the game resets and the other player scores. | |
| * @param {THREE.Group} targetPlayer - The player receiving damage. | |
| * @param {number} amount - The base amount of damage. | |
| * @param {THREE.Group} attackingPlayer - The player dealing damage. | |
| */ | |
| function applyDamage(targetPlayer, amount, attackingPlayer) { | |
| let actualDamage = amount; | |
| if (targetPlayer.isBlocking) { | |
| actualDamage = amount * (1 - blockReduction); // Reduce damage if blocking | |
| } | |
| if (targetPlayer === player1) { | |
| player1Health = Math.max(0, player1Health - actualDamage); | |
| if (player1Health === 0) { | |
| player2Score++; | |
| updateScores(); | |
| resetGame(); | |
| } | |
| } else if (targetPlayer === player2) { | |
| player2Health = Math.max(0, player2Health - actualDamage); | |
| if (player2Health === 0) { | |
| player1Score++; | |
| updateScores(); | |
| resetGame(); | |
| } | |
| } | |
| updateHealthBars(); | |
| } | |
| /** | |
| * Updates the visual width and color of the player health bars. | |
| */ | |
| function updateHealthBars() { | |
| const healthBarLeft = document.getElementById('health-bar-left'); | |
| const healthBarRight = document.getElementById('health-bar-right'); | |
| const maxWidth = 250; | |
| healthBarLeft.style.width = `${(player1Health / maxHealth) * maxWidth}px`; | |
| healthBarLeft.style.backgroundColor = player1Health > maxHealth * 0.6 ? '#28a745' : (player1Health > maxHealth * 0.3 ? '#ffc107' : '#dc3545'); | |
| healthBarRight.style.width = `${(player2Health / maxHealth) * maxWidth}px`; | |
| healthBarRight.style.backgroundColor = player2Health > maxHealth * 0.6 ? '#28a745' : (player2Health > maxHealth * 0.3 ? '#ffc107' : '#dc3545'); | |
| } | |
| /** | |
| * Updates the displayed scores for Player 1 and Player 2. | |
| */ | |
| function updateScores() { | |
| document.getElementById('score-left').textContent = `P1 Score: ${player1Score}`; | |
| document.getElementById('score-right').textContent = `P2 Score: ${player2Score}`; | |
| } | |
| /** | |
| * Resets the game to its initial state, including player health, positions, | |
| * and clearing all projectiles and particles. Also rebuilds player models. | |
| */ | |
| function resetGame() { | |
| player1Health = maxHealth; | |
| player2Health = maxHealth; | |
| updateHealthBars(); | |
| // Rebuild players to restore missing parts | |
| scene.remove(player1); | |
| scene.remove(player2); | |
| player1 = createPrimitiveCharacter(0xff4500); | |
| player2 = createPrimitiveCharacter(0x00aaff); | |
| scene.add(player1); | |
| scene.add(player2); | |
| player1.position.set(-10, 0.5 * playerScaleFactor, 0); | |
| player2.position.set(10, 0.5 * playerScaleFactor, 0); | |
| // Re-initialize gear | |
| updatePlayerGear(player1, player1GearIndex); | |
| updatePlayerGear(player2, player2GearIndex); | |
| // Reset any ongoing animations and arm positions | |
| [player1, player2].forEach(player => { | |
| player.isAttacking = false; | |
| player.isBlocking = false; | |
| player.attackAnimationProgress = 0; | |
| player.blockAnimationProgress = 0; | |
| // Arm rotations are reset by updatePlayerGear and createPrimitiveCharacter | |
| }); | |
| // Remove all active projectiles from scene and array | |
| while (activeProjectiles.length > 0) { | |
| const projectile = activeProjectiles.pop(); | |
| scene.remove(projectile); | |
| projectile.geometry.dispose(); | |
| projectile.material.dispose(); | |
| } | |
| // Clear all impact particles from scene and dispose | |
| while(impactParticlesGroup.children.length > 0){ | |
| const particle = impactParticlesGroup.children[0]; | |
| impactParticlesGroup.remove(particle); | |
| particle.geometry.dispose(); | |
| particle.material.dispose(); | |
| } | |
| // Clear all weapon range particles | |
| while(weaponRangeParticlesGroup.children.length > 0){ | |
| const particle = weaponRangeParticlesGroup.children[0]; | |
| weaponRangeParticlesGroup.remove(particle); | |
| particle.geometry.dispose(); | |
| particle.material.dispose(); | |
| } | |
| } | |
| /** | |
| * Handles window resize events to adjust the camera and renderer size. | |
| */ | |
| function onWindowResize() { | |
| const aspectRatio = window.innerWidth / window.innerHeight; | |
| const frustumSize = 30; // Keep consistent with init | |
| camera.left = frustumSize * aspectRatio / - 2; | |
| camera.right = frustumSize * aspectRatio / 2; | |
| camera.top = frustumSize / 2; | |
| camera.bottom = frustumSize / - 2; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| // Initialize the game when the window loads | |
| window.onload = init; | |
| </script> | |
| </body> | |
| </html> | |