The server itself: repo now mirrors the machine it runs on, plus install.sh
The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.
Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):
resources/[rp]/ rp_db, rp_core, rp_session, rp_loading, rp_ui,
rp_selftest, rp_dbtest
bin/ supervise.sh (keep-alive + console FIFO), rcon, init script
etc/schema.sql accounts, characters, transactions, inventory, vehicles
assets/fonts/ the bundled subsets
docs/SERVER.md how it is put together, resource by resource
install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.
No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80c1d75d2f
commit
71856b15e9
@@ -0,0 +1,325 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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(--sheet);
|
||||
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 rgba(217,178,60,.42);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.file .serial {
|
||||
margin-top: 7px;
|
||||
color: rgba(233,226,204,.62);
|
||||
}
|
||||
.file .serial span { color: var(--canary); }
|
||||
|
||||
/* --- 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(--canary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dash {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
|
||||
}
|
||||
|
||||
/* --- notices ------------------------------------------------------------- */
|
||||
|
||||
.notices { align-self: center; }
|
||||
|
||||
.notice-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(233,226,204,.14);
|
||||
}
|
||||
.notice-index { color: rgba(233,226,204,.40); }
|
||||
|
||||
.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: rgba(233,226,204,.62);
|
||||
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: rgba(233,226,204,.62);
|
||||
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(--stock-hi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: rgba(233,226,204,.40);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: rgba(233,226,204,.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--canary);
|
||||
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 rgba(233,226,204,.07);
|
||||
}
|
||||
|
||||
.mute {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(233,226,204,.14);
|
||||
color: rgba(233,226,204,.62);
|
||||
cursor: pointer;
|
||||
transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease);
|
||||
}
|
||||
.mute:hover { color: var(--canary); border-color: rgba(217,178,60,.42); }
|
||||
.mute:active { transform: translateY(1px); }
|
||||
.mute.off #wave { opacity: 0.18; }
|
||||
.mute.off { color: rgba(233,226,204,.40); }
|
||||
|
||||
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: rgba(233,226,204,.14);
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 3px;
|
||||
height: 15px;
|
||||
margin-top: -6.5px;
|
||||
background: var(--canary);
|
||||
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: rgba(233,226,204,.40); }
|
||||
|
||||
.audiohint {
|
||||
margin-top: 10px;
|
||||
color: var(--canary);
|
||||
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); }
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
The loading screen is the only screen with no paper on it, so the three
|
||||
voices have to be restated in their light-on-dark values.
|
||||
--------------------------------------------------------------------------- */
|
||||
.eyebrow { color: rgba(233,226,204,.42); }
|
||||
.data { color: rgba(233,226,204,.60); }
|
||||
body { color: var(--stock-hi); }
|
||||
|
||||
/* The posted notice is a real notice: a slip of the same stock the rest of the
|
||||
product is printed on, taped to the window of a city that is still loading. */
|
||||
.notice {
|
||||
background: var(--stock);
|
||||
color: var(--ink);
|
||||
padding: 15px 17px 16px;
|
||||
box-shadow: var(--lift-1);
|
||||
border-left: 3px solid var(--canary);
|
||||
}
|
||||
.notice-head { border-bottom: 1px solid var(--rule); padding-bottom: 7px; margin-bottom: 9px; }
|
||||
.notice-index { color: var(--ink-3); }
|
||||
.notice-title { color: var(--ink); }
|
||||
.notice-body { color: var(--ink-2); }
|
||||
@@ -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.
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,182 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Los Santos RP - design tokens.
|
||||
|
||||
The interface is a set of documents from the city's Office of Vital Records,
|
||||
lying on a dark desk while the city runs on behind them. Paper is the only
|
||||
surface; the game is the room the desk is in. Nothing here is a dark glass
|
||||
panel, because a city clerk does not hand you one.
|
||||
|
||||
Three typographic voices, and the rule between them is literal:
|
||||
|
||||
preprinted Archivo, expanded, caps what the form was printed with
|
||||
typed IBM Plex Mono what somebody entered on it
|
||||
prose IBM Plex Sans plain-English notes in the margin
|
||||
|
||||
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
src: url('fonts/archivo-var.woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-stretch: 62% 125%;
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plex Sans';
|
||||
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
|
||||
font-weight: 100 700;
|
||||
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 {
|
||||
/* --- the room ---------------------------------------------------------- */
|
||||
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
|
||||
--desk: #1a1b16; /* desk surface where the lamp reaches it */
|
||||
--lamp: rgba(224, 196, 128, 0.10);
|
||||
|
||||
/* --- the paper --------------------------------------------------------- */
|
||||
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
|
||||
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
|
||||
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
|
||||
--stock-3: #b3a888; /* deepest fold */
|
||||
|
||||
/* --- what is written on it --------------------------------------------- */
|
||||
--ink: #191c18; /* ballpoint black with a green cast */
|
||||
--ink-2: #4e5449; /* second-rank text */
|
||||
--ink-3: #7d8175; /* captions, disabled, ruled lines */
|
||||
|
||||
/* --- the three official inks, each with exactly one job ---------------- */
|
||||
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
|
||||
--canary-dp: #a8801d;
|
||||
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
|
||||
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
|
||||
|
||||
/* rules printed on the form */
|
||||
--rule: rgba(25, 28, 24, 0.22);
|
||||
--rule-soft: rgba(25, 28, 24, 0.11);
|
||||
--rule-firm: rgba(25, 28, 24, 0.55);
|
||||
|
||||
/* paper fibre, laid over every sheet at low opacity */
|
||||
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
|
||||
|
||||
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
|
||||
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
|
||||
|
||||
/* a sheet of paper casts a real shadow onto the desk */
|
||||
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
|
||||
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
|
||||
|
||||
--sheet: clamp(360px, 27vw, 460px);
|
||||
--sheet-w: clamp(440px, 34vw, 580px);
|
||||
|
||||
--gut-x: clamp(26px, 2.4vw, 40px);
|
||||
--gut-y: clamp(22px, 2.2vw, 34px);
|
||||
|
||||
--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(--room);
|
||||
color: var(--ink);
|
||||
font-family: 'Plex Sans', system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
The three voices.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* preprinted: everything the form arrived with */
|
||||
.pp,
|
||||
.eyebrow,
|
||||
.head,
|
||||
label,
|
||||
.btn,
|
||||
.tab,
|
||||
.ctrl-label,
|
||||
.charname,
|
||||
.spawnname,
|
||||
.stagechip span {
|
||||
font-family: 'Archivo', system-ui, sans-serif;
|
||||
font-variation-settings: 'wdth' 112, 'wght' 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
|
||||
/* typed: everything a person put on the form */
|
||||
.data,
|
||||
.typed,
|
||||
input,
|
||||
textarea,
|
||||
.ctrl-val,
|
||||
.stepval,
|
||||
.charmeta,
|
||||
.charmoney,
|
||||
.counter,
|
||||
.stagechip em,
|
||||
.filetag {
|
||||
font-family: 'Plex Mono', ui-monospace, monospace;
|
||||
font-variation-settings: normal;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* prose: the plain-English notes, the only voice allowed sentence case */
|
||||
.sub,
|
||||
.hint,
|
||||
.spawnblurb,
|
||||
.switch {
|
||||
font-family: 'Plex Sans', system-ui, sans-serif;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-variation-settings: 'wdth' 104, 'wght' 600;
|
||||
letter-spacing: 0.19em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.data {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.rule { height: 1px; background: var(--rule); border: 0; }
|
||||
|
||||
/* Focus is always the canary, always visible, and never subtle. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--canary-dp);
|
||||
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