v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+85
View File
@@ -0,0 +1,85 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/build-stages.js — Bouwfasen (window.BuildStages): de volgorde waarin
// een unit in de hal wordt opgebouwd. Eén begrip voor zowel de weergave
// (schuif "Bouwfase" in de 3D) als het weglaten van verborgen delen:
//
// 0 regelwerk vloer-, wand- en dakbalken
// 1 elektra installatiebuizen, dozen en meterkast (ElektraModel)
// 2 beplating spaanplaat binnen, OSB vloer/plafond, cementplaat buiten
// 3 doek vochtdoek
// 4 latwerk kruislat, trasraamplaat
// 5 gevel rabat, steen, kozijnen, dakbedekking, sandwichpanelen
// 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen
//
// Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de
// gekozen fase aan beide zijden is ingesloten door latere lagen wordt niet
// gebouwd (bijv. stijlen tussen spaanplaat en doek, latten achter de
// rabat). De specificaties rekenen altijd met alle lagen.
//
// Plain script (geen ES-module); het model-deel heeft geen DOM nodig.
// ============================================================
window.BuildStages = (() => {
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer en plafond, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
];
const FINAL = LIST.length - 1;
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
// Fase (getal of undefined = afgewerkt) normaliseren.
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
// Is laag `key` zichtbaar in fase `stage`?
const shows = (stage, key) => clamp(stage) >= INDEX[key];
// Schuif onderin een 3D-weergave (DOM). pane = de weergave (position:
// relative); onChange(stage) bij elke stap. Geeft { set(stage), el,
// extra } terug; extra = vrije rij onder de schuif (bijv. doorsnede).
function createBar(pane, stage, onChange) {
const el = document.createElement('div');
el.className = 'stage-bar';
el.innerHTML = `
<div class="stage-row">
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
<div class="stage-track">
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
</div>
<span class="stage-name"></span>
</div>
<div class="stage-extra"></div>`;
pane.appendChild(el);
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
const sync = () => {
const i = clamp(Number(range.value));
name.textContent = LIST[i].label;
name.title = LIST[i].hint;
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
};
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
const t = e.target.closest('[data-i]');
if (!t) return;
range.value = t.dataset.i;
sync();
onChange(clamp(Number(range.value)));
});
// Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...).
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
sync();
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
}
return { LIST, FINAL, INDEX, clamp, shows, createBar };
})();
+174
View File
@@ -0,0 +1,174 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/granny-kit.js — onderdelen van de easter egg (core/shooter.js):
// oma-sprite, gibs, kreten,
// geluidseffecten, soundtrack en de omroeper à la Unreal Tournament.
// Plain script (geen ES-module).
// ============================================================
const GrannyKit = (() => {
// Oud vrouwtje met rollator, 16x25 pixels (twee loopbeelden).
const LADY = [
'......hHHh......',
'.....HHHHHH.....',
'....HHHHHHHH....',
'....HSSSSSSH....',
'....SGEsSGES....',
'....SSSssSSS....',
'.....SsrrsS.....',
'......SSSS......',
'.....WWWWWW.....',
'...DDDDWWDDDD...',
'..DDDDDDDDDDDD..',
'..DdDDDDDDDDdD..',
'..DdDDDDDDDDdD..',
'MSSMMMMMMMMMMSSM',
'.M.DDDDDDDDDD.M.',
'.MRRRRRRRRRRRRM.',
'.MRrRrRrRrRrRRM.',
'.MRRRRRRRRRRRRM.',
'.M..dDDDDDDd..M.',
'.M...DDDDDD...M.',
'.M....LL.LL...M.',
'.M....LL..LL..M.',
'.M....BB..BB..M.',
'KKK..........KKK',
'KkK..........KkK'
];
const LADY_B = LADY.slice(0, 20).concat([
'.M...LL..LL...M.',
'.M...LL...LL..M.',
'.M...BB...BB..M.',
'KkK..........KkK',
'KKK..........KKK'
]);
const PAL = { h: '#9a9aa8', H: '#d0d0da', S: '#f0c8a0', s: '#d09878', E: '#101010', G: '#707080', r: '#a04040',
W: '#f4f4f4', D: '#8040a0', d: '#5a2a78', M: '#b8c0c8', R: '#c03030', L: '#c8a888', B: '#402018', K: '#202020', k: '#606060' };
// Gibs: 8-bit stukjes oma (vlees, botjes, gebit, bril, handtas, breibol, rollatorwiel).
const GIBS = [
['..rr....', '.rRRr...', 'rRRRRr..', 'rRrRRRr.', '.rRRRRr.', '..rRRr..', '...rr...'],
['.rr.rr..', 'rRRrRRr.', 'rRRRRRr.', '.rRRRr..', '..rrr...'],
['ww....ww', 'wWw..wWw', '.wWwwWw.', '..wWWw..', '.wWwwWw.', 'wWw..wWw', 'ww....ww'],
['.pppppp.', 'pWWWWWWp', 'pWkWkWkp', 'pWWWWWWp', '.pppppp.'],
['GG..GG..', 'GbG.GbG.', 'GGGGGGG.', '........'],
['..nn....', '.n..n...', 'nNNNNNn.', 'NNNNNNN.', 'NNyNNNN.', 'NNNNNNN.'],
['.YYYY...', 'YyYYyY..', 'YYyYYy..', 'YyYYyY..', '.YYYY...', '.....Y..', '......Y.'],
['.KKKK...', 'KkkkkK..', 'KkMMkK..', 'KkMMkK..', 'KkkkkK..', '.KKKK...']
];
const GIB_PAL = { r: '#6a0808', R: '#c01818', w: '#a09880', W: '#f0ecdc', p: '#e07888', k: '#303030', G: '#606070', b: '#a0c8f0',
n: '#402018', N: '#7a4a22', y: '#d0b040', Y: '#c050a0', K: '#202020', M: '#b8c0c8' };
const SHOUTS = ['"Jongeman! Voeten vegen!"', '"In mijn tijd hadden we geen raketten!"', '"Ik bel je moeder!"',
'"Waar is de bingo?"', '"Dat dak lekt, hoor!"', '"Is dit wel vergund?"', '"Ik heb een abonnement op de bus!"'];
const DEATH_SHOUTS = ['"Oeeehh, mijn heup!"', '"Dat zeg ik tegen de wijkagent!"', '"Aaargh, de bingo..."'];
const HIT_SHOUTS = ['*BAM* met de handtas!', '*klets* met de rollator!', '"Neem een koekje, en dan wegwezen!"'];
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Pixelraster -> canvas (1 px per teken, optioneel met rand en één tintkleur;
// bottom = figuur op de onderrand, voor sprites die op de grond staan).
function pixelCanvas(rows, pal, opts) {
const o = Object.assign({ pad: 0, size: 0, tint: null, bottom: false }, opts || {});
const w = Math.max(...rows.map(r => r.length)), h = rows.length;
const c = document.createElement('canvas');
c.width = o.size || w + o.pad * 2;
c.height = o.size || h + o.pad * 2;
const g = c.getContext('2d');
const ox = Math.floor((c.width - w) / 2), oy = o.bottom ? c.height - h : Math.floor((c.height - h) / 2);
rows.forEach((row, y) => [...row].forEach((ch, x) => {
if (!pal[ch]) return;
g.fillStyle = o.tint || pal[ch];
g.fillRect(ox + x, oy + y, 1, 1);
}));
return c;
}
// Geluidseffecten: gesynthetiseerde ruis via WebAudio.
function createSfx() {
let audio = null;
return {
bang(len, freq, vol) {
try {
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
const n = Math.floor(audio.sampleRate * len), buf = audio.createBuffer(1, n, audio.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
const src = audio.createBufferSource(), f = audio.createBiquadFilter(), g = audio.createGain();
src.buffer = buf; f.type = 'lowpass'; f.frequency.value = freq; g.gain.value = vol;
src.connect(f).connect(g).connect(audio.destination);
src.start();
} catch (e) { /* geen geluid, jammer */ }
},
close() { if (audio) audio.close(); audio = null; }
};
}
// Soundtrack: "Five Armies" Kevin MacLeod (incompetech.com), CC BY 4.0.
const MUSIC_CREDIT = '♪ "Five Armies" Kevin MacLeod (incompetech.com) · CC BY 4.0';
function createMusic() {
const music = new Audio('assets/audio/five-armies.mp3');
music.loop = true;
music.volume = 0.5;
return {
play() { music.currentTime = 0; music.play().catch(() => {}); }, // toetsdruk = gebruikersactie
stop() { music.pause(); }
};
}
// Omroeper (Web Speech API + grote tekst in labelEl).
// Multi-kill: kills binnen MULTI_WINDOW s na elkaar. Spree: kills zonder geraakt te worden.
const MULTI_WINDOW = 6;
const MULTI = { 2: 'Double Granny!', 3: 'Multi Granny!', 4: 'Mega Granny!', 5: 'Ultra Granny!', 6: 'M M M MONSTER GRANNY!' };
const SPREE = { 5: 'Bingo Spree!', 10: 'Rollator Rampage!', 15: 'Dominating the retirement home!', 20: 'Unstoppable!', 25: 'Godlike!', 30: 'Wicked Sick... of grannies!' };
function createAnnouncer(labelEl) {
let kills = 0, multi = 0, lastKillAt = -99, spree = 0, clock = 0, timer = 0;
function say(text) {
labelEl.textContent = text.replace(/^M M M /, 'M-M-M-');
labelEl.classList.remove('show'); void labelEl.offsetWidth; labelEl.classList.add('show');
timer = 2.2;
try {
const u = new SpeechSynthesisUtterance(text);
const v = speechSynthesis.getVoices().find(x => /^en(-|_)(US|GB)/i.test(x.lang) && /male|david|daniel|george/i.test(x.name))
|| speechSynthesis.getVoices().find(x => /^en/i.test(x.lang));
if (v) u.voice = v;
u.lang = 'en-US'; u.pitch = 0.1; u.rate = 0.8; u.volume = 1;
speechSynthesis.cancel();
speechSynthesis.speak(u);
} catch (e) { /* geen stem, dan alleen tekst */ }
}
return {
say,
get kills() { return kills; },
kill() {
kills++;
multi = clock - lastKillAt <= MULTI_WINDOW ? multi + 1 : 1;
lastKillAt = clock;
spree++;
if (kills === 1) say('First Blood!');
else if (multi >= 2) say(MULTI[Math.min(multi, 6)]);
else if (SPREE[spree]) say(SPREE[spree]);
return kills;
},
hurt() {
if (spree >= 5) say('Spree ended by a handbag!');
spree = 0;
},
reset() { kills = multi = spree = 0; lastKillAt = -99; },
update(dt) {
clock += dt;
if (timer > 0 && (timer -= dt) <= 0) labelEl.classList.remove('show');
},
stop() {
try { speechSynthesis.cancel(); } catch (e) { /* */ }
labelEl.classList.remove('show');
}
};
}
return { LADY, LADY_B, PAL, GIBS, GIB_PAL, SHOUTS, DEATH_SHOUTS, HIT_SHOUTS, MUSIC_CREDIT,
pick, pixelCanvas, createSfx, createMusic, createAnnouncer };
})();
+145
View File
@@ -0,0 +1,145 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/ribbon.js — Ribbon (window.Ribbon): de bedieningsbalk bovenin. Eén
// plek voor alle commando's en instellingen; de weergaven eronder blijven
// vrij.
//
// Tabbladen komen uit bronnen: 'app' (Bestand, Beeld, Extra) en 'editor'
// (de actieve editor, bijv. Start, Gevel, Dak, ... en een contextueel
// tabblad voor de selectie). Tabbladen met dezelfde sleutel uit meerdere
// bronnen worden samengevoegd (bijv. Beeld: weergave-opties van de woning
// + vensterknoppen van de app).
//
// Ribbon.setSource('editor', [{
// key, label, order (sortering), contextual (gekleurd, achteraan),
// groups: () => [{ label, html }], // inhoud, opnieuw bij elke weergave
// wire: (body) => {}, // events koppelen na het tekenen
// onShow: () => {} // tabblad wordt actief
// }]);
// Ribbon.activate(key) · Ribbon.refresh() · Ribbon.active() · Ribbon.onChange(fn)
//
// Groepen: een raster van 3 rijen hoog; velden (UI-kit) staan per kolom
// onder elkaar. Grote knop: Ribbon.big(...), kleine: Ribbon.small(...).
// Plain script (geen ES-module).
// ============================================================
window.Ribbon = (() => {
const sources = {}; // naam -> [tab]
const SOURCE_ORDER = ['app', 'editor'];
let root = null, tabsEl = null, bodyEl = null;
let activeKey = null, lastPlainKey = null;
let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start'
let collapsed = false, notified = null;
const listeners = [];
const PREF_KEY = 'tectum.ribbon.collapsed';
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
// Grote knop (3 rijen hoog): icoon boven het label.
const big = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
// Kleine knop (1 rij).
const small = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
// Waarde-regel (alleen lezen) binnen een groep.
const kv = (label, value, unit) =>
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
function allTabs() {
const byKey = new Map();
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
(sources[name] || []).forEach(t => {
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
const m = byKey.get(t.key);
if (t.order !== undefined && name !== 'app') m.order = t.order;
if (t.label && name !== 'app') m.label = t.label;
if (t.contextual) m.contextual = true;
m.parts.push(t);
});
});
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
}
function renderTabs(list) {
tabsEl.innerHTML = list.map(t =>
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
}
function renderBody(list) {
const tab = list.find(t => t.key === activeKey);
if (!tab) { bodyEl.innerHTML = ''; return; }
const groups = [];
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
bodyEl.innerHTML = groups.map(g =>
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
}
function render() {
if (!root) return;
const list = allTabs();
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
}
renderTabs(list);
renderBody(list);
root.classList.toggle('rb-collapsed', collapsed);
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
}
function activate(key) {
const list = allTabs();
const tab = list.find(t => t.key === key);
if (!tab) return;
activeKey = key;
auto = false;
if (!tab.contextual) lastPlainKey = key;
if (collapsed) { collapsed = false; savePref(); }
render();
tab.parts.forEach(p => p.onShow && p.onShow());
}
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { /* alleen deze sessie */ } }
function init(el) {
root = el;
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
tabsEl = el.querySelector('.rb-tabs');
bodyEl = el.querySelector('.rb-body');
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest('[data-rb-tab]');
if (!b) return;
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
activate(b.dataset.rbTab);
});
tabsEl.addEventListener('dblclick', (e) => {
if (!e.target.closest('[data-rb-tab]')) return;
collapsed = !collapsed; savePref(); render();
});
bodyEl.addEventListener('click', (e) => {
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
});
render();
}
// Bron vervangen. Een nieuw contextueel tabblad (andere sleutel) wordt
// actief als opts.focus; verdwijnt het actieve, dan terug naar het vorige.
function setSource(name, tabs, opts) {
sources[name] = tabs || [];
const ctx = (tabs || []).find(t => t.contextual);
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
render();
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
}
// fn(key) bij elke wissel van het actieve tabblad.
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
})();
+469
View File
@@ -0,0 +1,469 @@
/*!
* 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();
}
};
}
+1 -1
View File
@@ -212,5 +212,5 @@ window.Skybox = (() => {
return wrap;
}
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle };
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle };
})();
+32 -2
View File
@@ -7,7 +7,8 @@
// ============================================================
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
// specificatie-kaarten en de 3-staps weergave-cyclus (sneltoets T).
// inklapbare secties, schuiven, specificatie-kaarten en de 3-staps
// weergave-cyclus (sneltoets T).
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
//
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
@@ -34,6 +35,35 @@ window.UI = (() => {
`<span class="ui-unit">${unit}</span>`, o);
}
// Schuif met gekoppeld getal (voor maten met een logisch bereik, zoals
// hoogtes en hellingen). o: { min, max, step, unit ('mm' standaard), hint }.
// Het getal mag buiten het schuifbereik; het model klemt zelf.
function range(id, label, value, o) {
o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit;
const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`;
return `<div class="ui-field ui-field-range${o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o.hint)}</label>` +
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
}
// fn(v) bij schuiven en typen; set(id, v) zet beide zonder event.
function onRange(id, fn, after) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (!n || !r) return;
r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); });
n.addEventListener('input', () => {
const v = parseFloat(n.value);
if (!isFinite(v)) return;
r.value = v;
fn(v); after && after();
});
}
function setRange(id, v) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (n && document.activeElement !== n) n.value = v;
if (r) r.value = v;
}
// Keuzelijst. options: { waarde: 'Label' }.
function sel(id, label, options, value, o) {
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
@@ -126,5 +156,5 @@ window.UI = (() => {
el.classList.toggle('fullscreen-2d');
}
return { esc, icon, hint, num, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
})();
+9 -1
View File
@@ -27,6 +27,7 @@ function createWalkControls(camera, domElement, opts) {
let active = false;
let x = 0, z = 0, yaw = 0, pitch = 0;
let drag = null;
let speedFactor = 1; // bijv. 1,3 in de shooter-modus; reset bij start()
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
@@ -71,10 +72,17 @@ function createWalkControls(camera, domElement, opts) {
get active() { return active; },
start(pose) {
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
speedFactor = 1;
keys.clear();
active = true;
apply();
},
// Rondkijken van buitenaf (bijv. muis gevangen in core/shooter.js).
set speedFactor(f) { speedFactor = f; },
look(dYaw, dPitch) {
yaw += dYaw;
pitch = Math.max(-1.4, Math.min(1.4, pitch + dPitch));
},
stop() { active = false; keys.clear(); drag = null; },
update(dt) {
if (!active) return;
@@ -84,7 +92,7 @@ function createWalkControls(camera, domElement, opts) {
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
if (f || r) {
const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
const v = (keys.has('shift') ? o.run : o.speed) * speedFactor * t / Math.hypot(f, r);
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);