Files
Tectum/js/core/shooter.js
T

470 lines
20 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/shooter.js — easter egg: schieten in de loopmodus (Ctrl+Shift+Alt+L).
// klik in beeld muis vangen (Esc = loslaten)
// linkermuisknop vuren
// 1 / 2 / scrollwiel pistool / raketwerper
// Kogelgaten en roetplekken blijven op wanden/dak/vloer achter, raketten
// laten ook wat rondvliegend metselwerk achter. Bewust simpel en buggy.
// Plain script (geen ES-module); gebruikt THREE.
//
// const gun = createShooter({ scene, camera, domElement, pane, walk, targets: () => [..] });
// gun.start(); gun.update(dt); gun.stop(); gun.dispose();
// ============================================================
function createShooter(o) {
const { scene, camera, domElement, pane, walk } = o;
const group = new THREE.Group();
group.name = 'shooter';
let active = false, weapon = 0, cooldown = 0, recoil = 0;
const decals = [], rockets = [], blasts = [], debris = [];
const MAX_DECALS = 250;
// --- Texturen (canvas) ---
function canvasTex(size, draw) {
const c = document.createElement('canvas');
c.width = c.height = size;
draw(c.getContext('2d'), size);
const t = new THREE.CanvasTexture(c);
return t;
}
const holeTex = canvasTex(64, (g, s) => {
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
r.addColorStop(0, 'rgba(0,0,0,1)'); r.addColorStop(0.18, 'rgba(10,8,6,1)');
r.addColorStop(0.3, 'rgba(60,50,40,0.8)'); r.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = r; g.fillRect(0, 0, s, s);
g.strokeStyle = 'rgba(20,15,10,0.7)'; g.lineWidth = 1.5;
for (let i = 0; i < 6; i++) {
const a = Math.random() * Math.PI * 2, l = s * (0.25 + Math.random() * 0.2);
g.beginPath(); g.moveTo(s / 2, s / 2); g.lineTo(s / 2 + Math.cos(a) * l, s / 2 + Math.sin(a) * l); g.stroke();
}
});
const scorchTex = canvasTex(128, (g, s) => {
for (let i = 0; i < 40; i++) {
const x = s / 2 + (Math.random() - 0.5) * s * 0.5, y = s / 2 + (Math.random() - 0.5) * s * 0.5;
const rr = s * (0.1 + Math.random() * 0.25);
const r = g.createRadialGradient(x, y, 0, x, y, rr);
r.addColorStop(0, 'rgba(15,10,5,0.5)'); r.addColorStop(1, 'rgba(15,10,5,0)');
g.fillStyle = r; g.fillRect(0, 0, s, s);
}
g.fillStyle = 'rgba(255,120,20,0.6)';
for (let i = 0; i < 25; i++) g.fillRect(s / 2 + (Math.random() - 0.5) * s * 0.4, s / 2 + (Math.random() - 0.5) * s * 0.4, 2, 2);
});
const fireTex = canvasTex(64, (g, s) => {
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
r.addColorStop(0, 'rgba(255,255,220,1)'); r.addColorStop(0.3, 'rgba(255,180,40,0.9)');
r.addColorStop(0.7, 'rgba(200,50,0,0.4)'); r.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = r; g.fillRect(0, 0, s, s);
});
// Puin krijgt een willekeurige steentextuur uit de bibliotheek (als die laadt).
const brickTex = new THREE.TextureLoader().load(['assets/textures/buitenmuur/R200 - Witte voeg.jpg', 'assets/textures/buitenmuur/K739 - Witte voeg.jpg', 'assets/textures/buitenmuur/R480 - Witte voeg.jpg'][Math.floor(Math.random() * 3)]);
const decalMat = (map) => new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 });
const holeMat = decalMat(holeTex), scorchMat = decalMat(scorchTex);
const debrisMat = new THREE.MeshStandardMaterial({ map: brickTex, roughness: 0.9 });
const fireMat = new THREE.SpriteMaterial({ map: fireTex, blending: THREE.AdditiveBlending, depthWrite: false });
// --- Wapens (vastgeplakt aan de camera) ---
const metal = new THREE.MeshStandardMaterial({ color: 0x2a2c30, metalness: 0.7, roughness: 0.35 });
const olive = new THREE.MeshStandardMaterial({ color: 0x4b5a32, roughness: 0.6 });
function box(w, h, d, m, x, y, z) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); return b; }
const pistol = new THREE.Group();
pistol.add(box(0.035, 0.045, 0.2, metal, 0, 0, -0.1), box(0.03, 0.09, 0.04, metal, 0, -0.055, -0.02));
const launcher = new THREE.Group();
const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7, 12, 1, true), olive);
tube.material.side = THREE.DoubleSide;
tube.rotation.x = Math.PI / 2; tube.position.z = -0.25;
launcher.add(tube, box(0.03, 0.09, 0.04, metal, 0, -0.08, -0.05), box(0.02, 0.05, 0.03, metal, 0.05, 0.05, -0.2));
const flash = new THREE.Sprite(fireMat.clone());
flash.scale.set(0.15, 0.15, 1);
flash.visible = false;
const viewModel = new THREE.Group();
viewModel.add(pistol, launcher, flash);
const gunLight = new THREE.PointLight(0xffaa44, 0, 6);
group.add(viewModel, gunLight);
// --- HUD ---
const hud = document.createElement('div');
hud.className = 'shooter-hud';
hud.innerHTML = '<div class="shooter-cross"></div><div class="shooter-weapon"></div><div class="shooter-credit"></div><div class="shooter-kills"></div><div class="shooter-shout"></div><div class="shooter-ouch"></div><div class="shooter-announce"></div><div class="shooter-help">Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen</div>';
pane.appendChild(hud);
hud.querySelector('.shooter-credit').textContent = GrannyKit.MUSIC_CREDIT;
const weaponLabel = hud.querySelector('.shooter-weapon');
const killsLabel = hud.querySelector('.shooter-kills');
const shoutLabel = hud.querySelector('.shooter-shout');
const ouch = hud.querySelector('.shooter-ouch');
let shoutTimer = 0;
function shout(text) { shoutLabel.textContent = text; shoutLabel.classList.add('show'); shoutTimer = 2.5; }
function setKills(n) { killsLabel.textContent = "Oma's: " + n; }
const announcer = GrannyKit.createAnnouncer(hud.querySelector('.shooter-announce'));
function selectWeapon(i) {
weapon = (i + 2) % 2;
pistol.visible = weapon === 0;
launcher.visible = weapon === 1;
weaponLabel.textContent = weapon === 0 ? '[1] Pistool ∞' : '[2] Raketwerper ∞';
flash.position.set(0, 0, weapon === 0 ? -0.22 : -0.62);
}
selectWeapon(0);
// --- Vijand: oud vrouwtje met rollator (8-bit sprite, Doom-stijl) ---
// Eén tegelijk: spawnt op 12-18 m, schuifelt op je af, na de kill komt de volgende.
function spriteTex(rows, tint) {
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.PAL, { size: 32, tint, bottom: true }));
t.magFilter = t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
return t;
}
const ladyTex = [spriteTex(GrannyKit.LADY), spriteTex(GrannyKit.LADY_B)];
const ladyHurtTex = spriteTex(GrannyKit.LADY, '#ff3030');
const LADY_H = 1.6 * 32 / 25; // sprite-hoogte (m) zodat de figuur ~1,6 m is
let mob = null, spawnTimer = 1.5, attackTimer = 0, animT = 0;
function spawnMob() {
const me = camera.position;
let x = me.x, z = me.z;
for (let i = 0; i < 15; i++) {
const a = Math.random() * Math.PI * 2, d = 12 + Math.random() * 6;
x = me.x + Math.sin(a) * d; z = me.z + Math.cos(a) * d;
if (!o.blocked || !o.blocked(x, z)) break;
}
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: ladyTex[0], transparent: true, alphaTest: 0.5 }));
sp.scale.set(LADY_H, LADY_H, 1);
group.add(sp);
mob = { sp, x, z, hp: 3, alive: true, hurt: 0, dead: 0 };
placeMob();
shout(GrannyKit.pick(GrannyKit.SHOUTS));
}
function mobFloor(x, z) { return o.floorAt ? o.floorAt(x, z) : 0; }
function placeMob() { mob.sp.position.set(mob.x, mobFloor(mob.x, mob.z) + LADY_H / 2 - (mob.dead ? LADY_H * 0.3 : 0), mob.z); }
function mobCenter() { return new THREE.Vector3(mob.x, mobFloor(mob.x, mob.z) + 0.8, mob.z); }
// Straal tegen een staande cilinder (r 0,4 m, 1,6 m hoog) om de oma.
function rayHitsMob(origin, dir, maxDist) {
if (!mob || !mob.alive) return false;
const c = mobCenter();
const t = c.clone().sub(origin).dot(dir);
if (t < 0 || t > maxDist) return false;
const p = origin.clone().addScaledVector(dir, t);
return Math.hypot(p.x - c.x, p.z - c.z) < 0.4 && Math.abs(p.y - c.y) < 0.8;
}
const gibTex = GrannyKit.GIBS.map(rows => {
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.GIB_PAL, { size: 8 }));
t.magFilter = t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
return t;
});
const bloodTex = canvasTex(32, (g) => {
g.fillStyle = '#7a0a0a';
for (let i = 0; i < 14; i++) {
const a = Math.random() * 6.28, d = Math.random() * 10, r = 2 + Math.random() * 5;
g.beginPath(); g.arc(16 + Math.cos(a) * d, 16 + Math.sin(a) * d, r, 0, 6.28); g.fill();
}
});
bloodTex.magFilter = THREE.NearestFilter;
const bloodMat = decalMat(bloodTex);
function spawnGibs(center, count) {
for (let i = 0; i < count; i++) {
// Vooral vlees, plus een paar herkenbare stukken.
const k = i < count * 0.5 ? Math.floor(Math.random() * 2) : Math.floor(Math.random() * GrannyKit.GIBS.length);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: gibTex[k], alphaTest: 0.5 }));
const sz = 0.18 + Math.random() * 0.14;
sp.scale.set(sz, sz, 1);
sp.position.copy(center).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.4));
const v = new THREE.Vector3((Math.random() - 0.5) * 9, 2 + Math.random() * 6, (Math.random() - 0.5) * 9);
group.add(sp);
debris.push({ mesh: sp, v, spin: new THREE.Vector3((Math.random() - 0.5) * 20, 0, 0), rest: false, gib: true });
}
// Plas onder de plek des onheils.
bloodSplat(center.x, center.z, 1.4);
trimDebris();
}
function bloodSplat(x, z, size) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), bloodMat);
m.rotation.set(-Math.PI / 2, 0, Math.random() * 6.28);
m.position.set(x, mobFloor(x, z) + 0.006, z);
group.add(m);
decals.push(m);
}
function trimDebris() {
while (debris.length > 200) {
const old = debris.shift();
group.remove(old.mesh);
if (old.mesh.isSprite) old.mesh.material.dispose(); else old.mesh.geometry.dispose();
}
}
function damageMob(n, gib) {
if (!mob || !mob.alive) return;
mob.hp -= n;
mob.hurt = 0.15;
spawnBlast(mobCenter(), 0.4, 0.2);
if (mob.hp > 0) { bang(0.2, 900, 0.3); return; }
mob.alive = false;
mob.dead = 0.001;
mob.sp.material.map = ladyHurtTex;
mob.sp.material.rotation = Math.PI / 2; // omgevallen
if (gib) { mob.sp.visible = false; mob.dead = 2.4; spawnGibs(mobCenter(), 22); }
else spawnGibs(mobCenter(), 5);
setKills(announcer.kill());
shout(GrannyKit.pick(GrannyKit.DEATH_SHOUTS));
spawnTimer = 1;
}
function updateMob(t) {
if (!mob) { spawnTimer -= t; if (spawnTimer <= 0) spawnMob(); return; }
if (!mob.alive) {
mob.dead += t;
mob.sp.material.opacity = Math.max(0, 1 - (mob.dead - 1.5));
mob.sp.material.transparent = true;
placeMob();
if (mob.dead > 2.5) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
return;
}
const me = camera.position;
const dx = me.x - mob.x, dz = me.z - mob.z, dist = Math.hypot(dx, dz);
if (dist > 0.9) {
const v = 1.3 * t / dist;
const nx = mob.x + dx * v, nz = mob.z + dz * v;
if (!o.blocked || !o.blocked(nx, nz)) { mob.x = nx; mob.z = nz; }
else if (!o.blocked(nx, mob.z)) mob.x = nx;
else if (!o.blocked(mob.x, nz)) mob.z = nz;
animT += t;
} else {
attackTimer -= t;
if (attackTimer <= 0) {
attackTimer = 1.2;
bang(0.1, 400, 0.6);
ouch.classList.remove('show'); void ouch.offsetWidth; ouch.classList.add('show');
announcer.hurt();
shout(GrannyKit.pick(GrannyKit.HIT_SHOUTS));
}
}
mob.hurt -= t;
mob.sp.material.map = mob.hurt > 0 ? ladyHurtTex : ladyTex[Math.floor(animT * 4) % 2];
placeMob();
}
const music = GrannyKit.createMusic();
const sfx = GrannyKit.createSfx();
const bang = sfx.bang;
// --- Raken ---
const ray = new THREE.Raycaster();
function hitScan(origin, dir, far) {
ray.set(origin, dir);
ray.far = far;
const hits = ray.intersectObjects(o.targets().filter(t => t && t.visible), true);
return hits.find(h => h.face && h.object.isMesh && isVisible(h.object)) || null;
}
function isVisible(obj) {
for (let p = obj; p; p = p.parent) if (!p.visible) return false;
return true;
}
function addDecal(hit, mat, size) {
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
if (n.dot(ray.ray.direction) > 0) n.negate(); // altijd de kant waar je vandaan schiet
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), mat);
m.position.copy(hit.point).addScaledVector(n, 0.004);
m.lookAt(hit.point.clone().add(n));
m.rotateZ(Math.random() * Math.PI * 2);
group.add(m);
decals.push(m);
if (decals.length > MAX_DECALS) { const old = decals.shift(); group.remove(old); old.geometry.dispose(); }
}
const tmpDir = new THREE.Vector3();
function fire() {
if (cooldown > 0) return;
camera.getWorldDirection(tmpDir);
// Lichte spreiding, het blijft een configurator.
tmpDir.x += (Math.random() - 0.5) * 0.01; tmpDir.y += (Math.random() - 0.5) * 0.01;
tmpDir.normalize();
flash.visible = true;
flash.material.rotation = Math.random() * 6;
gunLight.intensity = 2;
if (weapon === 0) {
cooldown = 0.18; recoil = 1;
bang(0.15, 3000, 0.5);
const hit = hitScan(camera.position, tmpDir, 200);
if (rayHitsMob(camera.position, tmpDir, hit ? hit.distance : 200)) damageMob(1);
else if (hit) {
addDecal(hit, holeMat, 0.05 + Math.random() * 0.03);
spawnBlast(hit.point, 0.15, 0.12);
}
} else {
cooldown = 0.8; recoil = 2.5;
bang(0.5, 600, 0.6);
const r = new THREE.Group();
r.add(box(0.06, 0.06, 0.3, olive, 0, 0, 0));
const tail = new THREE.Sprite(fireMat);
tail.scale.set(0.3, 0.3, 1); tail.position.z = 0.2;
r.add(tail);
r.position.copy(camera.position).addScaledVector(tmpDir, 0.6);
r.lookAt(r.position.clone().add(tmpDir));
group.add(r);
rockets.push({ mesh: r, dir: tmpDir.clone(), speed: 14, life: 6 });
}
}
function spawnBlast(pos, size, life) {
const s = new THREE.Sprite(fireMat.clone());
s.position.copy(pos);
s.scale.set(size, size, 1);
group.add(s);
blasts.push({ s, size, life, t: 0 });
}
function explode(hit, point, normal) {
bang(1.2, 300, 1);
if (hit) {
addDecal(hit, scorchMat, 1.2 + Math.random() * 0.6);
point = hit.point;
normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
if (normal.dot(ray.ray.direction) > 0) normal.negate();
}
spawnBlast(point, 2.5, 0.5);
const light = new THREE.PointLight(0xff8833, 6, 12);
light.position.copy(point);
group.add(light);
blasts.push({ light, life: 0.5, t: 0 });
if (mob && mob.alive && mobCenter().distanceTo(point) < 3) damageMob(99, true);
if (!hit) return; // direct op de oma: alleen gibs, geen stenen
const n = normal;
for (let i = 0; i < 12; i++) {
const sz = 0.05 + Math.random() * 0.12;
const d = new THREE.Mesh(new THREE.BoxGeometry(sz * 2, sz, sz), debrisMat);
d.position.copy(hit.point).addScaledVector(n, 0.1);
d.rotation.set(Math.random() * 6, Math.random() * 6, 0);
const v = n.clone().multiplyScalar(2 + Math.random() * 4).add(new THREE.Vector3((Math.random() - 0.5) * 6, Math.random() * 5, (Math.random() - 0.5) * 6));
group.add(d);
debris.push({ mesh: d, v, spin: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 0), rest: false });
}
trimDebris();
}
// --- Invoer ---
function onClick(e) {
if (!active) return;
if (document.pointerLockElement !== domElement) { domElement.requestPointerLock && domElement.requestPointerLock(); return; }
if (e.button === 0) fire();
}
function onMouseMove(e) {
if (!active || document.pointerLockElement !== domElement) return;
walk.look(-e.movementX * 0.0025, -e.movementY * 0.0025);
}
function onWheel(e) {
if (!active) return;
e.preventDefault();
selectWeapon(weapon + (e.deltaY > 0 ? 1 : -1));
}
function onKey(e) {
if (!active || e.ctrlKey) return;
if (e.key === '1') selectWeapon(0);
if (e.key === '2') selectWeapon(1);
}
domElement.addEventListener('mousedown', onClick);
document.addEventListener('mousemove', onMouseMove);
domElement.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('keydown', onKey);
function clear() {
if (mob) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
shoutLabel.classList.remove('show');
[...decals, ...debris.map(d => d.mesh), ...rockets.map(r => r.mesh), ...blasts.map(b => b.s || b.light)].forEach(m => group.remove(m));
decals.length = debris.length = rockets.length = blasts.length = 0;
}
return {
get active() { return active; },
start() {
active = true;
scene.add(group);
hud.classList.add('show');
announcer.reset();
setKills(0);
spawnTimer = 1.5;
music.play();
},
stop() {
active = false;
if (document.pointerLockElement === domElement) document.exitPointerLock();
clear();
scene.remove(group);
hud.classList.remove('show');
music.stop();
announcer.stop();
},
update(dt) {
if (!active) return;
const t = Math.min(dt, 0.05);
cooldown -= t;
// Wapen volgt de camera, met wat terugslag.
recoil = Math.max(0, recoil - t * 8);
viewModel.position.copy(camera.position);
viewModel.quaternion.copy(camera.quaternion);
viewModel.translateX(0.18); viewModel.translateY(-0.16 + recoil * 0.01); viewModel.translateZ(-0.25 + recoil * 0.03);
viewModel.rotateX(recoil * 0.08);
gunLight.position.copy(camera.position);
if (flash.visible && cooldown < (weapon === 0 ? 0.13 : 0.72)) flash.visible = false;
gunLight.intensity = Math.max(0, gunLight.intensity - t * 30);
announcer.update(t);
updateMob(t);
if (shoutTimer > 0 && (shoutTimer -= t) <= 0) shoutLabel.classList.remove('show');
for (let i = rockets.length - 1; i >= 0; i--) {
const r = rockets[i];
r.life -= t;
const stepLen = r.speed * t;
r.speed += 10 * t;
const hit = hitScan(r.mesh.position, r.dir, stepLen);
if (rayHitsMob(r.mesh.position, r.dir, stepLen + 0.3)) {
explode(null, mobCenter(), r.dir.clone().negate());
group.remove(r.mesh);
rockets.splice(i, 1);
} else if (hit || r.life <= 0) {
if (hit) explode(hit);
group.remove(r.mesh);
rockets.splice(i, 1);
} else {
r.mesh.position.addScaledVector(r.dir, stepLen);
if (Math.random() < 0.5) spawnBlast(r.mesh.position.clone().addScaledVector(r.dir, -0.2), 0.2, 0.3);
}
}
for (let i = blasts.length - 1; i >= 0; i--) {
const b = blasts[i];
b.t += t;
const k = b.t / b.life;
if (b.s) { b.s.scale.setScalar(b.size * (0.6 + k)); b.s.material.opacity = 1 - k; }
if (b.light) b.light.intensity = 6 * (1 - k);
if (k >= 1) { group.remove(b.s || b.light); if (b.s) b.s.material.dispose(); blasts.splice(i, 1); }
}
for (const d of debris) {
if (d.rest) continue;
d.v.y -= 9.81 * t;
const next = d.mesh.position.clone().addScaledVector(d.v, t);
const floor = (o.floorAt ? o.floorAt(next.x, next.z) : 0) + (d.gib ? d.mesh.scale.y * 0.35 : 0.03);
if (next.y <= floor && d.v.y < 0) {
next.y = floor;
d.v.multiplyScalar(0.4); d.v.y = -d.v.y * 0.5; d.spin.multiplyScalar(0.5);
if (d.gib && !d.splat) { d.splat = true; bloodSplat(next.x, next.z, 0.3 + Math.random() * 0.3); }
if (d.v.length() < 0.3) d.rest = true;
}
d.mesh.position.copy(next);
if (d.mesh.isSprite) d.mesh.material.rotation += d.spin.x * t;
else { d.mesh.rotation.x += d.spin.x * t; d.mesh.rotation.y += d.spin.y * t; }
}
},
dispose() {
this.stop();
domElement.removeEventListener('mousedown', onClick);
document.removeEventListener('mousemove', onMouseMove);
domElement.removeEventListener('wheel', onWheel);
window.removeEventListener('keydown', onKey);
hud.remove();
sfx.close();
}
};
}