Part 1 final build: Los Santos RP written on camera by the agent
rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Loading screen.
|
||||
Layout: the city runs full bleed; the record sits in a rail on the left,
|
||||
held there by a gradient scrim rather than a panel, so the two never feel
|
||||
like separate windows.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
#sky, .grain, .scrim, .vignette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#sky { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.scrim {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(8, 9, 11, 0.97) 0%,
|
||||
rgba(8, 9, 11, 0.93) 34%,
|
||||
rgba(8, 9, 11, 0.72) 58%,
|
||||
rgba(8, 9, 11, 0.15) 82%,
|
||||
rgba(8, 9, 11, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.vignette {
|
||||
background: radial-gradient(120% 90% at 62% 45%, transparent 40%, rgba(0, 0, 0, 0.55) 100%);
|
||||
}
|
||||
|
||||
/* film grain, drawn once as an SVG turbulence tile */
|
||||
.grain {
|
||||
opacity: 0.16;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation: grainshift 640ms steps(2) infinite;
|
||||
}
|
||||
@keyframes grainshift {
|
||||
0% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-3px, 2px); }
|
||||
100% { transform: translate(2px, -2px); }
|
||||
}
|
||||
|
||||
/* --- rail ---------------------------------------------------------------- */
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--rail);
|
||||
height: 100%;
|
||||
padding: var(--gut-y) var(--gut-x);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: clamp(20px, 2.4vh, 34px);
|
||||
}
|
||||
|
||||
/* staged entrance; each element declares its own delay */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 900ms var(--ease-out) forwards;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@keyframes rise {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* --- file header --------------------------------------------------------- */
|
||||
|
||||
.file {
|
||||
border-top: 1px solid var(--sodium-line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.file .serial {
|
||||
margin-top: 7px;
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
.file .serial span { color: var(--sodium); }
|
||||
|
||||
/* --- wordmark ------------------------------------------------------------ */
|
||||
|
||||
.brand { align-self: end; }
|
||||
|
||||
.wordmark {
|
||||
font-size: clamp(46px, 5.2vw, 82px);
|
||||
font-weight: 800;
|
||||
line-height: 0.86;
|
||||
letter-spacing: 0.3em;
|
||||
text-indent: 0.3em; /* keeps the wide tracking optically centred */
|
||||
opacity: 0;
|
||||
animation: settle 1500ms var(--ease-out) 180ms forwards;
|
||||
}
|
||||
/* the name tightens into place, like a stamp being pressed */
|
||||
@keyframes settle {
|
||||
0% { opacity: 0; letter-spacing: 0.3em; text-indent: 0.3em; filter: blur(6px); }
|
||||
60% { opacity: 1; }
|
||||
100% { opacity: 1; letter-spacing: -0.028em; text-indent: 0; filter: blur(0); }
|
||||
}
|
||||
|
||||
.tagrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.tagline {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.42em;
|
||||
color: var(--sodium);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dash {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--sodium-line), transparent);
|
||||
}
|
||||
|
||||
/* --- notices ------------------------------------------------------------- */
|
||||
|
||||
.notices { align-self: center; }
|
||||
|
||||
.notice-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.notice-index { color: var(--muted); }
|
||||
|
||||
.notice { padding-top: 18px; min-height: 132px; }
|
||||
|
||||
.notice-title {
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice-body {
|
||||
font-size: 14.5px;
|
||||
color: var(--paper-dim);
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.notice.swap .notice-title,
|
||||
.notice.swap .notice-body { animation: none; }
|
||||
|
||||
.notice-title, .notice-body {
|
||||
animation: noticein 620ms var(--ease-out) both;
|
||||
}
|
||||
.notice-body { animation-delay: 70ms; }
|
||||
@keyframes noticein {
|
||||
from { opacity: 0; transform: translateY(7px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* --- progress ------------------------------------------------------------ */
|
||||
|
||||
.load { align-self: end; }
|
||||
|
||||
.loadhead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 11px;
|
||||
gap: 16px;
|
||||
}
|
||||
.step {
|
||||
color: var(--paper-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pct {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-weight: 500;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
color: var(--paper);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--sodium);
|
||||
transition: width 420ms var(--ease);
|
||||
}
|
||||
/* a light runs ahead of the fill so the bar reads as active even when the
|
||||
game stalls on a long step */
|
||||
.bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 64px;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 159, 69, 0.85));
|
||||
animation: crawl 1900ms linear infinite;
|
||||
}
|
||||
@keyframes crawl {
|
||||
0% { transform: translateX(-64px); opacity: 0; }
|
||||
35% { opacity: 1; }
|
||||
100% { transform: translateX(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/* --- volume fader -------------------------------------------------------- */
|
||||
|
||||
.fader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.mute {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--paper-dim);
|
||||
cursor: pointer;
|
||||
transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease);
|
||||
}
|
||||
.mute:hover { color: var(--sodium); border-color: var(--sodium-line); }
|
||||
.mute:active { transform: translateY(1px); }
|
||||
.mute.off #wave { opacity: 0.18; }
|
||||
.mute.off { color: var(--muted); }
|
||||
|
||||
input[type='range'] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 3px;
|
||||
height: 15px;
|
||||
margin-top: -6.5px;
|
||||
background: var(--sodium);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
|
||||
}
|
||||
input[type='range']:hover::-webkit-slider-thumb,
|
||||
input[type='range']:focus-visible::-webkit-slider-thumb {
|
||||
height: 21px;
|
||||
margin-top: -9.5px;
|
||||
}
|
||||
|
||||
.volval { width: 3ch; text-align: right; color: var(--muted); }
|
||||
|
||||
.audiohint {
|
||||
margin-top: 10px;
|
||||
color: var(--sodium);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: 0.45; } }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Once the world is ready the rail steps aside and reveals the live camera.
|
||||
The page background has to go transparent too, otherwise the fade reveals
|
||||
nothing but our own backdrop colour. */
|
||||
body.handover, body.handover .rail { background: transparent !important; }
|
||||
body.handover .scrim { transition: opacity 1400ms var(--ease); opacity: 0; }
|
||||
body.handover #sky { transition: opacity 1400ms var(--ease); opacity: 0; }
|
||||
body.handover .grain { transition: opacity 1400ms var(--ease); opacity: 0; }
|
||||
body.handover .rail { transition: opacity 900ms var(--ease), transform 1200ms var(--ease); opacity: 0; transform: translateY(-12px); }
|
||||
@@ -0,0 +1,567 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Los Santos Roleplay</title>
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- procedural night-city flyover; no external assets -->
|
||||
<canvas id="sky" aria-hidden="true"></canvas>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<div class="scrim" aria-hidden="true"></div>
|
||||
<div class="vignette" aria-hidden="true"></div>
|
||||
|
||||
<main class="rail">
|
||||
|
||||
<header class="file reveal" style="--d:0ms">
|
||||
<div class="eyebrow">City of Los Santos · Office of Records</div>
|
||||
<div class="serial data">
|
||||
FILE <span id="serial">0000-0000</span> / RESIDENCY APPLICATION
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="brand">
|
||||
<h1 class="wordmark" id="wordmark">LOS SANTOS</h1>
|
||||
<div class="tagrow reveal" style="--d:520ms">
|
||||
<span class="tagline">ROLEPLAY</span>
|
||||
<span class="dash" aria-hidden="true"></span>
|
||||
<span class="data" id="hostcount">Connecting</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notices reveal" style="--d:760ms" aria-live="polite">
|
||||
<div class="notice-head">
|
||||
<span class="eyebrow">Posted notice</span>
|
||||
<span class="notice-index data" id="noticeIndex">1 / 5</span>
|
||||
</div>
|
||||
<div class="notice" id="notice">
|
||||
<h2 class="notice-title" id="noticeTitle">Stay in character</h2>
|
||||
<p class="notice-body" id="noticeBody">Loading the city.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="load reveal" style="--d:940ms">
|
||||
<div class="loadhead">
|
||||
<span class="step data" id="step">Waiting for the game</span>
|
||||
<span class="pct" id="pct">0<i>%</i></span>
|
||||
</div>
|
||||
<div class="track"><i class="bar" id="bar"></i></div>
|
||||
|
||||
<div class="fader">
|
||||
<button class="mute" id="mute" type="button" aria-label="Mute ambience">
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||
<path id="spk" d="M3 6h2.5L9 3v10L5.5 10H3z" fill="currentColor"/>
|
||||
<path id="wave" d="M11 5.6a3.4 3.4 0 0 1 0 4.8M12.9 3.7a6 6 0 0 1 0 8.6"
|
||||
fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<label class="sr-only" for="vol">Ambience volume</label>
|
||||
<input id="vol" type="range" min="0" max="100" value="45" step="1">
|
||||
<span class="volval data" id="volval">45</span>
|
||||
</div>
|
||||
<p class="audiohint data" id="audiohint" hidden>Click anywhere to start the ambience</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Los Santos RP - design tokens.
|
||||
|
||||
The interface is styled as a municipal record being opened on you: strict
|
||||
grid, hairline rules, monospaced serials, and one accent taken from the only
|
||||
light source the city has at night - sodium street lamps.
|
||||
Shared verbatim by rp_loading and rp_ui so the four screens read as one
|
||||
product.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
src: url('fonts/archivo.woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plex Mono';
|
||||
src: url('fonts/plex-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plex Mono';
|
||||
src: url('fonts/plex-500.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* asphalt at night - cold, never pure black */
|
||||
--ink-900: #08090b;
|
||||
--ink-800: #0d0f13;
|
||||
--ink-700: #14171d;
|
||||
--ink-600: #1d212a;
|
||||
--ink-500: #272c37;
|
||||
|
||||
/* document stock */
|
||||
--paper: #e8e4dc;
|
||||
--paper-dim: #a9a69f;
|
||||
--muted: #6e747f;
|
||||
|
||||
/* the accent: sodium vapour, the colour of the city from the air */
|
||||
--sodium: #ff9f45;
|
||||
--sodium-soft: rgba(255, 159, 69, 0.14);
|
||||
--sodium-line: rgba(255, 159, 69, 0.42);
|
||||
--sodium-deep: #c4711f;
|
||||
|
||||
/* semantic only, never decorative */
|
||||
--stamp: #c4462f;
|
||||
--good: #7f9f5a;
|
||||
|
||||
--line: rgba(232, 228, 220, 0.13);
|
||||
--line-soft: rgba(232, 228, 220, 0.07);
|
||||
|
||||
--rail: clamp(400px, 31vw, 520px);
|
||||
|
||||
--gut-x: clamp(32px, 3.4vw, 56px);
|
||||
--gut-y: clamp(28px, 3vw, 48px);
|
||||
|
||||
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--ink-900);
|
||||
color: var(--paper);
|
||||
font-family: 'Archivo', system-ui, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Typographic roles --------------------------------------------------------- */
|
||||
|
||||
.eyebrow {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.data {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--paper-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Focus is always visible and always the accent. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--sodium);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user