Spaces:
Running
Running
Codex CLI
commited on
Commit
·
1f43352
1
Parent(s):
a297171
feat(enemies, waves): add golem enemy with specific behaviors, health orbs, and projectile mechanics
Browse files- src/combat.js +8 -2
- src/config.js +19 -0
- src/enemies.js +239 -2
- src/globals.js +2 -1
- src/grenades.js +6 -2
- src/main.js +2 -0
- src/pickups.js +2 -1
- src/projectiles.js +54 -4
- src/waves.js +5 -1
src/combat.js
CHANGED
|
@@ -156,8 +156,14 @@ export function performShooting(delta) {
|
|
| 156 |
enemy.deathTimer = 0;
|
| 157 |
G.waves.aliveCount--;
|
| 158 |
G.player.score += isHead ? 15 : 10;
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
} else if (firstHit.object !== G.ground) {
|
| 163 |
const n = firstHit.face?.normal
|
|
|
|
| 156 |
enemy.deathTimer = 0;
|
| 157 |
G.waves.aliveCount--;
|
| 158 |
G.player.score += isHead ? 15 : 10;
|
| 159 |
+
// Drop more orbs for special enemies
|
| 160 |
+
if (enemy.type === 'golem') {
|
| 161 |
+
const cnt = 15 + Math.floor(G.random() * 6); // 15..20
|
| 162 |
+
spawnHealthOrbs(enemy.pos, cnt);
|
| 163 |
+
} else {
|
| 164 |
+
const cnt = 1 + Math.floor(G.random() * 5);
|
| 165 |
+
spawnHealthOrbs(enemy.pos, cnt);
|
| 166 |
+
}
|
| 167 |
}
|
| 168 |
} else if (firstHit.object !== G.ground) {
|
| 169 |
const n = firstHit.face?.normal
|
src/config.js
CHANGED
|
@@ -104,6 +104,25 @@ export const CFG = {
|
|
| 104 |
teleportCooldown: 6,
|
| 105 |
teleportDistance: 12
|
| 106 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
gun: {
|
| 108 |
// Faster, closer to AK full-auto feel (~600 RPM is 10 RPS)
|
| 109 |
rof: 10.5,
|
|
|
|
| 104 |
teleportCooldown: 6,
|
| 105 |
teleportDistance: 12
|
| 106 |
},
|
| 107 |
+
// Golem-specific tuning
|
| 108 |
+
golem: {
|
| 109 |
+
hp: 1000, // 5x basic enemy (100)
|
| 110 |
+
radius: 1.8, // bigger footprint
|
| 111 |
+
baseSpeed: 1.6,
|
| 112 |
+
speedPerWave: 0.10,
|
| 113 |
+
dps: 20, // contact damage if you hug it
|
| 114 |
+
range: 90,
|
| 115 |
+
// Throwing
|
| 116 |
+
throwInterval: 1.8, // seconds between throws
|
| 117 |
+
throwWindup: 0.40,
|
| 118 |
+
bloom: 0.01,
|
| 119 |
+
// Rock projectile
|
| 120 |
+
rockSpeed: 34,
|
| 121 |
+
rockGravity: 22,
|
| 122 |
+
rockDamage: 45,
|
| 123 |
+
rockLife: 7,
|
| 124 |
+
rockHitRadius: 0.9
|
| 125 |
+
},
|
| 126 |
gun: {
|
| 127 |
// Faster, closer to AK full-auto feel (~600 RPM is 10 RPS)
|
| 128 |
rof: 10.5,
|
src/enemies.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CFG } from './config.js';
|
|
| 3 |
import { G } from './globals.js';
|
| 4 |
import { getTerrainHeight, getNearbyTrees, hasLineOfSight } from './world.js';
|
| 5 |
import { spawnMuzzleFlashAt, spawnDustAt, spawnPortalAt } from './fx.js';
|
| 6 |
-
import { spawnEnemyArrow, spawnEnemyFireball } from './projectiles.js';
|
| 7 |
|
| 8 |
// Reusable temps
|
| 9 |
const TMPv1 = new THREE.Vector3();
|
|
@@ -376,6 +376,178 @@ export function spawnEnemy(type = 'orc') {
|
|
| 376 |
return;
|
| 377 |
}
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
// Create enemy mesh (orc with cute helmet + bow)
|
| 380 |
const enemyGroup = new THREE.Group();
|
| 381 |
|
|
@@ -616,7 +788,8 @@ export function updateEnemies(delta, onPlayerDeath) {
|
|
| 616 |
}
|
| 617 |
|
| 618 |
// Ranged conditions
|
| 619 |
-
|
|
|
|
| 620 |
// Throttled line-of-sight check using fast approximations
|
| 621 |
enemy.losTimer -= delta;
|
| 622 |
if (enemy.losTimer <= 0) {
|
|
@@ -636,6 +809,13 @@ export function updateEnemies(delta, onPlayerDeath) {
|
|
| 636 |
enemy.shootCooldown = 1 / CFG.enemy.rof;
|
| 637 |
spawnEnemyFireball(START, dir, false);
|
| 638 |
spawnMuzzleFlashAt(START, 0xff5a22);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
} else {
|
| 640 |
// Archer orc: ballistic arrow
|
| 641 |
const spread = CFG.enemy.bloom;
|
|
@@ -785,6 +965,63 @@ export function updateEnemies(delta, onPlayerDeath) {
|
|
| 785 |
// reset
|
| 786 |
enemy.muzzlePivot.position.z = enemy.muzzleBase || 1.5;
|
| 787 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
}
|
| 789 |
}
|
| 790 |
}
|
|
|
|
| 3 |
import { G } from './globals.js';
|
| 4 |
import { getTerrainHeight, getNearbyTrees, hasLineOfSight } from './world.js';
|
| 5 |
import { spawnMuzzleFlashAt, spawnDustAt, spawnPortalAt } from './fx.js';
|
| 6 |
+
import { spawnEnemyArrow, spawnEnemyFireball, spawnEnemyRock } from './projectiles.js';
|
| 7 |
|
| 8 |
// Reusable temps
|
| 9 |
const TMPv1 = new THREE.Vector3();
|
|
|
|
| 376 |
return;
|
| 377 |
}
|
| 378 |
|
| 379 |
+
if (type === 'golem') {
|
| 380 |
+
// Blocky golem from simple boxes, with arm/leg pivots and a throw anchor
|
| 381 |
+
const golem = new THREE.Group();
|
| 382 |
+
|
| 383 |
+
// Materials inspired by the provided model
|
| 384 |
+
const iron = new THREE.MeshStandardMaterial({ color: 0xE0E0E0, roughness: 0.95, metalness: 0.05, flatShading: true });
|
| 385 |
+
const ironDark = new THREE.MeshStandardMaterial({ color: 0xCFCFCF, roughness: 0.95, metalness: 0.05, flatShading: true });
|
| 386 |
+
const woodish = new THREE.MeshStandardMaterial({ color: 0x8A6B58, roughness: 1.0, metalness: 0.0, flatShading: true });
|
| 387 |
+
const vine = new THREE.MeshStandardMaterial({ color: 0x2f8f38, roughness: 0.9, metalness: 0.0, flatShading: true });
|
| 388 |
+
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x661c1c, emissive: 0x5b0a0a, emissiveIntensity: 0.9, roughness: 1.0, flatShading: true });
|
| 389 |
+
|
| 390 |
+
// Dimensions (scaled down later to match world scale)
|
| 391 |
+
const bodyW = 4.0, bodyH = 4.6, bodyD = 2.2;
|
| 392 |
+
const headW = 2.2, headH = 2.2, headD = 2.0;
|
| 393 |
+
const armW = 1.3, armD = 1.3, armL = 5.0;
|
| 394 |
+
const handW = 1.5, handH = 0.9, handD = 1.5;
|
| 395 |
+
const legW = 1.2, legD = 1.2, legH = 3.2;
|
| 396 |
+
const footW = 1.6, footD = 1.8, footH = 0.8;
|
| 397 |
+
const legGap = 0.6;
|
| 398 |
+
const legX = (legW + legGap) * 0.5;
|
| 399 |
+
const hipsY = footH + legH;
|
| 400 |
+
|
| 401 |
+
// Torso
|
| 402 |
+
const torso = new THREE.Mesh(new THREE.BoxGeometry(bodyW, bodyH, bodyD), iron);
|
| 403 |
+
torso.position.y = hipsY + bodyH * 0.5;
|
| 404 |
+
torso.castShadow = false; torso.receiveShadow = true;
|
| 405 |
+
golem.add(torso);
|
| 406 |
+
|
| 407 |
+
// Head pivot
|
| 408 |
+
const headPivot = new THREE.Group();
|
| 409 |
+
headPivot.position.set(0, hipsY + bodyH, 0);
|
| 410 |
+
golem.add(headPivot);
|
| 411 |
+
|
| 412 |
+
const head = new THREE.Mesh(new THREE.BoxGeometry(headW, headH, headD), ironDark);
|
| 413 |
+
head.position.y = headH * 0.5;
|
| 414 |
+
head.castShadow = false; head.receiveShadow = true;
|
| 415 |
+
headPivot.add(head);
|
| 416 |
+
|
| 417 |
+
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 1.0), woodish);
|
| 418 |
+
nose.position.set(0, 0.2, headD * 0.5 + 0.5);
|
| 419 |
+
head.add(nose);
|
| 420 |
+
|
| 421 |
+
const eyeGeom = new THREE.BoxGeometry(0.35, 0.35, 0.12);
|
| 422 |
+
const leftEye = new THREE.Mesh(eyeGeom, eyeMat);
|
| 423 |
+
const rightEye = new THREE.Mesh(eyeGeom, eyeMat);
|
| 424 |
+
leftEye.position.set(-0.45, 0.55, headD * 0.5 + 0.07);
|
| 425 |
+
rightEye.position.set(0.45, 0.55, headD * 0.5 + 0.07);
|
| 426 |
+
head.add(leftEye, rightEye);
|
| 427 |
+
|
| 428 |
+
// Shoulders
|
| 429 |
+
const shoulderY = hipsY + bodyH - 0.2;
|
| 430 |
+
const shoulderX = bodyW * 0.5 + armW * 0.5 - 0.1;
|
| 431 |
+
|
| 432 |
+
function buildArm(sign = 1) {
|
| 433 |
+
const pivot = new THREE.Group();
|
| 434 |
+
pivot.position.set(sign * shoulderX, shoulderY, 0);
|
| 435 |
+
golem.add(pivot);
|
| 436 |
+
const arm = new THREE.Mesh(new THREE.BoxGeometry(armW, armL, armD), iron);
|
| 437 |
+
arm.position.y = -armL * 0.5;
|
| 438 |
+
arm.castShadow = false; arm.receiveShadow = true;
|
| 439 |
+
pivot.add(arm);
|
| 440 |
+
const hand = new THREE.Mesh(new THREE.BoxGeometry(handW, handH, handD), ironDark);
|
| 441 |
+
hand.position.y = -armL * 0.5 - handH * 0.5;
|
| 442 |
+
arm.add(hand);
|
| 443 |
+
const creeper1 = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.3, 0.10), vine);
|
| 444 |
+
creeper1.position.set(sign * 0.35, -0.8, armD * 0.5 + 0.06);
|
| 445 |
+
arm.add(creeper1);
|
| 446 |
+
// Return pivots plus a handle to hand for projectile spawn anchor
|
| 447 |
+
return { pivot, arm, hand };
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
const leftArm = buildArm(-1);
|
| 451 |
+
const rightArm = buildArm(1);
|
| 452 |
+
|
| 453 |
+
// Rock spawn anchor at right hand
|
| 454 |
+
const projectileSpawn = new THREE.Object3D();
|
| 455 |
+
projectileSpawn.position.set(0, -armL * 0.5 - handH, 0);
|
| 456 |
+
rightArm.arm.add(projectileSpawn);
|
| 457 |
+
|
| 458 |
+
function buildLeg(sign = 1) {
|
| 459 |
+
const pivot = new THREE.Group();
|
| 460 |
+
pivot.position.set(sign * legX, hipsY, 0);
|
| 461 |
+
golem.add(pivot);
|
| 462 |
+
const leg = new THREE.Mesh(new THREE.BoxGeometry(legW, legH, legD), iron);
|
| 463 |
+
leg.position.y = -legH * 0.5;
|
| 464 |
+
leg.castShadow = false; leg.receiveShadow = true;
|
| 465 |
+
pivot.add(leg);
|
| 466 |
+
const foot = new THREE.Mesh(new THREE.BoxGeometry(footW, footH, footD), ironDark);
|
| 467 |
+
foot.position.y = -legH * 0.5 - footH * 0.5;
|
| 468 |
+
leg.add(foot);
|
| 469 |
+
return { pivot, leg };
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
const leftLeg = buildLeg(-1);
|
| 473 |
+
const rightLeg = buildLeg(1);
|
| 474 |
+
|
| 475 |
+
// A few vines on torso & leg
|
| 476 |
+
function addVine(target, x, y, z, h) {
|
| 477 |
+
const strip = new THREE.Mesh(new THREE.BoxGeometry(0.14, h, 0.12), vine);
|
| 478 |
+
strip.position.set(x, y, z);
|
| 479 |
+
strip.castShadow = false; strip.receiveShadow = true;
|
| 480 |
+
target.add(strip);
|
| 481 |
+
}
|
| 482 |
+
addVine(torso, 0.9, 0.3, bodyD * 0.5 + 0.07, 2.2);
|
| 483 |
+
addVine(torso, -0.2, -0.7, bodyD * 0.5 + 0.07, 1.6);
|
| 484 |
+
addVine(leftLeg.leg, -0.3, -0.2, legD * 0.5 + 0.07, 1.8);
|
| 485 |
+
|
| 486 |
+
// Scale to world; triple the previous size
|
| 487 |
+
const scale = 0.66; // 3x bigger than before
|
| 488 |
+
golem.scale.setScalar(scale);
|
| 489 |
+
|
| 490 |
+
// Place in world
|
| 491 |
+
golem.position.set(x, getTerrainHeight(x, z), z);
|
| 492 |
+
G.scene.add(golem);
|
| 493 |
+
|
| 494 |
+
// Hit proxies: scale-compensated so they remain hittable in world units
|
| 495 |
+
const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
|
| 496 |
+
const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
|
| 497 |
+
headPivot.add(proxyHead);
|
| 498 |
+
torso.add(proxyBody);
|
| 499 |
+
const inv = 1 / scale;
|
| 500 |
+
// Scale proxies up so hits match the larger body
|
| 501 |
+
const proxyScale = inv * 3.0;
|
| 502 |
+
proxyHead.scale.setScalar(proxyScale);
|
| 503 |
+
proxyBody.scale.setScalar(proxyScale);
|
| 504 |
+
proxyHead.userData = { enemy: null, hitZone: 'head' };
|
| 505 |
+
proxyBody.userData = { enemy: null, hitZone: 'body' };
|
| 506 |
+
|
| 507 |
+
const enemy = {
|
| 508 |
+
type: 'golem',
|
| 509 |
+
mesh: golem,
|
| 510 |
+
body: torso,
|
| 511 |
+
pos: golem.position,
|
| 512 |
+
radius: CFG.golem?.radius ?? 1.2,
|
| 513 |
+
hp: (CFG.golem?.hp ?? 260),
|
| 514 |
+
baseSpeed: (CFG.golem?.baseSpeed ?? 1.7) + (CFG.golem?.speedPerWave ?? 0.12) * (G.waves.current - 1),
|
| 515 |
+
damagePerSecond: CFG.golem?.dps ?? CFG.enemy.dps,
|
| 516 |
+
alive: true,
|
| 517 |
+
deathTimer: 0,
|
| 518 |
+
projectileSpawn,
|
| 519 |
+
shootCooldown: 0,
|
| 520 |
+
helmet: null,
|
| 521 |
+
helmetAttached: false,
|
| 522 |
+
// LOS throttling
|
| 523 |
+
losTimer: 0,
|
| 524 |
+
hasLOS: true,
|
| 525 |
+
hitProxies: [],
|
| 526 |
+
// Anim state
|
| 527 |
+
animT: 0,
|
| 528 |
+
headPivot,
|
| 529 |
+
armL: leftArm?.pivot,
|
| 530 |
+
armR: rightArm?.pivot,
|
| 531 |
+
legL: leftLeg?.pivot,
|
| 532 |
+
legR: rightLeg?.pivot,
|
| 533 |
+
// Throw state
|
| 534 |
+
throwing: false,
|
| 535 |
+
throwTimer: 0,
|
| 536 |
+
throwSpawned: false
|
| 537 |
+
};
|
| 538 |
+
|
| 539 |
+
golem.userData = { enemy };
|
| 540 |
+
torso.userData = { enemy, hitZone: 'body' };
|
| 541 |
+
head.userData = { enemy, hitZone: 'head' };
|
| 542 |
+
proxyHead.userData.enemy = enemy;
|
| 543 |
+
proxyBody.userData.enemy = enemy;
|
| 544 |
+
enemy.hitProxies.push(proxyBody, proxyHead);
|
| 545 |
+
|
| 546 |
+
G.enemies.push(enemy);
|
| 547 |
+
G.waves.aliveCount++;
|
| 548 |
+
return;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
// Create enemy mesh (orc with cute helmet + bow)
|
| 552 |
const enemyGroup = new THREE.Group();
|
| 553 |
|
|
|
|
| 788 |
}
|
| 789 |
|
| 790 |
// Ranged conditions
|
| 791 |
+
const rangedRange = (enemy.type === 'golem') ? (CFG.golem?.range ?? CFG.enemy.range) : CFG.enemy.range;
|
| 792 |
+
if (dist < rangedRange && enemy.alive && enemy.type !== 'wolf') {
|
| 793 |
// Throttled line-of-sight check using fast approximations
|
| 794 |
enemy.losTimer -= delta;
|
| 795 |
if (enemy.losTimer <= 0) {
|
|
|
|
| 809 |
enemy.shootCooldown = 1 / CFG.enemy.rof;
|
| 810 |
spawnEnemyFireball(START, dir, false);
|
| 811 |
spawnMuzzleFlashAt(START, 0xff5a22);
|
| 812 |
+
} else if (enemy.type === 'golem') {
|
| 813 |
+
// Begin a throw sequence; spawn occurs at windup time via anim block
|
| 814 |
+
enemy.throwing = true;
|
| 815 |
+
enemy.throwTimer = 0;
|
| 816 |
+
enemy.throwSpawned = false;
|
| 817 |
+
// Longer interval between throws
|
| 818 |
+
enemy.shootCooldown = (CFG.golem?.throwInterval ?? 1.6);
|
| 819 |
} else {
|
| 820 |
// Archer orc: ballistic arrow
|
| 821 |
const spread = CFG.enemy.bloom;
|
|
|
|
| 965 |
// reset
|
| 966 |
enemy.muzzlePivot.position.z = enemy.muzzleBase || 1.5;
|
| 967 |
}
|
| 968 |
+
} else if (enemy.type === 'golem') {
|
| 969 |
+
// Heavy, slower gait
|
| 970 |
+
enemy.animT += delta * Math.max(0.6, enemy.baseSpeed * 0.6);
|
| 971 |
+
const t = enemy.animT;
|
| 972 |
+
const swing = Math.sin(t * 1.2) * 0.32;
|
| 973 |
+
const counter = Math.sin(t * 1.2 + Math.PI) * 0.32;
|
| 974 |
+
if (!enemy.throwing) {
|
| 975 |
+
if (enemy.armL) enemy.armL.rotation.x = swing * 0.9;
|
| 976 |
+
if (enemy.armR) enemy.armR.rotation.x = counter * 0.9;
|
| 977 |
+
}
|
| 978 |
+
if (enemy.legL) enemy.legL.rotation.x = counter * 0.5;
|
| 979 |
+
if (enemy.legR) enemy.legR.rotation.x = swing * 0.5;
|
| 980 |
+
if (enemy.headPivot) enemy.headPivot.rotation.x = Math.sin(t * 2.0) * 0.05;
|
| 981 |
+
// Throw animation: windup then release
|
| 982 |
+
if (enemy.throwing && enemy.armR) {
|
| 983 |
+
const wind = CFG.golem?.throwWindup ?? 0.4;
|
| 984 |
+
enemy.throwTimer += delta;
|
| 985 |
+
const u = Math.min(1, enemy.throwTimer / Math.max(0.001, wind));
|
| 986 |
+
const back = -1.3; // windup angle
|
| 987 |
+
const fwd = 0.6; // follow-through
|
| 988 |
+
if (enemy.throwTimer < wind) {
|
| 989 |
+
// Ease to windup back
|
| 990 |
+
const e = u * (2 - u);
|
| 991 |
+
enemy.armR.rotation.x = back * e;
|
| 992 |
+
} else {
|
| 993 |
+
// After windup, snap forward quickly
|
| 994 |
+
const k = Math.min(1, (enemy.throwTimer - wind) / 0.18);
|
| 995 |
+
enemy.armR.rotation.x = back + (fwd - back) * (k * (2 - k));
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
// Spawn rock at windup moment
|
| 999 |
+
if (!enemy.throwSpawned && enemy.throwTimer >= wind) {
|
| 1000 |
+
const spread = (CFG.golem?.bloom ?? 0.01);
|
| 1001 |
+
if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.set(enemy.pos.x, enemy.pos.y + 2.2, enemy.pos.z);
|
| 1002 |
+
TARGET.set(G.player.pos.x, G.player.pos.y + 0.2, G.player.pos.z);
|
| 1003 |
+
TARGET.x += (G.random() - 0.5) * spread * 20;
|
| 1004 |
+
TARGET.y += (G.random() - 0.5) * spread * 8;
|
| 1005 |
+
TARGET.z += (G.random() - 0.5) * spread * 20;
|
| 1006 |
+
const vel = (function() {
|
| 1007 |
+
// Reuse ballistic helper using golem settings
|
| 1008 |
+
const speed = CFG.golem?.rockSpeed ?? CFG.enemy.arrowSpeed;
|
| 1009 |
+
const grav = CFG.golem?.rockGravity ?? CFG.enemy.arrowGravity;
|
| 1010 |
+
return (ballisticVelocity(START, TARGET, speed, grav, false));
|
| 1011 |
+
})();
|
| 1012 |
+
if (vel) {
|
| 1013 |
+
spawnEnemyRock(START, vel, true);
|
| 1014 |
+
spawnMuzzleFlashAt(START, 0xb0b0b0);
|
| 1015 |
+
}
|
| 1016 |
+
enemy.throwSpawned = true;
|
| 1017 |
+
}
|
| 1018 |
+
// End throw shortly after
|
| 1019 |
+
if (enemy.throwTimer >= wind + 0.32) {
|
| 1020 |
+
enemy.throwing = false;
|
| 1021 |
+
enemy.throwTimer = 0;
|
| 1022 |
+
enemy.throwSpawned = false;
|
| 1023 |
+
}
|
| 1024 |
+
}
|
| 1025 |
}
|
| 1026 |
}
|
| 1027 |
}
|
src/globals.js
CHANGED
|
@@ -105,7 +105,8 @@ export const G = {
|
|
| 105 |
inBreak: false,
|
| 106 |
spawnAnchor: null,
|
| 107 |
shamansToSpawn: 0,
|
| 108 |
-
wolvesToSpawn: 0
|
|
|
|
| 109 |
},
|
| 110 |
|
| 111 |
random: null,
|
|
|
|
| 105 |
inBreak: false,
|
| 106 |
spawnAnchor: null,
|
| 107 |
shamansToSpawn: 0,
|
| 108 |
+
wolvesToSpawn: 0,
|
| 109 |
+
golemsToSpawn: 0
|
| 110 |
},
|
| 111 |
|
| 112 |
random: null,
|
src/grenades.js
CHANGED
|
@@ -160,8 +160,12 @@ function explodeAt(position) {
|
|
| 160 |
G.waves.aliveCount--;
|
| 161 |
// Award score like a body kill
|
| 162 |
G.player.score += 10;
|
| 163 |
-
//
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
}
|
| 167 |
}
|
|
|
|
| 160 |
G.waves.aliveCount--;
|
| 161 |
// Award score like a body kill
|
| 162 |
G.player.score += 10;
|
| 163 |
+
// Heals: larger drop for golems
|
| 164 |
+
if (e.type === 'golem') {
|
| 165 |
+
spawnHealthOrbs(e.pos, 15 + Math.floor(G.random() * 6)); // 15..20
|
| 166 |
+
} else {
|
| 167 |
+
spawnHealthOrbs(e.pos, 1 + Math.floor(G.random() * 3));
|
| 168 |
+
}
|
| 169 |
}
|
| 170 |
}
|
| 171 |
}
|
src/main.js
CHANGED
|
@@ -170,6 +170,8 @@ function startGame() {
|
|
| 170 |
G.waves.breakTimer = 0;
|
| 171 |
G.waves.inBreak = false;
|
| 172 |
G.waves.wolvesToSpawn = 0;
|
|
|
|
|
|
|
| 173 |
|
| 174 |
// Reset weapon
|
| 175 |
G.weapon.ammo = CFG.gun.magSize;
|
|
|
|
| 170 |
G.waves.breakTimer = 0;
|
| 171 |
G.waves.inBreak = false;
|
| 172 |
G.waves.wolvesToSpawn = 0;
|
| 173 |
+
G.waves.shamansToSpawn = 0;
|
| 174 |
+
G.waves.golemsToSpawn = 0;
|
| 175 |
|
| 176 |
// Reset weapon
|
| 177 |
G.weapon.ammo = CFG.gun.magSize;
|
src/pickups.js
CHANGED
|
@@ -12,7 +12,8 @@ const ORB_GEO = new THREE.SphereGeometry(0.12, 14, 12);
|
|
| 12 |
|
| 13 |
// Spawns N small glowing green health orbs around a position
|
| 14 |
export function spawnHealthOrbs(center, count) {
|
| 15 |
-
|
|
|
|
| 16 |
for (let i = 0; i < n; i++) {
|
| 17 |
const group = new THREE.Group();
|
| 18 |
|
|
|
|
| 12 |
|
| 13 |
// Spawns N small glowing green health orbs around a position
|
| 14 |
export function spawnHealthOrbs(center, count) {
|
| 15 |
+
// Allow larger drops (e.g., golem 15–20); cap to keep it reasonable
|
| 16 |
+
const n = Math.max(1, Math.min(30, Math.floor(count)));
|
| 17 |
for (let i = 0; i < n; i++) {
|
| 18 |
const group = new THREE.Group();
|
| 19 |
|
src/projectiles.js
CHANGED
|
@@ -27,6 +27,12 @@ const FIREBALL = (() => {
|
|
| 27 |
const UP = new THREE.Vector3(0, 1, 0);
|
| 28 |
const TMPv = new THREE.Vector3();
|
| 29 |
const TMPq = new THREE.Quaternion();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
// Spawns a visible enemy arrow projectile
|
| 32 |
export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) {
|
|
@@ -112,14 +118,46 @@ export function spawnEnemyFireball(start, dirOrVel, asVelocity = false) {
|
|
| 112 |
G.enemyProjectiles.push(projectile);
|
| 113 |
}
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
| 116 |
const gravity = CFG.enemy.arrowGravity;
|
| 117 |
|
| 118 |
for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) {
|
| 119 |
const p = G.enemyProjectiles[i];
|
| 120 |
|
| 121 |
-
// Integrate
|
| 122 |
if (p.kind === 'arrow') p.vel.y -= gravity * delta;
|
|
|
|
| 123 |
p.pos.addScaledVector(p.vel, delta);
|
| 124 |
|
| 125 |
// Re-orient to velocity
|
|
@@ -144,6 +182,7 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
|
| 144 |
TMPv.set(p.pos.x, gy + 0.02, p.pos.z);
|
| 145 |
spawnImpact(TMPv, UP);
|
| 146 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(TMPv, 0xff5522);
|
|
|
|
| 147 |
G.scene.remove(p.mesh);
|
| 148 |
G.enemyProjectiles.splice(i, 1);
|
| 149 |
continue;
|
|
@@ -156,10 +195,12 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
|
| 156 |
const dx = p.pos.x - tree.x;
|
| 157 |
const dz = p.pos.z - tree.z;
|
| 158 |
const dist2 = dx * dx + dz * dz;
|
| 159 |
-
const
|
|
|
|
| 160 |
if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish
|
| 161 |
spawnImpact(p.pos, UP);
|
| 162 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
|
|
|
|
| 163 |
G.scene.remove(p.mesh);
|
| 164 |
G.enemyProjectiles.splice(i, 1);
|
| 165 |
continue;
|
|
@@ -167,10 +208,18 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
|
| 167 |
}
|
| 168 |
|
| 169 |
// Player collision (sphere)
|
| 170 |
-
const hitR =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const pr = hitR + G.player.radius * 0.6; // slightly generous
|
| 172 |
if (p.pos.distanceTo(G.player.pos) < pr) {
|
| 173 |
-
const dmg =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
G.player.health -= dmg;
|
| 175 |
G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP);
|
| 176 |
if (G.player.health <= 0 && G.player.alive) {
|
|
@@ -180,6 +229,7 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
|
| 180 |
}
|
| 181 |
spawnImpact(p.pos, UP);
|
| 182 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
|
|
|
|
| 183 |
G.scene.remove(p.mesh);
|
| 184 |
G.enemyProjectiles.splice(i, 1);
|
| 185 |
continue;
|
|
|
|
| 27 |
const UP = new THREE.Vector3(0, 1, 0);
|
| 28 |
const TMPv = new THREE.Vector3();
|
| 29 |
const TMPq = new THREE.Quaternion();
|
| 30 |
+
// Shared jagged rock geometry/material
|
| 31 |
+
const ROCK = (() => {
|
| 32 |
+
const geo = new THREE.DodecahedronGeometry(0.6, 0);
|
| 33 |
+
const mat = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 1.0, metalness: 0.0 });
|
| 34 |
+
return { geo, mat };
|
| 35 |
+
})();
|
| 36 |
|
| 37 |
// Spawns a visible enemy arrow projectile
|
| 38 |
export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) {
|
|
|
|
| 118 |
G.enemyProjectiles.push(projectile);
|
| 119 |
}
|
| 120 |
|
| 121 |
+
// Spawns a heavy arcing rock projectile
|
| 122 |
+
export function spawnEnemyRock(start, dirOrVel, asVelocity = false) {
|
| 123 |
+
const speed = CFG.golem?.rockSpeed ?? 30;
|
| 124 |
+
const group = new THREE.Group();
|
| 125 |
+
const rock = new THREE.Mesh(ROCK.geo, ROCK.mat);
|
| 126 |
+
rock.castShadow = true; rock.receiveShadow = true;
|
| 127 |
+
group.add(rock);
|
| 128 |
+
|
| 129 |
+
// Orientation based on throw direction
|
| 130 |
+
const nd = dirOrVel.clone();
|
| 131 |
+
if (!asVelocity) nd.normalize();
|
| 132 |
+
TMPq.setFromUnitVectors(UP, nd.clone().normalize());
|
| 133 |
+
group.quaternion.copy(TMPq);
|
| 134 |
+
|
| 135 |
+
group.position.copy(start);
|
| 136 |
+
G.scene.add(group);
|
| 137 |
+
|
| 138 |
+
const vel = asVelocity
|
| 139 |
+
? dirOrVel.clone()
|
| 140 |
+
: nd.normalize().multiplyScalar(speed);
|
| 141 |
+
|
| 142 |
+
const projectile = {
|
| 143 |
+
kind: 'rock',
|
| 144 |
+
mesh: group,
|
| 145 |
+
pos: group.position,
|
| 146 |
+
vel,
|
| 147 |
+
life: CFG.golem?.rockLife ?? 6
|
| 148 |
+
};
|
| 149 |
+
G.enemyProjectiles.push(projectile);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
export function updateEnemyProjectiles(delta, onPlayerDeath) {
|
| 153 |
const gravity = CFG.enemy.arrowGravity;
|
| 154 |
|
| 155 |
for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) {
|
| 156 |
const p = G.enemyProjectiles[i];
|
| 157 |
|
| 158 |
+
// Integrate gravity by projectile kind
|
| 159 |
if (p.kind === 'arrow') p.vel.y -= gravity * delta;
|
| 160 |
+
else if (p.kind === 'rock') p.vel.y -= (CFG.golem?.rockGravity ?? gravity) * delta;
|
| 161 |
p.pos.addScaledVector(p.vel, delta);
|
| 162 |
|
| 163 |
// Re-orient to velocity
|
|
|
|
| 182 |
TMPv.set(p.pos.x, gy + 0.02, p.pos.z);
|
| 183 |
spawnImpact(TMPv, UP);
|
| 184 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(TMPv, 0xff5522);
|
| 185 |
+
if (p.kind === 'rock') spawnMuzzleFlashAt(TMPv, 0xb0b0b0);
|
| 186 |
G.scene.remove(p.mesh);
|
| 187 |
G.enemyProjectiles.splice(i, 1);
|
| 188 |
continue;
|
|
|
|
| 195 |
const dx = p.pos.x - tree.x;
|
| 196 |
const dz = p.pos.z - tree.z;
|
| 197 |
const dist2 = dx * dx + dz * dz;
|
| 198 |
+
const pad = p.kind === 'rock' ? 0.6 : 0.2; // rocks are bulkier
|
| 199 |
+
const r = tree.radius + pad;
|
| 200 |
if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish
|
| 201 |
spawnImpact(p.pos, UP);
|
| 202 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
|
| 203 |
+
if (p.kind === 'rock') spawnMuzzleFlashAt(p.pos, 0xb0b0b0);
|
| 204 |
G.scene.remove(p.mesh);
|
| 205 |
G.enemyProjectiles.splice(i, 1);
|
| 206 |
continue;
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
// Player collision (sphere)
|
| 211 |
+
const hitR = (
|
| 212 |
+
p.kind === 'arrow' ? CFG.enemy.arrowHitRadius :
|
| 213 |
+
p.kind === 'fireball' ? CFG.shaman.fireballHitRadius :
|
| 214 |
+
CFG.golem?.rockHitRadius ?? 0.9
|
| 215 |
+
);
|
| 216 |
const pr = hitR + G.player.radius * 0.6; // slightly generous
|
| 217 |
if (p.pos.distanceTo(G.player.pos) < pr) {
|
| 218 |
+
const dmg = (
|
| 219 |
+
p.kind === 'arrow' ? CFG.enemy.arrowDamage :
|
| 220 |
+
p.kind === 'fireball' ? CFG.shaman.fireballDamage :
|
| 221 |
+
CFG.golem?.rockDamage ?? 40
|
| 222 |
+
);
|
| 223 |
G.player.health -= dmg;
|
| 224 |
G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP);
|
| 225 |
if (G.player.health <= 0 && G.player.alive) {
|
|
|
|
| 229 |
}
|
| 230 |
spawnImpact(p.pos, UP);
|
| 231 |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
|
| 232 |
+
if (p.kind === 'rock') spawnMuzzleFlashAt(p.pos, 0xb0b0b0);
|
| 233 |
G.scene.remove(p.mesh);
|
| 234 |
G.enemyProjectiles.splice(i, 1);
|
| 235 |
continue;
|
src/waves.js
CHANGED
|
@@ -15,6 +15,7 @@ export function startNextWave() {
|
|
| 15 |
G.waves.nextSpawnTimer = 0;
|
| 16 |
G.waves.shamansToSpawn = 1; // exactly 1 shaman per wave
|
| 17 |
G.waves.wolvesToSpawn = 2; // exactly 2 wolves per wave
|
|
|
|
| 18 |
|
| 19 |
// Choose a single spawn anchor for this wave (not near the center)
|
| 20 |
const half = CFG.forestSize / 2;
|
|
@@ -48,7 +49,10 @@ export function updateWaves(delta) {
|
|
| 48 |
if (G.waves.spawnQueue > 0 && G.waves.aliveCount < CFG.waves.maxAlive) {
|
| 49 |
G.waves.nextSpawnTimer -= delta;
|
| 50 |
if (G.waves.nextSpawnTimer <= 0) {
|
| 51 |
-
if (G.waves.
|
|
|
|
|
|
|
|
|
|
| 52 |
spawnEnemy('shaman');
|
| 53 |
G.waves.shamansToSpawn--;
|
| 54 |
} else if (G.waves.wolvesToSpawn > 0) {
|
|
|
|
| 15 |
G.waves.nextSpawnTimer = 0;
|
| 16 |
G.waves.shamansToSpawn = 1; // exactly 1 shaman per wave
|
| 17 |
G.waves.wolvesToSpawn = 2; // exactly 2 wolves per wave
|
| 18 |
+
G.waves.golemsToSpawn = 1; // exactly 1 golem per wave
|
| 19 |
|
| 20 |
// Choose a single spawn anchor for this wave (not near the center)
|
| 21 |
const half = CFG.forestSize / 2;
|
|
|
|
| 49 |
if (G.waves.spawnQueue > 0 && G.waves.aliveCount < CFG.waves.maxAlive) {
|
| 50 |
G.waves.nextSpawnTimer -= delta;
|
| 51 |
if (G.waves.nextSpawnTimer <= 0) {
|
| 52 |
+
if (G.waves.golemsToSpawn > 0) {
|
| 53 |
+
spawnEnemy('golem');
|
| 54 |
+
G.waves.golemsToSpawn--;
|
| 55 |
+
} else if (G.waves.shamansToSpawn > 0) {
|
| 56 |
spawnEnemy('shaman');
|
| 57 |
G.waves.shamansToSpawn--;
|
| 58 |
} else if (G.waves.wolvesToSpawn > 0) {
|