/* --------------------------------------------------------------------------- Loading screen behaviour. Three jobs: 1. draw the city - a procedural night skyline flown over on canvas 2. report progress - bound to the load events the game actually emits 3. play ambience - synthesised in the browser, nothing is downloaded --------------------------------------------------------------------------- */ 'use strict'; const $ = (id) => document.getElementById(id); /* ========================================================================= 1. The city ========================================================================= */ /* Small deterministic PRNG so the skyline is stable across a resize. */ function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } const SKY = { canvas: $('sky'), ctx: null, layers: [], w: 0, h: 0, dpr: 1, t0: performance.now(), }; /* Depth layers, far to near. Every layer is darker than the hazy sky behind it, which is what makes a silhouette read at all; the far ones are lifted towards the sky colour to stand in for atmosphere. `base` is the ground line as a fraction of the viewport, so near towers rise right through the frame. The difference in scroll rate is what reads as flight. */ const LAYER_SPEC = [ { depth: 0.00, speed: 4, minW: 28, maxW: 66, minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 }, { depth: 0.25, speed: 11, minW: 36, maxW: 90, minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 }, { depth: 0.50, speed: 24, minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 }, { depth: 0.75, speed: 50, minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 }, { depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 }, ]; function buildLayer(spec, index, w, h) { const cv = document.createElement('canvas'); cv.width = Math.ceil(w * 2); cv.height = h; const c = cv.getContext('2d'); const rand = rng(9173 + index * 7717); const base = h * spec.base; const towers = []; let x = -60; while (x < cv.width + 60) { const bw = spec.minW + rand() * (spec.maxW - spec.minW); const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH)); const top = base - bh; c.fillStyle = spec.tone; c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top)); /* a hairline of sodium bounce along the roof edge */ if (rand() < 0.55) { c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`; c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1); } /* windows */ const cell = 6 + spec.depth * 8; const pad = Math.max(2, cell * 0.34); for (let wy = top + pad * 2; wy < base - pad; wy += cell) { for (let wx = x + pad; wx < x + bw - pad; wx += cell) { if (rand() > spec.lit) continue; const warm = rand(); /* mostly sodium/tungsten, a few cold offices; distant windows are dimmed so they read as depth rather than as static */ const col = warm < 0.72 ? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})` : `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`; c.fillStyle = col; c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42)); } } /* tall towers get an aviation light */ if (bh > h * 0.5 && rand() < 0.5) { towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 }); } x += bw + 2 + rand() * 16; } return { cv, spec, towers, base }; } function resizeSky() { const dpr = Math.min(window.devicePixelRatio || 1, 1.5); SKY.dpr = dpr; SKY.w = window.innerWidth; SKY.h = window.innerHeight; SKY.canvas.width = Math.floor(SKY.w * dpr); SKY.canvas.height = Math.floor(SKY.h * dpr); SKY.ctx = SKY.canvas.getContext('2d'); SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h)); } function drawSky(now) { const { ctx, w, h } = SKY; if (!ctx) return; const t = (now - SKY.t0) / 1000; /* Sky: light pollution rather than stars. It has to stay brighter than the buildings all the way down, otherwise there is no silhouette to see. */ const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0.00, '#080b14'); g.addColorStop(0.34, '#121627'); g.addColorStop(0.60, '#23202f'); g.addColorStop(0.78, '#412c27'); g.addColorStop(0.90, '#6b4527'); g.addColorStop(1.00, '#8a5726'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); /* the haze dome over downtown */ const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85); halo.addColorStop(0, 'rgba(255,159,69,0.34)'); halo.addColorStop(0.45, 'rgba(255,140,60,0.12)'); halo.addColorStop(1, 'rgba(255,140,60,0)'); ctx.fillStyle = halo; ctx.fillRect(0, 0, w, h); /* a slow descent: everything drifts down and grows a touch over time */ const descend = Math.sin(t * 0.06) * 10 + t * 0.9; for (const layer of SKY.layers) { const { cv, spec } = layer; const span = cv.width / 2; let ox = -((t * spec.speed) % span); const oy = descend * (0.25 + spec.depth * 0.9); ctx.globalAlpha = 1; ctx.drawImage(cv, ox, oy, cv.width, h); /* aviation lights blink on their own phase */ for (const tw of layer.towers) { const blink = Math.sin(t * 1.5 + tw.phase); if (blink < 0.72) continue; const px = tw.x + ox; const py = tw.y + oy - h * 0.06; ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`; ctx.beginPath(); ctx.arc(px, py, 1.6, 0, Math.PI * 2); ctx.fill(); if (px > span) { ctx.beginPath(); ctx.arc(px - span, py, 1.6, 0, Math.PI * 2); ctx.fill(); } } /* Aerial perspective: a warm veil laid over everything drawn so far, so each nearer layer sits in front of progressively more atmosphere. */ if (spec.haze > 0) { ctx.fillStyle = `rgba(104,72,50,${spec.haze})`; ctx.fillRect(0, 0, w, h); } } ctx.globalAlpha = 1; } /* ========================================================================= 2. Progress, bound to the game's own load events ========================================================================= */ const Progress = { shown: 0, target: 0, step: 'Waiting for the game', set(fraction, step) { if (typeof fraction === 'number' && isFinite(fraction)) { /* never let it walk backwards - it reads as a fault */ this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction))); } if (step) this.step = step; }, tick() { /* ease towards the real figure so long steps still feel alive */ this.shown += (this.target - this.shown) * 0.08; const pct = Math.min(100, Math.round(this.shown * 100)); $('bar').style.width = (this.shown * 100).toFixed(2) + '%'; $('pct').firstChild.nodeValue = String(pct); if ($('step').textContent !== this.step) $('step').textContent = this.step; }, }; /* Turns the game's internal init-function names into something a player can read. */ const STEP_NAMES = { MAP: 'Loading the map', BEFORE_MAP_LOADED: 'Preparing the world', AFTER_MAP_LOADED: 'Placing the world', SESSION_INIT: 'Joining the session', INIT_BEFORE_MAP_LOADED: 'Starting up', INIT_AFTER_MAP_LOADED: 'Finishing up', INIT_SESSION: 'Joining the session', }; window.addEventListener('message', (event) => { const d = event.data || {}; switch (d.eventName) { case 'loadProgress': Progress.set(d.loadFraction); break; case 'startInitFunction': Progress.set(null, STEP_NAMES[d.type] || 'Starting up'); break; case 'initFunctionInvoking': if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) { Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`); } break; case 'startDataFileEntries': Progress.set(null, `Streaming ${d.count} asset packs`); break; case 'performMapLoadFunction': Progress.set(null, 'Building the map'); break; case 'startWarning': case 'onLogLine': if (d.message) Progress.set(null, String(d.message).slice(0, 70)); break; /* --- our own messages, sent from rp_ui once the client is running --- */ case 'rp:rules': if (Array.isArray(d.rules) && d.rules.length) { Notices.load(d.rules); } break; case 'rp:server': if (d.name) document.querySelector('.wordmark').textContent = d.name; if (d.serial) $('serial').textContent = d.serial; if (d.status) $('hostcount').textContent = d.status; break; case 'rp:progress': Progress.set(d.fraction, d.step); break; /* the world is ready: dissolve into the live camera behind us */ case 'rp:handover': Progress.set(1, 'Ready'); document.body.classList.add('handover'); break; } }); /* ========================================================================= 3. Posted notices ========================================================================= */ const Notices = { items: [ { title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' }, { title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' }, { title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' }, { title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' }, { title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' }, ], i: 0, timer: null, load(items) { this.items = items; this.i = 0; this.render(); this.schedule(); }, render() { const item = this.items[this.i % this.items.length]; const title = $('noticeTitle'); const body = $('noticeBody'); /* restart the entry animation by reflowing the nodes */ title.style.animation = 'none'; body.style.animation = 'none'; void title.offsetWidth; title.style.animation = ''; body.style.animation = ''; title.textContent = item.title; body.textContent = item.body; $('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`; }, schedule() { clearInterval(this.timer); this.timer = setInterval(() => { this.i += 1; this.render(); }, 7600); }, }; /* ========================================================================= 4. Ambience - synthesised, never downloaded ========================================================================= */ class Ambience { constructor() { this.ctx = null; this.master = null; this.volume = 0.45; this.muted = false; this.started = false; } /* A short noise burst with exponential decay makes a serviceable hall. */ makeImpulse(seconds, decay) { const rate = this.ctx.sampleRate; const len = Math.floor(rate * seconds); const buf = this.ctx.createBuffer(2, len, rate); for (let ch = 0; ch < 2; ch++) { const data = buf.getChannelData(ch); for (let i = 0; i < len; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); } } return buf; } noiseBuffer(seconds) { const rate = this.ctx.sampleRate; const buf = this.ctx.createBuffer(1, rate * seconds, rate); const d = buf.getChannelData(0); let last = 0; for (let i = 0; i < d.length; i++) { /* brown-ish noise: closer to distant traffic than white hiss */ const white = Math.random() * 2 - 1; last = (last + 0.02 * white) / 1.02; d[i] = last * 3.2; } return buf; } start() { if (this.started) return; const Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) return; this.ctx = new Ctor(); this.started = true; const ctx = this.ctx; this.master = ctx.createGain(); this.master.gain.value = 0; this.master.connect(ctx.destination); const verb = ctx.createConvolver(); verb.buffer = this.makeImpulse(4.2, 2.6); const verbGain = ctx.createGain(); verbGain.gain.value = 0.55; verb.connect(verbGain).connect(this.master); const dry = ctx.createGain(); dry.gain.value = 0.75; dry.connect(this.master); const bus = ctx.createGain(); bus.connect(dry); bus.connect(verb); /* --- the pad: a minor triad that never quite resolves --------------- */ const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420; lp.Q.value = 0.7; lp.connect(bus); const lfo = ctx.createOscillator(); lfo.frequency.value = 0.031; const lfoAmt = ctx.createGain(); lfoAmt.gain.value = 210; lfo.connect(lfoAmt).connect(lp.frequency); lfo.start(); [55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => { const osc = ctx.createOscillator(); osc.type = i % 2 ? 'sine' : 'triangle'; osc.frequency.value = freq; osc.detune.value = (i - 2) * 4; const g = ctx.createGain(); g.gain.value = 0.16 / (1 + i * 0.35); /* each voice breathes on its own slow cycle */ const breath = ctx.createOscillator(); breath.frequency.value = 0.017 + i * 0.009; const breathAmt = ctx.createGain(); breathAmt.gain.value = g.gain.value * 0.6; breath.connect(breathAmt).connect(g.gain); breath.start(); osc.connect(g).connect(lp); osc.start(); }); /* --- distant traffic ------------------------------------------------ */ const noise = ctx.createBufferSource(); noise.buffer = this.noiseBuffer(8); noise.loop = true; const nf = ctx.createBiquadFilter(); nf.type = 'bandpass'; nf.frequency.value = 240; nf.Q.value = 0.55; const ng = ctx.createGain(); ng.gain.value = 0.09; noise.connect(nf).connect(ng).connect(bus); noise.start(); const wind = ctx.createOscillator(); wind.frequency.value = 0.043; const windAmt = ctx.createGain(); windAmt.gain.value = 130; wind.connect(windAmt).connect(nf.frequency); wind.start(); /* --- a siren, far away, every now and then -------------------------- */ const siren = () => { if (!this.ctx || this.ctx.state === 'closed') return; const t = ctx.currentTime; const o = ctx.createOscillator(); o.type = 'triangle'; const g = ctx.createGain(); const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null; g.gain.value = 0; o.frequency.setValueAtTime(620, t); o.frequency.linearRampToValueAtTime(880, t + 0.7); o.frequency.linearRampToValueAtTime(620, t + 1.4); g.gain.linearRampToValueAtTime(0.014, t + 0.6); g.gain.linearRampToValueAtTime(0.0, t + 2.6); if (pan) { pan.pan.value = Math.random() * 1.6 - 0.8; o.connect(g).connect(pan).connect(verb); } else { o.connect(g).connect(verb); } o.start(t); o.stop(t + 2.8); setTimeout(siren, 24000 + Math.random() * 40000); }; setTimeout(siren, 12000 + Math.random() * 12000); this.applyVolume(1.8); } /* perceptual curve: a linear fader sounds wrong */ applyVolume(rampSeconds) { if (!this.master) return; const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9; const now = this.ctx.currentTime; this.master.gain.cancelScheduledValues(now); this.master.gain.setValueAtTime(this.master.gain.value, now); this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12)); } setVolume(v) { this.volume = Math.min(1, Math.max(0, v)); if (this.volume > 0) this.muted = false; this.applyVolume(); } toggleMute() { this.muted = !this.muted; this.applyVolume(); return this.muted; } } const ambience = new Ambience(); function wireAudio() { const vol = $('vol'); const volval = $('volval'); const mute = $('mute'); const hint = $('audiohint'); const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10); vol.value = String(isFinite(saved) ? saved : 45); volval.textContent = vol.value; ambience.volume = Number(vol.value) / 100; const tryStart = () => { ambience.start(); if (ambience.ctx && ambience.ctx.state === 'suspended') { hint.hidden = false; return false; } hint.hidden = true; return true; }; /* Autoplay may be blocked; the first input of any kind releases it. */ if (!tryStart()) { const release = () => { if (ambience.ctx) ambience.ctx.resume(); hint.hidden = true; window.removeEventListener('pointerdown', release); window.removeEventListener('keydown', release); }; window.addEventListener('pointerdown', release); window.addEventListener('keydown', release); } vol.addEventListener('input', () => { volval.textContent = vol.value; ambience.setVolume(Number(vol.value) / 100); mute.classList.toggle('off', Number(vol.value) === 0); window.localStorage.setItem('rp.volume', vol.value); }); mute.addEventListener('click', () => { const muted = ambience.toggleMute(); mute.classList.toggle('off', muted); }); } /* ========================================================================= boot ========================================================================= */ function serial() { const n = Math.floor(Math.random() * 9000 + 1000); const m = Math.floor(Math.random() * 900 + 100); return `${new Date().getFullYear()}-${n}${m}`; } function frame(now) { drawSky(now); Progress.tick(); requestAnimationFrame(frame); } window.addEventListener('resize', resizeSky); document.addEventListener('DOMContentLoaded', () => { $('serial').textContent = serial(); resizeSky(); Notices.render(); Notices.schedule(); wireAudio(); requestAnimationFrame(frame); });