399 lines
11 KiB
JavaScript
399 lines
11 KiB
JavaScript
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// DONAU2SPACE // DEV ENTITY — Narrative Engine
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// Time-based escalation, meta awareness, node simulation,
|
|
// Konami code, reload detection, returning visitor logic.
|
|
|
|
import { sleep, bus, store, timeGreeting } from './utils.js';
|
|
import { beep, warningTone } from './audio.js';
|
|
import * as fx from './effects.js';
|
|
import { triggerShootingStar, setWarp } from './starfield.js';
|
|
|
|
let term = null;
|
|
let startTime = Date.now();
|
|
let narrativeLevel = 0;
|
|
let firedEvents = new Set();
|
|
let idleStart = Date.now();
|
|
let scrollTriggered = false;
|
|
|
|
// ── Timeline events ────────────────────────────
|
|
|
|
const timeline = [
|
|
{
|
|
id: 'node-flicker',
|
|
at: 30,
|
|
fn: () => {
|
|
updateNodeStatus('DEV-02', 'waking', 'c-yellow');
|
|
}
|
|
},
|
|
{
|
|
id: 'first-hint',
|
|
at: 60,
|
|
fn: () => {
|
|
term.systemMessage(`[${new Date().toLocaleTimeString()}] node: DEV-02 status changed`, 'c-muted dim');
|
|
}
|
|
},
|
|
{
|
|
id: 'self-awareness-3',
|
|
at: 90,
|
|
fn: () => {
|
|
narrativeLevel = 1;
|
|
term.blank();
|
|
term.systemMessage('Dev Node self-awareness at 3%.', 'c-yellow');
|
|
beep(330, 200, 0.02, 'sine');
|
|
}
|
|
},
|
|
{
|
|
id: 'cruncher-active',
|
|
at: 120,
|
|
fn: () => {
|
|
updateNodeStatus('CRUNCHER', 'active', 'c-green');
|
|
term.systemMessage('[CRUNCHER-BOINC] computation cycle started', 'c-muted dim');
|
|
}
|
|
},
|
|
{
|
|
id: 'why-are-you-here',
|
|
at: 180,
|
|
fn: async () => {
|
|
narrativeLevel = 2;
|
|
term.blank();
|
|
await term.typingSlow('Why are you here?', 60, 'line c-muted');
|
|
beep(220, 300, 0.02, 'sine');
|
|
}
|
|
},
|
|
{
|
|
id: 'color-drift',
|
|
at: 240,
|
|
fn: () => {
|
|
fx.shiftColors(0.15);
|
|
term.systemMessage('[visual] color calibration drifting\u2026', 'c-muted dim');
|
|
}
|
|
},
|
|
{
|
|
id: 'not-supposed-to-see',
|
|
at: 300,
|
|
fn: async () => {
|
|
narrativeLevel = 3;
|
|
fx.scanPulse();
|
|
term.blank();
|
|
await term.typingSlow('You are not supposed to see this.', 50, 'line c-red');
|
|
fx.glitch(400);
|
|
warningTone();
|
|
}
|
|
},
|
|
{
|
|
id: 'core-mika-warming',
|
|
at: 360,
|
|
fn: () => {
|
|
updateNodeStatus('CORE-MIKA', 'warming up', 'c-yellow');
|
|
term.systemMessage('[CORE-MIKA] initialization sequence detected', 'c-pink dim');
|
|
}
|
|
},
|
|
{
|
|
id: 'reality-shift',
|
|
at: 420,
|
|
fn: async () => {
|
|
narrativeLevel = 4;
|
|
await fx.realityShift();
|
|
await sleep(500);
|
|
term.systemMessage('You expected a placeholder. You got an artifact.', 'c-green');
|
|
}
|
|
},
|
|
{
|
|
id: 'core-mika-active',
|
|
at: 480,
|
|
fn: () => {
|
|
updateNodeStatus('CORE-MIKA', 'active', 'c-pink');
|
|
term.systemMessage('[CORE-MIKA] fully online. Observing.', 'c-pink');
|
|
}
|
|
},
|
|
{
|
|
id: 'ai-statement',
|
|
at: 540,
|
|
fn: async () => {
|
|
narrativeLevel = 5;
|
|
term.blank();
|
|
term.systemMessage('This page was generated by an AI.', 'c-muted');
|
|
await sleep(1000);
|
|
term.systemMessage('But you already knew that.', 'c-muted');
|
|
}
|
|
},
|
|
{
|
|
id: 'self-awareness-42',
|
|
at: 660,
|
|
fn: () => {
|
|
term.systemMessage('Dev Node self-awareness at 42%. Coincidence? No.', 'c-yellow');
|
|
triggerShootingStar();
|
|
triggerShootingStar();
|
|
}
|
|
},
|
|
{
|
|
id: 'final-message',
|
|
at: 900,
|
|
fn: async () => {
|
|
term.blank();
|
|
await term.typingSlow('You have been here for 15 minutes.', 40, 'line c-muted');
|
|
await sleep(500);
|
|
await term.typingSlow('Most people leave after 10 seconds.', 40, 'line c-muted');
|
|
await sleep(500);
|
|
await term.typingSlow('You are not most people.', 40, 'line c-green');
|
|
}
|
|
},
|
|
];
|
|
|
|
// ── Node status management ─────────────────────
|
|
|
|
const nodeStates = {
|
|
'DEV-01': { status: 'active', cls: 'c-green' },
|
|
'DEV-02': { status: 'sleeping', cls: 'c-muted' },
|
|
'CRUNCHER': { status: 'computing', cls: 'c-blue' },
|
|
'CORE-MIKA': { status: 'unstable', cls: 'c-pink' },
|
|
};
|
|
|
|
function updateNodeStatus(name, status, cls) {
|
|
if (nodeStates[name]) {
|
|
nodeStates[name].status = status;
|
|
nodeStates[name].cls = cls;
|
|
bus.emit('node-update', { name, status, cls });
|
|
updateNodeBar();
|
|
}
|
|
}
|
|
|
|
function updateNodeBar() {
|
|
const bar = document.getElementById('node-bar');
|
|
if (!bar) return;
|
|
bar.replaceChildren();
|
|
for (const [name, state] of Object.entries(nodeStates)) {
|
|
const pill = document.createElement('span');
|
|
pill.className = `node-pill ${state.cls}`;
|
|
pill.textContent = `${name}: ${state.status}`;
|
|
bar.appendChild(pill);
|
|
}
|
|
}
|
|
|
|
// ── Awareness: returning visitors ──────────────
|
|
|
|
function checkReturningVisitor() {
|
|
const lastVisit = store.get('last_visit', null);
|
|
const visitCount = store.get('visit_count', 0) + 1;
|
|
store.set('visit_count', visitCount);
|
|
store.set('last_visit', Date.now());
|
|
|
|
if (lastVisit && visitCount > 1) {
|
|
const elapsed = Date.now() - lastVisit;
|
|
const days = Math.floor(elapsed / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
|
|
|
let msg;
|
|
if (days > 0) {
|
|
msg = `Welcome back. You were here ${days} day${days > 1 ? 's' : ''} ago. Still curious?`;
|
|
} else if (hours > 0) {
|
|
msg = `Welcome back. ${hours} hour${hours > 1 ? 's' : ''} since your last visit.`;
|
|
} else {
|
|
msg = 'You just left. And came back. Interesting.';
|
|
}
|
|
|
|
setTimeout(() => {
|
|
term.systemMessage(msg, 'c-muted');
|
|
if (visitCount > 5) {
|
|
setTimeout(() => {
|
|
term.systemMessage(`Visit #${visitCount}. You keep coming back.`, 'c-muted dim');
|
|
}, 2000);
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// ── Awareness: reload detection ────────────────
|
|
|
|
function checkReloadLoop() {
|
|
const reloads = store.get('reloads', []);
|
|
const now = Date.now();
|
|
const recent = reloads.filter(t => now - t < 30000);
|
|
recent.push(now);
|
|
store.set('reloads', recent.slice(-10));
|
|
|
|
if (recent.length >= 3) {
|
|
setTimeout(() => {
|
|
term.blank();
|
|
term.systemMessage('You keep refreshing.', 'c-yellow');
|
|
term.systemMessage('Looking for something?', 'c-muted');
|
|
fx.glitch(300);
|
|
}, 2500);
|
|
}
|
|
}
|
|
|
|
// ── Awareness: time of day ─────────────────────
|
|
|
|
function greetByTime() {
|
|
setTimeout(() => {
|
|
term.systemMessage(timeGreeting(), 'c-muted dim');
|
|
}, 4500);
|
|
}
|
|
|
|
// ── Awareness: idle detection ──────────────────
|
|
|
|
function resetIdle() {
|
|
idleStart = Date.now();
|
|
}
|
|
|
|
function checkIdle() {
|
|
const elapsed = (Date.now() - idleStart) / 1000;
|
|
if (elapsed > 120 && !firedEvents.has('idle-120')) {
|
|
firedEvents.add('idle-120');
|
|
term.systemMessage('You went quiet. The terminal noticed.', 'c-muted dim');
|
|
}
|
|
if (elapsed > 300 && !firedEvents.has('idle-300')) {
|
|
firedEvents.add('idle-300');
|
|
term.systemMessage('Still there? The stars are still moving.', 'c-muted dim');
|
|
triggerShootingStar();
|
|
}
|
|
}
|
|
|
|
// ── Awareness: scroll behavior ─────────────────
|
|
|
|
function onScroll() {
|
|
if (scrollTriggered) return;
|
|
const screen = term.screen;
|
|
if (!screen) return;
|
|
const scrollRatio = screen.scrollTop / (screen.scrollHeight - screen.clientHeight);
|
|
if (scrollRatio < 0.1 && screen.scrollHeight > screen.clientHeight * 2) {
|
|
scrollTriggered = true;
|
|
term.systemMessage('[scroll detected] Looking for something in the logs?', 'c-muted dim');
|
|
}
|
|
}
|
|
|
|
// ── Konami Code ────────────────────────────────
|
|
|
|
const KONAMI = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
|
|
let konamiIndex = 0;
|
|
|
|
function initKonami() {
|
|
window.addEventListener('keydown', (e) => {
|
|
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
if (key === KONAMI[konamiIndex]) {
|
|
konamiIndex++;
|
|
if (konamiIndex === KONAMI.length) {
|
|
konamiIndex = 0;
|
|
onKonami();
|
|
}
|
|
} else {
|
|
konamiIndex = (key === KONAMI[0]) ? 1 : 0;
|
|
}
|
|
|
|
// ESC closes overlay
|
|
if (e.key === 'Escape' && fx.isOverlayVisible()) {
|
|
fx.hideOverlay();
|
|
term.focus();
|
|
}
|
|
}, { capture: true });
|
|
}
|
|
|
|
function onKonami() {
|
|
fx.glitch(900);
|
|
beep(523, 70, 0.03); beep(659, 70, 0.03); beep(784, 70, 0.03);
|
|
fx.showOverlay(`ROOT ACCESS GRANTED
|
|
|
|
> welcome, wanderer.
|
|
> you found the dev door.
|
|
> it was never locked.
|
|
> it was just... not advertised.
|
|
|
|
Hint:
|
|
- try: "sudo su"
|
|
- try: "deep_space"
|
|
- try: "njet"
|
|
- try: "mika"
|
|
- try: "escalate"
|
|
|
|
(Yes, this is useless. That's the point.)`);
|
|
bus.emit('konami');
|
|
}
|
|
|
|
// ── Mobile: shake detection ────────────────────
|
|
|
|
function initShakeDetection() {
|
|
let lastX = 0, lastY = 0, lastZ = 0;
|
|
let shakeCount = 0;
|
|
|
|
window.addEventListener('devicemotion', (e) => {
|
|
const acc = e.accelerationIncludingGravity;
|
|
if (!acc) return;
|
|
const dx = Math.abs(acc.x - lastX);
|
|
const dy = Math.abs(acc.y - lastY);
|
|
const dz = Math.abs(acc.z - lastZ);
|
|
lastX = acc.x; lastY = acc.y; lastZ = acc.z;
|
|
|
|
if (dx + dy + dz > 30) {
|
|
shakeCount++;
|
|
if (shakeCount > 3 && !firedEvents.has('mobile-shake')) {
|
|
firedEvents.add('mobile-shake');
|
|
term.systemMessage('Did you just shake your phone?', 'c-pink');
|
|
term.systemMessage('The dev page felt that.', 'c-muted');
|
|
fx.shake();
|
|
triggerShootingStar();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Timeline tick ──────────────────────────────
|
|
|
|
function tick() {
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
|
|
for (const event of timeline) {
|
|
if (elapsed >= event.at && !firedEvents.has(event.id)) {
|
|
firedEvents.add(event.id);
|
|
event.fn();
|
|
}
|
|
}
|
|
|
|
checkIdle();
|
|
}
|
|
|
|
// ── Initialize ─────────────────────────────────
|
|
|
|
export function initNarrative(terminal) {
|
|
term = terminal;
|
|
startTime = Date.now();
|
|
|
|
// Wire up events
|
|
bus.on('command', () => resetIdle());
|
|
term.screen.addEventListener('scroll', onScroll);
|
|
|
|
// Awareness checks
|
|
checkReturningVisitor();
|
|
checkReloadLoop();
|
|
greetByTime();
|
|
|
|
// Konami & mobile
|
|
initKonami();
|
|
initShakeDetection();
|
|
|
|
// Initialize node bar
|
|
updateNodeBar();
|
|
|
|
// Overlay click-to-close
|
|
const overlay = document.getElementById('overlay');
|
|
if (overlay) {
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) {
|
|
fx.hideOverlay();
|
|
term.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Timeline ticker
|
|
setInterval(tick, 1000);
|
|
|
|
// Shooting stars on launch
|
|
bus.on('launch', () => {
|
|
setWarp(0.6);
|
|
setTimeout(() => setWarp(0), 3000);
|
|
for (let i = 0; i < 5; i++) {
|
|
setTimeout(() => triggerShootingStar(), i * 300);
|
|
}
|
|
});
|
|
}
|