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:
2026-08-06 19:27:48 +00:00
co-authored by Claude Fable 5
parent cb0649016c
commit 80c1d75d2f
41 changed files with 6801 additions and 0 deletions
@@ -0,0 +1,839 @@
/* ---------------------------------------------------------------------------
Pre-spawn interface.
One spatial rule holds across every screen: the instrument sits in a rail on
the left, the world stays visible on the right. Nothing is ever a floating
centred card, because nothing here is a dialog - it is a file being worked
on while the city carries on behind it.
--------------------------------------------------------------------------- */
body {
background: transparent;
opacity: 1;
transition: opacity 420ms var(--ease);
}
body.hidden { opacity: 0; pointer-events: none; }
/* Keeps interface text legible over whatever the camera happens to be on. */
.edge {
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(8,9,11,.55) 0%, rgba(8,9,11,0) 55%),
radial-gradient(130% 100% at 70% 50%, transparent 45%, rgba(0,0,0,.6) 100%);
}
.grain {
position: fixed;
inset: 0;
pointer-events: none;
opacity: .1;
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='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* =========================================================================
chrome
========================================================================= */
.topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 74px;
padding: 0 var(--gut-x);
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
z-index: 40;
pointer-events: none;
}
.mark { display: flex; align-items: baseline; gap: 11px; }
.mark-name {
font-weight: 800;
font-size: 17px;
letter-spacing: -.01em;
}
.mark-tag {
font-family: 'Plex Mono', monospace;
font-size: 9.5px;
letter-spacing: .34em;
color: var(--sodium);
text-transform: uppercase;
}
.stages { display: flex; align-items: center; gap: 10px; }
.stagechip {
display: flex;
align-items: center;
gap: 9px;
background: none;
border: 0;
padding: 6px 2px;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .01em;
cursor: default;
transition: color 300ms var(--ease);
}
.stagechip em {
font-family: 'Plex Mono', monospace;
font-style: normal;
font-size: 10px;
letter-spacing: .1em;
padding: 3px 5px;
border: 1px solid var(--line);
color: var(--muted);
transition: color 300ms var(--ease), border-color 300ms var(--ease), background 300ms var(--ease);
}
.stagechip.done { color: var(--paper-dim); }
.stagechip.done em { color: var(--paper-dim); border-color: var(--line); }
.stagechip.on { color: var(--paper); }
.stagechip.on em {
color: var(--ink-900);
background: var(--sodium);
border-color: var(--sodium);
}
.stagelink {
width: 26px;
height: 1px;
background: var(--line);
}
.filetag { color: var(--muted); }
.filetag span { color: var(--paper-dim); }
/* =========================================================================
screens + rail
========================================================================= */
.screen {
position: fixed;
inset: 0;
z-index: 20;
}
.screen[hidden] { display: none; }
.rail {
position: absolute;
top: 0; left: 0; bottom: 0;
width: var(--rail);
display: flex;
align-items: center;
pointer-events: auto;
}
.rail.wide { width: clamp(470px, 36vw, 640px); }
.rail-scrim {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 168%;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(8,9,11,.96) 0%,
rgba(8,9,11,.92) 42%,
rgba(8,9,11,.6) 72%,
rgba(8,9,11,0) 100%
);
}
.rail-inner {
position: relative;
width: 100%;
padding: 96px var(--gut-x) var(--gut-y);
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--ink-500) transparent;
}
.rail-inner::-webkit-scrollbar { width: 3px; }
.rail-inner::-webkit-scrollbar-thumb { background: var(--ink-500); }
/* staged entrance, replayed each time a screen becomes current */
.screen.is-on .rail-inner > * { animation: rise 720ms var(--ease-out) both; }
.screen.is-on .rail-inner > *:nth-child(1) { animation-delay: 40ms; }
.screen.is-on .rail-inner > *:nth-child(2) { animation-delay: 110ms; }
.screen.is-on .rail-inner > *:nth-child(3) { animation-delay: 175ms; }
.screen.is-on .rail-inner > *:nth-child(4) { animation-delay: 235ms; }
.screen.is-on .rail-inner > *:nth-child(5) { animation-delay: 290ms; }
.screen.is-on .rail-inner > *:nth-child(6) { animation-delay: 340ms; }
.screen.is-on .rail-inner > *:nth-child(7) { animation-delay: 385ms; }
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
/* =========================================================================
type blocks
========================================================================= */
.kicker {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
.kicker .hair {
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--line), transparent);
}
.head {
font-size: clamp(30px, 2.9vw, 44px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.032em;
margin-bottom: 14px;
}
.sub {
color: var(--paper-dim);
font-size: 14.5px;
max-width: 46ch;
margin-bottom: 30px;
}
/* =========================================================================
form
========================================================================= */
.form { display: block; }
.field { margin-bottom: 20px; }
.field label {
display: block;
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
font-weight: 500;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 9px;
transition: color 220ms var(--ease);
}
.field:focus-within label { color: var(--sodium); }
.field input {
width: 100%;
height: 46px;
padding: 0 14px;
background: rgba(20, 23, 29, .72);
border: 1px solid var(--line);
border-radius: 0;
color: var(--paper);
font-family: inherit;
font-size: 15px;
letter-spacing: .005em;
transition: border-color 220ms var(--ease), background 220ms var(--ease), box-shadow 220ms var(--ease);
}
.field input::placeholder { color: #565c66; }
.field input:hover { border-color: rgba(232,228,220,.22); }
.field input:focus {
outline: none;
border-color: var(--sodium);
background: rgba(29, 33, 42, .85);
box-shadow: inset 0 -2px 0 var(--sodium-soft);
}
.withbtn { position: relative; display: flex; }
.withbtn input { padding-right: 62px; }
.reveal-btn {
position: absolute;
right: 1px; top: 1px; bottom: 1px;
padding: 0 13px;
background: none;
border: 0;
border-left: 1px solid var(--line);
color: var(--muted);
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
cursor: pointer;
transition: color 200ms var(--ease);
}
.reveal-btn:hover { color: var(--sodium); }
.hint {
margin-top: 8px;
font-family: 'Plex Mono', monospace;
font-size: 11px;
line-height: 1.45;
letter-spacing: .01em;
color: var(--muted);
transition: color 220ms var(--ease), transform 220ms var(--ease);
}
.field.bad input { border-color: var(--stamp); }
.field.bad .hint { color: var(--stamp); }
.field.good input { border-color: rgba(127,159,90,.55); }
.field.good .hint { color: var(--good); }
/* register-only row: grid trick so height animates without a fixed value */
.collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 460ms var(--ease-out), opacity 300ms var(--ease);
}
.collapse.open { grid-template-rows: 1fr; opacity: 1; }
.collapse-inner { overflow: hidden; min-height: 0; }
.formerror {
margin-bottom: 16px;
padding: 11px 13px;
border-left: 2px solid var(--stamp);
background: rgba(196,70,47,.09);
color: #e8a598;
font-size: 13.5px;
animation: shake 380ms var(--ease);
}
.formerror[hidden] { display: none; }
@keyframes shake {
0%,100% { transform: translateX(0); }
22% { transform: translateX(-5px); }
46% { transform: translateX(4px); }
72% { transform: translateX(-2px); }
}
.switch {
margin-top: 22px;
font-size: 13.5px;
color: var(--muted);
}
/* =========================================================================
buttons
========================================================================= */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 50px;
padding: 0 26px;
border: 1px solid transparent;
border-radius: 0;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: .07em;
text-transform: uppercase;
cursor: pointer;
overflow: hidden;
transition: transform 110ms var(--ease), background 240ms var(--ease),
color 240ms var(--ease), border-color 240ms var(--ease), opacity 240ms var(--ease);
}
.btn:active { transform: translateY(1px); }
.btn[disabled] { opacity: .34; cursor: not-allowed; }
.btn[disabled]:active { transform: none; }
.btn.primary {
width: 100%;
background: var(--sodium);
color: #17120b;
}
.btn.primary:hover:not([disabled]) { background: #ffb063; }
/* a light sweeps across on hover - the only ornament on the button */
.btn.primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.42) 50%, transparent 70%);
transform: translateX(-130%);
transition: transform 720ms var(--ease);
}
.btn.primary:hover:not([disabled])::after { transform: translateX(130%); }
.btn.ghost {
background: transparent;
border-color: var(--line);
color: var(--paper-dim);
}
.btn.ghost:hover:not([disabled]) { border-color: var(--sodium-line); color: var(--sodium); }
.btn-spin {
width: 13px; height: 13px;
border: 1.5px solid rgba(23,18,11,.3);
border-top-color: #17120b;
border-radius: 50%;
display: none;
animation: spin 640ms linear infinite;
}
.btn.busy .btn-spin { display: block; }
.btn.busy .btn-label { opacity: .55; }
@keyframes spin { to { transform: rotate(360deg); } }
.linkbtn {
background: none;
border: 0;
padding: 0;
color: var(--sodium);
font-family: inherit;
font-size: inherit;
cursor: pointer;
position: relative;
}
.linkbtn::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 1px;
background: var(--sodium);
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
}
.linkbtn:hover::after { transform: scaleX(1); }
/* =========================================================================
character list
========================================================================= */
.charlist { list-style: none; margin-bottom: 26px; }
.charcard {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 17px 18px;
margin-bottom: 10px;
background: rgba(20,23,29,.6);
border: 1px solid var(--line);
cursor: pointer;
transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease);
}
.charcard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); }
.charcard.on {
border-color: var(--sodium);
background: rgba(255,159,69,.07);
}
/* the accent bar only exists on the selected row */
.charcard.on::before {
content: '';
position: absolute;
left: -1px; top: -1px; bottom: -1px;
width: 2px;
background: var(--sodium);
}
.charslot {
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .1em;
color: var(--muted);
border: 1px solid var(--line);
padding: 4px 6px;
}
.charcard.on .charslot { color: var(--sodium); border-color: var(--sodium-line); }
.charname { display: block; font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
.charmeta {
display: block;
font-family: 'Plex Mono', monospace;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
letter-spacing: .02em;
}
.charmoney {
font-family: 'Plex Mono', monospace;
font-size: 12.5px;
color: var(--paper-dim);
text-align: right;
font-variant-numeric: tabular-nums;
}
.charcard.empty {
border-style: dashed;
background: transparent;
color: var(--muted);
grid-template-columns: auto 1fr;
}
.charcard.empty:hover { border-color: var(--sodium-line); color: var(--sodium); }
.charcard.empty .charname { font-size: 14px; font-weight: 500; color: inherit; }
.charactions { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
/* =========================================================================
creator
========================================================================= */
.creator { padding-bottom: 26px; }
.tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.tab {
position: relative;
background: none;
border: 0;
padding: 10px 13px;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .01em;
cursor: pointer;
transition: color 220ms var(--ease);
}
.tab:hover { color: var(--paper-dim); }
.tab.is-on { color: var(--paper); }
.tab.is-on::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 2px;
background: var(--sodium);
animation: tabin 320ms var(--ease-out);
}
@keyframes tabin { from { transform: scaleX(.2); } to { transform: scaleX(1); } }
.panes { min-height: 300px; }
.pane { display: none; }
.pane.is-on { display: block; animation: panein 420ms var(--ease-out); }
@keyframes panein {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: none; }
}
.ctrl { margin-bottom: 15px; }
.ctrl-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
gap: 12px;
}
.ctrl-label {
font-size: 13px;
color: var(--paper-dim);
}
.ctrl-val {
font-family: 'Plex Mono', monospace;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 20px;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-runnable-track { height: 2px; background: var(--line); }
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 3px;
height: 14px;
margin-top: -6px;
background: var(--sodium);
border: 0;
transition: height 150ms var(--ease), margin-top 150ms var(--ease);
}
.slider:hover::-webkit-slider-thumb,
.slider:focus-visible::-webkit-slider-thumb { height: 20px; margin-top: -9px; }
.stepper { display: flex; align-items: stretch; gap: 0; }
.stepper button {
width: 34px;
background: transparent;
border: 1px solid var(--line);
color: var(--paper-dim);
font-family: 'Plex Mono', monospace;
font-size: 14px;
cursor: pointer;
transition: color 180ms var(--ease), border-color 180ms var(--ease), background 180ms var(--ease);
}
.stepper button:hover { color: var(--sodium); border-color: var(--sodium-line); }
.stepper button:active { background: var(--sodium-soft); }
.stepper .stepval {
flex: 1;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-left: 0; border-right: 0;
font-family: 'Plex Mono', monospace;
font-size: 12px;
color: var(--paper);
font-variant-numeric: tabular-nums;
}
/* 64 colours is a lot of surface; kept small so a palette never outweighs the
control it belongs to */
.swatches { display: flex; flex-wrap: wrap; gap: 4px; }
.swatch {
width: 13px; height: 13px;
border: 1px solid var(--line);
cursor: pointer;
padding: 0;
transition: transform 160ms var(--ease), border-color 160ms var(--ease);
}
.swatch:hover { transform: scale(1.3); }
.swatch.on { border-color: var(--sodium); transform: scale(1.3); }
.seg { display: flex; gap: 0; margin-bottom: 22px; }
.seg button {
flex: 1;
height: 42px;
background: transparent;
border: 1px solid var(--line);
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
letter-spacing: .05em;
text-transform: uppercase;
cursor: pointer;
transition: color 220ms var(--ease), background 220ms var(--ease), border-color 220ms var(--ease);
}
.seg button + button { border-left: 0; }
.seg button.on {
color: var(--ink-900);
background: var(--sodium);
border-color: var(--sodium);
}
.seg button:not(.on):hover { color: var(--paper); }
.field textarea {
width: 100%;
min-height: 132px;
padding: 13px 14px;
background: rgba(20,23,29,.72);
border: 1px solid var(--line);
color: var(--paper);
font-family: inherit;
font-size: 14.5px;
line-height: 1.55;
resize: vertical;
transition: border-color 220ms var(--ease), background 220ms var(--ease);
}
.field textarea:focus {
outline: none;
border-color: var(--sodium);
background: rgba(29,33,42,.85);
}
.counter {
margin-top: 6px;
text-align: right;
font-family: 'Plex Mono', monospace;
font-size: 10.5px;
color: var(--muted);
}
.creator-foot { display: grid; grid-template-columns: auto 1fr; gap: 10px; margin-top: 24px; }
/* drag surface over the world half */
.turntable {
position: fixed;
top: 0; right: 0; bottom: 0;
left: clamp(470px, 36vw, 640px);
z-index: 15;
cursor: grab;
}
.turntable.dragging { cursor: grabbing; }
.turnhint {
position: absolute;
left: 50%;
bottom: 46px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid var(--line);
background: rgba(8,9,11,.55);
color: var(--paper-dim);
letter-spacing: .1em;
text-transform: uppercase;
font-size: 10px;
transition: opacity 420ms var(--ease);
}
.turntable.dragging .turnhint { opacity: 0; }
.turnicon {
width: 13px; height: 13px;
border: 1px solid currentColor;
border-radius: 50%;
border-left-color: transparent;
border-right-color: transparent;
animation: turnspin 2.6s linear infinite;
}
@keyframes turnspin { to { transform: rotate(360deg); } }
/* =========================================================================
spawn
========================================================================= */
.spawnlist { list-style: none; margin-bottom: 24px; }
.spawncard {
position: relative;
padding: 15px 17px;
margin-bottom: 8px;
border: 1px solid var(--line);
background: rgba(20,23,29,.55);
cursor: pointer;
transition: border-color 240ms var(--ease), background 240ms var(--ease), transform 240ms var(--ease);
}
.spawncard:hover { border-color: rgba(232,228,220,.24); transform: translateX(3px); }
.spawncard.on { border-color: var(--sodium); background: rgba(255,159,69,.07); }
.spawncard.on::before {
content: '';
position: absolute;
left: -1px; top: -1px; bottom: -1px;
width: 2px;
background: var(--sodium);
}
.spawnhead { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.spawnname { font-size: 15.5px; font-weight: 600; letter-spacing: -.01em; }
.spawnarea {
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.spawnblurb { margin-top: 5px; font-size: 13px; color: var(--paper-dim); max-width: 42ch; }
.chart {
position: fixed;
top: 50%;
right: clamp(40px, 7vw, 130px);
transform: translateY(-50%);
width: min(38vw, 44vh);
aspect-ratio: 1;
z-index: 18;
pointer-events: none;
opacity: 0;
animation: chartin 900ms var(--ease-out) 260ms forwards;
}
@keyframes chartin {
from { opacity: 0; transform: translateY(-50%) scale(.96); }
to { opacity: 1; transform: translateY(-50%) scale(1); }
}
.chart svg { width: 100%; height: 100%; display: block; }
/* the coastline draws itself once, then stays */
#coastline {
stroke-dasharray: 1400;
stroke-dashoffset: 1400;
animation: draw 2600ms var(--ease-out) 400ms forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
.pins { position: absolute; inset: 0; }
/* The marker rotates; the label must not, so they are separate elements
inside an unrotated anchor. */
.pin { position: absolute; width: 0; height: 0; }
.pin-mark {
position: absolute;
left: -6px; top: -6px;
width: 12px; height: 12px;
border: 1px solid var(--sodium-line);
background: rgba(255,159,69,.16);
transform: rotate(45deg);
transition: background 260ms var(--ease), border-color 260ms var(--ease), transform 260ms var(--ease);
}
.pin-label {
position: absolute;
left: 18px;
top: -7px;
white-space: nowrap;
font-family: 'Plex Mono', monospace;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
transition: color 260ms var(--ease);
}
.pin.on .pin-mark {
background: var(--sodium);
border-color: var(--sodium);
transform: rotate(45deg) scale(1.35);
}
.pin.on .pin-label { color: var(--paper); }
/* pins on the eastern side label leftwards so nothing runs off the sheet */
.pin.flip .pin-label { left: auto; right: 18px; }
/* a single ping when selected, not a permanent pulse */
.pin.on .pin-mark::before {
content: '';
position: absolute;
inset: -6px;
border: 1px solid var(--sodium);
animation: ping 900ms var(--ease-out);
}
/* survey chart furniture */
.chart-frame { fill: none; stroke: rgba(232,228,220,.10); }
.chart-ticks { fill: none; stroke: rgba(255,159,69,.45); stroke-width: 2; }
.chart-label {
font-family: 'Plex Mono', monospace;
font-size: 19px;
letter-spacing: 3.4px;
fill: #6e747f;
text-transform: uppercase;
}
@keyframes ping {
from { opacity: .9; transform: scale(.6); }
to { opacity: 0; transform: scale(1.9); }
}
/* =========================================================================
the stamp
========================================================================= */
.stamp {
position: fixed;
top: 46%;
left: 62%;
z-index: 60;
padding: 14px 30px;
border: 3px solid var(--stamp);
color: var(--stamp);
font-family: 'Plex Mono', monospace;
font-weight: 500;
font-size: 30px;
letter-spacing: .22em;
text-transform: uppercase;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) rotate(-19deg) scale(2.6);
}
.stamp::before {
content: '';
position: absolute;
inset: 4px;
border: 1px solid var(--stamp);
opacity: .55;
}
.stamp.show { animation: stamp 1500ms var(--ease-out) forwards; }
@keyframes stamp {
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-19deg) scale(2.6); }
38% { opacity: .95; transform: translate(-50%,-50%) rotate(-6deg) scale(.94); }
48% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.04); }
58% { transform: translate(-50%,-50%) rotate(-7deg) scale(1); }
80% { opacity: .95; }
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-7deg) scale(1); }
}
@@ -0,0 +1,958 @@
/* ---------------------------------------------------------------------------
Pre-spawn interface behaviour.
The client Lua owns the truth about stages; this file renders whatever it is
told and asks for changes. Validation here is for live feedback only - the
server re-checks everything and its answer is the one that counts.
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
async function post(name, data) {
try {
const res = await fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
});
return await res.json();
} catch (err) {
return { ok: false, error: 'The game stopped responding. Try again.' };
}
}
/* Fire-and-forget for high-frequency edits (slider drags). */
function poke(name, data) {
fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
}).catch(() => {});
}
/* =========================================================================
screen manager
========================================================================= */
const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
const CHIP_ORDER = ['auth', 'characters', 'spawn'];
let current = null;
function showScreen(name) {
SCREENS.forEach((s) => {
const el = $(`screen-${s}`);
if (!el) return;
if (s === name) {
el.hidden = false;
/* restart the entrance animation */
el.classList.remove('is-on');
void el.offsetWidth;
el.classList.add('is-on');
} else {
el.hidden = true;
el.classList.remove('is-on');
}
});
current = name;
paintChips(CHIP_FOR[name]);
document.body.classList.remove('hidden');
}
function paintChips(activeKey) {
const idx = CHIP_ORDER.indexOf(activeKey);
$$('.stagechip').forEach((chip) => {
const i = CHIP_ORDER.indexOf(chip.dataset.stage);
chip.classList.toggle('on', i === idx);
chip.classList.toggle('done', i < idx);
});
}
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
}
function busy(btn, on) {
btn.classList.toggle('busy', on);
btn.disabled = on;
}
function showError(el, message) {
if (!message) {
el.hidden = true;
return;
}
el.textContent = message;
el.hidden = false;
/* replay the shake even if the message is unchanged */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
}
/* =========================================================================
validation (mirrors shared/util.lua; the server still decides)
========================================================================= */
const check = {
username(v) {
v = (v || '').trim();
if (!v) return [null, 'Pick a username.'];
if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
return [true, 'That will do.'];
},
password(v) {
v = v || '';
if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
return [true, 'Strong enough.'];
},
name(v) {
v = (v || '').trim();
if (!v) return [null, 'Required.'];
if (v.length < 2) return [false, 'Too short.'];
if (v.length > 20) return [false, 'Too long.'];
if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
return [true, ''];
},
dob(v) {
v = (v || '').trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
if (!v) return [null, 'YYYY-MM-DD'];
if (!m) return [false, 'Use YYYY-MM-DD.'];
const y = +m[1], mo = +m[2], d = +m[3];
if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
return [true, ''];
},
};
/* Paints a field's state. `state` is true / false / null (neutral). */
function mark(fieldName, state, message) {
const field = document.querySelector(`.field[data-field="${fieldName}"]`);
if (!field) return;
field.classList.toggle('good', state === true);
field.classList.toggle('bad', state === false);
const hint = field.querySelector('.hint');
if (hint && message !== undefined && message !== null) hint.textContent = message;
}
/* =========================================================================
01 / auth
========================================================================= */
const Auth = {
mode: 'register',
init() {
$('switchMode').addEventListener('click', () => this.toggle());
$('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
$('revealPw').addEventListener('click', () => {
const input = $('password');
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
$('revealPw').textContent = show ? 'Hide' : 'Show';
$('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
$('username').addEventListener('input', () => {
const [ok, msg] = check.username($('username').value);
mark('username', ok, msg);
});
$('password').addEventListener('input', () => {
const [ok, msg] = check.password($('password').value);
mark('password', ok, msg);
if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
});
$('confirm').addEventListener('input', () => this.checkConfirm());
},
checkConfirm() {
const a = $('password').value;
const b = $('confirm').value;
if (!b) return mark('confirm', null, 'Both entries must match.');
mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
},
toggle() {
this.mode = this.mode === 'register' ? 'login' : 'register';
const reg = this.mode === 'register';
$('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
$('authSub').textContent = reg
? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
: 'Sign in and the city picks up where you left it.';
$('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
$('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
$('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';
$('confirmWrap').classList.toggle('open', reg);
$('confirmWrap').setAttribute('aria-hidden', String(!reg));
$('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');
showError($('authError'), null);
if (!reg) mark('confirm', null, 'Both entries must match.');
},
async submit() {
const username = $('username').value.trim();
const password = $('password').value;
const reg = this.mode === 'register';
const [uOk, uMsg] = check.username(username);
if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }
if (reg) {
const [pOk, pMsg] = check.password(password);
if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
if ($('confirm').value !== password) {
mark('confirm', false, 'These do not match yet.');
$('confirm').focus();
return;
}
} else if (!password) {
mark('password', false, 'Enter your password.');
$('password').focus();
return;
}
const btn = $('authSubmit');
busy(btn, true);
showError($('authError'), null);
const res = await post('auth:submit', { mode: this.mode, username, password });
busy(btn, false);
if (!res.ok) return showError($('authError'), res.error || 'That did not work.');
if (reg) stamp('Filed');
Characters.load(res.characters || [], res.maxChars || 3);
showScreen('characters');
},
};
/* =========================================================================
02 / characters
========================================================================= */
const Characters = {
list: [],
max: 3,
selected: null,
init() {
$('charEnter').addEventListener('click', () => this.enter());
$('charDelete').addEventListener('click', () => this.remove());
},
load(list, max) {
this.list = list || [];
this.max = max || 3;
this.selected = null;
this.render();
},
render() {
const ul = $('charList');
ul.innerHTML = '';
$('charCount').textContent = `${this.list.length} / ${this.max}`;
this.list.forEach((c, i) => {
const li = document.createElement('li');
li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<span class="charslot">0${i + 1}</span>
<span>
<span class="charname"></span>
<span class="charmeta"></span>
</span>
<span class="charmoney"></span>`;
li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
li.querySelector('.charmeta').textContent = this.meta(c);
li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;
const pick = () => this.select(c.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
});
if (this.list.length < this.max) {
const li = document.createElement('li');
li.className = 'charcard empty';
li.tabIndex = 0;
li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
const go = () => Creator.open();
li.addEventListener('click', go);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
ul.appendChild(li);
}
$('charEnter').disabled = this.selected === null;
$('charDelete').disabled = this.selected === null;
},
meta(c) {
const hours = Math.floor((c.playtime || 0) / 3600);
const played = hours > 0 ? `${hours}h played` : 'never played';
const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
return `${c.dob} · ${job} · ${played}`;
},
select(id) {
this.selected = id;
this.render();
showError($('charError'), null);
poke('char:preview', { id });
},
async enter() {
if (this.selected === null) return;
const btn = $('charEnter');
busy(btn, true);
const res = await post('char:select', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
Spawn.load(res.spawns || [], res.lastPosition, res.character);
showScreen('spawn');
},
async remove() {
if (this.selected === null) return;
const c = this.list.find((x) => x.id === this.selected);
if (!c) return;
const btn = $('charDelete');
/* two-step, in place: the button becomes the confirmation */
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = `Delete ${c.firstName}?`;
setTimeout(() => {
if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
}, 4000);
return;
}
btn.dataset.armed = '0';
btn.textContent = 'Delete';
busy(btn, true);
const res = await post('char:delete', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
this.load(res.characters || [], this.max);
},
};
/* =========================================================================
03 / creator
========================================================================= */
const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];
const Creator = {
appearance: null,
limits: null,
schema: null,
palettes: null,
section: 'heritage',
init() {
$$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
$('creatorCancel').addEventListener('click', () => this.cancel());
$('creatorPrev').addEventListener('click', () => this.step(-1));
$('creatorNext').addEventListener('click', () => this.step(1));
this.initDrag();
},
async open() {
const res = await post('creator:start', { gender: 'm' });
if (!res.ok) return showError($('charError'), 'Could not start the editor.');
this.absorb(res);
this.section = 'heritage';
this.paintTabs();
this.renderAll();
showScreen('creator');
poke('creator:frame', { section: 'heritage' });
},
absorb(res) {
this.appearance = res.appearance;
this.limits = res.limits;
this.schema = res.schema || this.schema;
this.palettes = res.palettes || this.palettes;
},
go(section) {
this.section = section;
this.paintTabs();
poke('creator:frame', { section });
},
paintTabs() {
$$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
$$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
const i = SECTIONS.indexOf(this.section);
$('creatorPrev').disabled = i === 0;
$('creatorNext').querySelector('.btn-label').textContent =
i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
},
step(dir) {
const i = SECTIONS.indexOf(this.section);
if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
this.go(next);
},
/* --- control builders ------------------------------------------------- */
slider(label, value, min, max, step, format, onInput) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<input class="slider" type="range">`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const input = wrap.querySelector('input');
input.min = min; input.max = max; input.step = step; input.value = value;
val.textContent = format(value);
input.addEventListener('input', () => {
const v = Number(input.value);
val.textContent = format(v);
onInput(v);
});
return wrap;
},
stepper(label, value, min, max, onChange) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<div class="stepper">
<button type="button" aria-label="Previous">&minus;</button>
<span class="stepval"></span>
<button type="button" aria-label="Next">+</button>
</div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const range = wrap.querySelector('.ctrl-val');
const out = wrap.querySelector('.stepval');
let v = value;
let lo = min;
let hi = max;
const paint = () => {
out.textContent = v < 0 ? 'None' : String(v);
range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}${hi}`;
};
const set = (next) => {
if (next < lo) next = hi; // wraps, so browsing a long list is quick
if (next > hi) next = lo;
v = next;
paint();
onChange(v);
};
/* Changing a garment changes how many variants it has; the variant control
is re-bounded in place rather than rebuilt. */
const setBounds = (newMax, newValue) => {
hi = typeof newMax === 'number' ? newMax : hi;
if (typeof newValue === 'number') v = newValue;
if (v > hi) v = lo;
paint();
};
wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
paint();
return { el: wrap, set, setBounds, get value() { return v; } };
},
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
<div class="swatches"></div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const row = wrap.querySelector('.swatches');
val.textContent = String(value);
(palette || []).forEach((c, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'swatch' + (i === value ? ' on' : '');
b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
b.title = `Colour ${i}`;
b.addEventListener('click', () => {
row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
b.classList.add('on');
val.textContent = String(i);
onPick(i);
});
row.appendChild(b);
});
return wrap;
},
/* --- panes ------------------------------------------------------------ */
renderAll() {
this.renderHeritage();
this.renderFace();
this.renderHair();
this.renderBody();
this.renderClothing();
this.renderIdentity();
},
pane(section) {
const el = document.querySelector(`.pane[data-section="${section}"]`);
el.innerHTML = '';
return el;
},
renderHeritage() {
const p = this.pane('heritage');
const a = this.appearance;
const seg = document.createElement('div');
seg.className = 'seg';
seg.innerHTML = `
<button type="button" data-g="m">Male</button>
<button type="button" data-g="f">Female</button>`;
seg.querySelectorAll('button').forEach((b) => {
b.classList.toggle('on', b.dataset.g === a.model);
b.addEventListener('click', async () => {
if (b.dataset.g === this.appearance.model) return;
const res = await post('creator:gender', { gender: b.dataset.g });
if (!res.ok) return;
this.absorb(res);
this.renderAll();
this.paintTabs();
});
});
p.appendChild(seg);
p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.father = v;
poke('creator:parent', { key: 'father', value: v });
}));
p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.mother = v;
poke('creator:parent', { key: 'mother', value: v });
}));
p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
},
renderFace() {
const p = this.pane('face');
const a = this.appearance;
(this.schema.features || []).forEach((f) => {
const key = String(f.id);
const v = a.features[key] || 0;
p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
});
p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
a.eyeColour = v;
poke('creator:eyes', { value: v });
}));
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
},
renderHair() {
const p = this.pane('hair');
const a = this.appearance;
const maxHair = (this.limits && this.limits.hair) || 0;
const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
a.hair.style = v;
poke('creator:hair', { style: v });
});
p.appendChild(st.el);
p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
a.hair.colour = i;
poke('creator:hair', { colour: i });
}));
p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
a.hair.highlight = i;
poke('creator:hair', { highlight: i });
}));
/* Only the hair-tinted overlays belong here; skin and make-up sit with
the face, which keeps either pane from becoming a wall of controls. */
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
},
overlayControls(p, list) {
const a = this.appearance;
list.forEach((o) => {
const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
ov.index = v;
poke('creator:overlay', { key: o.key, index: v });
});
p.appendChild(s.el);
p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
(v) => `${Math.round(v * 100)}%`,
(v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));
if (o.tint) {
const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
ov.colour = i;
poke('creator:overlay', { key: o.key, colour: i });
}));
}
});
},
renderBody() {
const p = this.pane('body');
const a = this.appearance;
const note = document.createElement('p');
note.className = 'sub';
note.textContent = 'Build is carried by the heritage blend and these two proportions.';
p.appendChild(note);
['13', '19'].forEach((id) => {
const f = (this.schema.features || []).find((x) => String(x.id) === id);
if (!f) return;
p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
});
},
renderClothing() {
const p = this.pane('clothing');
const a = this.appearance;
(this.schema.components || []).forEach((c) => {
const cc = a.components[c.key] || { drawable: 0, texture: 0 };
const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };
const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
cc.texture = v;
poke('creator:component', { key: c.key, texture: v });
});
const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
cc.drawable = v;
const res = await post('creator:component', { key: c.key, drawable: v });
if (res && res.ok) {
cc.texture = res.texture;
tex.setBounds(res.maxTexture, res.texture);
}
});
p.appendChild(draw.el);
p.appendChild(tex.el);
});
(this.schema.props || []).forEach((pr) => {
const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
pp.drawable = v;
poke('creator:prop', { key: pr.key, drawable: v });
});
p.appendChild(s.el);
});
},
renderIdentity() {
const p = this.pane('identity');
p.innerHTML = `
<div class="field" data-field="firstName">
<label for="firstName">First name</label>
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
<p class="hint">The name people will call you.</p>
</div>
<div class="field" data-field="lastName">
<label for="lastName">Last name</label>
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
<p class="hint">Family name. Must be unique on this server.</p>
</div>
<div class="field" data-field="dob">
<label for="dob">Date of birth</label>
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
<p class="hint">YYYY-MM-DD</p>
</div>
<div class="field" data-field="backstory">
<label for="backstory">Backstory</label>
<textarea id="backstory" maxlength="2000"
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
<p class="counter"><span id="storyCount">0</span> / 2000</p>
</div>`;
$('firstName').addEventListener('input', () => {
const [ok, msg] = check.name($('firstName').value);
mark('firstName', ok, msg || 'The name people will call you.');
});
$('lastName').addEventListener('input', () => {
const [ok, msg] = check.name($('lastName').value);
mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
});
$('dob').addEventListener('input', () => {
const [ok, msg] = check.dob($('dob').value);
mark('dob', ok, msg || 'YYYY-MM-DD');
});
$('backstory').addEventListener('input', () => {
$('storyCount').textContent = String($('backstory').value.length);
});
},
/* --- drag to turn ----------------------------------------------------- */
initDrag() {
const tt = $('turntable');
let dragging = false;
let lastX = 0;
let pending = 0;
let frame = null;
const flush = () => {
frame = null;
if (pending !== 0) {
poke('creator:rotate', { delta: pending });
pending = 0;
}
};
tt.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
tt.classList.add('dragging');
tt.setPointerCapture(e.pointerId);
});
tt.addEventListener('pointermove', (e) => {
if (!dragging) return;
pending += (e.clientX - lastX) * 0.42;
lastX = e.clientX;
if (!frame) frame = requestAnimationFrame(flush);
});
const stop = (e) => {
if (!dragging) return;
dragging = false;
tt.classList.remove('dragging');
try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
};
tt.addEventListener('pointerup', stop);
tt.addEventListener('pointercancel', stop);
},
async cancel() {
await post('creator:cancel', {});
showScreen('characters');
Characters.render();
},
async submit() {
const first = $('firstName') ? $('firstName').value.trim() : '';
const last = $('lastName') ? $('lastName').value.trim() : '';
const dob = $('dob') ? $('dob').value.trim() : '';
if (this.section !== 'identity') this.go('identity');
const [fOk, fMsg] = check.name(first);
if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
const [lOk, lMsg] = check.name(last);
if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
const [dOk, dMsg] = check.dob(dob);
if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }
const btn = $('creatorNext');
busy(btn, true);
showError($('creatorError'), null);
const res = await post('creator:submit', {
firstName: first,
lastName: last,
dob,
backstory: $('backstory').value,
});
busy(btn, false);
if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');
stamp('Approved');
Characters.load(res.characters || [], Characters.max);
showScreen('characters');
},
};
/* =========================================================================
04 / spawn
========================================================================= */
const Spawn = {
spawns: [],
selected: null,
last: null,
init() {
$('spawnConfirm').addEventListener('click', () => this.confirm());
},
load(spawns, lastPosition, character) {
this.spawns = spawns || [];
this.last = lastPosition || null;
this.selected = null;
if (character) {
$('spawnSub').textContent =
`${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
}
this.render();
},
render() {
const ul = $('spawnList');
const pins = $('pins');
ul.innerHTML = '';
pins.innerHTML = '';
const entries = this.spawns.slice();
if (this.last) {
entries.unshift({
id: 'last',
label: 'Where you left off',
area: 'Last known position',
blurb: 'Pick up exactly where this character stopped.',
map: null,
});
}
entries.forEach((sp) => {
const li = document.createElement('li');
li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<div class="spawnhead">
<span class="spawnname"></span>
<span class="spawnarea"></span>
</div>
<p class="spawnblurb"></p>`;
li.querySelector('.spawnname').textContent = sp.label;
li.querySelector('.spawnarea').textContent = sp.area;
li.querySelector('.spawnblurb').textContent = sp.blurb;
const pick = () => this.select(sp.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
if (sp.map) {
const pin = document.createElement('div');
pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
pin.style.left = `${sp.map.x * 100}%`;
pin.style.top = `${sp.map.y * 100}%`;
pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
pin.querySelector('.pin-label').textContent = sp.label;
pins.appendChild(pin);
}
});
$('spawnConfirm').disabled = this.selected === null;
},
select(id) {
this.selected = id;
this.render();
showError($('spawnError'), null);
if (id !== 'last') poke('spawn:preview', { id });
},
async confirm() {
if (this.selected === null) return;
const btn = $('spawnConfirm');
busy(btn, true);
const res = await post('spawn:confirm', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
stamp('Issued');
},
};
/* =========================================================================
messages from the client script
========================================================================= */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'stage') {
if (msg.stage === 'auth') {
const d = msg.data || {};
if (d.serverName) $('markName').textContent = d.serverName;
if (d.serverTag) $('markTag').textContent = d.serverTag;
$('fileSerial').textContent = fileSerial();
showScreen('auth');
setTimeout(() => $('username').focus(), 520);
} else if (msg.stage === 'flying' || msg.stage === 'live') {
/* the camera is doing the talking now */
SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
document.body.classList.add('hidden');
} else {
showScreen(msg.stage);
}
}
if (msg.action === 'creator:sync' && msg.data) {
Creator.absorb(msg.data);
Creator.renderAll();
}
});
function fileSerial() {
const y = new Date().getFullYear();
const n = String(Math.floor(Math.random() * 900000) + 100000);
return `${y}-${n}`;
}
/* Escape backs out of the creator; nothing else steals keys from the game. */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current === 'creator') Creator.cancel();
});
document.addEventListener('DOMContentLoaded', () => {
Auth.init();
Characters.init();
Creator.init();
Spawn.init();
Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */
});
@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Los Santos RP</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="hidden">
<div class="edge" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<!-- ======================= persistent chrome ======================= -->
<header class="topbar">
<div class="mark">
<span class="mark-name" id="markName">LOS SANTOS</span>
<span class="mark-tag" id="markTag">ROLEPLAY</span>
</div>
<nav class="stages" aria-label="Application progress">
<button class="stagechip" data-stage="auth" type="button" disabled>
<em>01</em><span>Credentials</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="characters" type="button" disabled>
<em>02</em><span>Identity</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="spawn" type="button" disabled>
<em>03</em><span>Placement</span>
</button>
</nav>
<div class="filetag data">
FILE <span id="fileSerial">0000-000000</span>
</div>
</header>
<!-- ======================= 02 / auth ======================= -->
<section class="screen" id="screen-auth" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner">
<div class="kicker">
<span class="eyebrow">Office of Records</span>
<span class="hair" aria-hidden="true"></span>
</div>
<h1 class="head" id="authTitle">Open a file</h1>
<p class="sub" id="authSub">
Everything you do in this city is recorded against this file. Choose a name you will
remember and a password you will not share.
</p>
<form class="form" id="authForm" novalidate>
<div class="field" data-field="username">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username"
spellcheck="false" maxlength="20" placeholder="e.g. dlarue">
<p class="hint" id="hint-username">3&ndash;20 characters. Letters, numbers and underscore.</p>
</div>
<div class="field" data-field="password">
<label for="password">Password</label>
<div class="withbtn">
<input id="password" name="password" type="password" autocomplete="current-password"
maxlength="72" placeholder="At least 8 characters">
<button class="reveal-btn" id="revealPw" type="button" aria-label="Show password">Show</button>
</div>
<p class="hint" id="hint-password">At least 8 characters, with a letter and a number.</p>
</div>
<!-- only present while registering; height animates, nothing reloads -->
<div class="collapse" id="confirmWrap" aria-hidden="true">
<div class="collapse-inner">
<div class="field" data-field="confirm">
<label for="confirm">Repeat password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
maxlength="72" placeholder="Type it once more">
<p class="hint" id="hint-confirm">Both entries must match.</p>
</div>
</div>
</div>
<p class="formerror" id="authError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="authSubmit" type="submit">
<span class="btn-label">Open the file</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</form>
<p class="switch">
<span id="switchText">Already have a file?</span>
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
</p>
</div>
</div>
</section>
<!-- ======================= 03 / characters ======================= -->
<section class="screen" id="screen-characters" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner">
<div class="kicker">
<span class="eyebrow">Registered identities</span>
<span class="hair" aria-hidden="true"></span>
<span class="data" id="charCount">0 / 3</span>
</div>
<h1 class="head">Who are you today?</h1>
<ul class="charlist" id="charList"></ul>
<div class="charactions">
<button class="btn primary" id="charEnter" type="button" disabled>
<span class="btn-label">Enter the city</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<button class="btn ghost" id="charDelete" type="button" disabled>Delete</button>
</div>
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
</section>
<!-- ======================= 04 / creator ======================= -->
<section class="screen" id="screen-creator" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner creator">
<div class="kicker">
<span class="eyebrow">New identity</span>
<span class="hair" aria-hidden="true"></span>
<button class="linkbtn" id="creatorCancel" type="button">Discard</button>
</div>
<nav class="tabs" id="creatorTabs" aria-label="Editor sections">
<button class="tab is-on" data-section="heritage" type="button">Heritage</button>
<button class="tab" data-section="face" type="button">Face</button>
<button class="tab" data-section="hair" type="button">Hair</button>
<button class="tab" data-section="body" type="button">Body</button>
<button class="tab" data-section="clothing" type="button">Clothing</button>
<button class="tab" data-section="identity" type="button">Identity</button>
</nav>
<div class="panes" id="creatorPanes">
<div class="pane is-on" data-section="heritage"></div>
<div class="pane" data-section="face"></div>
<div class="pane" data-section="hair"></div>
<div class="pane" data-section="body"></div>
<div class="pane" data-section="clothing"></div>
<div class="pane" data-section="identity"></div>
</div>
<div class="creator-foot">
<button class="btn ghost" id="creatorPrev" type="button">Back</button>
<button class="btn primary" id="creatorNext" type="button">
<span class="btn-label">Next</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
<div class="turntable" id="turntable" title="Drag to turn">
<div class="turnhint data"><span class="turnicon" aria-hidden="true"></span>Drag to turn</div>
</div>
</section>
<!-- ======================= 05 / spawn ======================= -->
<section class="screen" id="screen-spawn" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="rail-inner">
<div class="kicker">
<span class="eyebrow">Placement</span>
<span class="hair" aria-hidden="true"></span>
</div>
<h1 class="head">Where does the day start?</h1>
<p class="sub" id="spawnSub">Pick a district. You can move once you are on the ground.</p>
<ul class="spawnlist" id="spawnList"></ul>
<button class="btn primary" id="spawnConfirm" type="button" disabled>
<span class="btn-label">Place me here</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
</div>
</div>
<!-- survey chart: a simplified plan of the county, not a screenshot -->
<div class="chart" id="chart">
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(232,228,220,.055)" stroke-width="1"/>
</pattern>
</defs>
<rect width="1000" height="1000" fill="url(#grid)"/>
<!-- chart furniture: this is a survey sheet, not a screenshot -->
<rect class="chart-frame" x="34" y="34" width="932" height="932"/>
<path class="chart-ticks"
d="M34 96 L34 34 L96 34 M904 34 L966 34 L966 96
M966 904 L966 966 L904 966 M96 966 L34 966 L34 904"/>
<text class="chart-label" x="34" y="1000">Los Santos County</text>
<path id="landmass"
d="M318 62 L470 44 L604 74 L712 66 L806 128 L858 248 L872 372
L842 468 L868 556 L826 664 L742 742 L648 812 L560 872
L462 918 L392 902 L342 836 L296 742 L262 640 L240 528
L206 424 L196 320 L232 196 L272 108 Z"
fill="rgba(255,159,69,.04)" stroke="rgba(255,159,69,.30)" stroke-width="1.5"/>
<!-- the western coastline is the one edge worth drawing emphatically -->
<path id="coastline"
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"
fill="none" stroke="rgba(255,159,69,.58)" stroke-width="2.5"/>
</svg>
<div class="pins" id="pins"></div>
</div>
</section>
<!-- the one bold moment: the city stamps your file -->
<div class="stamp" id="stamp" aria-hidden="true"><span id="stampText">FILED</span></div>
<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;
}
}