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,820 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Los Santos RP - the pre-spawn screens.
|
||||
|
||||
Every screen is a sheet of paper from the Office of Vital Records, lying on a
|
||||
dark desk with the city running behind it. The body stays transparent: the
|
||||
game is the room, and the paper is the only thing we draw.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
transition: opacity 420ms var(--ease);
|
||||
}
|
||||
body.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* --- the room -------------------------------------------------------------
|
||||
A lamp over the desk on the left, and the dark closing in everywhere else.
|
||||
This is what makes an opaque sheet of paper look lit rather than pasted on. */
|
||||
.edge {
|
||||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(58% 74% at 22% 46%, var(--lamp) 0%, transparent 62%),
|
||||
radial-gradient(120% 96% at 26% 50%, transparent 38%, rgba(0,0,0,.58) 100%);
|
||||
}
|
||||
.grain {
|
||||
position: fixed; inset: 0; z-index: 60; pointer-events: none;
|
||||
opacity: .10; mix-blend-mode: overlay;
|
||||
background-image: var(--fiber);
|
||||
background-size: 160px 160px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
persistent chrome: the edge of the desk
|
||||
========================================================================= */
|
||||
|
||||
.topbar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 26px;
|
||||
padding: 20px var(--gut-x) 26px;
|
||||
background: linear-gradient(to bottom, rgba(6,7,5,.80), rgba(6,7,5,0));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mark { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.seal { width: 32px; height: 32px; flex: none; opacity: .92; }
|
||||
.seal-ring { fill: none; stroke: var(--stock-2); stroke-width: 3; }
|
||||
.seal-ring.thin { stroke-width: 1.4; opacity: .6; }
|
||||
.seal-ticks {
|
||||
fill: none; stroke: var(--stock-2); stroke-width: 5; opacity: .5;
|
||||
stroke-dasharray: 2 9;
|
||||
}
|
||||
.seal-star { fill: var(--canary); }
|
||||
|
||||
.mark-lines { display: flex; flex-direction: column; line-height: 1.1; }
|
||||
.mark-name {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 118, 'wght' 800;
|
||||
font-size: 15px; letter-spacing: .04em; text-transform: uppercase;
|
||||
color: var(--stock-hi);
|
||||
}
|
||||
.mark-tag {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 9px; letter-spacing: .3em; text-transform: uppercase;
|
||||
color: var(--canary);
|
||||
}
|
||||
|
||||
/* the routing box: three stations, in the order the file moves through them */
|
||||
.stages { display: flex; align-items: center; gap: 10px; }
|
||||
.stagechip {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: none; border: 0; padding: 0; cursor: default;
|
||||
}
|
||||
.stagechip em {
|
||||
font-style: normal; font-size: 9px; line-height: 1;
|
||||
width: 21px; height: 19px; display: grid; place-items: center;
|
||||
border: 1px solid rgba(222,216,198,.26);
|
||||
color: rgba(222,216,198,.45);
|
||||
transition: all 320ms var(--ease);
|
||||
}
|
||||
.stagechip span {
|
||||
font-size: 9.5px; letter-spacing: .17em;
|
||||
color: rgba(222,216,198,.42);
|
||||
transition: color 320ms var(--ease);
|
||||
}
|
||||
.stagechip.on em {
|
||||
background: var(--canary); border-color: var(--canary); color: var(--ink);
|
||||
}
|
||||
.stagechip.on span { color: var(--stock-hi); }
|
||||
.stagechip.done em { color: transparent; border-color: rgba(222,216,198,.4); position: relative; }
|
||||
.stagechip.done em::after {
|
||||
content: '\00D7';
|
||||
position: absolute; inset: 0; display: grid; place-items: center;
|
||||
font-size: 13px; color: var(--canary);
|
||||
}
|
||||
.stagelink { width: 26px; height: 1px; background: rgba(222,216,198,.2); }
|
||||
|
||||
.filetag { display: flex; align-items: baseline; gap: 8px; font-size: 11px; color: rgba(222,216,198,.55); }
|
||||
.filetag-k {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 104, 'wght' 600;
|
||||
font-size: 9px; letter-spacing: .19em; text-transform: uppercase;
|
||||
color: rgba(222,216,198,.35);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
the sheet
|
||||
========================================================================= */
|
||||
|
||||
.screen {
|
||||
position: fixed; inset: 0; z-index: 10;
|
||||
display: flex; align-items: center;
|
||||
padding: 96px var(--gut-x) var(--gut-y);
|
||||
}
|
||||
.screen[hidden] { display: none; }
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
width: var(--sheet);
|
||||
max-height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--stock);
|
||||
box-shadow: var(--lift-2);
|
||||
z-index: 2;
|
||||
}
|
||||
.rail.wide { width: var(--sheet-w); }
|
||||
|
||||
/* paper: fibre, a warmer lit edge on the lamp side, and a shaded far edge */
|
||||
.rail-scrim {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, transparent 70%, rgba(25,28,24,.10) 100%),
|
||||
var(--fiber);
|
||||
background-size: auto, auto, 160px 160px;
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
.rail-scrim::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background-image: var(--fiber);
|
||||
background-size: 150px 150px;
|
||||
opacity: .05;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.rail::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||
background: radial-gradient(78% 62% at 16% 6%, rgba(255,240,196,.55) 0%, rgba(255,240,196,0) 64%);
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
|
||||
.rail-inner {
|
||||
position: relative; z-index: 1;
|
||||
padding: 22px var(--gut-x) var(--gut-y);
|
||||
overflow-y: auto; overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--stock-3) transparent;
|
||||
}
|
||||
.rail-inner::-webkit-scrollbar { width: 4px; }
|
||||
.rail-inner::-webkit-scrollbar-thumb { background: var(--stock-3); }
|
||||
.rail-inner::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* --- letterhead ----------------------------------------------------------- */
|
||||
.letterhead {
|
||||
position: relative; z-index: 1; flex: none;
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
padding: 20px var(--gut-x) 14px;
|
||||
border-bottom: 2.5px solid var(--ink);
|
||||
}
|
||||
.letterhead::after {
|
||||
content: ''; position: absolute; left: var(--gut-x); right: var(--gut-x); bottom: -5px;
|
||||
height: 1px; background: var(--ink); opacity: .45;
|
||||
}
|
||||
.lh-dept { display: flex; flex-direction: column; gap: 2px; }
|
||||
.lh-city {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 9.5px; letter-spacing: .22em; text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.lh-office {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 116, 'wght' 800;
|
||||
font-size: 15px; letter-spacing: .015em; text-transform: uppercase;
|
||||
color: var(--ink); line-height: 1.15;
|
||||
}
|
||||
.lh-form {
|
||||
font-size: 10px; letter-spacing: .1em; color: var(--ink-2);
|
||||
border: 1px solid var(--rule); padding: 3px 7px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- headings ------------------------------------------------------------- */
|
||||
.head {
|
||||
font-size: clamp(23px, 2.05vw, 30px);
|
||||
font-variation-settings: 'wdth' 108, 'wght' 800;
|
||||
letter-spacing: .005em; line-height: 1.06;
|
||||
color: var(--ink);
|
||||
margin: 12px 0 10px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.sub {
|
||||
font-size: 13.5px; line-height: 1.6; color: var(--ink-2);
|
||||
max-width: 46ch; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kicker { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
|
||||
.hair { flex: 1; height: 1px; background: var(--rule); }
|
||||
|
||||
/* =========================================================================
|
||||
form fields - a printed box with its caption punched through the rule
|
||||
========================================================================= */
|
||||
|
||||
.form { display: block; }
|
||||
|
||||
.field { position: relative; margin-top: 24px; }
|
||||
.field:first-child { margin-top: 8px; }
|
||||
|
||||
.field label {
|
||||
position: absolute; top: -7px; left: 11px; z-index: 2;
|
||||
font-size: 9px; letter-spacing: .17em;
|
||||
color: var(--ink-2);
|
||||
background: var(--stock);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%; display: block;
|
||||
border: 1.4px solid var(--rule-firm);
|
||||
background: rgba(255,255,255,.34);
|
||||
color: var(--ink);
|
||||
font-size: 14px; line-height: 1.4;
|
||||
padding: 14px 13px 11px;
|
||||
transition: border-color 200ms var(--ease), background 200ms var(--ease);
|
||||
}
|
||||
.field textarea { min-height: 108px; resize: none; padding-top: 15px; }
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder { color: var(--ink-3); opacity: .65; }
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ink);
|
||||
background: rgba(255,255,255,.6);
|
||||
}
|
||||
|
||||
/* the clerk's tick in the margin of the box */
|
||||
.field::after {
|
||||
content: ''; position: absolute; top: 15px; right: 13px;
|
||||
font-size: 14px; line-height: 1; pointer-events: none;
|
||||
opacity: 0; transition: opacity 180ms var(--ease);
|
||||
}
|
||||
.field.good::after {
|
||||
content: ''; opacity: 1;
|
||||
width: 6px; height: 11px; top: 15px; right: 16px;
|
||||
border: 2px solid var(--verdi); border-top: 0; border-left: 0;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
.field.bad::after { content: '\00D7'; color: var(--stamp); opacity: 1; }
|
||||
.field.good input { border-color: var(--verdi); }
|
||||
.field.bad input { border-color: var(--stamp); }
|
||||
.field.good .hint { color: var(--verdi); }
|
||||
.field.bad .hint { color: var(--stamp); }
|
||||
|
||||
.hint {
|
||||
font-size: 11.5px; line-height: 1.45; color: var(--ink-3);
|
||||
margin-top: 7px;
|
||||
}
|
||||
.counter {
|
||||
font-size: 10.5px; color: var(--ink-3); margin-top: 7px; text-align: right;
|
||||
}
|
||||
|
||||
.withbtn { position: relative; }
|
||||
.withbtn input { padding-right: 68px; }
|
||||
.reveal-btn {
|
||||
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: 0; cursor: pointer;
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 104, 'wght' 600;
|
||||
font-size: 9.5px; letter-spacing: .15em; text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
border-bottom: 1px solid var(--rule-firm);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.reveal-btn:hover { color: var(--ink); border-bottom-color: var(--ink); }
|
||||
|
||||
.collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 380ms var(--ease); }
|
||||
.collapse.open { grid-template-rows: 1fr; }
|
||||
.collapse-inner { overflow: hidden; min-height: 0; }
|
||||
|
||||
.formerror {
|
||||
margin-top: 16px;
|
||||
font-family: 'Plex Sans', sans-serif;
|
||||
font-size: 12.5px; line-height: 1.45;
|
||||
color: var(--stamp);
|
||||
border-left: 3px solid var(--stamp);
|
||||
background: rgba(126,43,38,.07);
|
||||
padding: 9px 12px;
|
||||
animation: nudge 380ms var(--ease);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
buttons
|
||||
========================================================================= */
|
||||
|
||||
.btn {
|
||||
position: relative; overflow: hidden;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
font-size: 11px; letter-spacing: .15em;
|
||||
padding: 15px 24px;
|
||||
border: 1.4px solid var(--ink);
|
||||
background: none; color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: color 260ms var(--ease), border-color 260ms var(--ease), opacity 200ms var(--ease);
|
||||
}
|
||||
.btn::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: 0;
|
||||
background: var(--canary);
|
||||
transform: scaleX(0); transform-origin: left center;
|
||||
transition: transform 340ms var(--ease-out);
|
||||
}
|
||||
.btn:hover:not(:disabled)::before { transform: scaleX(1); }
|
||||
.btn-label, .btn-spin { position: relative; z-index: 1; }
|
||||
|
||||
.btn.primary { background: var(--ink); color: var(--stock-hi); width: 100%; margin-top: 22px; }
|
||||
.btn.primary:hover:not(:disabled) { color: var(--ink); }
|
||||
|
||||
.btn.ghost { background: rgba(255,255,255,.2); }
|
||||
.btn.ghost:hover:not(:disabled) { color: var(--ink); }
|
||||
|
||||
.btn.danger { border-color: var(--stamp); color: var(--stamp); }
|
||||
.btn.danger::before { background: var(--stamp); }
|
||||
.btn.danger:hover:not(:disabled) { color: var(--stock-hi); }
|
||||
|
||||
.btn:disabled { opacity: .32; cursor: default; }
|
||||
|
||||
.btn-spin { display: none; width: 13px; height: 13px; }
|
||||
.btn.busy .btn-label { opacity: .35; }
|
||||
.btn.busy .btn-spin {
|
||||
display: block;
|
||||
border: 1.6px solid currentColor; border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 720ms linear infinite;
|
||||
}
|
||||
|
||||
.switch { margin-top: 18px; font-size: 12.5px; color: var(--ink-2); }
|
||||
.linkbtn {
|
||||
background: none; border: 0; cursor: pointer; padding: 0;
|
||||
font-family: inherit; font-size: inherit; color: var(--ink);
|
||||
border-bottom: 1px solid var(--rule-firm);
|
||||
}
|
||||
.linkbtn:hover { border-bottom-color: var(--ink); }
|
||||
.linkbtn.danger {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 104, 'wght' 600;
|
||||
font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--stamp); border-bottom-color: rgba(126,43,38,.4);
|
||||
}
|
||||
.linkbtn.danger:hover { border-bottom-color: var(--stamp); }
|
||||
|
||||
/* =========================================================================
|
||||
02 / the register of persons - ruled index cards in a drawer
|
||||
========================================================================= */
|
||||
|
||||
.charlist { list-style: none; }
|
||||
|
||||
.charcard {
|
||||
position: relative;
|
||||
padding: 15px 18px 14px 52px;
|
||||
margin-bottom: 9px;
|
||||
background: var(--stock-hi);
|
||||
border: 1px solid var(--rule);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), background 200ms var(--ease);
|
||||
/* the faint ruling of a real index card */
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom, transparent 0 22px, rgba(46,97,85,.075) 22px 23px);
|
||||
}
|
||||
.charcard:hover { transform: translateY(-1px); box-shadow: 0 6px 14px -8px rgba(0,0,0,.55); }
|
||||
|
||||
.charslot {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 36px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--stock-2);
|
||||
border-right: 1px solid var(--rule);
|
||||
font-size: 10.5px; color: var(--ink-2);
|
||||
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||
}
|
||||
|
||||
.charname {
|
||||
display: block; font-size: 14.5px; letter-spacing: .045em;
|
||||
color: var(--ink); line-height: 1.2;
|
||||
}
|
||||
.charmeta { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; }
|
||||
.charmoney { font-size: 13px; color: var(--ink); white-space: nowrap; }
|
||||
|
||||
.charcard.on {
|
||||
background-color: #f4f0e4;
|
||||
border-color: var(--ink);
|
||||
box-shadow: inset 4px 0 0 var(--canary), 0 8px 18px -8px rgba(0,0,0,.6);
|
||||
}
|
||||
.charcard.on .charslot { background: var(--canary); color: var(--ink); }
|
||||
|
||||
.charcard.empty {
|
||||
background-color: transparent; background-image: none;
|
||||
border: 1.4px dashed var(--rule-firm);
|
||||
color: var(--ink-2);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.charcard.empty .charslot { background: transparent; border-right-color: var(--rule-soft); font-size: 15px; }
|
||||
.charcard.empty .charname { font-size: 11px; color: var(--ink-2); }
|
||||
.charcard.empty:hover { background-color: rgba(255,255,255,.24); }
|
||||
|
||||
.charactions { display: flex; gap: 10px; align-items: stretch; margin-top: 20px; }
|
||||
.charactions .btn.primary { margin-top: 0; }
|
||||
.charactions .btn.ghost { width: auto; flex: none; margin-top: 0; }
|
||||
|
||||
/* =========================================================================
|
||||
03 / application for identity
|
||||
========================================================================= */
|
||||
|
||||
.tabs {
|
||||
position: relative; z-index: 1; flex: none;
|
||||
display: flex; gap: 2px;
|
||||
padding: 14px var(--gut-x) 0;
|
||||
border-bottom: 1.4px solid var(--rule-firm);
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 11px 7px;
|
||||
font-size: 9.5px; letter-spacing: .12em;
|
||||
background: var(--stock-2);
|
||||
border: 1px solid var(--rule); border-bottom: 0;
|
||||
color: var(--ink-2); cursor: pointer;
|
||||
margin-bottom: -1.4px;
|
||||
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||
}
|
||||
.tab:hover { background: var(--stock); color: var(--ink); }
|
||||
.tab.is-on {
|
||||
background: var(--stock-hi); color: var(--ink);
|
||||
border-bottom: 1.4px solid var(--stock-hi);
|
||||
}
|
||||
|
||||
.rail-inner.creator { padding-top: 20px; }
|
||||
|
||||
.pane { display: none; }
|
||||
.pane.is-on {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px 20px;
|
||||
align-content: start;
|
||||
}
|
||||
.pane .sub { grid-column: 1 / -1; margin-bottom: 0; }
|
||||
.pane .field { grid-column: 1 / -1; }
|
||||
|
||||
.ctrl { min-width: 0; }
|
||||
.ctrl.span2 { grid-column: 1 / -1; }
|
||||
.ctrl-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.ctrl-label { font-size: 9.5px; letter-spacing: .14em; color: var(--ink-2); }
|
||||
.ctrl-val { font-size: 10.5px; color: var(--ink-3); white-space: nowrap; }
|
||||
|
||||
/* a measuring scale with a surveyor's marker riding on it */
|
||||
.slider {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 24px; background: transparent; cursor: pointer;
|
||||
}
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
height: 24px;
|
||||
background-image:
|
||||
linear-gradient(var(--rule-firm), var(--rule-firm)),
|
||||
repeating-linear-gradient(90deg, var(--rule) 0 1px, transparent 1px 10%);
|
||||
background-size: 100% 1.4px, 100% 7px;
|
||||
background-position: 0 12px, 0 13px;
|
||||
background-repeat: no-repeat, repeat-x;
|
||||
}
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 11px; height: 18px; margin-top: 3px;
|
||||
background: var(--ink); border: 0;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%);
|
||||
transition: background 160ms var(--ease);
|
||||
}
|
||||
.slider:hover::-webkit-slider-thumb { background: var(--canary-dp); }
|
||||
|
||||
.stepper {
|
||||
display: grid; grid-template-columns: 32px 1fr 32px;
|
||||
border: 1.4px solid var(--rule-firm);
|
||||
background: rgba(255,255,255,.34);
|
||||
}
|
||||
.stepper button {
|
||||
border: 0; background: none; height: 32px; cursor: pointer;
|
||||
color: var(--ink-2); font-size: 14px; line-height: 1;
|
||||
transition: background 160ms var(--ease), color 160ms var(--ease);
|
||||
}
|
||||
.stepper button:hover { background: var(--ink); color: var(--stock-hi); }
|
||||
.stepval {
|
||||
display: grid; place-items: center;
|
||||
font-size: 11.5px; color: var(--ink);
|
||||
border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
|
||||
overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* a printed colour chart: a fixed grid, so it can never wrap into orphans */
|
||||
.swatches {
|
||||
display: grid; grid-template-columns: repeat(16, 1fr);
|
||||
grid-auto-rows: 17px;
|
||||
gap: 1px; padding: 1px;
|
||||
background: var(--rule-firm);
|
||||
border: 1px solid var(--rule-firm);
|
||||
}
|
||||
.swatch {
|
||||
border: 0; padding: 0; cursor: pointer;
|
||||
position: relative; min-width: 0;
|
||||
transition: transform 140ms var(--ease);
|
||||
}
|
||||
.swatch:hover { transform: scale(1.18); z-index: 3; box-shadow: 0 0 0 1px var(--ink); }
|
||||
.swatch.on {
|
||||
z-index: 4;
|
||||
box-shadow: 0 0 0 2px var(--ink), 0 0 0 4px var(--canary);
|
||||
}
|
||||
|
||||
/* the sex field, ticked the way a form is ticked */
|
||||
.seg { display: flex; gap: 24px; grid-column: 1 / -1; margin-bottom: 2px; }
|
||||
.seg button {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
background: none; border: 0; cursor: pointer;
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 108, 'wght' 700;
|
||||
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.seg button::before {
|
||||
content: ''; width: 15px; height: 15px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
border: 1.4px solid var(--rule-firm);
|
||||
background: rgba(255,255,255,.4);
|
||||
font-size: 13px; line-height: 1; color: var(--stamp);
|
||||
}
|
||||
.seg button.on { color: var(--ink); }
|
||||
.seg button.on::before { content: '\00D7'; border-color: var(--ink); }
|
||||
|
||||
.creator-foot {
|
||||
position: relative; z-index: 1; flex: none;
|
||||
display: flex; gap: 10px;
|
||||
padding: 14px var(--gut-x) 18px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.creator-foot .btn { margin-top: 0; }
|
||||
.creator-foot .btn.ghost { flex: none; width: auto; }
|
||||
.creator-foot .btn.primary { flex: 1; }
|
||||
|
||||
#screen-creator .formerror { margin: 0 var(--gut-x); }
|
||||
|
||||
/* --- the photograph plate ------------------------------------------------
|
||||
The subject stands inside this frame, and the frame is also the drag
|
||||
surface, so the thing you aim at is the thing that turns. It is drawn wide
|
||||
on purpose: the ped is framed generously rather than pinned to a cut-out. */
|
||||
.turntable {
|
||||
position: absolute; left: 36%; right: 16%; top: 16%; bottom: 17%;
|
||||
z-index: 5; cursor: grab;
|
||||
}
|
||||
.turntable.dragging { cursor: grabbing; }
|
||||
|
||||
.plate-corner {
|
||||
position: absolute; width: 30px; height: 30px;
|
||||
border: 2px solid rgba(233,226,204,.72);
|
||||
transition: border-color 260ms var(--ease);
|
||||
}
|
||||
.plate-corner.tl { left: 0; top: 0; border-right: 0; border-bottom: 0; }
|
||||
.plate-corner.tr { right: 0; top: 0; border-left: 0; border-bottom: 0; }
|
||||
.plate-corner.bl { left: 0; bottom: 0; border-right: 0; border-top: 0; }
|
||||
.plate-corner.br { right: 0; bottom: 0; border-left: 0; border-top: 0; }
|
||||
.turntable.dragging .plate-corner { border-color: var(--canary); }
|
||||
|
||||
.plate-cap {
|
||||
position: absolute; left: 0; right: 0; bottom: -28px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
}
|
||||
.plate-cap-k {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 104, 'wght' 600;
|
||||
font-size: 9px; letter-spacing: .22em; text-transform: uppercase;
|
||||
color: rgba(222,216,198,.5);
|
||||
}
|
||||
.turnhint {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 10px; letter-spacing: .1em;
|
||||
color: rgba(222,216,198,.45);
|
||||
transition: opacity 200ms var(--ease);
|
||||
}
|
||||
.turntable.dragging .turnhint { opacity: 0; }
|
||||
.turnicon {
|
||||
width: 14px; height: 14px; display: block; flex: none;
|
||||
border: 1px solid currentColor; border-radius: 50%;
|
||||
border-right-color: transparent;
|
||||
transform: rotate(-30deg);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
04 / notice of placement
|
||||
========================================================================= */
|
||||
|
||||
.spawnlist { list-style: none; border-top: 1px solid var(--rule); }
|
||||
|
||||
.spawncard {
|
||||
position: relative;
|
||||
padding: 13px 14px 13px 44px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
cursor: pointer;
|
||||
transition: background 180ms var(--ease);
|
||||
}
|
||||
.spawncard:hover { background: rgba(255,255,255,.28); }
|
||||
|
||||
.spawncard::before {
|
||||
content: ''; position: absolute; left: 14px; top: 15px;
|
||||
width: 15px; height: 15px;
|
||||
display: grid; place-items: center;
|
||||
border: 1.4px solid var(--rule-firm);
|
||||
background: rgba(255,255,255,.45);
|
||||
font-family: 'Plex Sans', sans-serif;
|
||||
font-size: 13px; line-height: 1; color: var(--stamp);
|
||||
}
|
||||
.spawncard.on::before { content: '\00D7'; border-color: var(--ink); }
|
||||
.spawncard.on { background: rgba(217,178,60,.13); }
|
||||
|
||||
.spawnhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.spawnname { font-size: 12.5px; letter-spacing: .07em; color: var(--ink); }
|
||||
.spawnarea {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 9.5px; letter-spacing: .11em; text-transform: uppercase;
|
||||
color: var(--ink-3); white-space: nowrap;
|
||||
}
|
||||
.spawnblurb { font-size: 12px; line-height: 1.5; color: var(--ink-2); margin-top: 4px; }
|
||||
|
||||
/* --- the plan sheet ------------------------------------------------------- */
|
||||
.chart {
|
||||
position: absolute; right: 6%; top: 50%;
|
||||
transform: translateY(-50%) rotate(.5deg);
|
||||
width: min(33vw, 58vh); aspect-ratio: 1 / 1;
|
||||
z-index: 1;
|
||||
background: var(--stock);
|
||||
box-shadow: var(--lift-2);
|
||||
padding: 3%;
|
||||
}
|
||||
.chart-paper {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(80% 66% at 14% 8%, rgba(255,244,206,.26) 0%, rgba(255,244,206,0) 62%),
|
||||
var(--fiber);
|
||||
background-size: auto, 150px 150px;
|
||||
opacity: .9;
|
||||
}
|
||||
.chart svg { position: relative; width: 100%; height: 100%; display: block; }
|
||||
|
||||
.chart-frame { fill: none; stroke: var(--rule); stroke-width: 1; }
|
||||
.chart-ticks { fill: none; stroke: var(--ink); stroke-width: 2.5; opacity: .55; }
|
||||
#landmass { fill: rgba(46,97,85,.07); stroke: var(--ink-2); stroke-width: 1.4; }
|
||||
#coastline { fill: none; stroke: var(--ink); stroke-width: 2.6; }
|
||||
|
||||
.pins { position: absolute; inset: 3%; pointer-events: none; }
|
||||
.pin {
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.pin.flip { flex-direction: row-reverse; }
|
||||
.pin-mark {
|
||||
width: 9px; height: 9px; flex: none;
|
||||
background: var(--stock);
|
||||
border: 1.6px solid var(--ink);
|
||||
transform: rotate(45deg);
|
||||
transition: all 220ms var(--ease);
|
||||
}
|
||||
.pin-label {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 8.5px; letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-2); white-space: nowrap;
|
||||
}
|
||||
.pin.on .pin-mark {
|
||||
background: var(--canary); border-color: var(--ink);
|
||||
width: 13px; height: 13px;
|
||||
box-shadow: 0 0 0 3px rgba(217,178,60,.3);
|
||||
}
|
||||
.pin.on .pin-label { color: var(--ink); }
|
||||
|
||||
.titleblock {
|
||||
position: absolute; right: 3%; bottom: 3%;
|
||||
border: 1.4px solid var(--ink);
|
||||
background: var(--stock-hi);
|
||||
min-width: 44%;
|
||||
}
|
||||
.tb-row { display: flex; }
|
||||
.tb-row + .tb-row { border-top: 1px solid var(--rule); }
|
||||
.tb-k, .tb-v { padding: 4px 7px; font-size: 8px; line-height: 1.3; }
|
||||
.tb-k {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 100, 'wght' 600;
|
||||
letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
border-right: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
.tb-v {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
color: var(--ink); flex: 1;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.tb-row + .tb-row .tb-k { border-left: 1px solid var(--rule); }
|
||||
.tb-row + .tb-row .tb-k:first-child { border-left: 0; }
|
||||
.tb-main .tb-v {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 112, 'wght' 700;
|
||||
font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
the stamp - the one bold moment, and the only thing that moves loudly
|
||||
========================================================================= */
|
||||
|
||||
.stamp {
|
||||
position: fixed; left: 17%; top: 53%; z-index: 50;
|
||||
transform: translate(-50%, -50%) rotate(-8deg) scale(1);
|
||||
display: none;
|
||||
flex-direction: column; align-items: center; gap: 3px;
|
||||
padding: 13px 30px 11px;
|
||||
border: 4px solid var(--stamp);
|
||||
color: var(--stamp);
|
||||
pointer-events: none;
|
||||
/* real rubber never inks evenly */
|
||||
-webkit-mask-image: var(--inkmask);
|
||||
mask-image: var(--inkmask);
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
.stamp::before {
|
||||
content: ''; position: absolute; inset: 4px;
|
||||
border: 1.4px solid var(--stamp);
|
||||
}
|
||||
.stamp-word {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-variation-settings: 'wdth' 118, 'wght' 800;
|
||||
font-size: 37px; letter-spacing: .1em; text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
.stamp-sub {
|
||||
font-family: 'Plex Mono', monospace;
|
||||
font-size: 8.5px; letter-spacing: .2em; text-transform: uppercase;
|
||||
opacity: 1;
|
||||
}
|
||||
.stamp.show { display: flex; animation: stampdown 1700ms var(--ease-out) forwards; }
|
||||
|
||||
@keyframes stampdown {
|
||||
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(2.3); filter: blur(4px); }
|
||||
22% { opacity: 1; transform: translate(-50%,-50%) rotate(-8deg) scale(.93); filter: blur(0); }
|
||||
32% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.03); }
|
||||
42% { transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
|
||||
78% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
|
||||
}
|
||||
|
||||
/* the desk takes the hit when the stamp lands */
|
||||
body.struck .rail, body.struck .chart { animation: jolt 260ms var(--ease); }
|
||||
@keyframes jolt {
|
||||
0% { transform: translate(0,0); }
|
||||
18% { transform: translate(-1px, 2px); }
|
||||
46% { transform: translate(1px, -1px); }
|
||||
100% { transform: translate(0,0); }
|
||||
}
|
||||
/* the plan sheet keeps its rotation while it shakes */
|
||||
body.struck .chart { animation-name: joltchart; }
|
||||
@keyframes joltchart {
|
||||
0% { transform: translateY(-50%) rotate(.5deg); }
|
||||
18% { transform: translate(-1px, calc(-50% + 2px)) rotate(.5deg); }
|
||||
46% { transform: translate(1px, calc(-50% - 1px)) rotate(.5deg); }
|
||||
100% { transform: translateY(-50%) rotate(.5deg); }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
entrances - one orchestrated move per screen, not seven scattered ones
|
||||
========================================================================= */
|
||||
|
||||
.screen.is-on .rail { animation: sheetdown 520ms var(--ease-out) both; }
|
||||
.screen.is-on .chart { animation: chartin 620ms var(--ease-out) 120ms both; }
|
||||
.screen.is-on .turntable { animation: platein 700ms var(--ease-out) 180ms both; }
|
||||
.screen.is-on .rail-inner,
|
||||
.screen.is-on .creator-foot { animation: settle 520ms var(--ease-out) 160ms both; }
|
||||
|
||||
@keyframes sheetdown {
|
||||
from { opacity: 0; transform: translateY(-14px) scale(.995); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes settle {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes chartin {
|
||||
from { opacity: 0; transform: translateY(-50%) rotate(.5deg) scale(.97); }
|
||||
to { opacity: 1; transform: translateY(-50%) rotate(.5deg) scale(1); }
|
||||
}
|
||||
@keyframes platein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes nudge {
|
||||
0% { transform: translateX(0); }
|
||||
22% { transform: translateX(-5px); }
|
||||
44% { transform: translateX(4px); }
|
||||
68% { transform: translateX(-2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Small screens: the sheet takes the width, the plate and plan sheet stand down. */
|
||||
@media (max-width: 1100px) {
|
||||
.rail, .rail.wide { width: min(92vw, 560px); }
|
||||
.chart { display: none; }
|
||||
.turntable { left: auto; right: 4%; width: 34%; }
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* The stamp is the only loud thing in the interface, so it only ever marks a
|
||||
real change of state: the file opened, the application approved, the
|
||||
placement issued. The paper on the desk takes the hit with it. */
|
||||
function stamp(text) {
|
||||
const el = $('stamp');
|
||||
$('stampText').textContent = text;
|
||||
el.classList.remove('show');
|
||||
document.body.classList.remove('struck');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('show');
|
||||
setTimeout(() => document.body.classList.add('struck'), 170);
|
||||
setTimeout(() => document.body.classList.remove('struck'), 600);
|
||||
}
|
||||
|
||||
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">−</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');
|
||||
/* A colour chart is 16 columns wide, so it always takes the full form. */
|
||||
wrap.className = 'ctrl span2';
|
||||
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 */
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,294 @@
|
||||
<!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">
|
||||
<svg class="seal" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="46" class="seal-ring"/>
|
||||
<circle cx="50" cy="50" r="37" class="seal-ring thin"/>
|
||||
<circle cx="50" cy="50" r="41.5" class="seal-ticks"/>
|
||||
<path class="seal-star" d="M50 30 L54.7 43.8 L69.4 44 L57.6 52.7 L62 66.6
|
||||
L50 58.2 L38 66.6 L42.4 52.7 L30.6 44 L45.3 43.8 Z"/>
|
||||
</svg>
|
||||
<span class="mark-lines">
|
||||
<span class="mark-name" id="markName">LOS SANTOS</span>
|
||||
<span class="mark-tag" id="markTag">ROLEPLAY</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- A real sequence: you cannot be placed before you have an identity, and you
|
||||
cannot have an identity before the file exists. Printed as a routing box. -->
|
||||
<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">
|
||||
<span class="filetag-k">File</span>
|
||||
<span id="fileSerial">0000-000000</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ======================= 01 / auth ======================= -->
|
||||
<section class="screen" id="screen-auth" hidden>
|
||||
<div class="rail">
|
||||
<div class="rail-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="letterhead">
|
||||
<div class="lh-dept">
|
||||
<span class="lh-city">City of Los Santos</span>
|
||||
<span class="lh-office">Office of Vital Records</span>
|
||||
</div>
|
||||
<div class="lh-form data">VR‑1</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-inner">
|
||||
<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="dlarue">
|
||||
<p class="hint" id="hint-username">3–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="••••••••">
|
||||
<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="••••••••">
|
||||
<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>
|
||||
|
||||
<!-- ======================= 02 / characters ======================= -->
|
||||
<section class="screen" id="screen-characters" hidden>
|
||||
<div class="rail wide">
|
||||
<div class="rail-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="letterhead">
|
||||
<div class="lh-dept">
|
||||
<span class="lh-city">City of Los Santos</span>
|
||||
<span class="lh-office">Register of Persons</span>
|
||||
</div>
|
||||
<div class="lh-form data">VR‑4</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-inner">
|
||||
<h1 class="head">Who are you today?</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<ul class="charlist" id="charList"></ul>
|
||||
|
||||
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
|
||||
|
||||
<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 danger" id="charDelete" type="button" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 03 / creator ======================= -->
|
||||
<section class="screen" id="screen-creator" hidden>
|
||||
<div class="rail wide">
|
||||
<div class="rail-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="letterhead">
|
||||
<div class="lh-dept">
|
||||
<span class="lh-city">City of Los Santos</span>
|
||||
<span class="lh-office">Application for Identity</span>
|
||||
</div>
|
||||
<button class="linkbtn danger" 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="rail-inner creator">
|
||||
<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>
|
||||
|
||||
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- The plate the subject is photographed against. It is also the drag
|
||||
surface, so the frame you aim at is the thing that turns. -->
|
||||
<div class="turntable" id="turntable" title="Drag to turn">
|
||||
<span class="plate-corner tl" aria-hidden="true"></span>
|
||||
<span class="plate-corner tr" aria-hidden="true"></span>
|
||||
<span class="plate-corner bl" aria-hidden="true"></span>
|
||||
<span class="plate-corner br" aria-hidden="true"></span>
|
||||
<div class="plate-cap">
|
||||
<span class="plate-cap-k">Subject</span>
|
||||
<span class="turnhint"><span class="turnicon" aria-hidden="true"></span>Drag to turn</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= 04 / spawn ======================= -->
|
||||
<section class="screen" id="screen-spawn" hidden>
|
||||
<div class="rail">
|
||||
<div class="rail-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="letterhead">
|
||||
<div class="lh-dept">
|
||||
<span class="lh-city">City of Los Santos</span>
|
||||
<span class="lh-office">Notice of Placement</span>
|
||||
</div>
|
||||
<div class="lh-form data">VR‑9</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-inner">
|
||||
<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>
|
||||
|
||||
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A plan sheet from the same office: a drawing of the county, not a screenshot. -->
|
||||
<div class="chart" id="chart">
|
||||
<div class="chart-paper" aria-hidden="true"></div>
|
||||
<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(25,28,24,.10)" stroke-width="1"/>
|
||||
</pattern>
|
||||
<pattern id="grid10" width="250" height="250" patternUnits="userSpaceOnUse">
|
||||
<path d="M250 0 L0 0 0 250" fill="none" stroke="rgba(25,28,24,.18)" stroke-width="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="1000" height="1000" fill="url(#grid)"/>
|
||||
<rect width="1000" height="1000" fill="url(#grid10)"/>
|
||||
|
||||
<rect class="chart-frame" x="30" y="30" width="940" height="940"/>
|
||||
<path class="chart-ticks"
|
||||
d="M30 92 L30 30 L92 30 M908 30 L970 30 L970 92
|
||||
M970 908 L970 970 L908 970 M92 970 L30 970 L30 908"/>
|
||||
|
||||
<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"/>
|
||||
<!-- 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"/>
|
||||
</svg>
|
||||
<div class="pins" id="pins"></div>
|
||||
|
||||
<!-- every drawing from this office carries a title block -->
|
||||
<div class="titleblock">
|
||||
<div class="tb-row tb-main">
|
||||
<span class="tb-k">Sheet</span>
|
||||
<span class="tb-v">Los Santos County</span>
|
||||
</div>
|
||||
<div class="tb-row">
|
||||
<span class="tb-k">Scale</span><span class="tb-v">Not to scale</span>
|
||||
<span class="tb-k">Sheet</span><span class="tb-v">1 of 1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- the one bold moment: the city stamps your file -->
|
||||
<div class="stamp" id="stamp" aria-hidden="true">
|
||||
<span class="stamp-word" id="stampText">Filed</span>
|
||||
<span class="stamp-sub">Office of Vital Records</span>
|
||||
</div>
|
||||
|
||||
<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