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
Files changed (9) hide show
  1. src/combat.js +8 -2
  2. src/config.js +19 -0
  3. src/enemies.js +239 -2
  4. src/globals.js +2 -1
  5. src/grenades.js +6 -2
  6. src/main.js +2 -0
  7. src/pickups.js +2 -1
  8. src/projectiles.js +54 -4
  9. 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
- const cnt = 1 + Math.floor(G.random() * 5);
160
- spawnHealthOrbs(enemy.pos, cnt);
 
 
 
 
 
 
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
- if (dist < CFG.enemy.range && enemy.alive && enemy.type !== 'wolf') {
 
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
- // Modest heals
164
- spawnHealthOrbs(e.pos, 1 + Math.floor(G.random() * 3));
 
 
 
 
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
- const n = Math.max(1, Math.min(5, Math.floor(count)));
 
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 (gravity only affects arrows)
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 r = tree.radius + 0.2; // small allowance for arrow
 
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 = p.kind === 'arrow' ? CFG.enemy.arrowHitRadius : CFG.shaman.fireballHitRadius;
 
 
 
 
171
  const pr = hitR + G.player.radius * 0.6; // slightly generous
172
  if (p.pos.distanceTo(G.player.pos) < pr) {
173
- const dmg = p.kind === 'arrow' ? CFG.enemy.arrowDamage : CFG.shaman.fireballDamage;
 
 
 
 
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.shamansToSpawn > 0) {
 
 
 
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) {