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:
co-authored by
Claude Opus 5.5
parent
8cdfa21f46
commit
e1a688728c
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user