/*! * 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 = '
Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen
'; 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(); } }; }