31 Commits
Author SHA1 Message Date
fivemservertest 146c6592aa docs: 0.2 — the release notes and the new screenshots
Thirty commits since 0.1. The README now leads with what this version
adds rather than what the first one was, and carries shots of the panels
that did not exist then: inventory, chat with notifications, the
dealership, a garage, the berry buyer.

The limits section is the honest half. Most of 0.2 has been verified by
rendering the interfaces and reading the logs rather than by playing it
with several people, and placement in the world is the part most likely
to be wrong — which is why /coords shipped alongside it.
2026-09-01 17:05:08 +00:00
fivemservertest 6464bff749 chore: drop the chat's leftover scrollbar.js
Removed from the manifest and the markup when the chat's scrollbar went,
but the file itself stayed in the tree — it was never loaded, so nothing
pointed at it.

It survived because of how these changes reach the repository: a tar
extracted over the clone, which adds and overwrites but never deletes.
A file removed on the server therefore stays in git until something goes
looking for it.
2026-09-01 16:51:14 +00:00
fivemservertest 7367926afe fix: robbery refused people who had never been robbed
"They have nothing left on them" was landing on passers-by nobody had
touched. The per-victim cooldown is keyed on a network id, and the game
recycles those constantly as ambient peds come and go — over a half hour
window a fresh passer-by inherits the id of someone already robbed and
picks up their cooldown with it.

Three minutes now: short enough to outlive the id, long enough to stop
turning out the pockets of the man still standing in front of you. The
message says what it actually means, too.

An unnetworked ped has no id at all, and that case was returning in
silence — the search played out and then nothing happened, with no way to
tell whether it had worked. It now goes through on the player's cooldown
alone, which is the one that matters for whether a gun is an income.
2026-09-01 16:43:28 +00:00
fivemservertest 60d03b91f0 feat: weapons as inventory items
The robbery marker only appears while the character is armed, and there
was no way to be armed: the inventory had no weapons in it, and a gun
handed out by any other means is a gun nobody owns.

fiverp-weapons registers three — pistol, combat pistol, knife — through
the inventory's registerItems, so they belong to this resource rather than
to the registry file.

The bag is the truth and the ped is made to match it. Every inventory
change re-syncs, and the sync clears the ped first: working out the
difference would be cheaper but would also leave behind anything the world
handed over, and a weapon that survives a respawn is a weapon with no row
behind it. Dying strips a ped, so the set is put back once the character
is up again — the bag did not change, and being disarmed by respawning is
not something the player did.

Using one only decides which is in hand; carrying it is what the row does.
2026-09-01 16:31:17 +00:00
fivemservertest 8971a629cd fix: move the dealership outdoors; add /coords
FiveM does not stream the Premium Deluxe interior. The building shell is
there and behind its windows is grey nothing, so a showroom placed inside
it was a showroom nobody could walk into.

It is on the forecourt now, which is real map. The display car, the
delivery spot and the camera are all snapped to the ground they actually
find rather than to the height in the config, so those numbers only have
to be close — the forecourt is not level. The camera is also aimed a beat
after the panel opens, at the car as it stands rather than at where the
config guessed it would be, because until the first car spawns there is
nothing to aim at.

Adds /coords. Placing anything in this world means picking coordinates,
and I have been picking them from screenshots — which is how the seller
ended up in a bank's back office and a dealership inside a wall. It prints
the standing position in the form the config files take, on screen and to
the server console, so a spot that is off by a few metres can be corrected
with the actual number instead of another guess.
2026-09-01 16:27:32 +00:00
fivemservertest 4eb256cec2 feat: vehicle dealership with ownership and garages, and street robbery
Two mechanics.

fiverp-vehicles. Pick a car at Premium Deluxe and it appears on the
turntable in the colour you chose before any money moves — the panel keeps
the middle of the screen clear and takes no scrim, because the car is the
thing being sold and dimming the room to read about it is the wrong way
round. Buying pays from the bank, issues a plate checked for uniqueness
against the table rather than trusted to chance, and writes the row before
the car is handed over: one that exists in the world and not in the table
disappears at the next restart.

The car is driven out of the showroom door rather than delivered through a
wall, and a garage puts one on the nearest road node instead of at the
attendant's feet — a garage that drops a car into a wall is worse than one
that makes you walk five metres.

The price, the paint list and who owns what are all read server-side. A
colour that was never offered is not a colour anyone chose. `stored` is
the whole garage model: a car is in one or it is in the world, and
everything is put back on disconnect and on restart, because nothing
survives either.

fiverp-robbery. Draw a gun and the nearest passer-by wears a marker;
holding it puts their hands up and searches them. The marker is attached
to the entity rather than to a point, so a hold-up on somebody who has
strolled off is not a hold-up, and it re-checks the weapon every frame
through canUse. Police peds are excluded.

The payout and both cooldowns are the server's: ninety seconds per player,
because otherwise a gun is an income rather than a threat, and half an
hour per victim, because a person has one wallet. The ped is claimed
before the money is paid so two requests in one tick cannot both collect,
and the ped table is swept, since net ids are reused within a session.

The request is sent after the animation rather than before it. The search
is what the moment is; a payout that lands first makes it decoration.
2026-09-01 16:20:32 +00:00
fivemservertest 0d2306ffb6 fix: every bush vanished, crate-sized drops, blank drag ghost, wear on click
The berry patches emptied completely. GetGroundZAndNormalFor_3dCoord does
not return the same shape on every build, and reading .z off what came
back threw — inside the spawn loop, which took the thread down and with it
every bush on the server. The slope check is guarded now, and each bush is
placed inside its own pcall so one bad spot cannot cost the other forty
nine. A check that can silently remove the feature it guards is worse than
no check at all.

Dropped items came out as a shipping crate taller than the player. The
fallbacks are reordered to small containers and, more usefully,
size-checked: GetModelDimensions gives the bounding box, so a model
nothing like the size of a dropped object is rejected rather than
discovered in a screenshot. The item's own model is still taken as
authored — that one is content's to get right, the fallback is ours.

The drag ghost was an empty box once items had pictures: it was still
rendering initials as text, and the slot it came from is dimmed, so
nothing on screen said what was in hand. It carries the picture now.

Right-clicking clothing closed the inventory and did nothing. Wearing
something is not the same as using it: clothing now goes on or comes off
where it lies and the panel stays open, since the next thing you do is
usually to the slot beside it. Food still animates and closes. The
destination is derived from the item's own wear slot, so there is nothing
for a client to choose; whatever was already worn swaps into the vacated
bag slot rather than being destroyed.
2026-09-01 15:57:39 +00:00
fivemservertest 83355741c3 feat: artwork for every item currently in the registry
Ten icons, 256×256 RGBA, named after the item ids they belong to. Berries
and shears take one each; the five worn slots share one picture per slot
rather than per garment, because the registry stores which slot a thing
belongs in and not what it looks like — a jacket and a shirt are both
worn_top and therefore both draw the same icon. Giving them separate
artwork would mean keying pictures off the drawable, which is a different
piece of work.

The initials fallback stays for anything added later without a picture.
2026-09-01 15:44:47 +00:00
fivemservertest 67e42f7649 feat: dropped items are visible, eating is one at a time, first item artwork
A dropped item left nothing behind but its interact marker, which tells
you something is there and nothing about what. Items now fall back to a
box when they have no model of their own — and when the model they name
turns out not to exist, which is checked with IsModelInCdimage rather than
discovered by a silent failure to spawn.

Eating a stack ate the whole stack. The clicks came faster than the
animation, so twenty berries went down in one mouthful and the hunger bar
jumped as if the food were free. The gate is on the server, because the
client is where fast clicks come from: one mouthful holds for the length
of the animation and the rest are refused with a word about it. The
animation is told the same duration, so what you watch and what the server
enforces are the same number.

Using anything also closes the inventory now. Using something is doing
something, and doing it happens in the world — it also removes the way to
press one slot twenty times while the first press is still playing out.

Artwork is wired up: html/images, listed in the manifest so it reaches
clients, with the item's `image` field naming the file and the initials
still standing in for anything without one. Shears have theirs.
2026-09-01 15:40:25 +00:00
fivemservertest 952e5c95a0 feat: notifications resource; chat lightened; bushes stop landing in trees
Adds fiverp-notify and moves every server-to-player message onto it —
berries, banking, staff commands. A toast is read once and forgotten,
which is the wrong shape for a chat log and the reason the log kept
filling with things nobody said. Three kinds, and only `good` spends the
accent: an accent on every message means nothing.

Chat is translucent now. A line sits over the world for a quarter of a
minute at a time, and at the full card fill it read as a panel someone had
opened rather than as someone talking. It is also narrower and shorter,
and its scrollbar is gone entirely — the log pins itself to the bottom, so
the bar was a control nobody needed over a view of the world.

Bushes were still landing in trees. The height-above-patch check cannot
catch a canopy that hangs close to the patch's own level, and the real
problem was upstream: GetGroundZFor_3dCoord answers with the first solid
thing below the height it is given, and a branch is solid. Probing from
high lands on the canopy; probing from low answers nothing while the world
streams in.

So it now descends: find a surface, then keep asking what is below it
until nothing is, and what remains is terrain. Slope is read from the
ground normal rather than from four more probes — a flat floor points
straight up — with the four-point sample kept as a second opinion, since
a normal is one point and a bush can sit on a level rock at the top of a
drop.
2026-09-01 15:24:09 +00:00
fivemservertest 514f988819 fix: stuck marker, unwearable clothes, bushes on cliffs, chat that never left
Six reports, five distinct causes.

The interact marker pinned itself to the screen after cutting a bush.
RemovePoint cleared `active` but told the NUI nothing, so the dial stayed
drawn at its last position with no loop left to move or hide it. It now
sends the hide when the point under the cursor is the one going away.

Worn clothes showed as equipped while the body stayed bare. Three of the
four rows in the database had meta IS NULL — written before meta survived
a drop, and a garment without its drawable describes no actual clothing.
They are discarded on load rather than kept as an item that cannot be put
on, and applyWorn now leaves a slot alone when its row is unreadable
instead of stripping it: acting on a row we cannot read is how the clothes
vanished with no visible reason in the first place.

The berry buyer stood in the Paleto bank's back office. The coordinate was
picked by offsetting from the branch, which established it was on solid
ground and nothing about whether there were walls around it. He is out on
the Grapeseed farm road now.

Bushes were on cliff faces and up in trees. Two fixes: the patches moved
to the farm flats north-east of the Alamo Sea, and placement now refuses
bad ground on its own — a spot more than 18 m above the patch height was a
branch rather than soil, and one with more than 1.4 m of drop across two
metres is somewhere you cannot stand to cut. Rejected spots are remembered
so the sweep stops re-probing them, and the server generates 60% more
candidates than it wants bushes so a thinned patch is still a patch.

Chat piled up and kept its scrollbar. Lines now retire individually after
25 seconds with the chat closed, rather than the whole log fading at once
and leaving a busy minute stacked on screen; opening the chat brings the
history back. The scrollbar is appended to the body, so nothing about its
host fading reached it — it now walks its ancestors and hides with them.
2026-09-01 15:07:36 +00:00
fivemservertest 272510f8c0 fix: pause menu, blip grouping, floating bushes; add staff commands
The HUD stayed on screen over the pause menu. NUI is a layer the game does
not own, so it keeps drawing while everything else stops; it now stands
down on IsPauseMenuActive and comes back after.

Eight banks and four berry patches each took their own line in the map
legend. The map groups blips by sprite *and* name, so giving them one name
each collapses them to a single entry you can page through — the branch is
still named on the marker you walk up to.

The berry buyer had no blip at all: sprite 500 is outside the vanilla
range and renders as nothing. It is a shop now, and separate from the
patches by design, since the two are different errands.

Bushes hung in the air. A single ground probe from a guessed height
answers from whatever sits directly under that altitude — in a forest as
often a branch as soil — and answers nothing while the world there is
still streaming. It now sweeps down from several heights after requesting
collision, and a bush whose ground cannot be found is left for the next
pass rather than planted somewhere merely plausible.

Adds fiverp-admin: /revive, /noclip and /tpm, each gated on fiverp.admin
and checked on the server. The client half holds no permission — it only
runs what the server sent. To anyone without the ace they answer "Unknown
command", because a refusal that names the thing refused is a list of
staff commands for anyone who guesses.

/tpm and leaving noclip use the same downward sweep as the bushes: a
waypoint carries no height, and the world does not stream around an entity
that is not colliding with it.
2026-09-01 14:54:19 +00:00
fivemservertest 37648feafd feat: berry picking — shears, patches, a buyer, and something to eat
Buy shears from the buyer in Paleto, cut berries in the hills, eat them or
sell them back. Four kinds, weighted so the money is in walking further:
blackberries at $12 up to cloudberries at $130.

Shears are a gate rather than a nicety. Cutting the cluster leaves the
bush alive; tearing it off does not, which is the reason the job has a
tool at all and why no bush answers without one.

The server owns the bushes. It places them once from a fixed seed — random
placement would move every bush on every restart, and a patch nobody can
learn is noise rather than a place — and answers cuts by re-measuring the
distance, checking the cooldown and rolling the yield itself. A bush is
claimed before anything can fail, so two people cutting the same one in
the same second cannot both be paid. Cooldowns are announced to everyone,
so a stripped bush goes quiet for all of them and not only the cutter.

Distance is compared flat. The client snaps each bush to the ground it
finds and the server only stores its patch's height, so a 3D comparison
would refuse someone standing right in front of it.

Items live here, not in the inventory's registry: fiverp-inventory gained
a registerItems export and now pushes the merged registry to clients. A
job's berries belong to the job. That also fixes the client only ever
seeing its own half of the registry, which would have left a dropped berry
with no prop.

Buying shears refunds if the bag turns out to be full, rather than taking
the money and handing over nothing.
2026-09-01 14:40:17 +00:00
fivemservertest b1bccda4a8 fix: dropped clothing forgot which garment it was
Take a jacket off, drop it, pick it back up, put it on — the slot filled
but the body did not change.

The drop record carried item and count and left meta behind, so the jacket
came back as a jacket that no longer remembered its drawable. applyWorn
then found a row with no meta, read that as an empty slot, and wrote bare
skin — which is exactly what the inventory and the character disagreeing
looks like.

Meta now travels with the drop and is handed back on pickup. addTo takes
it too, and refuses to merge an item that has any: meta is what makes one
instance different from another, so stacking two would keep one drawable
and silently destroy the other. giveItem goes through the same path
instead of assigning meta afterwards, which only ever worked when the item
happened to land alone in a slot.
2026-09-01 14:30:16 +00:00
fivemservertest 63e571196f feat: worn slots drive the character's actual clothes
The five worn slots were empty on a fully dressed character, because what
the creator put on them are ped components and the inventory only knew
about items. The two are now connected in both directions.

A moment after spawning, the client reads the drawables off the ped and
offers them; the server adopts them into any worn slot that is still
empty. Only the client can read a ped, so that is where the reading
happens — but the rows become the truth immediately, and re-adopting is
skipped for a filled slot, which is what stops a shirt the player took off
reappearing on the next respawn.

From then on the body follows the rows: every state push writes all five
components, filled or not. An empty slot has to actively become bare skin,
or taking a shirt off would change a row and nothing else. "Bare" is the
game's own undressed freemode drawables, per gender, so stripping leaves
you in underwear rather than in a hole where a torso was.

Two details that would otherwise show: the arms drawable belongs to the
torso and is carried with it, since leaving it behind puts sleeves on a
bare chest; and a component already sitting on its bare value is not
adopted, or every character would start out owning an "Undershirt" that is
actually their skin.

This needed five entries in the registry — one carrier per slot, holding
the drawable in its meta. They are the mechanism rather than content: with
an empty registry nothing may sit in a worn slot at all, so the slots
could only ever have stayed empty.
2026-09-01 14:22:02 +00:00
fivemservertest bc610a7f93 feat: inventory — 16 bag slots, five worn slots, and drops on the ground
The server owns every slot. A client asks to move, use, drop or pick up;
it never says what it is holding. Each request is checked against the row
in the database rather than anything that arrived over the wire, and the
reply is the whole inventory instead of a delta — a client that missed one
update would otherwise diverge quietly and stay wrong for the rest of the
session.

character_items stores one row per occupied slot; empty slots are absent,
because a table of NULLs carries no information and has to be kept in step
with the slot count. A unique key on (character, container, slot) is what
stops an item existing twice — enforced by the database rather than by
remembering to check, since a duplication bug here is a duplication bug in
the economy.

shared/items.lua and shared/items.js are the registry and are empty on
purpose. The comment at the top is the contract: label, description,
weight, stack, action, model and an optional wear slot. Both runtimes get
a copy because the server must answer "what does this weigh" without
asking a client, and the FiveM JS runtime has no require(). `action` is a
name, not code — the inventory emits it and whoever owns that effect
handles it, which keeps food, weapons and paperwork out of the registry.

Clothing may only sit in the slot it was made for, checked on both sides:
the panel dims the slots that cannot take what is being dragged, and the
server refuses anyway.

Dropping puts a real object in the world using the registry's model and
registers a hold-to-pick-up marker through fiverp-interact. Piles live in
memory, not the database: a bag left in an alley should not survive a
restart. Pickup re-measures distance server-side, and anything that does
not fit stays on the ground rather than vanishing.

Two grid tracks needed minmax(0, 1fr) rather than 1fr. A track's automatic
minimum is its content's min-content width, and a nowrap label like
"Handheld radio" is wider than a quarter of the panel, so the bag pushed
itself past the panel edge — measured at 175px columns in a 583px space.
2026-09-01 14:16:21 +00:00
fivemservertest e4918bf688 fix: roleplay lines carried the platform name, not the character's
/me, /do and /try printed "FivemTest drop gun" rather than the name the
city knows, and plain speech fell back to "Citizen" even for a player who
had a character.

Both came from reading the wrong export. fiverp-characters'
getActiveCharacter returns the character's row id — a plain number — so
`active.firstName` was undefined every time and the code took its fallback
branch, which is the platform name. It looked like the server was speaking
because that is the only name left when the character's is missing.

fiverp-ids already stores the character's full name beside the ids, from
the row it reads on selection, and hands both back from one getIds call.
Chat now asks it once and derives everything from that: the roleplay
channels take the name, plain speech takes name and id together, and the
Citizen fallback is left for players who genuinely have no character yet.
2026-09-01 13:57:34 +00:00
fivemservertest 35773c1216 fix: chat doubled every line, dropped plain speech, and printed two slashes
Four faults, one round.

Plain speech never reached the server at all. `_chat:messageEntered` was
declared with on() rather than onNet(), so the engine refused to route it —
"Event _chat:messageEntered was not safe for net" in the console, twice per
attempt — and the message was dropped in silence. Commands still worked,
because those go through ExecuteCommand, which is why only the roleplay
channels appeared to function.

Every line arrived twice. RegisterNetEvent with a function attaches a
handler of its own, and it was paired with an AddEventHandler for the same
event, so render ran twice per message. The events are now declared
net-safe on their own and handled exactly once.

The speaker carried neither name nor id. Plain speech is now labelled
"Vinni Admin (14)": the name the city knows, and the id everyone can see
regardless. Before a character is chosen there is no name to use, so it
falls back to "Citizen (14)" and keeps the id.

The input showed two slashes — a decorative one beside the field plus the
one being typed. The decoration is gone; a command line is signalled by
the field's own edge turning accent instead.

Also replaced getPlayers() with an index walk. It is a Lua-side convenience
and not guaranteed in this runtime, and it sat directly in the path every
message takes.
2026-09-01 13:45:41 +00:00
fivemservertest fa7625befd feat: roleplay channels, proximity, and quick buttons in chat
Plain speech now reads "Citizen (14): text" rather than carrying a name.
A stranger's name is something they tell you; the id is the thing everyone
can actually see, so that is what labels the line.

Adds /me, /do and /try, plus /s, /w and /b to round out the family. Each
carries a distance — 4 m for a whisper, 45 m for a shout, 20 m for
everything else — and the audience is worked out on the server from both
players' positions. A client cannot widen its own range.

/try's outcome is decided server-side for the same reason: a client that
rolled its own attempts would never lose one.

The palette has a single accent, so the channels are told apart by shape
instead of colour. /me and /do drop the name chip entirely and become
italic with a leading rule, because they are narration rather than someone
speaking — a wall of them should not read as conversation. /do puts the
author in double brackets, /b wraps the whole line in them, a shout is
uppercase and a whisper is small and grey.

The buttons above the input fill the channel in rather than sending
anything; they bind on mousedown, since click lands after blur and losing
the caret mid-press is what makes a chat box feel broken.
2026-09-01 13:38:41 +00:00
fivemservertest a92879bd41 fix: teller stands behind the counter, marker sits on the ATM keypad
Three things the first pass got wrong, all visible the moment it was
actually walked up to.

The teller was buried in the carpet. The stored coordinate was already a
metre below the player-standing height, and CreatePed subtracted another
metre on top of it. Rather than fix the constant and hope, the floor is
now measured: GetGroundZFor_3dCoord from just above the spot, falling back
to the old offset only if the probe misses.

The teller was also on the customer's side of the counter, because that is
the coordinate the branch was recorded at. That coordinate is now named
`counter` — it is where the player stands — and the teller is placed by
pushing back along the direction they face, which is one number
(tellerSetback) shared by every Fleeca since they share one interior. The
server still measures distance to the counter, not to the teller: the
customer is what has to be in reach.

Ambient peds inside 6 m of a teller are removed while the player is near.
The stock crowd queues at that same counter playing the same clipboard
idle, so from the doorway there looked to be four tellers and only one of
them answered. The game keeps spawning them, so the sweep repeats.

The ATM marker was on the pavement: the supplied coordinates are the
machine's foot. It is lifted to keypad height for display only — the
server keeps measuring against the coordinate it was given.
2026-09-01 13:27:59 +00:00
fivemservertest d23e931ad7 feat: hold-to-act markers, and move banking onto them
Replaces the stock help-text-and-tap prompt with a marker that sits on the
thing itself: a dial at the world point, the key inside it, and a ring that
fills while the key is held. The action fires when the ring is full and
nowhere else, so letting go early cancels and a tap does nothing — which is
the point, since these are the presses that move money.

fiverp-interact owns it, and everything else registers through it:

  exports['fiverp-interact']:add({
    id = 'atm:14', coords = ..., label = 'ATM',
    distance = 1.6, hold = 700, onSelect = function() ... end
  })

The dial tracks a projected world point, so Lua owns its position and the
stylesheet owns everything else. Two things follow from that split. The
ring's fill is a CSS transition whose duration Lua sets, while Lua times
the real hold itself — the bar cannot show one thing and the game accept
another. And the position is only posted when it actually changed in
whole pixels, because the loop runs every frame and most frames the marker
has not moved.

It stands down while dead, in a vehicle, or with a menu holding NUI focus,
and clears itself before firing a callback so a menu never opens behind a
marker that is still on screen.

Banking now registers through it. Points come and go within 40 m rather
than all at once: 121 ATMs as live points would be 121 distance checks a
frame, and the marker decides what is close enough to use anyway.
2026-09-01 13:20:13 +00:00
fivemservertest 3bfb6808f4 feat: chat with a live command list, and banking
fiverp-chat replaces the stock chat. It reimplements the events the stock
one exposes — chat:addMessage, chat:addSuggestion, addSuggestions,
removeSuggestion, chat:clear, both as local and net handlers — so nothing
else on the server had to change; `ensure chat` is simply commented out.

The point of it is the slash key. Typing "/" lists every command that
actually exists here and keeps narrowing as you type; prefix matches come
first, then anything containing what you typed, so a half-remembered name
still finds its command. The list is seeded from GetRegisteredCommands()
rather than only from what resources announce, so a command registered
without a suggestion still shows up — just without help text. Enter on a
partly-typed name completes it instead of running it, which stops a
mistyped command going off by accident; Tab does the same.

fiverp-bank adds 8 branches and the 121 ATM coordinates. Branch blips are
full size, ATM blips are short-range — 121 icons would otherwise bury the
map. Tellers are peds spawned within 60 m and deleted past 90 m, so eight
of them are not standing in empty interiors all session.

A teller deposits, withdraws and transfers to another player by ID, all
free. An ATM does the first two and keeps 3% of a withdrawal, minimum $5.
The fee is taken as its own ledger move rather than folded into the
withdrawal, so a statement can tell the two apart.

Nothing the client sends is trusted. The server re-derives the location
from its own copy of the coordinates, re-measures the distance against the
player's server-side position, re-reads both balances, and recomputes the
fee — the menu computes it too, but only to show you the number before you
commit. Transfers resolve the target through fiverp-ids rather than
accepting a source from the client, and refuse your own ID.
2026-09-01 13:08:46 +00:00
fivemservertest 0434c0979e ui: replace the native scrollbar with one we control
The settings panel was ending in the platform scrollbar: a light trough
with stepper arrows, the one piece of chrome the palette never reached.

Styling it was tried first and does not hold. ::-webkit-scrollbar-thumb
and -track take the palette fine, but -button keeps drawing a 3px stub at
each end in CEF whether it is given display:none, zero size or a
transparent fill. Measured off a render: a stub at y182-184 and y911-913,
both in the thumb's colour, on every attempt.

So the native bar is hidden outright and scrollbar.js draws the
replacement — a plain element, therefore ours. It is position:fixed and
measured from the host's own rect, so it needs no particular ancestor and
survives a modal that moves; it hides itself when there is nothing to
scroll, drags from the window rather than the 4px thumb, and pages on a
track click.

Panels that are hidden until opened have no rect to measure, so opening
the HUD settings and switching an editor tab both fire fiverp:relayout,
and a MutationObserver covers content changing underneath.

Attach with data-scroll. In use on the HUD settings body and the
appearance editor's control list.
2026-09-01 12:55:53 +00:00
fivemservertest 65abfae9ae fix: centre the condition rings instead of pinning them
With "Show numbers" on, the ring had to hold a glyph and a value and did
neither well: the number sat on the arc and the glyph collapsed to a dot.

Two causes. The offsets were hand-tuned for the glyph-only case and never
rechecked with a value present, and `.status.show-numbers .ring svg`
matched the glyph's own icon as well as the arc — a descendant selector
where a child was meant — so it inherited the arc's inset and shrank.

Both go away by letting the box centre its own contents: the ring is a
centred column, the glyph and value are static children, and the arc is
the only absolutely positioned thing in it. Hiding the value now recentres
the glyph on its own, with no second rule to keep in sync. The ring still
grows to 3.9em in numbers mode, because two items genuinely do not fit in
3.1em.

Verified in both modes rather than only the default, which is how this was
missed the first time: the screenshots were taken with numbers off while
the report had them on.
2026-09-01 12:38:43 +00:00
fivemservertest 074e73b07a hud: move condition beside the radar, add voice range, clear the login screen
Login and character select were still showing the game's own radar, health
bar and cash counter — state belonging to a character that does not exist
yet. Both screens now hide them per frame, and spawning turns the radar
back on: DisplayRadar is a sticky flag, so it needs an explicit true.

The condition rings move from a row above the radar to a column beside it,
anchored to the radar's own baseline. Both figures are measured off a 1080p
frame rather than guessed: the radar ends at 15.6vw and its foot sits 1.7vh
from the bottom, so the column starts at 16.4vw and shares that baseline
and stays aligned however many rings are on screen. The "Position"
preference keeps working; its first option is now "Beside radar".

New voice module under the rings: three proximities cycled with Z, applied
to Mumble through MumbleSetTalkerProximity, so the pips are not decoration
— they are how far the player actually carries. The call is wrapped in
pcall so a server without voice loses the indicator, not the HUD. Talking
is the one state that takes the accent, because it is the only thing on the
HUD another player can perceive.

Colours stay strictly inside the eight tokens: no per-need hues.
2026-09-01 12:26:03 +00:00
fivemservertest 5b247ab35e fix: let the loadscreen resource own its own shutdown
The previous commit added ShutdownLoadingScreenNui() but it did nothing.
Without loadscreen_manual_shutdown in the manifest the engine owns the
loading screen's lifetime and tears it down on spawn — which never happens,
because auth keeps spawning disabled until the player has an identity. The
manual call is not honoured in that mode, so the screen stayed up and its
opaque #181818 body covered the world behind every menu.

Measured from the report: the background was exactly rgb(24,24,24), not
black, and fiverp-loadscreen's body is the only full-screen opaque fill
left in the project — auth, characters and hud all render alpha 0 outside
their panels when captured over a transparent backdrop.

Declaring manual shutdown makes the existing call authoritative. Because
that also means nothing else will ever remove the screen, character
selection calls it too: shutting it down twice is harmless, never shutting
it down strands the player behind it.
2026-09-01 11:54:45 +00:00
fivemservertest 2d19815e5b fix: shut the loading screen down before showing the login form
The grey behind the login form was the loading screen, not the auth page.
FiveM destroys the loadscreen NUI when the player spawns, and auth calls
setAutoSpawn(false) to hold the gate shut until there is an identity — so
nothing ever spawned and nothing ever tore it down. It stayed mounted over
the world for the whole login, hiding the drifting city camera underneath.

It only became visible as a problem with the redesign: the old loadscreen
was near-white, the new one is #181818, which reads as a dead grey screen
rather than a backdrop.

Shut it down explicitly once the camera is up.
2026-09-01 11:43:08 +00:00
fivemservertest ea5c8d1924 fix: stop the auth layer painting over the game
The redesign put an opaque background on fiverp-auth's body. That NUI page
is mounted for the whole session, not just while the form is open, so
#181818 covered the entire screen behind every other interface — the
character editor showed a flat grey panel where the live ped should be.

Back to a transparent html/body, with the fill left to the panels. Same
reason removed the fill I had added to the character select screen: the
game renders behind it too, and the slot cards already carry their own
background.

Verified by capturing each page with --default-background-color=00000000:
auth, characters and hud report alpha 0 outside their panels, loadscreen
stays opaque because it does own the whole screen.
2026-09-01 11:21:56 +00:00
fivemservertest 8b65a47790 redesign: Clean UI / Dark Minimalism across every interface
Rewrites all four NUI stylesheets against one palette and one set of
geometry rules, and replaces the design system in CLAUDE.md to match.

Palette is now eight tokens: #181818 background, #1E1E1E cards, #333333
controls, #FF7716 accent, #F1F1F1 / #999999 text, a 50% #333333 hairline
border and a #464646 hover. Nothing outside that set appears in any
stylesheet — depth comes from borders and spacing, not shadows.

Dropped, per the brief: gradients, blur, inset shadows and skeuomorphism.
That took out the loadscreen's drifting colour blobs and the frosted veil
over them, and it took out the auth screen's paper passport — the
guilloche, the laminate sheen and the embossed seal are gone, replaced by
a flat permit card. Every node app.js writes to survived the rewrite, so
the MRZ, the check digits and the issue/expiry dates still work as before.

Typography: Unbounded throughout, headings uppercase at 700-900 with 1px
tracking. The guide sets a 10px floor, which the HUD was breaking in eight
places once it scaled from a 16px base, so those sizes went up and the
server, wallet and vehicle cards widened to hold them.

Also fixed while re-rendering:
- Status rings were sized for a value that is hidden by default, leaving
  the glyph floating above a gap. The glyph now centres, and only shifts
  up when "Show numbers" is on (new .show-numbers class from applyPrefs).
- "Save & enter city" wrapped to two lines beside two ghost buttons in a
  420px panel and stood taller than its row; the primary now takes its
  own row.
- The permit card showed an "Issued" chip before anything was issued.
  It is hidden until .is-stamped.

Screenshots re-rendered from the current code, plus two new ones for the
character select and the appearance editor.
2026-09-01 11:16:42 +00:00
fivemservertest f353327ffd revert: remove fiverp-cinema resource 2026-08-13 19:45:25 +00:00
fivemservertest c3e5012043 feat: add fiverp-cinema cinematic flyover resource
5 pre-built camera sequences over different districts:
Vinewood Hills, Downtown Canyons, Pacific Coast,
Port of LS, Blaine County Desert.

Commands:
  /capturecinema [n]  – play scene n (or all scenes)
  /stopcamera         – abort
  /cinemalist         – list scenes
2026-08-13 19:33:29 +00:00
125 changed files with 11888 additions and 1744 deletions
+209 -249
View File
@@ -1,8 +1,10 @@
# FiveRP — Server Design System # FiveRP — Server Design System
Единый стиль для **всего** сервера: loading screen, авторизация, будущие меню Единый стиль для **всего** сервера: экран загрузки, авторизация, выбор
(инвентарь, телефон, банк, HUD). Основан на белой «стеклянной» теме Filmorum персонажа, HUD и все будущие меню (инвентарь, телефон, банк).
(`fiveM_design.md`), адаптирован под CEF/NUI FiveM.
Стиль: **Clean UI / Dark Minimalism**. Плоско, контрастно, строго.
Никакого объёма, скевоморфизма, клейморфизма и матового стекла.
> Правило номер один: **ни один компонент не хардкодит цвет, радиус или шрифт.** > Правило номер один: **ни один компонент не хардкодит цвет, радиус или шрифт.**
> Только `var(--…)` из блока токенов ниже. Смена темы = одна строка. > Только `var(--…)` из блока токенов ниже. Смена темы = одна строка.
@@ -11,112 +13,85 @@
## 0. Шрифт — Unbounded ## 0. Шрифт — Unbounded
**Unbounded — основной шрифт всего сервера.** Геометрический дисплейный гротеск, Один шрифт на весь проект. Начертания: `400` Regular, `500` Medium,
переменная ось веса 300–800. Никаких других сансов в интерфейсе. `700` Bold, `900` Black.
Файлы лежат в каждом ресурсе локально (`html/fonts/`), а НЕ грузятся с Шрифт лежит **локально** в каждом ресурсе (`html/fonts/*.woff2`) — CEF не
`fonts.googleapis.com`: у части игроков CEF стартует раньше сети, и веб-шрифт всегда имеет доступ в интернет, а Google Fonts по CDN молча не загрузится
успевает не приехать — интерфейс мигает системным шрифтом. Локальный woff2 и всё поедет на системный шрифт.
рисуется с первого кадра.
```css Три файла и три `@font-face` с `unicode-range`: latin, latin-ext, cyrillic.
@font-face { Первый блок покрывает только `U+0000-00FF` — если ограничиться им одним,
font-family: 'Unbounded'; кириллица отвалится. Копировать блок целиком из любого готового ресурса.
font-style: normal;
font-weight: 300 800; /* вариативный: один файл на все веса */
font-display: block; /* block, не swap — подмены шрифта быть не должно */
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* + unbounded-latin-ext.woff2 и unbounded-cyrillic.woff2 теми же блоками */
```
```css Правила набора:
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
```
**Как набирать Unbounded.** Шрифт широкий и характерный — он требует другой | Что | Начертание | Размер | Прочее |
типографики, чем SF Pro:
| Роль | Размер | Вес | Трекинг |
|---|---|---|---| |---|---|---|---|
| Display (лого, экран загрузки) | 44–72px | 800 | `-0.04em` | | Заголовок экрана | 900 | 26–46px | UPPERCASE, `letter-spacing: 1px` |
| H1 (заголовок панели) | 26–30px | 700 | `-0.035em` | | Заголовок панели/карточки | 700–900 | 13–24px | UPPERCASE, `letter-spacing: 1px` |
| H2 (секция) | 18–20px | 600 | `-0.03em` | | Надзаголовок (eyebrow) | 500 | 10–11px | UPPERCASE, `letter-spacing: 1px` |
| Кнопка / линк | 14px | 600 | `-0.01em` | | Подпись поля / label | 500 | 10px | UPPERCASE, `letter-spacing: 1px` |
| Body / инпут | 14–15px | 400 | `-0.005em` | | Основной текст | 400 | 11–13px | обычный регистр |
| Лейбл поля | 11px | 600 | `+0.09em`, `uppercase` | | Значение, цифра | 700 | 11–20px | `font-variant-numeric: tabular-nums` |
| Eyebrow / надзаголовок | 10px | 600 | `+0.20em`, `uppercase` | | Кнопка, таб, чип | 700 | 10–12px | UPPERCASE, `letter-spacing: 1px` |
| Метаданные | 11–12px | 400 | `0` |
Правила: **Минимальный размер — 10px.** Ничего мельче, даже в HUD. В HUD размеры
- **Крупный кегль = минусовой трекинг.** У Unbounded широкие апроши; без заданы в `em` от `font-size` контейнера (`16px × --scale`), поэтому
`letter-spacing: -0.03em` заголовок расползается. `0.63em` — нижняя допустимая граница.
- **Мелкий кегль = плюсовой трекинг + uppercase.** Ниже 12px строчные буквы
Unbounded слипаются — все подписи только капслоком с разрядкой.
- **Веса 300 и 800 — крайности**, в интерфейсе живут 400/500/600/700.
- **Не набирать Unbounded длинный текст.** Абзац больше двух строк — читать
тяжело. Для многострочных описаний кегль 13px/1.65 и вес 400, не больше.
- **Цифры документов, ID, MRZ — только `--mono`.** Unbounded для них слишком
«дизайнерский», а нужен вид машинного считывания.
--- ---
## 1. Философия ## 1. Философия
- **Air, light, frosted** — полупрозрачные стеклянные панели поверх живой игры. * Плоскость. Глубина создаётся **границей и отступом**, а не тенью.
- **Кислород** — между карточками 12px, между секциями 40–48px. * Воздух. Много пустого места, `padding` карточек 20–25px.
- **Один акцент** — синий `#0071e3`, дозированно: кнопка, ссылка, фокус. * Один акцент. Оранжевый — только для активного и главного. Если на экране
- **Сдержанное движение** — fade и подъём на 4px. Анимации ради анимации нет. три оранжевых элемента, два из них лишние.
- **Один герой на экран** — крупный объект (паспорт, лого), остальное тихое. * Резкость. `transition: all 0.2s ease`. Никакой «желейности» и пружин.
--- ---
## 2. Токены ## 2. Токены
Копировать в `:root` каждого нового интерфейса без изменений.
```css ```css
:root { :root {
--bg: #f5f5f7; --bg: rgba(24, 24, 24, 1); /* #181818 — фон приложения */
--bg-deep: #e8e8ed; --card-bg: rgba(30, 30, 30, 1); /* #1E1E1E — карточки, панели */
--surface: rgba(255, 255, 255, 0.72); --btn-bg: rgba(51, 51, 51, 1); /* #333333 — кнопки, треки, чипы */
--surface-solid:#ffffff; --btn-hover: rgba(70, 70, 70, 1); /* #464646 — hover */
--panel: rgba(255, 255, 255, 0.80); --accent: rgba(255, 119, 22, 1); /* #FF7716 — акцент */
--header-bg: rgba(255, 255, 255, 0.70); --text-primary: rgba(241, 241, 241, 1); /* #F1F1F1 — текст, иконки */
--field-bg: rgba(0, 0, 0, 0.04); --text-secondary: rgba(153, 153, 153, 1); /* #999999 — подписи, цифры */
--field-bg-focus:#ffffff; --border: rgba(51, 51, 51, 0.5); /* тонкая рамка вместо тени */
--fg: #1d1d1f; --sans: 'Unbounded', sans-serif;
--fg-secondary: #6e6e73; }
--fg-tertiary: #a1a1a6;
--accent: #0071e3; * {
--accent-strong:#0077ed; margin: 0;
--accent-soft: rgba(0, 113, 227, 0.08); padding: 0;
--ring: rgba(0, 113, 227, 0.18); box-sizing: border-box;
font-family: var(--sans);
--line: rgba(0, 0, 0, 0.06); user-select: none;
--line-strong: rgba(0, 0, 0, 0.10);
--hover: rgba(0, 0, 0, 0.04);
--success: #34c759;
--danger: #ff3b30;
--radius-lg: 1.5rem; /* 24px — крупные панели */
--radius-md: 1rem; /* 16px — карточки, инпуты */
--radius-sm: 0.7rem; /* 11px — мелочь */
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
} }
``` ```
Тёмная и бежевая темы — через `data-theme` на `html`, переопределением тех же `user-select: none` на всём — кроме `input`, там нужен `user-select: text`,
токенов (`--bg`, `--surface`, `--fg`, `--accent`, `--success`, `--danger`). иначе игрок не выделит то, что печатает.
### Геометрия
| Элемент | Радиус |
|---|---|
| Карточка, панель, модалка | `16px` |
| Кнопка, поле ввода, notice | `12px` |
| Таб, чип, бейдж, переключатель (pill) | `20px` |
| Контейнер сегментированного контрола | `20–30px` |
Рамка везде одна: `1px solid var(--border)`.
--- ---
@@ -125,155 +100,175 @@
Это не «хорошо бы», а условия, при нарушении которых интерфейс ломается Это не «хорошо бы», а условия, при нарушении которых интерфейс ломается
в игре, оставаясь идеальным в браузере. в игре, оставаясь идеальным в браузере.
1. **`backdrop-filter` и `box-shadow` не живут на одном элементе.** 1. **Ни `backdrop-filter`, ни `box-shadow`.** Композитор CEF рисует и то,
Композитор CEF рисует тень такого элемента непрозрачным прямоугольником — и другое непрозрачным прямоугольником — в игре под панелью появляется
в игре под панелью появляется чёрный квадрат. Если нужны и блюр, и тень — чёрный квадрат. В текущей теме они и не нужны: панель отделяет от игры
тень вешать на элемент-обёртку, а блюр на внутренний. По умолчанию сплошная заливка `--card-bg` и рамка `--border`. Запрет абсолютный, в том
**у стеклянных панелей тени нет вообще**: их отделяет от игры блюр и числе для hover-состояний.
светлая граница, этого достаточно.
2. **`html, body { background: transparent }`** — иначе NUI закрашивает игру. 2. **`html, body { background: transparent }`** для всего, что рисуется
поверх игры (HUD, выбор персонажа). Иначе NUI закрасит кадр. Экран
загрузки и авторизация владеют экраном целиком — им положен `--bg`.
3. **Никаких затемняющих подложек.** Экран за панелью — это игра, её видно 3. **Шрифты — локальные woff2.** См. раздел 0.
и она красивая. Контраст добирается блюром и белой заливкой стекла, а не
`rgba(0,0,0,…)` поверх кадра. Допустим только **осветляющий** вуаль:
`radial-gradient(…, rgba(255,255,255,0.28), transparent)`.
4. **Шрифты — локальные woff2.** См. раздел 0. 4. **Иконки — только SVG-штрихом**, `stroke: currentColor`, `fill: none`.
Эмодзи в CEF отрисуются пустыми прямоугольниками: цветного эмодзи-шрифта
там нет.
5. **`prefers-reduced-motion`** — обязательный блок с самого начала. 5. **Курсор и фокус** — `SetNuiFocus(true, true)` только пока панель открыта,
6. **Курсор и фокус** — `SetNuiFocus(true, true)` только когда панель открыта,
и обязательно `SetNuiFocus(false, false)` при закрытии, иначе игрок и обязательно `SetNuiFocus(false, false)` при закрытии, иначе игрок
остаётся без управления. останется без управления.
--- ---
## 4. Стеклянная панель ## 4. Карточка
Базовая поверхность. Всё, что группирует содержимое, выглядит так:
```css ```css
.glass { .card {
background: var(--surface); background-color: var(--card-bg);
backdrop-filter: saturate(180%) blur(24px); border: 1px solid var(--border);
-webkit-backdrop-filter: saturate(180%) blur(24px); border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55); padding: 25px;
border-radius: var(--radius-lg);
/* без box-shadow — см. раздел 3.1 */
} }
``` ```
Тень разрешена только элементам **без** `backdrop-filter` (карточки на Разделитель внутри карточки — `border-top: 1px solid var(--border)`
непрозрачном фоне, экран загрузки): вместе с симметричным `padding`, а не отдельный `<hr>` и не смена фона.
Hover у кликабельной карточки:
```css ```css
.shadow-soft { box-shadow: 0 1px 2px rgba(0,0,0,.03), 0 12px 40px -12px rgba(0,0,0,.12); } .card:hover {
.shadow-lift { box-shadow: 0 2px 4px rgba(0,0,0,.04), 0 24px 50px -16px rgba(0,0,0,.20); } background-color: var(--btn-bg);
transform: translateY(-2px);
}
``` ```
Ховер кликабельной карточки: `transform: translateY(-4px)`, 0.4s `--ease`.
--- ---
## 5. Кнопки ## 5. Кнопки
```css Две штуки, третьей нет.
.btn-primary {
border-radius: 999px;
padding: 12px 24px;
font: 600 14px var(--sans);
letter-spacing: -0.01em;
color: #fff;
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
box-shadow: 0 1px 1px rgba(0,0,0,.10), inset 0 1px 0 rgba(255,255,255,.20);
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: .5; pointer-events: none; }
.btn-ghost { ```css
border-radius: 999px; /* главное действие */
padding: 10px 20px; .btn-primary {
font: 600 14px var(--sans); background-color: var(--accent);
color: var(--accent); color: var(--bg); /* тёмный текст на акценте — максимум контраста */
background: var(--accent-soft); border: 0;
border-radius: 12px;
padding: 14px 20px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
transition: all 0.2s ease;
} }
.btn-ghost:hover { background: rgba(0,113,227,.14); } .btn-primary:hover { transform: translateY(-2px); }
/* всё остальное */
.btn-ghost {
background-color: var(--btn-bg);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 12px;
/* типографика та же */
transition: all 0.2s ease;
}
.btn-ghost:hover { background-color: var(--btn-hover); }
``` ```
Ровно два стиля кнопок на весь сервер. Третий не заводить. **На одном экране ровно одна `.btn-primary`.**
Табы, чипы и сегментированные контролы — та же логика в pill-форме:
неактивный `--btn-bg` + `--text-secondary`, активный `--accent` + `--bg`.
Опасное действие (удаление) — это `.btn-ghost` с `--text-secondary`,
который становится акцентным на hover. Отдельного красного в палитре нет,
и заводить его не нужно: подтверждение делается вторым нажатием, а не цветом.
--- ---
## 6. Поля ввода ## 6. Поля ввода
```css ```css
.field { input {
width: 100%; background-color: var(--btn-bg);
border-radius: var(--radius-md); border: 1px solid var(--border);
padding: 13px 14px; border-radius: 12px;
font: 400 15px var(--sans); padding: 14px 16px;
letter-spacing: -0.005em; color: var(--text-primary);
color: var(--fg); font-size: 13px;
background: var(--field-bg); font-weight: 500;
border: 1px solid transparent;
outline: none; outline: none;
transition: background-color .2s, border-color .2s, box-shadow .2s; user-select: text;
transition: all 0.2s ease;
} }
.field::placeholder { color: var(--fg-tertiary); } input:focus { border-color: var(--accent); }
.field:focus { input.is-bad { border-color: var(--accent); }
background: var(--field-bg-focus); input::placeholder { color: var(--text-secondary); }
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring); /* можно: у инпута нет backdrop-filter */
}
.field.is-bad { border-color: var(--danger); box-shadow: 0 0 0 4px rgba(255,59,48,.14); }
``` ```
Лейбл над полем — 11px/600/`+0.09em`/uppercase/`--fg-secondary`. Ошибка показывается рамкой и текстом в `.notice`, а не тряской поля.
--- ---
## 7. Компоновка ## 7. Компоновка
- Панель по центру экрана, ширина `min(940px, 92vw)`, радиус `--radius-lg`. Grid и Flexbox. Никаких плавающих и абсолютных раскладок там, где хватает
- Внутренние отступы панели 32–40px. сетки. Между карточками — `gap: 20px`, внутри карточки между блоками —
- Между полями формы 16px, между группами 24px. `14–22px`.
- Двухколоночная схема «форма + герой»: `grid-template-columns: minmax(0,1fr) 420px`.
- Сворачивание в одну колонку — анимацией `grid-template-columns` и `width`, Ширины, принятые в проекте: панель формы `440px`, карточка персонажа `300px`,
620ms `--ease`. боковая панель редактора `420px`, модалка настроек `min(560px, 84vw)`.
--- ---
## 8. Motion ## 8. Motion
```css ```css
.fade-up { animation: fadeUp .6s cubic-bezier(.22,1,.36,1) both; } transition: all 0.2s ease;
.delay-1 { animation-delay: .06s } .delay-2 { animation-delay: .12s } ```
.delay-3 { animation-delay: .18s } .delay-4 { animation-delay: .24s }
@keyframes fadeUp { Входная анимация — одна на проект:
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) { ```css
*, *::before, *::after { .fade-up { animation: fade-up 0.4s ease both; }
animation-duration: .001ms !important; @keyframes fade-up {
transition-duration: .001ms !important; from { opacity: 0; transform: translateY(8px); }
} to { opacity: 1; transform: none; }
} }
``` ```
Задержки `.delay-1…4` шагом 60ms, не больше четырёх ступеней.
В каждом интерфейсе есть класс-выключатель для headless-скриншотов:
```css
body.no-anim *, body.no-anim *::before, body.no-anim *::after {
animation: none !important;
transition: none !important;
}
```
Без него `--virtual-time-budget` снимает первый, ещё прозрачный кадр:
он не прокручивает CSS-анимации.
--- ---
## 9. Фокус и доступность ## 9. Чего делать нельзя
```css 1. **Теней.** Ни `box-shadow`, ни `filter: drop-shadow`, ни `inset`-теней.
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; } (`inset: 0` как сокращение для `top/right/bottom/left` — это другое
::selection { background: var(--accent-soft); color: var(--accent-strong); } свойство, оно разрешено и используется повсеместно.)
``` 2. **Градиентов.** Только сплошные заливки.
3. **Блюра.** Ни `backdrop-filter`, ни `filter: blur`.
Контраст текста к стеклу — не ниже 4.5:1. `--fg-tertiary` только для 4. **Скевоморфизма.** Ничего, что притворяется бумагой, металлом, стеклом
плейсхолдеров и декоративных подписей, никогда для смысловых. или физической кнопкой.
5. **Цветов вне палитры.** Восемь токенов из раздела 2 — это весь набор.
--- ---
@@ -282,77 +277,42 @@
| Ресурс | Что это | Файлы | | Ресурс | Что это | Файлы |
|---|---|---| |---|---|---|
| `fiverp-loadscreen` | экран загрузки сервера | `html/index.html`, `style.css`, `app.js` | | `fiverp-loadscreen` | экран загрузки сервера | `html/index.html`, `style.css`, `app.js` |
| `fiverp-auth` | регистрация и вход, паспорт США | `html/index.html`, `style.css`, `app.js` | | `fiverp-auth` | регистрация и вход | `html/index.html`, `style.css`, `app.js` |
| `fiverp-characters` | выбор персонажа и редактор внешности | `html/index.html`, `style.css`, `app.js` |
| `fiverp-hud` | игровой HUD и его настройки | `html/index.html`, `style.css`, `app.js` |
`fiverp-auth` использует один «герой» — разворот паспорта США (страница с `fiverp-auth` показывает карточку паспорта, которая заполняется по мере
данными: фото, поля, MRZ). Паспорт — единственный насыщенный цветом объект ввода имени. Это **плоская карточка с данными**, а не изображение документа:
на экране; форма рядом с ним остаётся полностью нейтральной. никакой бумаги, гильошей и бликов ламината.
`fiverp-hud` живёт поверх игры и потому не имеет собственного фона; его
масштаб и прозрачность задаются переменными `--scale` и `--hud-opacity`
на `#hud`, которые проставляет `applyPrefs()`.
Раскладка HUD (решения приняты и менять их без причины не нужно):
* Шкалы состояния и индикатор рации — колонкой **справа от миникарты**.
Габариты радара на 1080p измерены по кадру: правый край `15.6vw`, низ
`1.7vh`. Колонка начинается с `16.4vw` и растёт вверх от той же линии.
* Цвета шкал — **строго палитра раздела 2**. Никаких «зелёное здоровье,
синяя броня»: акцент только на низком значении и на активной рации.
* Карточка сервера — сверху справа, кошелёк и панель авто — снизу справа.
* Пока игрок не в мире (авторизация, выбор персонажа), игровой HUD скрыт
целиком: `DisplayRadar(false)` плюс `HideHudComponentThisFrame` на
деньги, звёзды розыска и названия улиц. `DisplayRadar` — залипающий
флаг, его возвращают в `true` в момент спавна.
--- ---
## 11. Чек-лист нового интерфейса ## 11. Чек-лист нового интерфейса
1. Скопировать `html/fonts/` и блок `:root` из `fiverp-auth`. 1. Скопировать `html/fonts/` и блок `:root` из любого готового ресурса.
2. `html, body { background: transparent }`. 2. Поверх игры — `html, body { background: transparent }`; на весь экран —
3. Панель — `.glass`, **без** `box-shadow`. `background-color: var(--bg)`.
4. Ни одного затемняющего слоя поверх игры. 3. Панель — карточка из раздела 4, **без** `box-shadow`.
5. Шрифт Unbounded, трекинг по таблице из раздела 0. 4. Ровно одна `.btn-primary` на экран.
6. Акцент только `--accent`; кнопки только `.btn-primary` / `.btn-ghost`. 5. Заголовки UPPERCASE, вес 700–900, `letter-spacing: 1px`.
7. Подключить `prefers-reduced-motion`. 6. Ничего мельче 10px.
8. `SetNuiFocus(false, false)` на закрытии — проверить руками. 7. Иконки — SVG-штрихом, не эмодзи.
9. Отрендерить в headless Chrome и **посмотреть глазами** до выката. 8. Есть `body.no-anim` для скриншотов.
9. Прогнать глазами: нет градиентов, теней, блюра, цветов вне палитры.
---
## 12. Тёмный слой: HUD и всё, что живёт поверх игры
Разделы 1–11 описывают **меню** — экраны, которые забирают себе внимание
целиком. HUD — другой случай, и правила у него другие.
Меню открыто минуту, HUD висит перед глазами часами. Светлая панель в углу —
первое, что игрок выключает: она спорит с кадром, светит в тёмных сценах и
перекрывает игру, ради которой он здесь. Поэтому **оверлей поверх игры —
тёмный**, и это не «вторая тема», а другой класс интерфейса.
```css
:root {
--panel: rgba(12, 14, 18, 0.68); /* основная подложка */
--panel-deep: rgba(12, 14, 18, 0.82); /* когда нужно больше контраста */
--inset: rgba(255, 255, 255, 0.06);/* вложенная поверхность: тег, чип */
--line: rgba(255, 255, 255, 0.09);
--line-soft: rgba(255, 255, 255, 0.05);
--fg: #f4f5f7;
--fg-2: rgba(244, 245, 247, 0.60);
--fg-3: rgba(244, 245, 247, 0.34);
--accent: #4b8dff; /* синий из раздела 2, поднятый под тёмный фон */
--radius: 14px; /* HUD компактнее меню: 14/9, а не 24/16 */
}
```
Правила поверх общих:
1. **Контраст даёт подложка и волосяная граница, не тень и не блюр.**
Запрет `box-shadow` и `backdrop-filter` из раздела 3 действует и здесь.
2. **Затемняющий слой на весь экран запрещён** (раздел 3.3) — но сама панель
HUD тёмная. Это не противоречие: затемняется область панели, а не кадр.
Единственное исключение — скрим под открытой панелью настроек: пока она
открыта, игрок не играет.
3. **Ничего не рисовать дважды.** Здоровье и броню игра уже показывает на
миникарте — в HUD их нет. Прежде чем добавить показатель, проверь, не
рисует ли его уже сам GTA.
4. **Размеры в `vh`/`vw`, не в `px`.** Пиксельный HUD, собранный на 1080p,
на 1440p становится нечитаемо мелким. Базовый кегль:
`font-size: calc(1.75vh * var(--scale, 1))`, всё внутри — в `em`.
5. **Углы экрана заняты игрой.** Левый нижний — радар; HUD туда не лезет.
6. **Цифры — `font-variant-numeric: tabular-nums`.** Иначе счётчик денег и
спидометр дёргают соседние элементы на каждом изменении.
7. **`pointer-events: none` на всём слое.** Игрок ведёт машину, а не работает
с интерфейсом. Клики принимает только открытая панель настроек.
| Ресурс | Класс | Тема |
|---|---|---|
| `fiverp-loadscreen`, `fiverp-auth` | меню | светлая, разделы 1–11 |
| `fiverp-hud` | оверлей | тёмная, раздел 12 |
+188 -19
View File
@@ -1,4 +1,4 @@
# FiveRP — 0.1 # FiveRP — 0.2
A roleplay base for FiveM, written from scratch rather than forked from a A roleplay base for FiveM, written from scratch rather than forked from a
framework. Eight resources: an account login, a character creator, permanent framework. Eight resources: an account login, a character creator, permanent
@@ -29,12 +29,46 @@ Running against FXServer **25770** (recommended channel), GTA V game build
![Identity](docs/screenshots/identity.png) ![Identity](docs/screenshots/identity.png)
**HUD** — a dark overlay that never repeats what the game already draws. There **Character select** — three slots per account, each one deleted only on a
is no health ring: health is on the minimap already. The bottom-left corner is second, deliberate press.
left to the radar.
![Character select](docs/screenshots/characters.png)
**Appearance editor** — heritage, face, hair, skin and clothes, with the ped
rendered live by the game beside the panel.
![Appearance editor](docs/screenshots/editor.png)
**HUD** — a flat overlay that never repeats what the game already draws. There
is no health ring: health is on the minimap already. The condition rings and the
voice indicator stand in a column beside the radar, measured to its right edge
so the two line up at 1080p.
![HUD](docs/screenshots/hud.png) ![HUD](docs/screenshots/hud.png)
**Inventory** (**I**) — sixteen bag slots and five worn ones. Drag to move, drop
onto the strip to put something on the ground, right-click to use or to wear.
![Inventory](docs/screenshots/inventory.png)
**Chat and notifications** — the chat is for things people said; everything the
server tells you is a toast on the right.
![Chat](docs/screenshots/chat.png)
**Dealership** — the car stands in front of you, in the colour you picked,
before any money moves.
![Dealership](docs/screenshots/dealership.png)
**Garage** — any attendant fetches one of yours or puts away the one you are in.
![Garage](docs/screenshots/garage.png)
**Berry buyer** — sells the shears, buys what you bring back.
![Berry buyer](docs/screenshots/berry-buyer.png)
**HUD settings** (`/hud`) — every module can be turned off, and the choice is **HUD settings** (`/hud`) — every module can be turned off, and the choice is
saved on that computer, not on the character. saved on that computer, not on the character.
@@ -53,6 +87,15 @@ saved on that computer, not on the character.
| `fiverp-economy` | Cash and bank, every movement written to a ledger | | `fiverp-economy` | Cash and bank, every movement written to a ledger |
| `fiverp-status` | Hunger, thirst and mood, persisted per character | | `fiverp-status` | Hunger, thirst and mood, persisted per character |
| `fiverp-time` | One clock for the whole server, running at real speed | | `fiverp-time` | One clock for the whole server, running at real speed |
| `fiverp-vehicles` | Dealership, ownership and garages |
| `fiverp-robbery` | Holding up a passer-by at gunpoint |
| `fiverp-notify` | Toasts, so the chat stays a place people talk |
| `fiverp-admin` | Staff commands, gated on the `fiverp.admin` ace |
| `fiverp-berries` | Berry picking: shears, four patches, and a buyer in Paleto |
| `fiverp-inventory` | 16 bag slots, five worn slots, and what ends up on the pavement |
| `fiverp-interact` | The hold-to-act marker every other resource uses |
| `fiverp-chat` | Chat, and the command list behind the slash key |
| `fiverp-bank` | Branches, tellers, 121 ATMs and every move between accounts |
| `fiverp-hud` | The in-game overlay and its settings panel | | `fiverp-hud` | The in-game overlay and its settings panel |
Server logic is JavaScript (Node 22, shipped inside FXServer), client logic is Server logic is JavaScript (Node 22, shipped inside FXServer), client logic is
@@ -194,11 +237,147 @@ add_ace group.admin fiverp.economy allow
| `/setmoney <id> <cash\|bank> <amount>` | admin | Set a balance outright | | `/setmoney <id> <cash\|bank> <amount>` | admin | Set a balance outright |
| `/setneed <id> <hunger\|thirst\|mood> <0-100>` | admin | Set a need | | `/setneed <id> <hunger\|thirst\|mood> <0-100>` | admin | Set a need |
Keys: **B** seatbelt, **L** lock or unlock the car you are in or looking at. Vehicles: the forecourt outside Premium Deluxe — outdoors, because FiveM does not
stream that building's interior. Hold **E** at the salesman, pick a car
and a colour, and it appears on the turntable in front of you before you pay. It
is bought from the bank, gets a plate of its own, and belongs to the character
who bought it. Any garage attendant will fetch one of yours or put the one you
are sitting in away. Anything left in the world goes back to a garage when you
disconnect or the server restarts.
Robbery: draw a gun and walk up to someone. A marker appears on them, hold **E**,
their hands go up and you go through their pockets — $500 to $1,400. A minute and
a half before you can do it again, and half an hour before that particular person
has anything left.
Berry picking: the buyer in Paleto Bay sells shears for $150 — without them no
bush will let you work, because cutting is what leaves it alive to fruit again.
Four patches are marked on the map; hold **E** at a bush to cut, and it goes
quiet for three minutes afterwards for everyone. Four kinds grow, from common
blackberries at $12 to cloudberries at $130. Eat them for hunger, a little
thirst and a lift, or sell the lot back at the counter.
Inventory: **I** opens it. Sixteen bag slots and five worn ones — headwear,
outerwear, undershirt, trousers, shoes. Drag to move, drop onto the strip at the
bottom to put something on the ground, double-click or right-click to use it.
A dropped item becomes a real object you can walk back to and pick up.
The clothes the creator put on your character are adopted into the worn slots a
moment after you spawn, so you can take them off and stand there in your
underwear. They go into the bag like anything else and can be put back on.
`shared/items.lua` is the item registry and is empty apart from five garment
carriers, one per worn slot — those are the mechanism, not content; the comment
at the top is the shape an entry takes — label, description, weight, action and
the world model it becomes when dropped. `action` is a name, not code: the
inventory announces it and whichever resource owns that effect handles it.
Interacting: anything you can act on shows a dial where it is. Walk up, hold **E**,
and a ring fills around the key — the action only fires when it is full. Letting go
early cancels it. A tap does nothing, deliberately: nothing that moves money should
be one twitch away.
Banking: hold **E** at a teller or an ATM, or type `/bank` while standing at one. A teller
deposits, withdraws and transfers to another player by ID, all free. An ATM does
the first two only and keeps 3% of a withdrawal, minimum $5 — walking into a
branch is what saves you the fee.
Chat: **T** opens it, `/` lists every command registered on the server and keeps
narrowing as you type. Tab or Enter completes the highlighted one. A row of
buttons fills in the roleplay channels for you.
| Channel | Reaches | Reads as |
| --- | --- | --- |
| plain text | 20 m | `Vinni Admin (14): text` — the name, and the id everyone can see |
| `/me` | 20 m | *Marcus Reyes counts the notes* |
| `/do` | 20 m | *the machine is already dead ((Marcus Reyes))* |
| `/try` | 20 m | the attempt, then **succeeds** or **fails** — the server flips it |
| `/s` | 45 m | shouted |
| `/w` | 4 m | whispered |
| `/b` | 20 m | `(( out of character ))` |
Range is measured server-side from both players' positions, so it cannot be
argued with from a client.
Anything the server tells you — money arriving, a bush picked bare, a command
refused — is a toast on the right rather than a line of chat:
```lua
exports['fiverp-notify']:show('Berries', '**3** × Blackberries', 'good')
```
```js
exports['fiverp-notify'].notify(src, 'Bank', '**$2,500** arrived', 'good')
```
`kind` is `good`, `bad` or `info`; only `good` spends the accent. Wrap a number
in `**stars**` to set it apart. New resources should use this — a toast is read
once and forgotten, and the chat is for things people said.
Staff commands need `add_ace group.admin fiverp.admin allow`. `/revive [id]`
brings someone back up, `/noclip` flies (shift faster, alt finer, space and
ctrl for height), `/tpm` teleports to your map marker, `/coords` prints where you are standing in the
form the config files want, to the screen and to the server console. To anyone without the
ace they answer "Unknown command" — a refusal that names what it refused is a
list of staff commands for anyone who guesses.
Keys: **B** seatbelt, **L** lock or unlock the car you are in or looking at,
**Z** cycle voice range (whisper 3 m / normal 8 m / shout 20 m — applied to
Mumble, so the HUD shows how far you actually carry).
--- ---
## 0.1 — what this version is ## 0.2 — what this version adds
Everything in 0.1 still holds. On top of it:
- **A new look.** Every interface was rewritten to one style — Clean UI / Dark
Minimalism, eight colour tokens, flat surfaces, a single orange accent, and
no gradients, shadows or blur. The design system in `CLAUDE.md` was rewritten
with it, so the next screen matches without anyone having to remember.
- **Chat**, replacing the stock one. Typing `/` lists every command that exists
on the server and narrows as you type. Roleplay channels — `/me`, `/do`,
`/try`, `/s`, `/w`, `/b` — each with a range the server measures.
- **Notifications**, so the chat can stay a place people talk.
- **Hold-to-act markers.** Anything you can interact with shows a dial where it
is; hold **E** and a ring fills. Nothing important is one tap away.
- **Banking.** Eight branches with tellers, 121 ATMs. Deposits, withdrawals,
and transfers by player ID at a teller; an ATM does the first two and keeps
3%.
- **Inventory.** Sixteen bag slots and five worn ones, drag and drop, and
dropped items become real objects you can walk back to. The clothes your
character is already wearing are adopted into the worn slots, so you can take
them off.
- **Weapons** as inventory items — the bag is the truth, and the ped is made to
match it.
- **Berry picking**, a full job: buy shears, work four patches, eat what you
pick or sell it back.
- **Vehicles.** A dealership where the car stands in front of you in the colour
you chose before any money moves, ownership tied to the character, and
garages that fetch and store.
- **Robbery.** Draw a gun, hold up a passer-by, go through their pockets.
- **Staff commands**: `/revive`, `/noclip`, `/tpm`, `/coords`.
Known limits, honestly:
- Most of this has been verified by rendering the interfaces and by reading the
logs, not by playing it with several people. Placement in the world — where a
ped stands, where a car appears — is the part most likely to be off, which is
why `/coords` exists.
- The item registry ships with berries, clothing carriers and three weapons.
There is no economy of goods yet, and nothing to buy with what a job pays
except a car.
- Worn clothing is one icon per slot, not per garment. A jacket and a shirt are
both `worn_top` and draw the same picture.
- Vehicles have no fuel, no damage persistence and no keys beyond ownership.
Locking is the HUD's `L`, which is the game's own lock and nothing more.
- Robbery has no consequence attached: no police response, no witnesses, no
wanted level.
- Still no migration runner. `sql/schema.sql` is idempotent — every table is
`CREATE TABLE IF NOT EXISTS` — so re-running it is how you upgrade.
---
## 0.1 — what the first version was
First tagged version. What works: First tagged version. What works:
@@ -212,17 +391,7 @@ First tagged version. What works:
player's, and the date is the real date player's, and the date is the real date
- The HUD, with per-computer settings - The HUD, with per-computer settings
Known limits, honestly:
- Nothing here has been load-tested with a full server. It has been run and
verified with a handful of players.
- No jobs, no vehicles you can own, no inventory. Money exists, but nothing
worth spending it on ships in this version.
- The vehicle panel's fuel is client-side. Fine for a HUD, not something to
build a fuel economy on later without moving it server-side first.
- `sql/schema.sql` is the only migration path. There is no migration runner yet,
so upgrades between versions will be hand-applied SQL until there is.
`CLAUDE.md` in the root is the design system and the working rules for this `CLAUDE.md` in the root is the design system and the working rules for this
codebase — read it before adding an interface, especially section 12, which is codebase — read it before adding an interface. Every screen follows one style,
why the HUD is dark and the menus are light. Clean UI / Dark Minimalism: eight colour tokens, flat surfaces, a single orange
accent, and no gradients, shadows or blur anywhere.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+174
View File
@@ -0,0 +1,174 @@
-- Staff commands — the client half.
-- Nothing here is a permission. Each of these only runs because the server sent
-- the event, and the server is where the ace was checked.
local noclip = false
local noclipSpeed = 1.0
-- ---------------------------------------------------------------- revive
RegisterNetEvent('fiverp-admin:revive', function()
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
if IsEntityDead(ped) then
-- Resurrect first, then put everything back: the natives below are ignored
-- on a corpse.
NetworkResurrectLocalPlayer(coords.x, coords.y, coords.z,
GetEntityHeading(ped), true, false)
end
SetEntityHealth(ped, GetEntityMaxHealth(ped))
ClearPedBloodDamage(ped)
ClearPedTasksImmediately(ped)
SetPlayerInvincible(PlayerId(), false)
SetEntityInvincible(ped, false)
FreezeEntityPosition(ped, false)
end)
-- ------------------------------------------------------------------- tpm
RegisterNetEvent('fiverp-admin:tpm', function()
local blip = GetFirstBlipInfoId(8) -- the waypoint
if not DoesBlipExist(blip) then
TriggerServerEvent('fiverp-admin:tpmDone', false)
return
end
local at = GetBlipInfoIdCoord(blip)
local ped = PlayerPedId()
local entity = IsPedInAnyVehicle(ped, false) and GetVehiclePedIsIn(ped, false) or ped
-- A waypoint carries no height, so the ground has to be found. Sweeping down
-- from a series of heights is the only reliable way: a single probe answers
-- from whatever happens to be under that exact altitude, and answers nothing
-- while the world there is still streaming in.
FreezeEntityPosition(entity, true)
DoScreenFadeOut(250)
Wait(300)
local found, z = false, 0.0
for _, from in ipairs({ 1000.0, 700.0, 400.0, 200.0, 100.0, 50.0, 20.0 }) do
SetEntityCoordsNoOffset(entity, at.x, at.y, from, false, false, false)
RequestCollisionAtCoord(at.x, at.y, from)
local deadline = GetGameTimer() + 900
while not HasCollisionLoadedAroundEntity(entity) and GetGameTimer() < deadline do
Wait(0)
end
found, z = GetGroundZFor_3dCoord(at.x, at.y, from, false)
if found then break end
end
SetEntityCoordsNoOffset(entity, at.x, at.y, (found and z or 100.0) + 1.0,
false, false, false)
FreezeEntityPosition(entity, false)
DoScreenFadeIn(350)
TriggerServerEvent('fiverp-admin:tpmDone', true)
end)
-- ---------------------------------------------------------------- coords
RegisterNetEvent('fiverp-admin:coords', function()
local ped = PlayerPedId()
local c = GetEntityCoords(ped)
local h = GetEntityHeading(ped)
local text = ('vector4(%.2f, %.2f, %.2f, %.1f)'):format(c.x, c.y, c.z, h)
TriggerServerEvent('fiverp-admin:coordsAt', text)
-- Also on screen, because the point of the command is to read it.
exports['fiverp-notify']:show('Coords', text, 'info', 12000)
print('[coords] ' .. text)
end)
-- ---------------------------------------------------------------- noclip
local function stopNoclip(ped)
noclip = false
SetEntityCollision(ped, true, true)
SetEntityVisible(ped, true, false)
SetEntityInvincible(ped, false)
FreezeEntityPosition(ped, false)
SetEveryoneIgnorePlayer(PlayerId(), false)
end
RegisterNetEvent('fiverp-admin:noclip', function()
local ped = PlayerPedId()
if noclip then
stopNoclip(ped)
return
end
noclip = true
noclipSpeed = 1.0
SetEntityCollision(ped, false, false)
SetEntityVisible(ped, false, false)
SetEntityInvincible(ped, true)
SetEveryoneIgnorePlayer(PlayerId(), true)
CreateThread(function()
while noclip do
local me = PlayerPedId()
FreezeEntityPosition(me, true)
-- Movement follows the camera, not the ped: while flying, where you are
-- looking is the only direction that means anything.
local cam = GetGameplayCamRot(2)
local pitch, yaw = math.rad(cam.x), math.rad(cam.z)
local cosPitch = math.cos(pitch)
local forward = vector3(-math.sin(yaw) * cosPitch,
math.cos(yaw) * cosPitch,
math.sin(pitch))
local right = vector3(math.cos(yaw), math.sin(yaw), 0.0)
local pos = GetEntityCoords(me)
local move = vector3(0.0, 0.0, 0.0)
if IsControlPressed(0, 32) then move = move + forward end -- W
if IsControlPressed(0, 33) then move = move - forward end -- S
if IsControlPressed(0, 34) then move = move - right end -- A
if IsControlPressed(0, 35) then move = move + right end -- D
if IsControlPressed(0, 22) then move = move + vector3(0.0, 0.0, 1.0) end -- space
if IsControlPressed(0, 36) then move = move - vector3(0.0, 0.0, 1.0) end -- ctrl
-- Shift is faster, alt is finer. Held rather than toggled, because the
-- speed you want changes several times crossing one room.
local speed = noclipSpeed
if IsControlPressed(0, 21) then speed = speed * 4.0 end
if IsControlPressed(0, 19) then speed = speed * 0.25 end
if #move > 0.0 then
SetEntityCoordsNoOffset(me,
pos.x + move.x * speed, pos.y + move.y * speed, pos.z + move.z * speed,
false, false, false)
end
SetEntityHeading(me, math.deg(-yaw) % 360.0)
-- The world does not stream around an entity that is not colliding with
-- it, so ask for it directly or flying ends up over a hole.
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
DisableControlAction(0, 21, true)
DisableControlAction(0, 22, true)
Wait(0)
end
local me = PlayerPedId()
FreezeEntityPosition(me, false)
-- Land on whatever is under the exit point rather than in it.
local pos = GetEntityCoords(me)
local found, z = GetGroundZFor_3dCoord(pos.x, pos.y, pos.z, false)
if found then
SetEntityCoordsNoOffset(me, pos.x, pos.y, z + 1.0, false, false, false)
end
end)
end)
AddEventHandler('onResourceStop', function(name)
if name == GetCurrentResourceName() and noclip then
stopNoclip(PlayerPedId())
end
end)
+12
View File
@@ -0,0 +1,12 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-admin'
author 'FiveRP'
description 'Staff commands'
version '0.1.0'
dependency 'fiverp-notify'
client_scripts { 'client/cl_admin.lua' }
server_scripts { 'server/sv_admin.js' }
+118
View File
@@ -0,0 +1,118 @@
// Staff commands.
//
// Every one of them is gated on an ACE, checked here. A client asking to be
// revived or to teleport is asking the server, and the server is what decides
// whether they may — nothing in the client half is a permission.
//
// add_ace group.admin fiverp.admin allow
// add_principal identifier.fivem:1 group.admin
const ACE = 'fiverp.admin';
function ask(resource, method, fallback, ...args) {
try {
const target = exports[resource];
const result = target[method](...args);
return result === undefined ? fallback : result;
} catch (err) {
return fallback;
}
}
function tell(src, text, kind) {
try {
exports['fiverp-notify'].notify(Number(src), 'Staff', text, kind || 'info');
} catch (err) {
// Notify is not running: say it in the chat rather than not at all.
emitNet('chat:addMessage', Number(src), {
kind: 'system', system: true, args: ['Staff', text]
});
}
}
// The console is source 0 and is always allowed; anyone else needs the ace.
function allowed(src) {
return Number(src) === 0 || IsPlayerAceAllowed(String(src), ACE);
}
function staffCommand(name, help, handler) {
RegisterCommand(name, (source, args) => {
const src = Number(source);
if (!allowed(src)) {
// Says nothing about what the command does: a refusal that describes the
// thing refused is a list of staff commands for anyone who guesses.
if (src) tell(src, 'Unknown command');
return;
}
handler(src, args);
}, false);
// Registered with a false restriction on purpose — the check above is the
// real one, and this only puts the command in the slash list for staff.
emit('chat:addSuggestion', '/' + name, help);
}
// Resolve a player from a dynamic id, falling back to the caller.
function targetOf(src, arg) {
if (arg === undefined || arg === '') return src;
const id = Number(arg);
if (!Number.isFinite(id)) return null;
const found = ask('fiverp-ids', 'sourceFromDynamic', null, id);
return found ? Number(found) : null;
}
// ---------------------------------------------------------------- revive
staffCommand('revive', 'Bring a player back up', (src, args) => {
const target = targetOf(src, args[0]);
if (!target) return tell(src, 'Nobody online with that ID');
emitNet('fiverp-admin:revive', target);
const who = ask('fiverp-ids', 'getDynamicId', '?', target);
if (target !== src) {
tell(src, `Revived ID ${who}`);
tell(target, 'A member of staff brought you back up.');
} else {
tell(src, 'Back up');
}
});
// ---------------------------------------------------------------- noclip
// Toggled entirely on the client, but only ever started from here: otherwise
// the command exists on every machine whether or not it is allowed to work.
staffCommand('noclip', 'Fly through the world', (src) => {
if (!src) return;
emitNet('fiverp-admin:noclip', src);
});
// ------------------------------------------------------------------- tpm
staffCommand('tpm', 'Teleport to your map marker', (src) => {
if (!src) return;
emitNet('fiverp-admin:tpm', src);
});
// ------------------------------------------------------------------ coords
// Where you are standing, formatted the way the config files want it. Placing
// anything in this world means picking coordinates, and picking them by
// guessing from a screenshot is how a car dealership ends up inside a wall.
staffCommand('coords', 'Print where you are standing', (src) => {
if (!src) return;
emitNet('fiverp-admin:coords', src);
});
onNet('fiverp-admin:coordsAt', (text) => {
const src = Number(global.source);
if (!allowed(src)) return;
// To the console as well: a toast cannot be copied out of.
console.log(`[coords] ${GetPlayerName(String(src))}: ${text}`);
tell(src, text);
});
// The client asks for the ground height at the marker, which only it can read.
onNet('fiverp-admin:tpmDone', (ok) => {
const src = Number(global.source);
if (!allowed(src)) return;
tell(src, ok ? 'Teleported' : 'Set a waypoint first');
});
+25
View File
@@ -61,6 +61,22 @@ CreateThread(function()
FreezeEntityPosition(ped, true) FreezeEntityPosition(ped, true)
SetPlayerInvincible(PlayerId(), true) SetPlayerInvincible(PlayerId(), true)
DisableAllControlActions(0) DisableAllControlActions(0)
-- Nothing the game draws belongs on a login screen: there is no character
-- yet, so the radar, the health bar and the cash counter are all showing
-- state that has no owner.
DisplayRadar(false)
HideHudComponentThisFrame(1) -- WANTED_STARS
HideHudComponentThisFrame(2) -- WEAPON_ICON
HideHudComponentThisFrame(3) -- CASH
HideHudComponentThisFrame(4) -- MP_CASH
HideHudComponentThisFrame(6) -- VEHICLE_NAME
HideHudComponentThisFrame(7) -- AREA_NAME
HideHudComponentThisFrame(9) -- STREET_NAME
HideHudComponentThisFrame(13) -- CASH_CHANGE
HideHudComponentThisFrame(19) -- WEAPON_WHEEL
HideHudComponentThisFrame(20) -- WEAPON_WHEEL_STATS
Wait(0) Wait(0)
end end
end) end)
@@ -82,6 +98,15 @@ CreateThread(function()
Wait(500) Wait(500)
startCamera() startCamera()
-- FiveM tears the loading screen down when the player spawns, and nobody
-- spawns here: auth holds the gate shut until there is an identity. Left
-- alone it stays mounted on top of the world for the whole login, so the
-- camera below renders into a screen nobody can see. Shut it down by hand
-- now that there is a camera to show.
ShutdownLoadingScreenNui()
ShutdownLoadingScreen()
openAuth() openAuth()
end) end)
+14 -77
View File
@@ -98,113 +98,50 @@
</form> </form>
</div> </div>
<!-- ---------------------------------------------------------- passport --> <!-- ------------------------------------------------------- permit card -->
<div class="pane pane-card" id="pane-card"> <div class="pane pane-card" id="pane-card">
<div class="passport" id="permit"> <div class="passport" id="permit">
<div class="pp-guilloche" aria-hidden="true"></div> <!-- Kept because app.js writes to it; it draws nothing. A laminate
glare would be a gradient, and gradients are out. -->
<div class="pp-sheen" id="sheen" aria-hidden="true"></div> <div class="pp-sheen" id="sheen" aria-hidden="true"></div>
<header class="pp-head"> <header class="pp-head">
<span class="pp-seal" aria-hidden="true"> <span class="pp-seal" aria-hidden="true">FR</span>
<svg viewBox="0 0 64 64">
<circle class="ring-out" cx="32" cy="32" r="30"/>
<circle class="ring-in" cx="32" cy="32" r="26"/>
<!-- 13 stars, arced over the shield -->
<g class="stars">
<circle cx="32" cy="14" r="1.5"/>
<circle cx="25" cy="15.3" r="1.5"/><circle cx="39" cy="15.3" r="1.5"/>
<circle cx="19" cy="19" r="1.5"/><circle cx="45" cy="19" r="1.5"/>
<circle cx="15" cy="25" r="1.5"/><circle cx="49" cy="25" r="1.5"/>
</g>
<!-- shield: blue chief over 13 pales -->
<path class="shield-chief" d="M17 28h30v7H17z"/>
<g class="shield-pales">
<path d="M17 35h3v9.5c0 .6.1 1.2.2 1.8H17z"/>
<path d="M23 35h3v13h-3z"/>
<path d="M29 35h3v13h-3z"/>
<path d="M35 35h3v13h-3z"/>
<path d="M41 35h3v13h-3z"/>
</g>
<path class="shield-edge" d="M17 28h30v16.5c0 5.5-6.5 10.5-15 13.5-8.5-3-15-8-15-13.5z"/>
</svg>
</span>
<span class="pp-head-text"> <span class="pp-head-text">
<span class="pp-kind">Passport</span> <span class="pp-kind">Passport</span>
<span class="pp-country">United States of America</span> <span class="pp-country">United States of America</span>
</span> </span>
<span class="pp-chip" aria-hidden="true"> <span class="pp-stamp" id="seal">Issued <b id="stampDate">— — —</b></span>
<svg viewBox="0 0 20 16">
<rect x="0.6" y="0.6" width="18.8" height="14.8" rx="2.4"/>
<path d="M7 .6v15M13 .6v15M.6 5.4h18.8M.6 10.6h18.8"/>
</svg>
</span>
</header> </header>
<div class="pp-body"> <div class="pp-body">
<div class="pp-left"> <div class="pp-photo" aria-hidden="true">
<div class="pp-photo"> <svg viewBox="0 0 48 60">
<svg class="pp-bust" viewBox="0 0 48 60" aria-hidden="true"> <circle cx="24" cy="19" r="10"/>
<defs> <path d="M5 58c0-10.5 8.5-19 19-19s19 8.5 19 19"/>
<linearGradient id="bust" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9aa7b8"/>
<stop offset="1" stop-color="#5d6b7e"/>
</linearGradient>
</defs>
<circle cx="24" cy="18" r="11" fill="url(#bust)"/>
<path d="M3 60c0-11.5 9.4-20 21-20s21 8.5 21 20z" fill="url(#bust)"/>
</svg> </svg>
<span class="pp-photo-label">Photo</span>
</div>
<div class="pp-sign">
<span class="pp-sign-ink" id="cardSign"></span>
<span class="pp-sign-rule"></span>
<span class="pp-sign-label">Signature</span>
</div>
</div> </div>
<dl class="pp-fields"> <dl class="pp-fields">
<div class="pp-f pp-f--type"><dt>Type</dt><dd>P</dd></div>
<div class="pp-f pp-f--code"><dt>Code</dt><dd>USA</dd></div>
<div class="pp-f pp-f--no"><dt>Passport No.</dt><dd id="cardNo" class="mono">—</dd></div>
<div class="pp-f pp-f--wide"><dt>Surname</dt> <div class="pp-f pp-f--wide"><dt>Surname</dt>
<dd class="pp-name" id="cardLast" data-empty="—"></dd></div> <dd class="pp-name" id="cardLast" data-empty="—"></dd></div>
<div class="pp-f pp-f--wide"><dt>Given names</dt> <div class="pp-f pp-f--wide"><dt>Given names</dt>
<dd class="pp-name" id="cardFirst" data-empty="—"></dd></div> <dd class="pp-name" id="cardFirst" data-empty="—"></dd></div>
<div class="pp-f pp-f--wide"><dt>Nationality</dt>
<dd>United States of America</dd></div>
<div class="pp-f"><dt>Permit no.</dt><dd class="mono" id="cardNo">—</dd></div>
<div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob">—</dd></div> <div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob">—</dd></div>
<div class="pp-f"><dt>Sex</dt><dd>X</dd></div> <div class="pp-f"><dt>Issued</dt><dd class="mono" id="cardIssue">—</dd></div>
<div class="pp-f pp-f--wide"><dt>Place of birth</dt><dd>Los Santos, San Andreas</dd></div> <div class="pp-f"><dt>Expires</dt><dd class="mono" id="cardExpiry">—</dd></div>
<div class="pp-f"><dt>Date of issue</dt><dd class="mono" id="cardIssue">—</dd></div>
<div class="pp-f"><dt>Date of expiration</dt><dd class="mono" id="cardExpiry">—</dd></div>
<!-- Spans 4 of 6 columns, leaving the bottom-right corner clear so <div class="pp-f pp-f--wide"><dt>Signature</dt>
the entry stamp lands on paper rather than on top of data. --> <dd class="pp-sign-ink" id="cardSign"></dd></div>
<div class="pp-f pp-f--auth"><dt>Authority</dt>
<dd>United States Department of State</dd></div>
</dl> </dl>
<div class="pp-ghost" aria-hidden="true">
<svg viewBox="0 0 48 60">
<circle cx="24" cy="20" r="12"/>
<path d="M2 60c0-12 10-21 22-21s22 9 22 21z"/>
</svg>
</div>
</div> </div>
<div class="pp-mrz mono"> <div class="pp-mrz mono">
<span id="mrz1">P&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span> <span id="mrz1">P&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
<span id="mrz2">&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span> <span id="mrz2">&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
</div> </div>
<div class="pp-stamp" id="seal" aria-hidden="true">
<span class="pp-stamp-top">Admitted</span>
<span class="pp-stamp-mid">Los Santos</span>
<span class="pp-stamp-bot" id="stampDate">— — —</span>
</div>
</div> </div>
<p class="pp-caption">Issued once. The city will know you by this name.</p> <p class="pp-caption">Issued once. The city will know you by this name.</p>
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
-- FiveRP bank — the client half.
-- Draws the blips, spawns the tellers, and decides when the prompt is on screen.
-- It holds no money and validates no transaction: every button in the NUI ends
-- up as a server event that re-checks distance, balance and fee from scratch.
local locations = json.decode(LoadResourceFile(GetCurrentResourceName(), 'shared/locations.json'))
local BANKS = locations.banks
local ATMS = locations.atms
local isOpen = false
local peds = {}
-- ------------------------------------------------------------------- blips
CreateThread(function()
for _, bank in ipairs(BANKS) do
local blip = AddBlipForCoord(bank.blip[1], bank.blip[2], bank.blip[3])
SetBlipSprite(blip, 108) -- bank
SetBlipDisplay(blip, 4)
SetBlipScale(blip, 0.85)
SetBlipColour(blip, 2)
SetBlipAsShortRange(blip, true)
-- One legend entry for all of them. The map groups blips by sprite *and*
-- name, so eight distinct names meant eight lines to scroll past; the
-- branch itself is still named on the marker you walk up to.
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName('Bank')
EndTextCommandSetBlipName(blip)
end
-- 121 of these. Short-range so they only appear on the minimap when you are
-- near one, otherwise the map turns into a wall of icons.
for _, atm in ipairs(ATMS) do
local blip = AddBlipForCoord(atm[1], atm[2], atm[3])
SetBlipSprite(blip, 277) -- atm
SetBlipDisplay(blip, 4)
SetBlipScale(blip, 0.55)
SetBlipColour(blip, 2)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName('ATM')
EndTextCommandSetBlipName(blip)
end
end)
-- ------------------------------------------------------------------- peds
local function loadModel(name)
local hash = GetHashKey(name)
RequestModel(hash)
local timeout = GetGameTimer() + 8000
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
-- Where the teller actually stands: behind the counter, which is the customer's
-- spot pushed back along the direction the teller faces.
local function tellerSpot(bank)
local h = math.rad(bank.heading)
-- GTA heading: 0 is +Y and turns anticlockwise, so forward is (-sin, cos).
local fx, fy = -math.sin(h), math.cos(h)
return vector3(
bank.counter[1] - fx * Config.tellerSetback,
bank.counter[2] - fy * Config.tellerSetback,
bank.counter[3]
)
end
local function spawnTeller(index)
if peds[index] and DoesEntityExist(peds[index]) then return end
local bank = BANKS[index]
local hash = loadModel(Config.pedModel)
if not hash then return end
local at = tellerSpot(bank)
-- The floor is measured, not assumed. Guessing an offset is what put the
-- first version of this ped waist-deep in the carpet.
local found, groundZ = GetGroundZFor_3dCoord(at.x, at.y, at.z + 1.0, false)
local z = found and groundZ or (at.z - 1.0)
local ped = CreatePed(4, hash, at.x, at.y, z, bank.heading + 0.0, false, true)
SetEntityInvincible(ped, true)
FreezeEntityPosition(ped, true)
SetBlockingOfNonTemporaryEvents(ped, true) -- do not flee, do not wander
SetPedCanRagdoll(ped, false)
SetPedDiesWhenInjured(ped, false)
SetPedCanBeTargetted(ped, false)
TaskStartScenarioInPlace(ped, 'WORLD_HUMAN_CLIPBOARD', 0, true)
SetModelAsNoLongerNeeded(hash)
peds[index] = ped
end
local function despawnTeller(index)
local ped = peds[index]
if ped and DoesEntityExist(ped) then DeleteEntity(ped) end
peds[index] = nil
end
local function isOurs(ped)
for _, mine in pairs(peds) do
if mine == ped then return true end
end
return false
end
-- The stock crowd queues at the same counter and plays the same idle animation,
-- so from the doorway there appear to be several tellers and only one of them
-- answers. Clear them; the game keeps spawning more, so this has to repeat.
local function clearAmbient(centre)
local handle, ped = FindFirstPed()
local ok = true
repeat
if DoesEntityExist(ped)
and not IsPedAPlayer(ped)
and not isOurs(ped)
and #(GetEntityCoords(ped) - centre) <= Config.clearAmbientRadius
then
SetEntityAsMissionEntity(ped, true, true)
DeleteEntity(ped)
end
ok, ped = FindNextPed(handle)
until not ok
EndFindPed(handle)
end
-- Tellers exist only while somebody could see them: 121 ATMs cost nothing, but
-- eight peds standing in empty interiors do.
CreateThread(function()
while true do
local me = GetEntityCoords(PlayerPedId())
for i, bank in ipairs(BANKS) do
local at = tellerSpot(bank)
local d = #(me - at)
if d < 60.0 then
spawnTeller(i)
if d < 25.0 then clearAmbient(at) end
elseif d > 90.0 then
despawnTeller(i)
end
end
Wait(2000)
end
end)
-- --------------------------------------------------------------- proximity
-- Points are registered with fiverp-interact rather than drawn here, so a
-- teller and an ATM feel the same as every other thing on the server you can
-- act on: a dial, a held key, a ring that fills.
local registered = {}
local function pointId(kind, index) return kind .. ':' .. index end
local function addPoint(kind, index, coords, label)
local id = pointId(kind, index)
if registered[id] then return end
registered[id] = true
exports['fiverp-interact']:add({
id = id,
coords = coords,
label = label,
distance = kind == 'teller' and Config.reachTeller or Config.reachAtm,
hold = kind == 'teller' and 550 or 700,
onSelect = function()
TriggerServerEvent('fiverp-bank:open', kind, index)
end
})
end
local function dropPoint(kind, index)
local id = pointId(kind, index)
if not registered[id] then return end
registered[id] = nil
exports['fiverp-interact']:remove(id)
end
-- 121 ATMs cannot all be live points at once, so only what is nearby is
-- registered. The window is generous — the marker itself decides what is close
-- enough to use.
CreateThread(function()
while true do
local me = GetEntityCoords(PlayerPedId())
for i, bank in ipairs(BANKS) do
local at = vector3(bank.counter[1], bank.counter[2], bank.counter[3])
if #(me - at) < 40.0 then
addPoint('teller', i, at, 'Teller')
else
dropPoint('teller', i)
end
end
for i, atm in ipairs(ATMS) do
-- Marker on the keypad, not on the pavement the machine is bolted to.
local at = vector3(atm[1], atm[2], atm[3] + Config.atmMarkerHeight)
if #(me - at) < 40.0 then
addPoint('atm', i, at, 'ATM')
else
dropPoint('atm', i)
end
end
Wait(1500)
end
end)
-- Still needed by /bank, which does not go through the marker.
local function findNearest()
local me = GetEntityCoords(PlayerPedId())
for i, bank in ipairs(BANKS) do
if #(me - vector3(bank.counter[1], bank.counter[2], bank.counter[3])) <= Config.reachTeller then
return { kind = 'teller', index = i }
end
end
for i, atm in ipairs(ATMS) do
if #(me - vector3(atm[1], atm[2], atm[3])) <= Config.reachAtm then
return { kind = 'atm', index = i }
end
end
return nil
end
-- ------------------------------------------------------------------- menu
RegisterNetEvent('fiverp-bank:show', function(payload)
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open', data = payload })
end)
RegisterNetEvent('fiverp-bank:update', function(payload)
SendNUIMessage({ action = 'update', data = payload })
end)
RegisterNetEvent('fiverp-bank:result', function(payload)
SendNUIMessage({ action = 'result', data = payload })
end)
local function closeMenu()
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
RegisterNUICallback('close', function(_, cb) closeMenu(); cb('ok') end)
RegisterNUICallback('action', function(data, cb)
TriggerServerEvent('fiverp-bank:action', {
kind = data.kind,
index = data.index,
op = data.op,
amount = data.amount,
target = data.target
})
cb('ok')
end)
RegisterCommand('bank', function()
local here = findNearest()
if not here then
exports['fiverp-notify']:show('Bank', 'Stand at a teller or an ATM first.', 'bad')
return
end
TriggerServerEvent('fiverp-bank:open', here.kind, here.index)
end, false)
TriggerEvent('chat:addSuggestion', '/bank', 'Open banking at a teller or an ATM')
AddEventHandler('onResourceStop', function(name)
if name ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
for i in pairs(peds) do despawnTeller(i) end
end)
+26
View File
@@ -0,0 +1,26 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-bank'
author 'FiveRP'
description 'Bank branches, tellers and ATMs'
version '0.1.0'
dependency 'fiverp-interact'
ui_page 'html/index.html'
shared_script 'shared/config.lua'
client_scripts { 'client/cl_bank.lua' }
server_scripts { 'server/sv_bank.js' }
files {
'shared/locations.json',
'html/index.html',
'html/style.css',
'html/app.js',
'html/scrollbar.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+206
View File
@@ -0,0 +1,206 @@
/* FiveRP bank — the menu.
Shows balances and collects an amount. It computes the ATM fee only to print
it before you commit; the number that actually gets charged is worked out
again on the server, from its own config. */
const RES = 'fiverp-bank';
const $ = (id) => document.getElementById(id);
let state = { kind: 'teller', index: 0, cash: 0, bank: 0, feePercent: 0, feeMinimum: 0 };
let op = 'deposit';
const COPY = {
deposit: {
button: 'Deposit',
note: 'Cash in your pocket goes into the account. No charge, here or at an ATM.'
},
withdraw: {
teller: {
button: 'Withdraw',
note: 'The teller hands it over at face value. Walking in is what makes it free.'
},
atm: {
button: 'Withdraw',
note: 'The machine keeps a cut of anything it hands out. A teller does not.'
}
},
transfer: {
button: 'Send',
note: 'Straight from your account to another player\'s, by their ID. No charge. They have to be online.'
}
};
function money(value) {
return '$' + Math.floor(Number(value) || 0).toLocaleString('en-US');
}
function amountEntered() {
const raw = String($('amount').value).replace(/[^0-9]/g, '');
return raw ? Math.floor(Number(raw)) : 0;
}
// Mirrors the server's rule. Kept deliberately in one small function so there is
// one place to change if the rate ever moves.
function feeFor(amount) {
if (state.kind !== 'atm' || op !== 'withdraw' || amount <= 0) return 0;
return Math.max(state.feeMinimum, Math.ceil(amount * state.feePercent / 100));
}
function ceiling() {
if (op === 'deposit') return state.cash;
return state.bank;
}
function paint() {
$('cash').textContent = money(state.cash);
$('bank').textContent = money(state.bank);
$('place').textContent = state.name || 'Bank';
$('kind').textContent = state.kind === 'atm' ? 'ATM' : 'Teller';
// An ATM has no transfer, so the tab is removed rather than shown as refused.
const transferTab = document.querySelector('[data-op="transfer"]');
transferTab.classList.toggle('is-gone', state.kind === 'atm');
if (state.kind === 'atm' && op === 'transfer') setOp('deposit');
const copy = op === 'withdraw' ? COPY.withdraw[state.kind] : COPY[op];
$('note').textContent = copy.note;
$('submit').textContent = copy.button;
$('targetField').classList.toggle('is-gone', op !== 'transfer');
paintQuick();
paintFee();
}
function paintQuick() {
const max = ceiling();
const steps = [100, 500, 1000, 5000].filter((v) => v <= max);
const host = $('quick');
host.innerHTML = steps.map((v) =>
`<button class="chip" type="button" data-v="${v}">${money(v)}</button>`).join('')
+ (max > 0 ? `<button class="chip" type="button" data-v="${max}">All ${money(max)}</button>` : '');
host.querySelectorAll('.chip').forEach((el) => {
el.addEventListener('click', () => {
$('amount').value = el.dataset.v;
paintFee();
});
});
}
function paintFee() {
const amount = amountEntered();
const fee = feeFor(amount);
const el = $('fee');
if (op === 'transfer') {
el.innerHTML = amount > 0 ? `They receive <b>${money(amount)}</b>.` : '';
} else if (fee > 0) {
el.innerHTML = `Fee <b>${money(fee)}</b> — leaves your account as <b>${money(amount + fee)}</b>.`;
} else if (state.kind === 'atm' && op === 'withdraw') {
el.innerHTML = `Fee is ${state.feePercent}%, minimum ${money(state.feeMinimum)}.`;
} else {
el.textContent = '';
}
const over = amount > 0 && (op === 'deposit'
? amount > state.cash
: amount + fee > state.bank);
$('submit').disabled = amount <= 0 || over;
if (over) notice(op === 'deposit' ? 'More than you are carrying' : 'More than the account holds');
}
function notice(text, good) {
const el = $('notice');
el.textContent = text || '';
el.classList.toggle('is-shown', !!text);
el.classList.toggle('is-good', !!good);
}
function setOp(next) {
op = next;
document.querySelectorAll('.tab').forEach((t) =>
t.classList.toggle('is-on', t.dataset.op === next));
notice('');
paint();
}
function post(name, data) {
fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
function submit() {
const amount = amountEntered();
if (amount <= 0) return notice('Enter an amount');
if (op === 'transfer') {
const target = String($('target').value).replace(/[^0-9]/g, '');
if (!target) return notice('Enter the player ID to send to');
post('action', { kind: state.kind, index: state.index, op, amount, target: Number(target) });
} else {
post('action', { kind: state.kind, index: state.index, op, amount });
}
$('submit').disabled = true;
}
function close() {
$('root').classList.remove('is-open');
post('close', {});
}
/* --------------------------------------------------------------- wiring */
document.querySelectorAll('.tab').forEach((t) =>
t.addEventListener('click', () => setOp(t.dataset.op)));
$('close').addEventListener('click', close);
$('submit').addEventListener('click', submit);
$('amount').addEventListener('input', () => { notice(''); paintFee(); });
$('amount').addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
$('target').addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
/* ------------------------------------------------------------- from Lua */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'open') {
state = Object.assign(state, msg.data || {});
op = 'deposit';
$('amount').value = '';
$('target').value = '';
notice('');
setOp('deposit');
$('root').classList.add('is-open');
setTimeout(() => $('amount').focus(), 30);
return;
}
if (msg.action === 'update') {
state = Object.assign(state, msg.data || {});
$('amount').value = '';
paint();
return;
}
if (msg.action === 'result') {
const p = msg.data || {};
notice(p.message, p.ok);
$('submit').disabled = false;
return;
}
if (msg.action === 'close') {
$('root').classList.remove('is-open');
}
});
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP — Bank</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root" class="root">
<div class="panel">
<header class="head">
<div>
<p class="eyebrow" id="place">Fleeca</p>
<h1 class="title" id="kind">Teller</h1>
</div>
<button class="close" id="close" type="button">Close</button>
</header>
<div class="cards">
<div class="card">
<span class="card__label">Cash on hand</span>
<span class="card__value" id="cash">$0</span>
</div>
<div class="card card--bank">
<span class="card__label">Bank</span>
<span class="card__value" id="bank">$0</span>
</div>
</div>
<nav class="tabs" id="tabs">
<button class="tab is-on" data-op="deposit" type="button">Deposit</button>
<button class="tab" data-op="withdraw" type="button">Withdraw</button>
<button class="tab" data-op="transfer" type="button">Transfer</button>
</nav>
<div class="body">
<p class="note" id="note"></p>
<label class="field" id="targetField">
<span class="label">Player ID</span>
<input id="target" type="text" inputmode="numeric" autocomplete="off"
spellcheck="false" placeholder="14">
</label>
<label class="field">
<span class="label">Amount</span>
<div class="amount">
<span class="amount__sign">$</span>
<input id="amount" type="text" inputmode="numeric" autocomplete="off"
spellcheck="false" placeholder="0">
</div>
</label>
<div class="quick" id="quick"></div>
<p class="fee" id="fee"></p>
<p class="notice" id="notice"></p>
</div>
<footer class="foot">
<button class="btn-primary" id="submit" type="button">Deposit</button>
</footer>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
/* A scrollbar we actually own.
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
drawing a stepper stub at each end whatever display, size or background it is
given. So the native bar is hidden outright and this draws the replacement.
Attach with data-scroll on any scrolling element. The bar is position:fixed
and measured from the element's own rect, so it needs no particular ancestor
and works inside a modal that moves. */
(() => {
const MIN_THUMB = 28;
function attach(host) {
const bar = document.createElement('div');
bar.className = 'sbar';
const thumb = document.createElement('i');
thumb.className = 'sbar__thumb';
bar.appendChild(thumb);
document.body.appendChild(bar);
let dragging = false;
let grabOffset = 0;
// The bar is appended to the body, not to the host, so nothing about the
// host being faded out reaches it. Walk the ancestors and hide with them —
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
function hostVisible() {
let el = host;
while (el && el !== document.body) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (Number(style.opacity) < 0.05) return false;
el = el.parentElement;
}
return true;
}
function layout() {
const box = host.getBoundingClientRect();
const scrollable = host.scrollHeight - host.clientHeight;
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
// control that does nothing.
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
bar.style.display = 'none';
return;
}
bar.style.display = 'block';
bar.style.top = box.top + 'px';
bar.style.height = box.height + 'px';
bar.style.left = (box.right - 12) + 'px';
const ratio = host.clientHeight / host.scrollHeight;
const h = Math.max(MIN_THUMB, box.height * ratio);
const travel = box.height - h;
const progress = host.scrollTop / scrollable;
thumb.style.height = h + 'px';
thumb.style.transform = `translateY(${travel * progress}px)`;
}
function onDrag(event) {
if (!dragging) return;
const box = host.getBoundingClientRect();
const h = thumb.getBoundingClientRect().height;
const travel = box.height - h;
if (travel <= 0) return;
const wanted = event.clientY - box.top - grabOffset;
const progress = Math.max(0, Math.min(1, wanted / travel));
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
}
thumb.addEventListener('pointerdown', (event) => {
dragging = true;
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
bar.classList.add('is-held');
event.preventDefault();
});
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
// wide target mid-drag and the drag has to survive that.
window.addEventListener('pointermove', onDrag);
window.addEventListener('pointerup', () => {
dragging = false;
bar.classList.remove('is-held');
});
// Clicking the empty track jumps a page, the way a real scrollbar does.
bar.addEventListener('pointerdown', (event) => {
if (event.target === thumb) return;
const box = thumb.getBoundingClientRect();
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
});
host.addEventListener('scroll', layout);
window.addEventListener('resize', layout);
// The panel is rebuilt (settings reset, tab switch) without the element ever
// being replaced, so the size has to be re-read when its contents change.
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
layout();
return layout;
}
const layouts = [];
function scan() {
document.querySelectorAll('[data-scroll]').forEach((el) => {
if (el.dataset.scrollBound) return;
el.dataset.scrollBound = '1';
layouts.push(attach(el));
});
}
// Re-measure when a panel is opened: a hidden element has no useful rect.
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
scan();
}
})();
+365
View File
@@ -0,0 +1,365 @@
/* FiveRP — chat.
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
Sits over the game, so the page itself stays transparent and only the
panels take a fill. No blur, no shadow, no gradient. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: rgba(24, 24, 24, 1);
--card-bg: rgba(30, 30, 30, 1);
--btn-bg: rgba(51, 51, 51, 1);
--btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--sans: 'Unbounded', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--sans);
user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body { color: var(--text-primary); }
.root {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background-color: rgba(24, 24, 24, 0.86);
}
.root.is-open { display: flex; }
.panel {
width: min(520px, 86vw);
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
}
/* ────────────────────────────────────────────────────────────────── head */
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 25px;
border-bottom: 1px solid var(--border);
}
.eyebrow {
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 6px;
}
.title {
font-size: 20px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
}
.close {
flex-shrink: 0;
border: 1px solid var(--border);
cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-primary);
background-color: var(--btn-bg);
border-radius: 20px;
padding: 10px 18px;
transition: all 0.2s ease;
}
.close:hover { background-color: var(--btn-hover); }
/* ───────────────────────────────────────────────────────────── balances */
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 20px 25px 0;
}
.card {
background-color: var(--btn-bg);
border-radius: 12px;
padding: 16px 18px;
}
.card__label {
display: block;
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.card__value {
display: block;
font-size: 20px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.card--bank .card__value { color: var(--accent); }
/* ───────────────────────────────────────────────────────────────── tabs */
.tabs {
display: flex;
gap: 4px;
margin: 20px 25px 0;
background-color: var(--btn-bg);
border-radius: 20px;
padding: 4px;
}
.tab {
flex: 1;
border: 0;
cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
background: transparent;
border-radius: 20px;
padding: 11px 14px;
transition: all 0.2s ease;
}
.tab:hover { color: var(--text-primary); }
.tab.is-on {
background-color: var(--accent);
color: var(--bg);
}
/* An ATM cannot transfer, so the tab is not offered rather than offered and
refused. */
.tab.is-gone { display: none; }
/* ───────────────────────────────────────────────────────────────── body */
.body { padding: 22px 25px 0; }
.note {
font-size: 11px;
font-weight: 400;
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: 18px;
}
.field {
display: block;
margin-bottom: 16px;
}
.field.is-gone { display: none; }
.label {
display: block;
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.field input {
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
background-color: var(--btn-bg);
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
padding: 14px 16px;
outline: none;
transition: all 0.2s ease;
user-select: text;
font-variant-numeric: tabular-nums;
}
.field input::placeholder { color: var(--text-secondary); font-weight: 500; }
.field input:focus { border-color: var(--accent); }
.amount {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background-color: var(--btn-bg);
padding: 0 16px;
transition: all 0.2s ease;
}
.amount:focus-within { border-color: var(--accent); }
.amount__sign {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
}
.amount input {
border: 0;
padding: 14px 0;
background: transparent;
}
.amount input:focus { border: 0; }
/* quick amounts */
.quick {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.chip {
border: 1px solid var(--border);
cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
background-color: var(--btn-bg);
border-radius: 20px;
padding: 9px 15px;
transition: all 0.2s ease;
}
.chip:hover {
background-color: var(--btn-hover);
color: var(--text-primary);
}
.fee {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
min-height: 15px;
}
.fee b {
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.notice {
display: none;
margin-top: 14px;
font-size: 11px;
font-weight: 500;
line-height: 1.45;
border-radius: 12px;
padding: 12px 15px;
background-color: var(--btn-bg);
color: var(--accent);
}
.notice.is-shown { display: block; }
.notice.is-good { color: var(--text-primary); }
/* ───────────────────────────────────────────────────────────────── foot */
.foot {
padding: 22px 25px 25px;
}
.btn-primary {
width: 100%;
border: 0;
cursor: pointer;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--bg);
background-color: var(--accent);
border-radius: 12px;
padding: 15px 20px;
transition: all 0.2s ease;
}
.btn-primary:hover { transform: translateY(-2px); }
.btn-primary:disabled {
cursor: default;
background-color: var(--btn-bg);
color: var(--text-secondary);
transform: none;
}
body.no-anim *,
body.no-anim *::before,
body.no-anim *::after {
animation: none !important;
transition: none !important;
}
+207
View File
@@ -0,0 +1,207 @@
// FiveRP bank — the server half.
// Every number here is re-derived server-side. The client sends what the player
// pressed; it never sends a balance, a fee, or a claim about where it is
// standing that is taken at face value.
const CONFIG = {
atmFeePercent: 3.0,
atmFeeMinimum: 5,
reachTeller: 2.2,
reachAtm: 1.6,
maxPerAction: 5000000
};
const LOC = JSON.parse(
LoadResourceFile(GetCurrentResourceName(), 'shared/locations.json'));
// A little slack on top of the client's reach: the position the server has for
// a player lags their own by a frame or two, and refusing an honest player
// standing still is worse than allowing one who stepped back half a metre.
const SLACK = 2.5;
function ask(resource, method, fallback, ...args) {
try {
const target = exports[resource];
const result = target[method](...args);
return result === undefined ? fallback : result;
} catch (err) {
return fallback;
}
}
function distance(a, b) {
const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
// Where the player actually is, according to the server.
function whereIs(src) {
const ped = GetPlayerPed(String(src));
if (!ped) return null;
const c = GetEntityCoords(ped);
return [c[0], c[1], c[2]];
}
function spotOf(kind, index) {
const i = Number(index);
if (kind === 'teller') {
const bank = LOC.banks[i - 1];
// The counter is where the customer stands, which is what the server has to
// measure against — not where the teller is standing behind it.
return bank ? { coords: bank.counter, name: bank.name } : null;
}
if (kind === 'atm') {
const atm = LOC.atms[i - 1];
return atm ? { coords: atm, name: 'ATM' } : null;
}
return null;
}
// The single gate every request goes through.
function verify(src, kind, index) {
const spot = spotOf(kind, index);
if (!spot) return { ok: false, error: 'No such place' };
const me = whereIs(src);
if (!me) return { ok: false, error: 'Where are you?' };
const reach = (kind === 'teller' ? CONFIG.reachTeller : CONFIG.reachAtm) + SLACK;
if (distance(me, spot.coords) > reach) {
return { ok: false, error: 'Too far from the terminal' };
}
return { ok: true, spot };
}
// Round, positive, whole, and inside one sane move.
function amountOf(value) {
const n = Math.floor(Number(value));
if (!Number.isFinite(n) || n <= 0 || n > CONFIG.maxPerAction) return null;
return n;
}
// The ATM's cut, in whole dollars, on withdrawals only.
function feeFor(kind, amount) {
if (kind !== 'atm') return 0;
return Math.max(CONFIG.atmFeeMinimum,
Math.ceil(amount * CONFIG.atmFeePercent / 100));
}
async function balancesOf(src) {
const b = await ask('fiverp-economy', 'getBalances', null, src);
return b && typeof b.cash === 'number' ? b : { cash: 0, bank: 0 };
}
async function pushState(src, kind, index, name) {
const b = await balancesOf(src);
emitNet('fiverp-bank:update', src, {
kind, index, name,
cash: b.cash,
bank: b.bank,
feePercent: kind === 'atm' ? CONFIG.atmFeePercent : 0,
feeMinimum: kind === 'atm' ? CONFIG.atmFeeMinimum : 0
});
}
function done(src, ok, message) {
emitNet('fiverp-bank:result', src, { ok, message });
}
// ------------------------------------------------------------------ open
onNet('fiverp-bank:open', async (kind, index) => {
const src = Number(global.source);
const check = verify(src, kind, index);
if (!check.ok) return done(src, false, check.error);
const b = await balancesOf(src);
emitNet('fiverp-bank:show', src, {
kind,
index: Number(index),
name: check.spot.name,
cash: b.cash,
bank: b.bank,
feePercent: kind === 'atm' ? CONFIG.atmFeePercent : 0,
feeMinimum: kind === 'atm' ? CONFIG.atmFeeMinimum : 0
});
});
// ---------------------------------------------------------------- actions
onNet('fiverp-bank:action', async (req) => {
const src = Number(global.source);
const kind = req && req.kind;
const index = req && req.index;
const check = verify(src, kind, index);
if (!check.ok) return done(src, false, check.error);
const amount = amountOf(req.amount);
if (amount === null) return done(src, false, 'Enter a whole amount above zero');
const balances = await balancesOf(src);
// ---- deposit
if (req.op === 'deposit') {
if (balances.cash < amount) return done(src, false, 'Not enough cash on you');
const res = await ask('fiverp-economy', 'deposit', { ok: false }, src, amount,
check.spot.name);
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Deposit refused');
await pushState(src, kind, index, check.spot.name);
return done(src, true, `Deposited $${amount.toLocaleString('en-US')}`);
}
// ---- withdraw
if (req.op === 'withdraw') {
const fee = feeFor(kind, amount);
const total = amount + fee;
if (balances.bank < total) {
return done(src, false, fee
? `Not enough in the bank — ${amount} plus a $${fee} fee`
: 'Not enough in the bank');
}
const res = await ask('fiverp-economy', 'withdraw', { ok: false }, src, amount,
check.spot.name);
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Withdrawal refused');
// The fee is taken as a separate move so it shows up in the ledger as a fee
// and not as part of what the player asked for.
if (fee > 0) {
await ask('fiverp-economy', 'removeBank', { ok: false }, src, fee,
'atm_fee', check.spot.name);
}
await pushState(src, kind, index, check.spot.name);
return done(src, true, fee
? `Withdrew $${amount.toLocaleString('en-US')} — fee $${fee}`
: `Withdrew $${amount.toLocaleString('en-US')}`);
}
// ---- transfer (tellers only)
if (req.op === 'transfer') {
if (kind !== 'teller') {
return done(src, false, 'Transfers need a teller — an ATM cannot do them');
}
const targetSrc = ask('fiverp-ids', 'sourceFromDynamic', null, Number(req.target));
if (!targetSrc) return done(src, false, 'Nobody online with that ID');
if (Number(targetSrc) === src) return done(src, false, 'That is your own ID');
if (balances.bank < amount) return done(src, false, 'Not enough in the bank');
const res = await ask('fiverp-economy', 'transfer', { ok: false },
src, Number(targetSrc), amount, 'bank', 'bank_transfer');
if (!res || !res.ok) return done(src, false, (res && res.error) || 'Transfer refused');
await pushState(src, kind, index, check.spot.name);
ask('fiverp-notify', 'notify', false, Number(targetSrc), 'Bank',
`**$${amount.toLocaleString('en-US')}** arrived from ID ${
ask('fiverp-ids', 'getDynamicId', '?', src)}`, 'good');
return done(src, true, `Sent $${amount.toLocaleString('en-US')} to ID ${req.target}`);
}
return done(src, false, 'Unknown action');
});
+32
View File
@@ -0,0 +1,32 @@
-- Numbers both halves have to agree on. The server re-derives every one of them
-- for itself; nothing here is trusted because it arrived from a client.
Config = {
-- What the ATM keeps for handing you your own money. Tellers charge nothing:
-- that difference is the whole reason to walk into a branch.
atmFeePercent = 3.0,
atmFeeMinimum = 5,
-- How close you must be to use either. Checked on the client for the prompt
-- and again on the server for the transaction.
reachTeller = 2.4,
reachAtm = 1.6,
-- A single move. Not a balance cap — a cap on one press of a button.
maxPerAction = 5000000,
-- The teller stands this far behind the point the customer stands on, along
-- the direction they face. Nudge it if a branch puts them on the wrong side
-- of the counter; every Fleeca shares one interior, so one number covers them.
tellerSetback = 1.9,
-- The ATM coordinates are at the machine's foot. The marker belongs on the
-- keypad, which is about this far up.
atmMarkerHeight = 0.95,
-- Ambient peds inside this radius of a teller are removed. The stock crowd
-- queues at the same counter and plays the same idle, which reads as a second
-- set of tellers nobody can talk to.
clearAmbientRadius = 6.0,
pedModel = 'a_m_y_business_02'
}
+723
View File
@@ -0,0 +1,723 @@
{
"banks": [
{
"name": "Fleeca \u2014 Legion Square",
"counter": [
149.93,
-1040.54,
29.37
],
"heading": 340.0,
"blip": [
149.93,
-1040.54,
29.37
]
},
{
"name": "Fleeca \u2014 Hawick",
"counter": [
313.17,
-278.9,
54.17
],
"heading": 340.0,
"blip": [
313.17,
-278.9,
54.17
]
},
{
"name": "Fleeca \u2014 Alta",
"counter": [
-351.34,
-49.53,
49.04
],
"heading": 340.0,
"blip": [
-351.34,
-49.53,
49.04
]
},
{
"name": "Fleeca \u2014 Burton",
"counter": [
-1212.15,
-331.19,
37.79
],
"heading": 25.0,
"blip": [
-1212.15,
-331.19,
37.79
]
},
{
"name": "Fleeca \u2014 Rockford",
"counter": [
-2962.51,
482.53,
15.7
],
"heading": 88.0,
"blip": [
-2962.51,
482.53,
15.7
]
},
{
"name": "Fleeca \u2014 Great Ocean",
"counter": [
1175.05,
2706.28,
38.09
],
"heading": 180.0,
"blip": [
1175.05,
2706.28,
38.09
]
},
{
"name": "Fleeca \u2014 Paleto Bay",
"counter": [
-111.24,
6470.11,
31.63
],
"heading": 135.0,
"blip": [
-111.24,
6470.11,
31.63
]
},
{
"name": "Pacific Standard",
"counter": [
235.04,
216.36,
106.29
],
"heading": 340.0,
"blip": [
235.04,
216.36,
106.29
]
}
],
"atms": [
[
-301.657,
-829.589,
31.42
],
[
-303.226,
-829.312,
31.42
],
[
-204.019,
-861.009,
29.271
],
[
118.642,
-883.569,
30.139
],
[
24.593,
-945.543,
28.333
],
[
5.686,
-919.955,
28.481
],
[
296.176,
-896.232,
28.29
],
[
296.878,
-894.32,
28.261
],
[
147.473,
-1036.218,
28.368
],
[
145.839,
-1035.625,
28.368
],
[
112.476,
-819.808,
30.34
],
[
111.389,
-774.84,
30.438
],
[
114.547,
-775.972,
30.417
],
[
-256.639,
-715.89,
32.788
],
[
-259.277,
-723.265,
32.702
],
[
-254.522,
-692.887,
32.578
],
[
-27.89,
-724.109,
43.223
],
[
-30.1,
-723.286,
43.223
],
[
228.032,
337.85,
104.501
],
[
158.797,
234.745,
105.643
],
[
527.778,
-160.661,
56.137
],
[
-57.17,
-92.379,
56.751
],
[
89.813,
2.88,
67.352
],
[
285.348,
142.975,
103.162
],
[
357.128,
174.084,
102.06
],
[
1137.811,
-468.863,
65.699
],
[
1167.06,
-455.654,
65.819
],
[
1077.779,
-776.966,
57.257
],
[
289.53,
-1256.788,
28.441
],
[
289.268,
-1282.32,
28.655
],
[
-165.584,
234.766,
93.929
],
[
-165.584,
232.695,
93.929
],
[
-1044.466,
-2739.641,
8.124
],
[
-1205.378,
-326.529,
36.851
],
[
-1206.142,
-325.032,
36.851
],
[
-846.654,
-341.509,
37.669
],
[
-847.204,
-340.429,
37.679
],
[
-720.629,
-415.524,
33.98
],
[
-867.013,
-187.993,
36.882
],
[
-867.975,
-186.342,
36.882
],
[
-1415.48,
-212.332,
45.495
],
[
-1430.663,
-211.359,
45.472
],
[
-1410.736,
-98.928,
51.397
],
[
-1410.183,
-100.645,
51.397
],
[
-1282.098,
-210.56,
41.43
],
[
-1286.704,
-213.783,
41.43
],
[
-1289.742,
-227.165,
41.43
],
[
-1285.136,
-223.942,
41.43
],
[
-712.936,
-818.483,
22.741
],
[
-710.083,
-818.476,
22.736
],
[
-617.803,
-708.859,
29.043
],
[
-617.803,
-706.852,
29.043
],
[
-614.519,
-705.598,
30.224
],
[
-611.858,
-705.598,
30.224
],
[
-660.676,
-854.488,
23.457
],
[
-537.805,
-854.936,
28.275
],
[
-594.614,
-1160.852,
21.334
],
[
-596.125,
-1160.85,
21.334
],
[
-526.779,
-1223.374,
17.453
],
[
-1569.84,
-547.031,
33.932
],
[
-1570.765,
-547.704,
33.932
],
[
-1305.708,
-706.688,
24.314
],
[
-1315.416,
-834.431,
15.952
],
[
-1314.466,
-835.691,
15.952
],
[
-2071.928,
-317.286,
12.318
],
[
-821.894,
-1081.555,
10.137
],
[
-1110.228,
-1691.154,
3.378
],
[
-2956.848,
487.216,
14.478
],
[
-2958.977,
487.307,
14.478
],
[
-2974.586,
380.127,
14.0
],
[
-1091.888,
2709.054,
17.919
],
[
-2295.852,
357.935,
173.601
],
[
-2295.069,
356.256,
173.601
],
[
-2294.3,
354.606,
173.601
],
[
-3144.888,
1127.811,
19.838
],
[
-3043.835,
594.164,
6.733
],
[
-3241.455,
997.908,
11.548
],
[
2564.0,
2584.553,
37.068
],
[
2558.324,
350.988,
107.597
],
[
156.189,
6643.2,
30.594
],
[
173.825,
6638.217,
30.594
],
[
-282.714,
6226.43,
30.496
],
[
-95.87,
6457.462,
30.474
],
[
-97.637,
6455.732,
30.468
],
[
-132.666,
6366.876,
30.473
],
[
-386.46,
6046.411,
30.474
],
[
1687.395,
4815.9,
41.006
],
[
1700.694,
6426.762,
31.633
],
[
1822.971,
3682.577,
33.267
],
[
1171.523,
2703.139,
37.148
],
[
1172.457,
2703.139,
37.148
],
[
238.268,
217.109,
105.406
],
[
238.698,
216.187,
105.406
],
[
237.838,
218.031,
105.406
],
[
237.408,
218.954,
105.406
],
[
236.978,
219.876,
105.406
],
[
264.869,
209.949,
105.406
],
[
265.217,
210.905,
105.406
],
[
265.565,
211.861,
105.406
],
[
265.913,
212.817,
105.406
],
[
266.261,
213.773,
105.406
],
[
380.656,
322.842,
102.566
],
[
1153.111,
-326.902,
68.205
],
[
33.194,
-1348.806,
28.497
],
[
130.579,
-1292.369,
28.271
],
[
130.15,
-1291.626,
28.271
],
[
129.698,
-1290.842,
28.271
],
[
-57.402,
-1751.747,
28.421
],
[
-718.261,
-915.713,
18.216
],
[
-273.367,
-2024.208,
29.17
],
[
-262.361,
-2012.054,
29.17
],
[
-1391.344,
-589.863,
29.316
],
[
-1827.689,
784.465,
137.315
],
[
-3040.205,
593.297,
6.909
],
[
-3240.028,
1008.545,
11.831
],
[
2559.052,
389.474,
107.623
],
[
1703.315,
4934.053,
41.064
],
[
1735.01,
6410.01,
34.037
],
[
2683.592,
3286.3,
54.241
],
[
1968.392,
3743.078,
31.344
],
[
540.221,
2671.683,
41.156
]
]
}
@@ -0,0 +1,343 @@
-- Berry picking — the client half.
-- Places the buyer and the bushes, plays the cut, and shows the counter. It
-- decides nothing: what came off a bush and what it is worth are answered by
-- the server, which is why neither number appears in this file.
local bushes = {} -- id -> { prop, coords }
-- Rejected spots are remembered so the sweep does not probe them again every
-- two seconds for the rest of the session.
local rejected = {}
local buyerPed = nil
local cutting = false
-- ------------------------------------------------------------------ blips
CreateThread(function()
-- Sprite 52 (a shop) rather than something basket-shaped: the ids beyond the
-- vanilla range render as nothing at all, which is why the buyer had no blip
-- to find in the first place.
local blip = AddBlipForCoord(BerryConfig.buyer.coords)
SetBlipSprite(blip, 52)
SetBlipDisplay(blip, 4)
SetBlipScale(blip, 0.9)
SetBlipColour(blip, 2)
SetBlipAsShortRange(blip, false)
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName(BerryConfig.buyer.label)
EndTextCommandSetBlipName(blip)
-- One blip per patch, not per bush, and every one of them named the same:
-- the map groups by sprite and name, so four names meant four lines in the
-- legend instead of one entry you can page through.
for _, patch in ipairs(BerryConfig.patches) do
local ring = AddBlipForRadius(patch.centre, patch.radius)
SetBlipColour(ring, 2)
SetBlipAlpha(ring, 80)
local m = AddBlipForCoord(patch.centre)
SetBlipSprite(m, 1)
SetBlipDisplay(m, 4)
SetBlipScale(m, 0.75)
SetBlipColour(m, 2)
SetBlipAsShortRange(m, false)
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName('Berry patch')
EndTextCommandSetBlipName(m)
end
end)
-- ------------------------------------------------------------------- peds
local function loadModel(name)
local hash = GetHashKey(name)
RequestModel(hash)
local timeout = GetGameTimer() + 8000
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function spawnBuyer()
if buyerPed and DoesEntityExist(buyerPed) then return end
local hash = loadModel(BerryConfig.buyer.model)
if not hash then return end
local at = BerryConfig.buyer.coords
local found, groundZ = GetGroundZFor_3dCoord(at.x, at.y, at.z + 1.0, false)
local z = found and groundZ or (at.z - 1.0)
buyerPed = CreatePed(4, hash, at.x, at.y, z, BerryConfig.buyer.heading, false, true)
SetEntityInvincible(buyerPed, true)
FreezeEntityPosition(buyerPed, true)
SetBlockingOfNonTemporaryEvents(buyerPed, true)
SetPedCanRagdoll(buyerPed, false)
TaskStartScenarioInPlace(buyerPed, 'WORLD_HUMAN_STAND_IMPATIENT', 0, true)
SetModelAsNoLongerNeeded(hash)
exports['fiverp-interact']:add({
id = 'berry:buyer',
coords = vector3(at.x, at.y, at.z),
label = BerryConfig.buyer.label,
distance = 2.4,
hold = 550,
onSelect = function() TriggerServerEvent('fiverp-berries:open') end
})
end
local function despawnBuyer()
if buyerPed and DoesEntityExist(buyerPed) then DeleteEntity(buyerPed) end
buyerPed = nil
exports['fiverp-interact']:remove('berry:buyer')
end
-- ----------------------------------------------------------------- bushes
-- Finding the actual ground, which is harder than it sounds in a forest.
--
-- GetGroundZFor_3dCoord answers with the first solid thing below the height you
-- give it, and a branch is solid. Probing from high up therefore lands on the
-- canopy, and probing from low down answers nothing while the world is still
-- streaming. So: find something, then keep looking underneath it until there is
-- nothing further down. What is left is terrain.
local function surfaceBelow(x, y, from)
local found, z = GetGroundZFor_3dCoord(x, y, from + 0.0, false)
if found and z > -200.0 then return z end
return nil
end
local function groundAt(x, y, hint)
RequestCollisionAtCoord(x, y, hint)
local z = nil
for _, from in ipairs({ hint + 4.0, hint + 20.0, hint + 60.0, 200.0, 600.0 }) do
z = surfaceBelow(x, y, from)
if z then break end
Wait(0)
end
if not z then return nil end
-- Descend. Each step asks what is below the surface we just found; if there
-- is another one a clear step further down, the first was something we were
-- standing on rather than the floor.
for _ = 1, 8 do
local below = surfaceBelow(x, y, z - 0.8)
if not below or (z - below) < 1.0 then break end
z = below
end
return z
end
local function spawnBush(entry)
if rejected[entry.id] then return end
if bushes[entry.id] and bushes[entry.id].prop then return end
local x, y = entry.coords[1], entry.coords[2]
-- No ground yet means the world around it has not loaded. Leave it for the
-- next pass rather than planting it at a height that is merely plausible.
local z = groundAt(x, y, entry.coords[3])
if not z then return end
-- Too high above the patch means the probe found a branch, not soil. This is
-- what put bushes up in the trees.
if math.abs(z - entry.coords[3]) > BerryConfig.place.maxLift then
rejected[entry.id] = true
return
end
-- Slope, from the surface normal where the game will give one. Guarded,
-- because this native's return shape differs between builds and reading .z
-- off the wrong thing threw — which killed the spawn loop and took every
-- bush on the server with it. A check that can silently remove the feature
-- it guards is worse than no check.
local okNormal, normalZ = pcall(function()
local found, _, normal = GetGroundZAndNormalFor_3dCoord(x, y, z + 1.0)
if found and normal and normal.z then return normal.z end
return nil
end)
if okNormal and normalZ and normalZ < BerryConfig.place.minNormalZ then
rejected[entry.id] = true
return
end
-- And a second opinion from the terrain either side, because the normal is
-- one point and a bush can sit on a flat rock at the top of a drop.
local lowest, highest = z, z
for _, off in ipairs({ { 1.2, 0.0 }, { -1.2, 0.0 }, { 0.0, 1.2 }, { 0.0, -1.2 } }) do
local nz = groundAt(x + off[1], y + off[2], z)
if not nz then rejected[entry.id] = true return end
if nz < lowest then lowest = nz end
if nz > highest then highest = nz end
end
if highest - lowest > BerryConfig.place.maxSlope then
rejected[entry.id] = true
return
end
local name = BerryConfig.bushModels[(entry.model % #BerryConfig.bushModels) + 1]
local hash = loadModel(name)
local prop = nil
if hash then
prop = CreateObject(hash, x, y, z, false, false, false)
PlaceObjectOnGroundProperly(prop)
FreezeEntityPosition(prop, true)
SetModelAsNoLongerNeeded(hash)
z = GetEntityCoords(prop).z
end
bushes[entry.id] = { prop = prop, coords = vector3(x, y, z) }
exports['fiverp-interact']:add({
id = 'berry:bush:' .. entry.id,
coords = vector3(x, y, z + 0.85),
label = 'Berries',
distance = BerryConfig.reach,
hold = BerryConfig.holdMs,
onSelect = function() cut(entry.id) end
})
end
local function despawnBush(id)
local held = bushes[id]
if not held then return end
if held.prop and DoesEntityExist(held.prop) then DeleteEntity(held.prop) end
exports['fiverp-interact']:remove('berry:bush:' .. id)
bushes[id] = nil
end
-- ------------------------------------------------------------------- cut
local ANIM_DICT = 'amb@prop_human_bum_bin@idle_b'
local ANIM_NAME = 'idle_d'
function cut(id)
if cutting then return end
cutting = true
local ped = PlayerPedId()
RequestAnimDict(ANIM_DICT)
local timeout = GetGameTimer() + 3000
while not HasAnimDictLoaded(ANIM_DICT) and GetGameTimer() < timeout do Wait(0) end
if HasAnimDictLoaded(ANIM_DICT) then
TaskPlayAnim(ped, ANIM_DICT, ANIM_NAME, 4.0, -4.0, -1, 1, 0, false, false, false)
end
-- The animation runs first and the ask goes out after it: a cut that is
-- refused should still have looked like reaching into a bush.
CreateThread(function()
Wait(BerryConfig.animMs)
ClearPedTasks(PlayerPedId())
TriggerServerEvent('fiverp-berries:cut', id)
cutting = false
end)
end
-- A bush somebody just stripped goes quiet until it regrows, for everyone.
RegisterNetEvent('fiverp-berries:cutAccepted', function(id, seconds)
local held = bushes[id]
if not held then return end
exports['fiverp-interact']:remove('berry:bush:' .. id)
CreateThread(function()
Wait((seconds or 180) * 1000)
if bushes[id] then
exports['fiverp-interact']:add({
id = 'berry:bush:' .. id,
coords = vector3(held.coords.x, held.coords.y, held.coords.z + 0.85),
label = 'Berries',
distance = BerryConfig.reach,
hold = BerryConfig.holdMs,
onSelect = function() cut(id) end
})
end
end)
end)
-- ---------------------------------------------------------------- streaming
local bushList = {}
RegisterNetEvent('fiverp-berries:bushList', function(list)
bushList = list or {}
end)
CreateThread(function()
Wait(3000)
TriggerServerEvent('fiverp-berries:bushes')
while true do
local me = GetEntityCoords(PlayerPedId())
local d = #(me - BerryConfig.buyer.coords)
if d < 60.0 then spawnBuyer() elseif d > 90.0 then despawnBuyer() end
for _, entry in ipairs(bushList) do
local at = vector3(entry.coords[1], entry.coords[2], entry.coords[3])
local dist = #(me - at)
if dist < 70.0 then
-- One bush that cannot be placed must not stop the other forty-nine.
local ok, err = pcall(spawnBush, entry)
if not ok then
print('[fiverp-berries] bush ' .. tostring(entry.id) .. ': ' .. tostring(err))
rejected[entry.id] = true
end
elseif dist > 110.0 then
despawnBush(entry.id)
end
end
Wait(2000)
end
end)
-- ------------------------------------------------------------------- menu
local menuOpen = false
RegisterNetEvent('fiverp-berries:counter', function(data)
SendNUIMessage({ action = 'counter', data = data })
end)
RegisterNetEvent('fiverp-berries:show', function()
menuOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end)
RegisterNetEvent('fiverp-berries:told', function(text, kind)
-- The counter shows it too while it is open; out in the field the toast is
-- the only place it appears. The chat is for people talking.
SendNUIMessage({ action = 'told', text = text })
exports['fiverp-notify']:show('Berries', text, kind or 'info')
end)
RegisterNetEvent('fiverp-berries:ate', function(ms)
local ped = PlayerPedId()
local dict, clip = 'mp_player_inteat@burger', 'mp_player_int_eat_burger'
RequestAnimDict(dict)
local timeout = GetGameTimer() + 2000
while not HasAnimDictLoaded(dict) and GetGameTimer() < timeout do Wait(0) end
if not HasAnimDictLoaded(dict) then return end
-- Flag 49 keeps it upper-body only, so eating does not stop you walking.
TaskPlayAnim(ped, dict, clip, 4.0, -4.0, ms or 2600, 49, 0, false, false, false)
end)
local function closeMenu()
menuOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
RegisterNUICallback('close', function(_, cb) closeMenu(); cb('ok') end)
RegisterNUICallback('buyShears', function(_, cb) TriggerServerEvent('fiverp-berries:buyShears'); cb('ok') end)
RegisterNUICallback('sell', function(data, cb) TriggerServerEvent('fiverp-berries:sell', data.item); cb('ok') end)
AddEventHandler('onResourceStop', function(name)
if name ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
despawnBuyer()
for id in pairs(bushes) do despawnBush(id) end
end)
+25
View File
@@ -0,0 +1,25 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-berries'
author 'FiveRP'
description 'Berry picking: shears, patches, and a buyer in Paleto'
version '0.1.0'
dependency 'fiverp-interact'
dependency 'fiverp-inventory'
ui_page 'html/index.html'
shared_script 'shared/config.lua'
client_scripts { 'client/cl_berries.lua' }
server_scripts { 'server/sv_berries.js' }
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+95
View File
@@ -0,0 +1,95 @@
/* Berry buyer — the counter.
Every number on screen arrived from the server, including how many berries
the player is carrying: asking the inventory here would only produce a second
opinion, and the one that pays out is the server's. */
const RES = 'fiverp-berries';
const $ = (id) => document.getElementById(id);
let counter = { stock: [], shears: { price: 0, owned: false }, cash: 0 };
function money(value) {
return '$' + Math.floor(Number(value) || 0).toLocaleString('en-US');
}
function post(name, data) {
fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
function escape(text) {
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function draw() {
$('cash').textContent = money(counter.cash);
const shears = counter.shears || {};
$('tool').innerHTML = `
<div class="tool__text">
<div class="tool__name">Pruning shears</div>
<div class="tool__note">
Cut the cluster and the bush fruits again. Tear it off by hand and the
patch stops giving. You need a pair before any bush will let you work.
</div>
</div>
${shears.owned
? '<span class="tool__owned">In your bag</span>'
: `<button class="tool__buy" id="buy" type="button">Buy ${money(shears.price)}</button>`}`;
if (!shears.owned) {
$('buy').addEventListener('click', () => post('buyShears', {}));
}
const stock = counter.stock || [];
$('rows').innerHTML = stock.map((s) => `
<div class="row${s.have > 0 ? '' : ' is-empty'}">
<span class="row__name">${escape(s.label)}</span>
<span class="row__have">${s.have}</span>
<span class="row__price"><b>${money(s.price * s.have)}</b> at ${money(s.price)}</span>
<button class="row__sell" data-item="${escape(s.item)}" type="button">Sell</button>
</div>`).join('');
$('rows').querySelectorAll('.row__sell').forEach((el) => {
el.addEventListener('click', () => post('sell', { item: el.dataset.item }));
});
const total = stock.reduce((sum, s) => sum + s.price * s.have, 0);
const btn = $('sellAll');
btn.disabled = total <= 0;
btn.textContent = total > 0 ? `Sell everything — ${money(total)}` : 'Nothing to sell';
}
let noticeTimer = null;
function notice(text) {
$('notice').textContent = text || '';
clearTimeout(noticeTimer);
if (text) noticeTimer = setTimeout(() => { $('notice').textContent = ''; }, 4000);
}
function close() {
$('root').classList.remove('is-open');
post('close', {});
}
$('close').addEventListener('click', close);
$('sellAll').addEventListener('click', () => post('sell', { item: 'all' }));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'counter') {
counter = Object.assign(counter, msg.data || {});
draw();
return;
}
if (msg.action === 'open') { $('root').classList.add('is-open'); notice(''); return; }
if (msg.action === 'close') { $('root').classList.remove('is-open'); return; }
if (msg.action === 'told') { notice(msg.text); }
});
draw();
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP — Berry buyer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root" class="root">
<div class="panel">
<header class="head">
<div>
<p class="eyebrow">Paleto Bay</p>
<h1 class="title">Berry buyer</h1>
</div>
<div class="cash">
<span class="cash__label">Cash</span>
<span class="cash__value" id="cash">$0</span>
</div>
<button class="close" id="close" type="button">Close</button>
</header>
<section class="tool" id="tool"></section>
<section class="stock">
<p class="group">What you brought</p>
<div class="rows" id="rows"></div>
</section>
<footer class="foot">
<p class="notice" id="notice"></p>
<button class="btn-primary" id="sellAll" type="button">Sell everything</button>
</footer>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+215
View File
@@ -0,0 +1,215 @@
/* FiveRP — chat.
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
Sits over the game, so the page itself stays transparent and only the
panels take a fill. No blur, no shadow, no gradient. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: rgba(24, 24, 24, 1);
--card-bg: rgba(30, 30, 30, 1);
--btn-bg: rgba(51, 51, 51, 1);
--btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--sans: 'Unbounded', sans-serif;
}
* {
margin: 0; padding: 0; box-sizing: border-box;
font-family: var(--sans); user-select: none;
}
html, body { width: 100%; height: 100%; overflow: hidden; background: transparent; }
body { color: var(--text-primary); }
.root {
position: fixed; inset: 0; display: none;
align-items: center; justify-content: center;
background-color: rgba(24, 24, 24, 0.86);
}
.root.is-open { display: flex; }
.panel {
width: min(520px, 88vw);
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
}
.head {
display: flex; align-items: center; gap: 20px;
padding: 25px; border-bottom: 1px solid var(--border);
}
.eyebrow {
font-size: 10px; font-weight: 500; letter-spacing: 1px;
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 6px;
}
.title {
font-size: 20px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase;
}
.cash { flex: 1; text-align: right; }
.cash__label {
display: block; font-size: 10px; font-weight: 500; letter-spacing: 1px;
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 5px;
}
.cash__value {
font-size: 16px; font-weight: 900; font-variant-numeric: tabular-nums;
}
.close {
flex-shrink: 0; border: 1px solid var(--border); cursor: pointer;
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
color: var(--text-primary); background-color: var(--btn-bg);
border-radius: 20px; padding: 10px 18px; transition: all 0.2s ease;
}
.close:hover { background-color: var(--btn-hover); }
/* ─────────────────────────────────────────────────────────────── shears */
.tool {
display: flex; align-items: center; gap: 16px;
margin: 25px; padding: 18px;
background-color: var(--btn-bg); border-radius: 12px;
}
.tool__text { flex: 1; min-width: 0; }
.tool__name {
font-size: 12px; font-weight: 700; letter-spacing: 1px;
text-transform: uppercase; margin-bottom: 5px;
}
.tool__note {
font-size: 10px; font-weight: 400; line-height: 1.5; color: var(--text-secondary);
}
.tool__buy {
flex-shrink: 0; border: 0; cursor: pointer;
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
color: var(--bg); background-color: var(--accent);
border-radius: 20px; padding: 11px 18px; transition: all 0.2s ease;
}
.tool__buy:hover { transform: translateY(-2px); }
.tool__owned {
flex-shrink: 0; font-size: 10px; font-weight: 700; letter-spacing: 1px;
text-transform: uppercase; color: var(--text-secondary);
}
/* ──────────────────────────────────────────────────────────────── stock */
.stock { padding: 0 25px; }
.group {
font-size: 10px; font-weight: 700; letter-spacing: 1px;
text-transform: uppercase; color: var(--text-secondary); margin-bottom: 14px;
}
.rows { display: flex; flex-direction: column; gap: 8px; }
.row {
display: flex; align-items: center; gap: 14px;
background-color: var(--btn-bg); border-radius: 12px; padding: 13px 16px;
transition: all 0.2s ease;
}
/* Nothing of that kind in the bag: shown, but plainly not actionable. */
.row.is-empty { opacity: 0.45; }
.row__name {
flex: 1; min-width: 0;
font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row__have {
font-size: 11px; font-weight: 700; color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.row__price {
font-size: 11px; font-weight: 500; color: var(--text-secondary);
font-variant-numeric: tabular-nums; min-width: 74px; text-align: right;
}
.row__price b { font-weight: 700; color: var(--text-primary); }
.row__sell {
flex-shrink: 0; border: 1px solid var(--border); cursor: pointer;
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
color: var(--text-primary); background-color: var(--card-bg);
border-radius: 20px; padding: 9px 16px; transition: all 0.2s ease;
}
.row__sell:hover { background-color: var(--btn-hover); }
.row.is-empty .row__sell { visibility: hidden; }
/* ───────────────────────────────────────────────────────────────── foot */
.foot {
display: flex; align-items: center; gap: 20px; padding: 25px;
}
.notice {
flex: 1; min-width: 0;
font-size: 11px; font-weight: 500; line-height: 1.45; color: var(--accent);
min-height: 16px;
}
.btn-primary {
flex-shrink: 0; border: 0; cursor: pointer;
font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
color: var(--bg); background-color: var(--accent);
border-radius: 12px; padding: 15px 24px; transition: all 0.2s ease;
}
.btn-primary:hover { transform: translateY(-2px); }
.btn-primary:disabled {
cursor: default; background-color: var(--btn-bg);
color: var(--text-secondary); transform: none;
}
body.no-anim *, body.no-anim *::before, body.no-anim *::after {
animation: none !important; transition: none !important;
}
@@ -0,0 +1,345 @@
// Berry picking — the server half.
//
// It owns the bushes, the cooldowns and the money. The client says "I cut bush
// 41"; the server decides whether that bush exists, whether the player is
// standing near it, whether it has regrown, whether they own shears, and only
// then what came off it. Nothing about the yield or the price ever travels
// upward.
const CONFIG = {
shearsItem: 'berry_shears',
shearsPrice: 150,
regrowSeconds: 180,
reach: 1.8,
// Slack on top of the client's reach: the server's copy of a position lags a
// frame or two, and refusing an honest player standing still is worse than
// allowing one who stepped back half a metre.
slack: 2.5,
buyer: { coords: [2224.4, 5155.6, 57.0] },
// Flat farmland. Kept identical to shared/config.lua by hand, because the two
// runtimes cannot read one file and the server must not learn a bush's
// position from a client.
patches: [
{ name: 'Grapeseed fields', centre: [2140.0, 4880.0, 41.0], radius: 55.0, bushes: 14 },
{ name: 'Alamo flats', centre: [1720.0, 4680.0, 42.0], radius: 50.0, bushes: 12 },
{ name: 'East pastures', centre: [2420.0, 4720.0, 34.0], radius: 50.0, bushes: 12 },
{ name: 'Braddock plain', centre: [1930.0, 5040.0, 45.0], radius: 48.0, bushes: 12 }
],
// Candidates per wanted bush. The client drops the ones that land on a slope
// or in a canopy, so there have to be spares or a patch ends up sparse.
spareFactor: 1.6,
berries: [
{ item: 'berry_black', label: 'Blackberries', draw: 55, price: 12, yield: [2, 4] },
{ item: 'berry_rasp', label: 'Raspberries', draw: 25, price: 24, yield: [2, 3] },
{ item: 'berry_blue', label: 'Blueberries', draw: 15, price: 45, yield: [1, 3] },
{ item: 'berry_cloud', label: 'Cloudberries', draw: 5, price: 130, yield: [1, 2] }
]
};
const PRICE = {};
CONFIG.berries.forEach((b) => { PRICE[b.item] = b.price; });
// -------------------------------------------------------------- the items
// Registered with the inventory rather than written into its registry file:
// berries belong to this job, and the job is what should carry them.
const ITEMS = {
berry_shears: {
label: 'Pruning shears',
description: 'Cuts a berry cluster off cleanly, so the bush lives to fruit again.',
weight: 0.6, stack: 1, model: 'prop_tool_shears', image: 'berry_shears.png'
},
berry_black: {
label: 'Blackberries',
description: 'Common, and they stain everything they touch.',
weight: 0.15, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_black.png'
},
berry_rasp: {
label: 'Raspberries',
description: 'Sweeter, and they bruise if you are careless.',
weight: 0.12, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_rasp.png'
},
berry_blue: {
label: 'Blueberries',
description: 'Grow high up the slope where few people bother to walk.',
weight: 0.10, stack: 24, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_blue.png'
},
berry_cloud: {
label: 'Cloudberries',
description: 'Rare enough that the buyer will ask where you found them.',
weight: 0.08, stack: 12, action: 'eat_berry', model: 'prop_cs_bowl_01', image: 'berry_cloud.png'
}
};
// What eating one does. Berries are food first, water second, and a small lift
// either way — foraging should be worth doing when you are out in the hills
// with nothing else.
const EAT = { hunger: 6, thirst: 2, mood: 3 };
function ask(resource, method, fallback, ...args) {
try {
const target = exports[resource];
const result = target[method](...args);
return result === undefined ? fallback : result;
} catch (err) {
return fallback;
}
}
// ------------------------------------------------------------- the bushes
// Placed once, from a fixed seed. Random placement would move every bush on
// every restart, and a patch nobody can learn is not a place, it is noise.
function lcg(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const bushes = []; // { id, patch, coords: [x,y,z], model }
const cutAt = new Map(); // bush id -> game timer of the last cut
function buildBushes() {
const rand = lcg(20260901);
let id = 1;
CONFIG.patches.forEach((patch, pi) => {
const candidates = Math.ceil(patch.bushes * CONFIG.spareFactor);
for (let i = 0; i < candidates; i++) {
// Square root on the radius, or every bush clusters in the middle.
const angle = rand() * Math.PI * 2;
const dist = Math.sqrt(rand()) * patch.radius;
bushes.push({
id: id++,
patch: pi,
patchName: patch.name,
coords: [
patch.centre[0] + Math.cos(angle) * dist,
patch.centre[1] + Math.sin(angle) * dist,
patch.centre[2]
],
model: Math.floor(rand() * 4)
});
}
});
}
buildBushes();
function bushById(id) {
return bushes.find((b) => b.id === Number(id)) || null;
}
function whereIs(src) {
const ped = GetPlayerPed(String(src));
if (!ped) return null;
const c = GetEntityCoords(ped);
return [c[0], c[1], c[2]];
}
// Flat distance. The client snaps a bush to the ground it finds, so its z and
// the z this file stores are not the same number, and comparing them would
// refuse people standing right in front of the thing.
function flatDistance(a, b) {
const dx = a[0] - b[0], dy = a[1] - b[1];
return Math.sqrt(dx * dx + dy * dy);
}
function pickBerry(roll) {
const total = CONFIG.berries.reduce((sum, b) => sum + b.draw, 0);
let at = roll * total;
for (const berry of CONFIG.berries) {
at -= berry.draw;
if (at <= 0) return berry;
}
return CONFIG.berries[0];
}
// ------------------------------------------------------------------ start
setTimeout(() => {
ask('fiverp-inventory', 'registerItems', false, ITEMS);
}, 2000);
onNet('fiverp-berries:bushes', () => {
emitNet('fiverp-berries:bushList', Number(global.source), bushes.map((b) => ({
id: b.id, coords: b.coords, model: b.model
})));
});
// ------------------------------------------------------------------- cut
onNet('fiverp-berries:cut', async (bushId) => {
const src = Number(global.source);
const bush = bushById(bushId);
if (!bush) return;
const me = whereIs(src);
if (!me) return;
if (flatDistance(me, bush.coords) > CONFIG.reach + CONFIG.slack) {
return emitNet('fiverp-berries:told', src, 'Too far from the bush', 'bad');
}
if (!ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)) {
return emitNet('fiverp-berries:told', src,
'You need shears. The buyer sells them.', 'bad');
}
const last = cutAt.get(bush.id) || 0;
const waited = (GetGameTimer() - last) / 1000;
if (last && waited < CONFIG.regrowSeconds) {
return emitNet('fiverp-berries:told', src,
`Picked bare — **${Math.ceil(CONFIG.regrowSeconds - waited)}s** to regrow`, 'info');
}
// Claim the bush before anything can fail, so two players cutting the same
// one in the same second cannot both be paid for it.
cutAt.set(bush.id, GetGameTimer());
emitNet('fiverp-berries:cutAccepted', -1, bush.id, CONFIG.regrowSeconds);
const berry = pickBerry(Math.random());
const [low, high] = berry.yield;
const amount = low + Math.floor(Math.random() * (high - low + 1));
const given = await ask('fiverp-inventory', 'giveItem', { ok: false },
src, berry.item, amount);
if (!given || !given.placed) {
return emitNet('fiverp-berries:told', src, 'Your bag is full', 'bad');
}
emitNet('fiverp-berries:told', src,
`**${given.placed}** × ${berry.label}` + (given.left ? ' — the rest would not fit' : ''),
'good');
});
// ------------------------------------------------------------- the buyer
function nearBuyer(src) {
const me = whereIs(src);
return !!me && flatDistance(me, CONFIG.buyer.coords) <= 3.5 + CONFIG.slack;
}
async function sendCounter(src) {
const stock = CONFIG.berries.map((b) => ({
item: b.item,
label: b.label,
price: b.price,
have: ask('fiverp-inventory', 'countItem', 0, src, b.item)
}));
emitNet('fiverp-berries:counter', src, {
stock,
shears: {
item: CONFIG.shearsItem,
price: CONFIG.shearsPrice,
owned: ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)
},
cash: ask('fiverp-economy', 'getCash', 0, src)
});
}
onNet('fiverp-berries:open', async () => {
const src = Number(global.source);
if (!nearBuyer(src)) return;
await sendCounter(src);
emitNet('fiverp-berries:show', src);
});
onNet('fiverp-berries:buyShears', async () => {
const src = Number(global.source);
if (!nearBuyer(src)) return;
if (ask('fiverp-inventory', 'hasItem', false, src, CONFIG.shearsItem, 1)) {
return emitNet('fiverp-berries:told', src, 'You already have a pair', 'info');
}
const paid = await ask('fiverp-economy', 'removeCash', { ok: false },
src, CONFIG.shearsPrice, 'berry_shears', 'Berry buyer');
if (!paid || !paid.ok) {
return emitNet('fiverp-berries:told', src, 'Not enough cash on you', 'bad');
}
const given = await ask('fiverp-inventory', 'giveItem', { ok: false },
src, CONFIG.shearsItem, 1);
if (!given || !given.ok) {
// Refund rather than take the money and hand over nothing.
await ask('fiverp-economy', 'addCash', null, src, CONFIG.shearsPrice,
'berry_shears_refund', 'Berry buyer');
return emitNet('fiverp-berries:told', src, 'No room in your bag', 'bad');
}
await sendCounter(src);
emitNet('fiverp-berries:told', src, 'Shears bought — cut, do not tear', 'good');
});
// Sell one kind, or everything. The count is never taken from the client: it is
// whatever the inventory says is actually there.
onNet('fiverp-berries:sell', async (which) => {
const src = Number(global.source);
if (!nearBuyer(src)) return;
const kinds = which === 'all'
? CONFIG.berries.map((b) => b.item)
: [String(which)];
let earned = 0;
const sold = [];
for (const item of kinds) {
if (!PRICE[item]) continue;
const have = ask('fiverp-inventory', 'countItem', 0, src, item);
if (have <= 0) continue;
const removed = await ask('fiverp-inventory', 'removeItem', { ok: false },
src, item, have);
const n = (removed && removed.removed) || 0;
if (n <= 0) continue;
earned += n * PRICE[item];
sold.push(`${n} × ${ITEMS[item].label}`);
}
if (!earned) {
return emitNet('fiverp-berries:told', src, 'Nothing to sell', 'info');
}
await ask('fiverp-economy', 'addCash', null, src, earned, 'berry_sale', 'Berry buyer');
await sendCounter(src);
emitNet('fiverp-berries:told', src,
`Sold ${sold.join(', ')} for **$${earned.toLocaleString('en-US')}**`, 'good');
});
// ------------------------------------------------------------------ eating
// One at a time. The check lives here rather than on the client because the
// client is where a hundred clicks a second come from: without it, holding a
// stack of twenty and pressing quickly eats all twenty in one animation and the
// hunger bar jumps as if the food were free.
const EAT_MS = 2600;
const eating = new Map(); // source -> when the current mouthful ends
on('fiverp-inventory:used', async (src, item, action) => {
if (action !== 'eat_berry') return;
src = Number(src);
const now = GetGameTimer();
const until = eating.get(src) || 0;
if (now < until) {
return ask('fiverp-notify', 'notify', false, src, 'Berries',
'Finish the mouthful first', 'info');
}
eating.set(src, now + EAT_MS);
const removed = await ask('fiverp-inventory', 'removeItem', { ok: false }, src, item, 1);
if (!removed || !removed.removed) {
eating.delete(src);
return;
}
ask('fiverp-status', 'give', false, src, 'hunger', EAT.hunger);
ask('fiverp-status', 'give', false, src, 'thirst', EAT.thirst);
ask('fiverp-status', 'give', false, src, 'mood', EAT.mood);
emitNet('fiverp-berries:ate', src, EAT_MS);
});
on('playerDropped', () => { eating.delete(Number(global.source)); });
@@ -0,0 +1,78 @@
-- Berry picking: the shared numbers.
-- The server re-derives every one of them; nothing here is trusted because it
-- arrived from a client.
BerryConfig = {
-- The buyer. Sells the shears, buys what you bring back.
-- A few metres up the street from the Paleto Fleeca, which is a coordinate
-- this server already knows is on solid ground.
-- Out in the open on the Grapeseed farm road. The first attempt put him a few
-- metres from the Paleto bank and he landed inside its back office: a
-- coordinate being on solid ground says nothing about whether there is a wall
-- around it.
buyer = {
coords = vector3(2224.4, 5155.6, 57.0),
heading = 60.0,
model = 'a_m_m_farmer_01',
label = 'Berry buyer'
},
-- Cutting with shears leaves the bush alive, which is why they are required
-- rather than merely useful: tearing berries off by hand is how a patch stops
-- producing.
shears = {
item = 'berry_shears',
price = 150
},
-- How long a bush takes to be worth cutting again.
regrowSeconds = 180,
-- Reach, and how long the cut takes.
reach = 1.8,
holdMs = 900,
animMs = 2600,
-- Where the patches are. Bushes are placed by the server in a fixed scatter
-- around each centre, so everyone sees the same bush in the same place and
-- they do not move between restarts.
-- Flat country only. The first set sat on the north face of Chiliad and in
-- Raton Canyon, where half the bushes ended up on ground too steep to stand
-- on and the rest in tree canopy. These are the farm flats north-east of the
-- Alamo Sea, which are open fields.
--
-- `bushes` is how many are wanted; the client generates half again as many
-- candidates and keeps the ones that pass the ground checks, so a patch on
-- awkward terrain thins out instead of hovering.
patches = {
{ name = 'Grapeseed fields', centre = vector3(2140.0, 4880.0, 41.0), radius = 55.0, bushes = 14 },
{ name = 'Alamo flats', centre = vector3(1720.0, 4680.0, 42.0), radius = 50.0, bushes = 12 },
{ name = 'East pastures', centre = vector3(2420.0, 4720.0, 34.0), radius = 50.0, bushes = 12 },
{ name = 'Braddock plain', centre = vector3(1930.0, 5040.0, 45.0), radius = 48.0, bushes = 12 }
},
-- A bush is only planted where a person could stand to cut it.
place = {
maxSlope = 1.2, -- metres of drop across the 2.4 m span sampled around it
maxLift = 12.0, -- how far above the patch height the ground may be
minNormalZ = 0.94 -- how level the surface has to be; 1.0 is dead flat,
-- 0.94 is about a 20 degree slope
},
-- Bush props. Several so a patch does not look stamped out.
bushModels = {
'prop_bush_lrg_04b',
'prop_bush_lrg_04c',
'prop_bush_med_05',
'prop_bush_med_07'
},
-- What grows, how likely it is, and what the buyer pays for one.
-- Weight is the draw weight, not the kilograms.
berries = {
{ item = 'berry_black', label = 'Blackberries', weight = 55, price = 12, yield = { 2, 4 } },
{ item = 'berry_rasp', label = 'Raspberries', weight = 25, price = 24, yield = { 2, 3 } },
{ item = 'berry_blue', label = 'Blueberries', weight = 15, price = 45, yield = { 1, 3 } },
{ item = 'berry_cloud', label = 'Cloudberries', weight = 5, price = 130, yield = { 1, 2 } }
}
}
@@ -177,6 +177,19 @@ CreateThread(function()
DisableControlAction(0, 1, true) DisableControlAction(0, 1, true)
DisableControlAction(0, 2, true) DisableControlAction(0, 2, true)
SetPlayerInvincible(PlayerId(), true) SetPlayerInvincible(PlayerId(), true)
-- Same reason as auth: picking a character is not being in the world, so
-- the radar and the cash counter have nothing truthful to show yet.
DisplayRadar(false)
HideHudComponentThisFrame(1) -- WANTED_STARS
HideHudComponentThisFrame(2) -- WEAPON_ICON
HideHudComponentThisFrame(3) -- CASH
HideHudComponentThisFrame(4) -- MP_CASH
HideHudComponentThisFrame(6) -- VEHICLE_NAME
HideHudComponentThisFrame(7) -- AREA_NAME
HideHudComponentThisFrame(9) -- STREET_NAME
HideHudComponentThisFrame(13) -- CASH_CHANGE
Wait(0) Wait(0)
else else
Wait(300) Wait(300)
@@ -188,6 +201,11 @@ end)
local function beginSelection() local function beginSelection()
exports.spawnmanager:setAutoSpawn(false) exports.spawnmanager:setAutoSpawn(false)
-- Safety net. fiverp-loadscreen is manual-shutdown, so if auth ever fails to
-- take it down the player would sit behind it forever. Calling it twice is
-- harmless; never calling it is not.
ShutdownLoadingScreenNui()
-- A clear midday sky so nobody sculpts a face in the dark. -- A clear midday sky so nobody sculpts a face in the dark.
NetworkOverrideClockTime(13, 0, 0) NetworkOverrideClockTime(13, 0, 0)
SetWeatherTypeNowPersist('EXTRASUNNY') SetWeatherTypeNowPersist('EXTRASUNNY')
@@ -254,6 +272,11 @@ local function enterCity(data)
SetEntityCollision(ped, true, true) SetEntityCollision(ped, true, true)
EnableAllControlActions(0) EnableAllControlActions(0)
SetPlayerInvincible(PlayerId(), false) SetPlayerInvincible(PlayerId(), false)
-- DisplayRadar is a sticky flag, not a per-frame one: auth and the slot
-- screen switched it off, and this is the moment it becomes true again.
DisplayRadar(true)
DoScreenFadeIn(800) DoScreenFadeIn(800)
TriggerEvent('chat:addMessage', { TriggerEvent('chat:addMessage', {
@@ -16,6 +16,7 @@ files {
'html/index.html', 'html/index.html',
'html/style.css', 'html/style.css',
'html/app.js', 'html/app.js',
'html/scrollbar.js',
'html/fonts/unbounded-latin.woff2', 'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2', 'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2' 'html/fonts/unbounded-cyrillic.woff2'
+2
View File
@@ -400,6 +400,8 @@ function renderTab() {
const def = TABS.find((t) => t.id === tab) || TABS[0]; const def = TABS.find((t) => t.id === tab) || TABS[0];
def.build(host); def.build(host);
renderTabs(); renderTabs();
// Each tab is a different length, so the custom scrollbar has to re-measure.
requestAnimationFrame(() => window.dispatchEvent(new Event('fiverp:relayout')));
} }
// ---------------------------------------------------------------- editor // ---------------------------------------------------------------- editor
+2 -1
View File
@@ -32,7 +32,7 @@
<nav class="tabs" id="tabs"></nav> <nav class="tabs" id="tabs"></nav>
<div class="controls" id="controls"></div> <div class="controls scroll-y" id="controls" data-scroll></div>
<p class="notice" id="notice"></p> <p class="notice" id="notice"></p>
@@ -56,5 +56,6 @@
</main> </main>
<script src="app.js"></script> <script src="app.js"></script>
<script src="scrollbar.js"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,123 @@
/* A scrollbar we actually own.
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
drawing a stepper stub at each end whatever display, size or background it is
given. So the native bar is hidden outright and this draws the replacement.
Attach with data-scroll on any scrolling element. The bar is position:fixed
and measured from the element's own rect, so it needs no particular ancestor
and works inside a modal that moves. */
(() => {
const MIN_THUMB = 28;
function attach(host) {
const bar = document.createElement('div');
bar.className = 'sbar';
const thumb = document.createElement('i');
thumb.className = 'sbar__thumb';
bar.appendChild(thumb);
document.body.appendChild(bar);
let dragging = false;
let grabOffset = 0;
// The bar is appended to the body, not to the host, so nothing about the
// host being faded out reaches it. Walk the ancestors and hide with them —
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
function hostVisible() {
let el = host;
while (el && el !== document.body) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (Number(style.opacity) < 0.05) return false;
el = el.parentElement;
}
return true;
}
function layout() {
const box = host.getBoundingClientRect();
const scrollable = host.scrollHeight - host.clientHeight;
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
// control that does nothing.
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
bar.style.display = 'none';
return;
}
bar.style.display = 'block';
bar.style.top = box.top + 'px';
bar.style.height = box.height + 'px';
bar.style.left = (box.right - 12) + 'px';
const ratio = host.clientHeight / host.scrollHeight;
const h = Math.max(MIN_THUMB, box.height * ratio);
const travel = box.height - h;
const progress = host.scrollTop / scrollable;
thumb.style.height = h + 'px';
thumb.style.transform = `translateY(${travel * progress}px)`;
}
function onDrag(event) {
if (!dragging) return;
const box = host.getBoundingClientRect();
const h = thumb.getBoundingClientRect().height;
const travel = box.height - h;
if (travel <= 0) return;
const wanted = event.clientY - box.top - grabOffset;
const progress = Math.max(0, Math.min(1, wanted / travel));
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
}
thumb.addEventListener('pointerdown', (event) => {
dragging = true;
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
bar.classList.add('is-held');
event.preventDefault();
});
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
// wide target mid-drag and the drag has to survive that.
window.addEventListener('pointermove', onDrag);
window.addEventListener('pointerup', () => {
dragging = false;
bar.classList.remove('is-held');
});
// Clicking the empty track jumps a page, the way a real scrollbar does.
bar.addEventListener('pointerdown', (event) => {
if (event.target === thumb) return;
const box = thumb.getBoundingClientRect();
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
});
host.addEventListener('scroll', layout);
window.addEventListener('resize', layout);
// The panel is rebuilt (settings reset, tab switch) without the element ever
// being replaced, so the size has to be re-read when its contents change.
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
layout();
return layout;
}
const layouts = [];
function scan() {
document.querySelectorAll('[data-scroll]').forEach((el) => {
if (el.dataset.scrollBound) return;
el.dataset.scrollBound = '1';
layouts.push(attach(el));
});
}
// Re-measure when a panel is opened: a hidden element has no useful rect.
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
scan();
}
})();
+537 -208
View File
@@ -1,297 +1,626 @@
/* FiveRP — character slots and appearance creator. /* FiveRP — character select and the appearance editor.
Follows CLAUDE.md. Two hard rules from that document apply everywhere here: Design system: /opt/fivem/server-data/CLAUDE.md — Clean UI / Dark Minimalism.
no backdrop-filter and no box-shadow. CEF composites both as opaque black The game renders the ped behind this page, so the editor keeps the right half
plates the size of the element, which is the black-square artefact. Depth of the screen clear. No backdrop-filter and no box-shadow: CEF composites both
comes from opacity, hairlines and inner borders instead. */ as opaque black plates. */
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2'); src: url('fonts/unbounded-latin.woff2') format('woff2');
font-weight: 300 800; font-display: block; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+2122, U+2191, U+2193, U+2212; U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2'); src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
font-weight: 300 800; font-display: block; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
unicode-range: U+0100-02AF, U+0304, U+0308, U+1E00-1EFF, U+2020, U+20A0-20AB; U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2'); src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
:root { :root {
--surface: rgba(255, 255, 255, 0.95); --bg: rgba(24, 24, 24, 1);
--surface-soft: rgba(255, 255, 255, 0.88); --card-bg: rgba(30, 30, 30, 1);
--fg: #1d1d1f; --btn-bg: rgba(51, 51, 51, 1);
--fg-secondary: #6e6e73; --btn-hover: rgba(70, 70, 70, 1);
--fg-tertiary: #a1a1a6; --accent: rgba(255, 119, 22, 1);
--accent: #0071e3; --text-primary: rgba(241, 241, 241, 1);
--accent-strong: #0077ed; --text-secondary: rgba(153, 153, 153, 1);
--accent-soft: rgba(0, 113, 227, 0.08); --border: rgba(51, 51, 51, 0.5);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10); --sans: 'Unbounded', sans-serif;
--field-bg: rgba(0, 0, 0, 0.04);
--danger: #ff3b30;
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
color-scheme: light;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * {
margin: 0;
html, body { padding: 0;
width: 100%; height: 100%; box-sizing: border-box;
background: transparent; /* the game is the backdrop */ font-family: var(--sans);
overflow: hidden;
font-family: 'Unbounded', -apple-system, 'Segoe UI', sans-serif;
color: var(--fg);
-webkit-font-smoothing: antialiased;
user-select: none; user-select: none;
} }
.root { position: fixed; inset: 0; display: none; } html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body { color: var(--text-primary); }
.root {
position: fixed;
inset: 0;
display: none;
}
.root.is-open { display: block; } .root.is-open { display: block; }
.screen { position: absolute; inset: 0; display: none; } .screen {
position: absolute;
inset: 0;
display: none;
}
.screen.is-active { display: flex; } .screen.is-active { display: flex; }
/* --------------------------------------------------------- shared bits */ /* ─────────────────────────────────────────────────────── slot select screen */
/* No fill: the game renders behind this screen too, and the cards carry their
own background. */
.screen--select {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.head {
text-align: center;
margin-bottom: 36px;
}
.eyebrow { .eyebrow {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em; font-size: 11px;
text-transform: uppercase; color: var(--fg-tertiary); font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 12px;
} }
.title { .title {
font-size: 30px; font-weight: 700; letter-spacing: -0.035em; font-size: 34px;
line-height: 1.1; margin-top: 10px; font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
line-height: 1.1;
margin-bottom: 12px;
} }
.sub { margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--fg-secondary); }
.btn-primary { .sub {
border: none; border-radius: 999px; padding: 11px 22px; font-size: 13px;
font-family: inherit; font-size: 13px; font-weight: 600; letter-spacing: -0.01em; font-weight: 400;
color: #fff; cursor: pointer; color: var(--text-secondary);
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
transition: filter .2s var(--ease), transform .2s var(--ease);
} }
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: .45; pointer-events: none; }
.btn-ghost { .slots {
border: none; border-radius: 999px; padding: 11px 18px; display: grid;
font-family: inherit; font-size: 13px; font-weight: 600; grid-template-columns: repeat(3, 300px);
color: var(--accent); background-color: var(--accent-soft); cursor: pointer; gap: 20px;
transition: background-color .2s var(--ease);
} }
.btn-ghost:hover { background-color: rgba(0, 113, 227, 0.14); }
.btn-danger { color: var(--danger); background-color: rgba(255, 59, 48, 0.08); }
.btn-danger:hover { background-color: rgba(255, 59, 48, 0.15); }
/* ---------------------------------------------------------- slot screen */
.screen--select { flex-direction: column; align-items: center; justify-content: center; gap: 34px; }
.head { text-align: center; }
.slots { display: flex; gap: 16px; }
.slot { .slot {
position: relative; min-height: 320px;
width: 268px; min-height: 330px; display: flex;
display: flex; flex-direction: column; flex-direction: column;
padding: 26px 22px 22px; background-color: var(--card-bg);
border-radius: var(--radius-lg); border: 1px solid var(--border);
border: 1px solid var(--line-strong); border-radius: 16px;
background: var(--surface); padding: 25px;
transition: transform .35s var(--ease), border-color .35s var(--ease); transition: all 0.2s ease;
}
.slot--empty {
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
}
.slot--empty:hover {
background-color: var(--btn-bg);
transform: translateY(-2px);
}
.plus {
width: 56px;
height: 56px;
border-radius: 16px;
background-color: var(--btn-bg);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 400;
line-height: 1;
margin-bottom: 18px;
}
.slot--empty:hover .plus {
background-color: var(--accent);
color: var(--bg);
}
.slot--empty .label {
font-size: 13px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 8px;
}
.slot--empty .hint {
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
} }
.slot:hover { transform: translateY(-4px); border-color: rgba(0, 0, 0, 0.14); }
.slot-no { .slot-no {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em; font-size: 10px;
text-transform: uppercase; color: var(--fg-tertiary); font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 14px;
} }
.slot-name { .slot-name {
margin-top: 14px; font-size: 24px;
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2; font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
line-height: 1.15;
margin-bottom: 12px;
}
.slot-meta {
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
margin-bottom: 16px;
} }
.slot-meta { margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--fg-secondary); }
.slot-badge { .slot-badge {
display: inline-block; margin-top: 12px; align-self: flex-start; align-self: flex-start;
padding: 4px 11px; border-radius: 999px; font-size: 10px;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; font-weight: 700;
color: var(--accent); background: var(--accent-soft); letter-spacing: 1px;
text-transform: uppercase;
color: var(--bg);
background-color: var(--accent);
border-radius: 20px;
padding: 6px 14px;
} }
.slot-badge.is-draft { color: #b25000; background: rgba(255, 149, 0, 0.12); }
.slot-actions { margin-top: auto; display: flex; flex-direction: column; gap: 8px; } .slot-badge.is-draft {
.slot-actions button { width: 100%; } background-color: var(--btn-bg);
color: var(--text-secondary);
/* Empty slot: one big invitation, centred. */
.slot--empty {
align-items: center; justify-content: center; text-align: center;
gap: 14px; cursor: pointer;
background: var(--surface-soft);
border-style: dashed; border-color: rgba(0, 0, 0, 0.14);
} }
.slot--empty:hover { border-color: var(--accent); background: rgba(255, 255, 255, 0.97); }
.plus { .slot-actions {
width: 54px; height: 54px; border-radius: 50%; margin-top: auto;
display: grid; place-items: center; padding-top: 22px;
font-size: 26px; font-weight: 300; line-height: 1; display: flex;
color: var(--accent); background: var(--accent-soft); flex-direction: column;
gap: 8px;
} }
.slot--empty .label { font-size: 14px; font-weight: 600; letter-spacing: -0.02em; }
.slot--empty .hint { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
.foot-note { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); } .foot-note {
margin-top: 28px;
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
text-align: center;
}
/* -------------------------------------------------------- editor screen */ /* ──────────────────────────────────────────────────────────────── buttons */
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
.screen--editor .panel, .btn-primary,
.screen--editor .stagebar { pointer-events: auto; } .btn-ghost {
border: 0;
cursor: pointer;
width: 100%;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
border-radius: 12px;
padding: 13px 18px;
transition: all 0.2s ease;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg);
}
.btn-primary:hover { transform: translateY(-2px); }
.btn-ghost {
background-color: var(--btn-bg);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-ghost:hover { background-color: var(--btn-hover); }
.btn-danger { color: var(--text-secondary); }
.btn-danger:hover {
background-color: var(--btn-hover);
color: var(--accent);
}
/* ────────────────────────────────────────────────────── the editor screen */
.screen--editor {
align-items: stretch;
justify-content: flex-start;
}
.panel { .panel {
position: absolute; top: 24px; right: 24px; bottom: 24px; width: 420px;
width: 372px; flex-shrink: 0;
display: flex; flex-direction: column; display: flex;
padding: 24px 0 20px; flex-direction: column;
border-radius: var(--radius-lg); background-color: var(--card-bg);
border: 1px solid var(--line-strong); border-right: 1px solid var(--border);
background: var(--surface); }
.panel-head {
padding: 25px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: 20px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
} }
.panel-head { padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.panel-title { margin-top: 8px; font-size: 20px; font-weight: 700; letter-spacing: -0.03em; }
.tabs { .tabs {
display: flex; flex-wrap: wrap; gap: 6px; display: flex;
padding: 14px 24px; border-bottom: 1px solid var(--line); flex-wrap: wrap;
gap: 6px;
padding: 18px 25px;
border-bottom: 1px solid var(--border);
} }
.tab {
border: none; border-radius: 999px; padding: 7px 13px;
font-family: inherit; font-size: 10px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
transition: color .2s var(--ease), background-color .2s var(--ease);
}
.tab:hover { color: var(--fg); }
.tab.is-on { color: #fff; background: var(--accent); }
.controls { flex: 1; overflow-y: auto; padding: 18px 24px 8px; } .tab {
.controls::-webkit-scrollbar { width: 5px; } border: 0;
.controls::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.14); border-radius: 3px; } cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
background-color: var(--btn-bg);
border-radius: 20px;
padding: 9px 16px;
transition: all 0.2s ease;
}
.tab:hover {
background-color: var(--btn-hover);
color: var(--text-primary);
}
.tab.is-on {
background-color: var(--accent);
color: var(--bg);
}
.controls {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 22px 25px;
}
.group-label { .group-label {
margin: 16px 0 10px; font-size: 10px;
font-size: 10px; font-weight: 600; letter-spacing: 0.14em; font-weight: 700;
text-transform: uppercase; color: var(--fg-tertiary); letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin: 22px 0 12px;
} }
.group-label:first-child { margin-top: 0; } .group-label:first-child { margin-top: 0; }
.ctl { margin-bottom: 13px; } /* a labelled slider */
.ctl { margin-bottom: 16px; }
.ctl-top { .ctl-top {
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
} }
.ctl-name { font-size: 11px; font-weight: 500; letter-spacing: -0.01em; color: var(--fg); }
.ctl-val { font-size: 10px; font-weight: 600; color: var(--fg-tertiary); font-variant-numeric: tabular-nums; }
input[type="range"] { .ctl-name {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px; font-size: 11px;
background: rgba(0, 0, 0, 0.09); cursor: pointer; outline: none; font-weight: 500;
}
.ctl-val {
font-size: 11px;
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.ctl input[type='range'],
.controls input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background-color: var(--btn-bg);
cursor: pointer;
}
.controls input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--accent);
cursor: pointer;
}
/* male / female */
.toggle {
display: flex;
gap: 4px;
background-color: var(--btn-bg);
border-radius: 20px;
padding: 4px;
} }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: #fff; border: 1px solid rgba(0, 0, 0, 0.16);
transition: transform .15s var(--ease), border-color .15s var(--ease);
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.12); border-color: var(--accent); }
/* Two-state toggle (male / female) */
.toggle { display: flex; gap: 6px; margin-bottom: 16px; }
.toggle button { .toggle button {
flex: 1; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); flex: 1;
padding: 10px; font-family: inherit; font-size: 12px; font-weight: 600; border: 0;
letter-spacing: -0.01em; color: var(--fg-secondary); cursor: pointer;
background: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 10px;
transition: all .2s var(--ease); font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
background: transparent;
border-radius: 20px;
padding: 10px 14px;
transition: all 0.2s ease;
}
.toggle button:hover { color: var(--text-primary); }
.toggle button.is-on {
background-color: var(--accent);
color: var(--bg);
}
/* the legal-name fields */
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
} }
.toggle button.is-on { color: #fff; background: var(--accent); border-color: var(--accent); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field-label { .field-label {
display: block; margin-bottom: 6px; display: block;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; font-size: 10px;
text-transform: uppercase; color: var(--fg-tertiary); font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 7px;
} }
.field {
width: 100%; border: 1px solid transparent; border-radius: var(--radius-sm); input.field {
padding: 11px 12px; font-family: inherit; font-size: 13px; font-weight: 500; width: 100%;
color: var(--fg); background: var(--field-bg); outline: none; border: 1px solid var(--border);
transition: background-color .2s, border-color .2s; border-radius: 12px;
background-color: var(--btn-bg);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
padding: 12px 14px;
outline: none;
transition: all 0.2s ease;
user-select: text;
} }
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
.field:focus { background: #fff; border-color: var(--accent); } input.field:focus { border-color: var(--accent); }
input.field:disabled { color: var(--text-secondary); }
input.field::placeholder { color: var(--text-secondary); }
/* notice + footer */
.notice { .notice {
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger); display: none;
opacity: 0; transition: opacity .2s var(--ease); margin: 0 25px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
background-color: var(--btn-bg);
border-radius: 12px;
padding: 12px 16px;
} }
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
.notice.is-shown { display: block; }
.panel-foot { .panel-foot {
display: flex; gap: 8px; padding: 16px 24px 0; display: flex;
border-top: 1px solid var(--line); flex-wrap: wrap;
gap: 8px;
padding: 20px 25px;
border-top: 1px solid var(--border);
} }
.panel-foot .btn-primary { flex: 1; }
.btn-dice { padding: 11px 14px; }
/* Camera controls, centred under the ped. */ /* The primary sits on its own row: "Save & enter city" does not fit beside two
ghosts in a 420px panel, and letting it wrap made it taller than its row. */
.panel-foot .btn-primary {
order: -1;
flex: 1 1 100%;
width: 100%;
}
.panel-foot .btn-ghost {
flex: 1;
width: auto;
}
/* ─────────────────────────────────────────── the bar under the live ped */
.stagebar { .stagebar {
position: absolute; left: calc(50% - 210px); bottom: 40px; position: absolute;
transform: translateX(-50%); left: 420px;
display: flex; align-items: center; gap: 10px; right: 0;
padding: 8px; border-radius: 999px; bottom: 4vh;
border: 1px solid var(--line-strong); background: var(--surface); display: flex;
align-items: center;
justify-content: center;
gap: 12px;
} }
.round { .round {
width: 34px; height: 34px; border: none; border-radius: 50%; width: 44px;
font-family: inherit; font-size: 17px; font-weight: 500; line-height: 1; height: 44px;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer; flex-shrink: 0;
transition: color .2s, background-color .2s; border: 1px solid var(--border);
cursor: pointer;
font-size: 18px;
font-weight: 400;
line-height: 1;
color: var(--text-primary);
background-color: var(--card-bg);
border-radius: 50%;
transition: all 0.2s ease;
} }
.round:hover { color: #fff; background: var(--accent); }
.chips { display: flex; gap: 4px; } .round:hover { background-color: var(--btn-hover); }
.chips {
display: flex;
gap: 4px;
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 30px;
padding: 4px;
}
.chip { .chip {
border: none; border-radius: 999px; padding: 8px 14px; border: 0;
font-family: inherit; font-size: 10px; font-weight: 600; cursor: pointer;
letter-spacing: 0.09em; text-transform: uppercase; font-size: 10px;
color: var(--fg-secondary); background: transparent; cursor: pointer; font-weight: 700;
transition: color .2s, background-color .2s; letter-spacing: 1px;
} text-transform: uppercase;
.chip:hover { color: var(--fg); } color: var(--text-secondary);
.chip.is-on { color: #fff; background: var(--accent); } background: transparent;
border-radius: 20px;
/* ---------------------------------------------------------------- motion */ padding: 10px 20px;
.fade-up { animation: fadeUp .6s var(--ease) both; } transition: all 0.2s ease;
.delay-1 { animation-delay: .06s; }
.delay-2 { animation-delay: .12s; }
.delay-3 { animation-delay: .18s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
} }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .chip:hover { color: var(--text-primary); }
@media (prefers-reduced-motion: reduce) { .chip.is-on {
*, *::before, *::after { background-color: var(--accent);
animation-duration: .001ms !important; color: var(--bg);
transition-duration: .001ms !important;
} }
/* ───────────────────────────────────────────────────────────── entrances */
.fade-up { animation: fade-up 0.4s ease both; }
.delay-1 { animation-delay: 0.06s; }
.delay-2 { animation-delay: 0.12s; }
.delay-3 { animation-delay: 0.18s; }
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
body.no-anim *,
body.no-anim *::before,
body.no-anim *::after {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
/* ─────────────────────────────────────────────────────── custom scrollbar */
/* The native bar is hidden rather than styled: CEF keeps drawing a stepper stub
at each end no matter what ::-webkit-scrollbar-button is told. scrollbar.js
draws the replacement, which is a plain element and therefore ours. */
.scroll-y {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-y::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.sbar {
position: fixed;
width: 4px;
border-radius: 20px;
background-color: var(--btn-bg);
z-index: 50;
display: none;
}
.sbar__thumb {
display: block;
width: 100%;
border-radius: 20px;
background-color: var(--text-secondary);
transition: background-color 0.2s ease;
}
.sbar:hover .sbar__thumb,
.sbar.is-held .sbar__thumb {
background-color: var(--accent);
} }
+170
View File
@@ -0,0 +1,170 @@
-- FiveRP chat — the client half.
-- Speaks the same events the stock chat does (chat:addMessage, chat:addSuggestion
-- and friends), so every resource on the server keeps working untouched. What it
-- adds is the command list: typing a slash shows what actually exists here.
local isOpen = false
local suggestions = {} -- name (lower, no slash) -> { name, params, help }
local function push()
local list = {}
for _, entry in pairs(suggestions) do list[#list + 1] = entry end
SendNUIMessage({ action = 'commands', data = list })
end
local function remember(name, help, params)
local clean = (name or ''):gsub('^/', ''):lower()
if clean == '' then return end
-- Params arrive as a list of { name, help }; flatten to "<a> <b>" for display.
local text = ''
if type(params) == 'table' then
for _, p in ipairs(params) do
text = text .. (text == '' and '' or ' ') .. '<' .. tostring(p.name or '?') .. '>'
end
end
suggestions[clean] = { name = clean, help = help or '', params = text }
end
-- The engine knows every command registered on this client and, once the player
-- has spawned, the server's too. That is the floor: anything registered without
-- a chat suggestion still shows up, just without help text.
local function harvestRegistered()
local ok, list = pcall(GetRegisteredCommands)
if not ok or type(list) ~= 'table' then return end
for _, cmd in ipairs(list) do
local name = (cmd.name or ''):lower()
-- Internal plumbing the player can never usefully type.
if name ~= '' and not name:match('^[+-]') and not name:match('^__') then
if not suggestions[name] then
suggestions[name] = { name = name, help = '', params = '' }
end
end
end
end
-- ------------------------------------------------------------------ events
-- The stock chat's API, reimplemented so nothing else has to change.
AddEventHandler('chat:addSuggestion', function(name, help, params)
remember(name, help, params)
push()
end)
AddEventHandler('chat:addSuggestions', function(list)
for _, s in ipairs(list or {}) do remember(s.name, s.help, s.params) end
push()
end)
AddEventHandler('chat:removeSuggestion', function(name)
suggestions[(name or ''):gsub('^/', ''):lower()] = nil
push()
end)
-- Same rule for the suggestion events: declared net-safe above, handled once by
-- the AddEventHandler pair already registered.
RegisterNetEvent('chat:addSuggestion')
RegisterNetEvent('chat:addSuggestions')
RegisterNetEvent('chat:removeSuggestion')
-- args is the stock shape: { colour, { author, body } } or a plain string.
local function render(...)
local args = { ... }
local payload = args[1]
if type(payload) == 'table' and payload.args then
local a = payload.args
SendNUIMessage({ action = 'message', data = {
author = #a > 1 and a[1] or nil,
body = #a > 1 and a[2] or a[1],
-- Roleplay channels carry a third value (/try's outcome); the renderer
-- decides what to do with it based on kind.
extra = a[3],
kind = payload.kind,
system = payload.system or (#a < 2)
}})
return
end
-- Legacy call: addMessage(colour, { author, body })
local a = args[2]
if type(a) == 'table' then
SendNUIMessage({ action = 'message', data = {
author = #a > 1 and a[1] or nil,
body = #a > 1 and a[2] or a[1],
system = #a < 2
}})
return
end
SendNUIMessage({ action = 'message', data = { body = tostring(payload), system = true } })
end
-- RegisterNetEvent with a function attaches a handler of its own, so pairing it
-- with AddEventHandler ran render twice and every line arrived doubled. Declare
-- the event as net-safe, then attach exactly once.
RegisterNetEvent('chat:addMessage')
AddEventHandler('chat:addMessage', render)
RegisterNetEvent('chat:clear')
AddEventHandler('chat:clear', function() SendNUIMessage({ action = 'clear' }) end)
RegisterNetEvent('chat:commands', function(list)
for _, s in ipairs(list or {}) do remember(s.name, s.help, s.params) end
push()
end)
-- ------------------------------------------------------------------- input
local function openChat()
if isOpen then return end
isOpen = true
harvestRegistered() -- pick up anything registered since the last time
push()
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end
RegisterCommand('fiverp:chat', function() openChat() end, false)
RegisterKeyMapping('fiverp:chat', 'Chat: open', 'keyboard', 't')
RegisterNUICallback('close', function(_, cb)
isOpen = false
SetNuiFocus(false, false)
cb('ok')
end)
RegisterNUICallback('send', function(data, cb)
local text = (data and data.text or ''):gsub('^%s+', ''):gsub('%s+$', '')
if text ~= '' then
if text:sub(1, 1) == '/' then
ExecuteCommand(text:sub(2))
else
TriggerServerEvent('_chat:messageEntered', text)
end
end
cb('ok')
end)
-- Nothing to type into while a menu owns the cursor, and T would otherwise be
-- swallowed by it.
CreateThread(function()
while true do
if isOpen then
DisableControlAction(0, 245, true) -- INPUT_MP_TEXT_CHAT_ALL
Wait(0)
else
Wait(250)
end
end
end)
CreateThread(function()
Wait(2000)
harvestRegistered()
push()
end)
AddEventHandler('onResourceStop', function(name)
if name == GetCurrentResourceName() then SetNuiFocus(false, false) end
end)
+21
View File
@@ -0,0 +1,21 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-chat'
author 'FiveRP'
description 'Chat with live command suggestions'
version '0.1.0'
ui_page 'html/index.html'
client_scripts { 'client/cl_chat.lua' }
server_scripts { 'server/sv_chat.js' }
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+328
View File
@@ -0,0 +1,328 @@
/* FiveRP chat — the renderer.
Owns the log, the input and the command list. It never decides what a command
does; it only knows what exists, so that typing a slash answers "what can I
type here" without the player leaving the game to find out. */
const RES = 'fiverp-chat';
const $ = (id) => document.getElementById(id);
const chat = $('chat');
const log = $('log');
const bar = $('bar');
const input = $('input');
const hintBox = $('hints');
const MAX_LINES = 60;
const LINE_LIFE = 25000; // how long a line stays on screen with the chat closed
const MAX_HINTS = 8;
let commands = []; // [{ name, params, help }]
let matches = [];
let cursor = 0;
let idleTimer = null;
const history = [];
let historyAt = -1;
/* ------------------------------------------------------------------- log */
function escape(text) {
return String(text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Colour codes (^1..^9) are a convention half the ecosystem still emits. We do
// not paint with them — the palette has one accent — but they must not be left
// on screen as literal text.
function stripCodes(text) {
return String(text).replace(/\^[0-9]/g, '');
}
function addLine(msg) {
const kind = msg.kind || (msg.system ? 'system' : 'say');
const line = document.createElement('div');
line.className = 'line line--' + kind + (msg.system ? ' line--system' : '');
const who = msg.author ? `<span class="line__who">${escape(stripCodes(msg.author))}</span>` : '';
const body = escape(stripCodes(msg.body));
const name = escape(stripCodes(msg.author || ''));
let inner;
if (kind === 'me') {
// Third person, because this is what everyone else watches happen.
inner = `<span class="line__body">${name} ${body}</span>`;
} else if (kind === 'do') {
// The scene, with the author in brackets so it is clear who asserted it.
inner = `<span class="line__body">${body} ((${name}))</span>`;
} else if (kind === 'try') {
const failed = msg.extra === 'fails';
inner = `<span class="line__body">${name} ${body}</span>` +
`<span class="line__outcome${failed ? ' is-fail' : ''}">${escape(msg.extra || '')}</span>`;
} else if (kind === 'ooc') {
// Brackets are the convention, and they matter: without them an aside about
// dinner reads as something a character actually said.
inner = who + `<span class="line__body">(( ${body} ))</span>`;
} else {
inner = who + `<span class="line__body">${body}</span>`;
}
line.innerHTML = inner;
log.appendChild(line);
while (log.childElementCount > MAX_LINES) log.removeChild(log.firstElementChild);
// Each line retires on its own clock. Fading the whole log at once meant a
// busy minute left a wall of text sitting there, and the scrollbar with it.
setTimeout(() => {
line.classList.add('is-stale');
window.dispatchEvent(new Event('fiverp:relayout'));
}, LINE_LIFE);
log.scrollTop = log.scrollHeight;
window.dispatchEvent(new Event('fiverp:relayout'));
wake();
}
// The log fades out when nothing has happened for a while, so an idle screen is
// the game rather than a box of old text.
function wake() {
chat.classList.add('is-shown');
clearTimeout(idleTimer);
if (chat.classList.contains('is-open')) return;
idleTimer = setTimeout(() => {
chat.classList.remove('is-shown');
window.dispatchEvent(new Event('fiverp:relayout'));
}, 12000);
}
/* ------------------------------------------------------------ quick keys */
// The channels worth one press. Each one only fills the input: what you write
// still has to be written, and nothing is sent by a single click.
const QUICK = [
{ label: 'Me', insert: '/me ', title: 'Something your character does' },
{ label: 'Do', insert: '/do ', title: 'Something true about the scene' },
{ label: 'Try', insert: '/try ', title: 'Attempt something that can fail' },
{ label: 'Shout', insert: '/s ', title: 'Twice as far as normal' },
{ label: 'Whisper', insert: '/w ', title: 'Only right beside you' },
{ label: 'OOC', insert: '/b ', title: 'Out of character' }
];
function buildQuick() {
const host = $('quick');
host.innerHTML = QUICK.map((q, i) =>
`<button type="button" data-i="${i}" title="${q.title}">${q.label}</button>`).join('');
host.querySelectorAll('button').forEach((el) => {
// mousedown, not click: click fires after blur, and the input losing focus
// mid-press is what makes a chat box feel broken.
el.addEventListener('mousedown', (event) => {
event.preventDefault();
const q = QUICK[Number(el.dataset.i)];
input.value = q.insert;
cursor = 0;
renderHints();
markQuick();
input.focus();
});
});
}
// Highlight whichever channel the line currently belongs to.
function markQuick() {
const value = input.value;
$('quick').querySelectorAll('button').forEach((el) => {
const q = QUICK[Number(el.dataset.i)];
el.classList.toggle('is-on', value.startsWith(q.insert) ||
value.trim() === q.insert.trim());
});
}
buildQuick();
/* -------------------------------------------------------------- commands */
function setCommands(list) {
const seen = new Map();
(list || []).forEach((c) => {
const name = String(c.name || '').replace(/^\//, '').toLowerCase();
if (!name) return;
// Later registrations win: a resource that adds help text should beat the
// bare name we picked up from the engine's own list.
const prev = seen.get(name);
if (prev && !c.help && prev.help) return;
seen.set(name, { name, params: c.params || '', help: c.help || '' });
});
commands = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function typedCommand() {
const value = input.value;
if (!value.startsWith('/')) return null;
// Only while still typing the name itself. Once there is a space the player
// is filling in arguments and a list of other commands is noise.
const head = value.slice(1);
return head.includes(' ') ? null : head.toLowerCase();
}
function renderHints() {
const typed = typedCommand();
bar.classList.toggle('is-command', input.value.startsWith('/'));
if (typed === null) {
hintBox.classList.remove('is-shown');
matches = [];
return;
}
// Empty (just "/") lists everything; otherwise prefix first, then anything
// containing it, so a half-remembered name still finds its command.
const starts = commands.filter((c) => c.name.startsWith(typed));
const contains = typed
? commands.filter((c) => !c.name.startsWith(typed) && c.name.includes(typed))
: [];
matches = starts.concat(contains);
cursor = Math.min(cursor, Math.max(0, matches.length - 1));
hintBox.classList.add('is-shown');
if (!matches.length) {
hintBox.innerHTML =
`<div class="hints__empty">No command starts with “${escape(typed)}”. ` +
`Press / on its own to see all ${commands.length}.</div>`;
return;
}
const shown = matches.slice(0, MAX_HINTS);
hintBox.innerHTML = shown.map((c, i) => {
const hit = c.name.startsWith(typed) ? typed.length : 0;
const head = escape(c.name.slice(0, hit));
const rest = escape(c.name.slice(hit));
return `<div class="hint${i === cursor ? ' is-active' : ''}" data-i="${i}">
<span class="hint__name">/${head}<span class="hint__rest">${rest}</span></span>
${c.params ? `<span class="hint__args">${escape(c.params)}</span>` : ''}
${c.help ? `<span class="hint__help">${escape(c.help)}</span>` : ''}
</div>`;
}).join('') + (matches.length > MAX_HINTS
? `<div class="hints__count">${matches.length - MAX_HINTS} more — keep typing</div>`
: '');
hintBox.querySelectorAll('.hint').forEach((el) => {
el.addEventListener('mousedown', (event) => {
event.preventDefault(); // keep focus in the input
cursor = Number(el.dataset.i);
complete();
});
});
}
function complete() {
const pick = matches[cursor];
if (!pick) return;
input.value = '/' + pick.name + (pick.params ? ' ' : '');
renderHints();
}
/* ----------------------------------------------------------- open / close */
function open() {
chat.classList.add('is-open', 'is-shown');
clearTimeout(idleTimer);
input.value = '';
historyAt = -1;
cursor = 0;
renderHints();
markQuick();
setTimeout(() => input.focus(), 0);
window.dispatchEvent(new Event('fiverp:relayout'));
}
function close(sent) {
chat.classList.remove('is-open');
hintBox.classList.remove('is-shown');
input.value = '';
input.blur();
wake();
post('close', { sent: !!sent });
}
function post(name, data) {
fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
function send() {
const text = input.value.trim();
if (!text) { close(false); return; }
history.unshift(text);
if (history.length > 40) history.pop();
post('send', { text });
close(true);
}
/* ------------------------------------------------------------------ keys */
input.addEventListener('input', () => { cursor = 0; renderHints(); markQuick(); });
input.addEventListener('keydown', (event) => {
const open = hintBox.classList.contains('is-shown') && matches.length;
if (event.key === 'Escape') { close(false); return; }
if (event.key === 'Enter') {
// Enter on a highlighted suggestion completes it rather than sending a
// half-typed command: two keystrokes, no wrong command run by accident.
if (open && typedCommand() && matches[cursor] && matches[cursor].name !== typedCommand()) {
event.preventDefault();
complete();
return;
}
send();
return;
}
if (event.key === 'Tab') {
event.preventDefault();
if (open) complete();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const step = event.key === 'ArrowDown' ? 1 : -1;
if (open) {
event.preventDefault();
const count = Math.min(matches.length, MAX_HINTS);
cursor = (cursor + step + count) % count;
renderHints();
return;
}
// No suggestions on screen, so the arrows walk what you said before.
if (!history.length) return;
event.preventDefault();
historyAt = Math.max(-1, Math.min(history.length - 1, historyAt + step));
input.value = historyAt < 0 ? '' : history[historyAt];
renderHints();
}
});
/* -------------------------------------------------------------- from Lua */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'message') { addLine(msg.data || {}); return; }
if (msg.action === 'open') { open(); return; }
if (msg.action === 'close') { close(false); return; }
if (msg.action === 'clear') { log.innerHTML = ''; return; }
if (msg.action === 'commands') { setCommands(msg.data); renderHints(); return; }
if (msg.action === 'visible') {
chat.style.display = msg.value ? '' : 'none';
}
});
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP Chat</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="chat" class="chat">
<!-- No data-scroll: the log scrolls itself to the bottom and a bar over
the world is one more thing to look at. -->
<div class="log" id="log"></div>
<!-- One press instead of remembering the slash. -->
<div class="quick" id="quick"></div>
<div class="bar" id="bar">
<input id="input" type="text" autocomplete="off" spellcheck="false" maxlength="240"
placeholder="Say something, or start with / for a command">
</div>
<!-- Filled while the input starts with a slash. -->
<div class="hints" id="hints"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+391
View File
@@ -0,0 +1,391 @@
/* FiveRP — chat.
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
Sits over the game, so the page itself stays transparent and only the
panels take a fill. No blur, no shadow, no gradient. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: rgba(24, 24, 24, 1);
--card-bg: rgba(30, 30, 30, 1);
--btn-bg: rgba(51, 51, 51, 1);
--btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--sans: 'Unbounded', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--sans);
user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body { color: var(--text-primary); }
.chat {
position: fixed;
left: 1.6vw;
top: 2.2vh;
width: 25vw;
min-width: 330px;
display: flex;
flex-direction: column;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
/* Reading the log and typing in it are different states. Idle, the log is
visible but faded and the input is gone; open, everything is solid. */
.chat.is-shown { opacity: 0.75; }
.chat.is-open { opacity: 1; }
/* ────────────────────────────────────────────────────────────────── log */
.log {
max-height: 26vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 5px;
/* No gutter: the scrollbar is gone, so nothing needs reserving for it. */
}
/* Retired while the chat is closed; opening it brings the history back. */
.chat:not(.is-open) .line.is-stale { display: none; }
.line {
/* Translucent rather than the solid card fill. A line of chat sits over the
world for a quarter of a minute at a time, and at full opacity it reads as
a panel someone opened rather than as something being said. */
background-color: rgba(30, 30, 30, 0.62);
border: 1px solid rgba(51, 51, 51, 0.35);
border-radius: 12px;
padding: 8px 12px;
font-size: 11.5px;
font-weight: 400;
line-height: 1.45;
word-wrap: break-word;
overflow-wrap: anywhere;
user-select: text;
animation: line-in 0.2s ease both;
}
@keyframes line-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
/* The author is a chip rather than coloured text: one accent, and a name is a
label, not a value. */
.line__who {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--bg);
background-color: var(--accent);
border-radius: 20px;
padding: 3px 9px;
margin-right: 8px;
vertical-align: 1px;
}
/* A message the server sent about the world, not a person saying something. */
.line--system .line__who {
color: var(--text-primary);
background-color: var(--btn-bg);
}
.line--system { color: var(--text-secondary); }
.line__body b { font-weight: 700; }
/* ────────────────────────────────────────────────────── quick actions */
/* The roleplay channels, one press away. They fill the input rather than
sending anything: what you write still has to be written. */
.quick {
display: none;
flex-wrap: wrap;
gap: 5px;
}
.chat.is-open .quick { display: flex; }
.quick button {
border: 1px solid var(--border);
cursor: pointer;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
background-color: rgba(30, 30, 30, 0.88);
border-radius: 20px;
padding: 7px 12px;
transition: all 0.2s ease;
}
.quick button:hover {
background-color: var(--btn-bg);
color: var(--text-primary);
}
.quick button.is-on {
background-color: var(--accent);
color: var(--bg);
}
/* ──────────────────────────────────────────────────────────── input bar */
.bar {
display: none;
align-items: center;
gap: 9px;
background-color: rgba(30, 30, 30, 0.88);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0 14px;
}
.chat.is-open .bar { display: flex; }
/* The slash the player types is the only slash on screen. A decorative one
beside it read as a second character they had not entered. Typing a command
is signalled by the field's edge instead. */
.bar.is-command { border-color: var(--accent); }
.bar input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
padding: 12px 0;
user-select: text;
}
.bar input::placeholder { color: var(--text-secondary); }
/* ─────────────────────────────────────────────────── command suggestions */
.hints {
display: none;
flex-direction: column;
background-color: rgba(30, 30, 30, 0.94);
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px;
max-height: 32vh;
overflow-y: auto;
}
.hints.is-shown { display: flex; }
.hint {
display: flex;
align-items: baseline;
gap: 10px;
border-radius: 12px;
padding: 9px 11px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.hint:hover { background-color: var(--btn-bg); }
.hint.is-active { background-color: var(--btn-bg); }
.hint__name {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.hint.is-active .hint__name { color: var(--accent); }
/* The part of the name still to be typed, so a partial match reads as a
completion rather than as a different command. */
.hint__rest { color: var(--text-secondary); }
.hint__args {
flex-shrink: 0;
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
}
.hint__help {
flex: 1;
min-width: 0;
text-align: right;
font-size: 10px;
font-weight: 400;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hints__empty {
padding: 11px;
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
}
.hints__count {
padding: 6px 11px 8px;
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ─────────────────────────────────────────────────────── custom scrollbar */
.scroll-y {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-y::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.sbar {
position: fixed;
width: 4px;
border-radius: 20px;
background-color: var(--btn-bg);
z-index: 50;
display: none;
}
.sbar__thumb {
display: block;
width: 100%;
border-radius: 20px;
background-color: var(--text-secondary);
transition: background-color 0.2s ease;
}
.sbar:hover .sbar__thumb,
.sbar.is-held .sbar__thumb { background-color: var(--accent); }
body.no-anim *,
body.no-anim *::before,
body.no-anim *::after {
animation: none !important;
transition: none !important;
}
/* ────────────────────────────────────────────────── roleplay channels */
/* Each channel has to be identifiable at a glance, but the palette has one
accent — so they are told apart by shape and weight, not by colour. */
/* /me and /do are narration, not speech: no chip, italic, and a leading rule
so a wall of them does not read as people talking. */
.line--me,
.line--do,
.line--try {
border-left: 2px solid var(--accent);
font-style: italic;
color: var(--text-primary);
}
.line--me .line__who,
.line--do .line__who,
.line--try .line__who {
display: none;
}
.line--do { border-left-color: var(--text-secondary); }
/* The outcome of a /try, which the server decided. */
.line__outcome {
font-style: normal;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 10px;
color: var(--accent);
margin-left: 6px;
}
.line__outcome.is-fail { color: var(--text-secondary); }
/* Out of character is deliberately the quietest thing on screen. */
.line--ooc {
color: var(--text-secondary);
font-size: 11px;
}
.line--ooc .line__who {
color: var(--text-primary);
background-color: var(--btn-bg);
}
/* Distance, shown as weight: a shout is loud, a whisper is barely there. */
.line--shout .line__body {
font-weight: 700;
text-transform: uppercase;
}
.line--whisper {
color: var(--text-secondary);
font-size: 11px;
}
.line--whisper .line__who {
color: var(--text-primary);
background-color: var(--btn-bg);
}
+199
View File
@@ -0,0 +1,199 @@
// FiveRP chat — the server half.
// Turns what a player typed into what everyone in earshot sees. Every channel
// here is proximity-based: the server reads both players' positions itself, so
// range cannot be argued with from a client.
const MAX_LENGTH = 240;
// How far each channel carries, in metres. A number here is the whole
// difference between a private aside and shouting across the street.
const RANGE = {
say: 20.0,
me: 20.0,
do: 20.0,
try: 20.0,
ooc: 20.0,
shout: 45.0,
whisper: 4.0
};
const KNOWN = [
{ name: 'help', help: 'Every command you can use here' },
{ name: 'me', help: 'Something your character does', params: [{ name: 'action' }] },
{ name: 'do', help: 'Something that is true about the scene', params: [{ name: 'detail' }] },
{ name: 'try', help: 'Attempt something — it may fail', params: [{ name: 'action' }] },
{ name: 's', help: 'Shout, twice as far as normal', params: [{ name: 'text' }] },
{ name: 'w', help: 'Whisper, only right beside you', params: [{ name: 'text' }] },
{ name: 'b', help: 'Out of character, in brackets', params: [{ name: 'text' }] },
{ name: 'id', help: "Your own ids, or another player's", params: [{ name: 'id' }] },
{ name: 'players', help: 'Who is online right now' },
{ name: 'balance', help: 'Your cash and your bank' },
{ name: 'pay', help: 'Hand cash to someone within 8 m',
params: [{ name: 'id' }, { name: 'amount' }] },
{ name: 'time', help: 'The server clock and its timezone' },
{ name: 'hud', help: 'Open the HUD settings' },
{ name: 'bank', help: 'Open banking at a teller or an ATM' }
];
function clean(text) {
return String(text || '')
.replace(/\^[0-9]/g, '') // colour codes: not our palette
.replace(/[\x00-\x1F\x7F]/g, '') // control characters
.trim()
.slice(0, MAX_LENGTH);
}
function ask(resource, method, fallback, ...args) {
try {
const target = exports[resource];
const result = target[method](...args);
return result === undefined ? fallback : result;
} catch (err) {
return fallback;
}
}
// Who is speaking, in one lookup.
//
// Not fiverp-characters: its getActiveCharacter export returns the character's
// row id — a number — so reading .firstName off it is undefined, and every
// roleplay line came out under the player's platform name instead. fiverp-ids
// already keeps the character name beside the ids for exactly this.
function identity(src) {
const ids = ask('fiverp-ids', 'getIds', null, Number(src));
return {
name: (ids && ids.name) || null,
id: (ids && ids.dynamicId) || null
};
}
// The name the city knows, for the roleplay channels.
function characterName(src) {
const { name } = identity(src);
return name || GetPlayerName(String(src)) || 'Unknown';
}
// Both halves of who is speaking: the name the city knows them by, and the id
// everyone can see regardless. Before a character is picked there is no name to
// use, so the label falls back to Citizen and keeps the id.
function speakerLabel(src) {
const { name, id } = identity(src);
const who = name || 'Citizen';
return id ? `${who} (${id})` : who;
}
function positionOf(src) {
const ped = GetPlayerPed(String(src));
if (!ped) return null;
const c = GetEntityCoords(ped);
return [c[0], c[1], c[2]];
}
// Everyone within range of the speaker, the speaker included.
// Enumerated by index rather than through getPlayers(), which is a Lua-side
// convenience and not guaranteed to exist in this runtime.
function everyone() {
const out = [];
for (let i = 0; i < GetNumPlayerIndices(); i++) {
out.push(Number(GetPlayerFromIndex(i)));
}
return out;
}
function audience(src, range) {
const from = positionOf(src);
if (!from) return [Number(src)];
return everyone().filter((id) => {
if (id === Number(src)) return true;
const at = positionOf(id);
if (!at) return false;
const dx = at[0] - from[0], dy = at[1] - from[1], dz = at[2] - from[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz) <= range;
});
}
// kind reaches the stylesheet, which is what decides how a line reads.
function broadcast(src, range, payload) {
audience(src, range).forEach((id) => emitNet('chat:addMessage', id, payload));
}
// ------------------------------------------------------------ plain speech
// onNet, not on: this arrives from a client, and the engine refuses to route a
// net event into a plain handler — "was not safe for net", and the message is
// dropped in silence.
onNet('_chat:messageEntered', function (text) {
const src = Number(global.source);
const body = clean(text);
if (!body) return;
broadcast(src, RANGE.say, {
kind: 'say',
args: [speakerLabel(src), body]
});
});
// ------------------------------------------------------------- roleplay
function rpCommand(name, range, build, emptyHint) {
RegisterCommand(name, (source, args) => {
const src = Number(source);
if (!src) return;
const body = clean(args.join(' '));
if (!body) {
emitNet('chat:addMessage', src, {
kind: 'system', args: ['Chat', emptyHint], system: true
});
return;
}
broadcast(src, range, build(src, body));
}, false);
}
// What your character does. Written in the third person because that is how
// everyone else sees it happen.
rpCommand('me', RANGE.me,
(src, body) => ({ kind: 'me', args: [characterName(src), body] }),
'Usage: /me opens the briefcase');
// Something true about the scene that your character cannot say out loud —
// a state of the world rather than an action.
rpCommand('do', RANGE.do,
(src, body) => ({ kind: 'do', args: [characterName(src), body] }),
'Usage: /do the lock is already broken');
// An attempt that can fail. The coin is flipped here, on the server: a client
// that could decide its own outcome would never lose one.
rpCommand('try', RANGE.try, (src, body) => ({
kind: 'try',
args: [characterName(src), body, Math.random() < 0.5 ? 'succeeds' : 'fails']
}), 'Usage: /try to pick the lock');
rpCommand('s', RANGE.shout,
(src, body) => ({ kind: 'shout', args: [speakerLabel(src), body] }),
'Usage: /s over here!');
rpCommand('w', RANGE.whisper,
(src, body) => ({ kind: 'whisper', args: [speakerLabel(src), body] }),
'Usage: /w keep this between us');
// Out of character. Kept visually distinct on purpose: the whole point is that
// it is not something anyone in the world said.
rpCommand('b', RANGE.ooc,
(src, body) => ({ kind: 'ooc', args: [speakerLabel(src), body] }),
'Usage: /b back in a minute');
// ------------------------------------------------------------------ misc
on('playerJoining', () => {
emitNet('chat:commands', Number(global.source), KNOWN);
});
RegisterCommand('help', (source) => {
const src = Number(source);
if (!src) return;
emitNet('chat:addMessage', src, {
kind: 'system', system: true,
args: ['Help', 'Press T then / to see every command, and keep typing to narrow it down.']
});
}, false);
+66
View File
@@ -131,6 +131,58 @@ CreateThread(function()
end end
end) end)
-- -------------------------------------------------------------------- voice
-- Three proximities, cycled with Z. The range is applied to Mumble, so the
-- indicator is not decoration: what it says is what other players can hear.
local VOICE_RANGES = {
{ label = 'Whisper', metres = 3.0 },
{ label = 'Normal', metres = 8.0 },
{ label = 'Shout', metres = 20.0 }
}
local voiceStep = 2 -- start on Normal
local function applyVoiceRange()
local r = VOICE_RANGES[voiceStep]
-- Guarded: a server running without Mumble voice should not lose its HUD.
pcall(function() MumbleSetTalkerProximity(r.metres + 0.0) end)
end
RegisterCommand('fiverp:voiceCycle', function()
if not visible then return end
voiceStep = voiceStep % #VOICE_RANGES + 1
applyVoiceRange()
end, false)
RegisterKeyMapping('fiverp:voiceCycle', 'Voice: change range', 'keyboard', 'z')
CreateThread(function()
applyVoiceRange()
local wasTalking, lastStep = nil, nil
while true do
if visible then
local talking = NetworkIsPlayerTalking(PlayerId())
-- Only post on change: this loop runs fast enough to matter.
if talking ~= wasTalking or voiceStep ~= lastStep then
wasTalking, lastStep = talking, voiceStep
SendNUIMessage({
action = 'voice',
data = {
talking = talking,
range = VOICE_RANGES[voiceStep].label,
step = voiceStep,
steps = #VOICE_RANGES
}
})
end
Wait(100)
else
Wait(400)
end
end
end)
-- ---------------------------------------------------------------- settings -- ---------------------------------------------------------------- settings
RegisterCommand('hud', function() RegisterCommand('hud', function()
if not visible then return end if not visible then return end
@@ -155,6 +207,20 @@ CreateThread(function()
end end
end) end)
-- The pause menu is not the world. NUI keeps drawing over it because it is a
-- separate layer the game does not own, so the HUD has to stand down itself.
CreateThread(function()
local hidden = false
while true do
local paused = IsPauseMenuActive()
if paused ~= hidden then
hidden = paused
SendNUIMessage({ action = 'visible', value = visible and not paused })
end
Wait(150)
end
end)
AddEventHandler('onResourceStop', function(name) AddEventHandler('onResourceStop', function(name)
if name == GetCurrentResourceName() then SetNuiFocus(false, false) end if name == GetCurrentResourceName() then SetNuiFocus(false, false) end
end) end)
+1
View File
@@ -19,6 +19,7 @@ files {
'html/index.html', 'html/index.html',
'html/style.css', 'html/style.css',
'html/app.js', 'html/app.js',
'html/scrollbar.js',
'html/fonts/unbounded-latin.woff2', 'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2', 'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2' 'html/fonts/unbounded-cyrillic.woff2'
+35 -3
View File
@@ -13,11 +13,12 @@ const DEFAULTS = {
status: true, status: true,
money: true, money: true,
vehicle: true, vehicle: true,
voice: true,
seconds: true, // let the clock tick seconds: true, // let the clock tick
numbers: false, // print the value under each status ring numbers: false, // print the value under each status ring
dimFull: true, // fade a need that is comfortably full dimFull: true, // fade a need that is comfortably full
units: 'kmh', units: 'kmh',
place: 'radar', // where the condition rings live place: 'radar', // 'radar' is the column beside the minimap
scale: 100, scale: 100,
opacity: 100 opacity: 100
}; };
@@ -54,6 +55,9 @@ function applyPrefs() {
const status = $('status'); const status = $('status');
status.classList.toggle('at-bottom', prefs.place === 'bottom'); status.classList.toggle('at-bottom', prefs.place === 'bottom');
status.classList.toggle('at-top', prefs.place === 'top'); status.classList.toggle('at-top', prefs.place === 'top');
// With the numbers hidden the glyph should sit in the middle of the ring, not
// above a gap where a number would have been.
status.classList.toggle('show-numbers', !!prefs.numbers);
} }
/* ------------------------------------------------------------ status rings */ /* ------------------------------------------------------------ status rings */
@@ -111,6 +115,23 @@ function setRing(key, value) {
ring.classList.toggle('is-calm', prefs.dimFull && pct >= 95); ring.classList.toggle('is-calm', prefs.dimFull && pct >= 95);
} }
/* --------------------------------------------------------------- the voice */
// The range is not decoration: cl_hud applies it to Mumble, so what the pips
// show is genuinely how far the player carries.
function setVoice(v) {
if (!v) return;
$('voiceRange').textContent = v.range || 'Normal';
$('voice').classList.toggle('is-talking', !!v.talking);
const steps = Number(v.steps) || 3;
const step = Number(v.step) || 1;
const pips = $('voiceSteps');
if (pips.childElementCount !== steps) {
pips.innerHTML = Array.from({ length: steps }, () => '<u></u>').join('');
}
[...pips.children].forEach((el, i) => el.classList.toggle('is-on', i < step));
}
/* --------------------------------------------------------------- the money */ /* --------------------------------------------------------------- the money */
const money = { cash: null, bank: null }; const money = { cash: null, bank: null };
@@ -206,7 +227,8 @@ const CONTROLS = [
{ key: 'server', type: 'switch', label: 'Server card', note: 'Name, clock, players online and your ids' }, { key: 'server', type: 'switch', label: 'Server card', note: 'Name, clock, players online and your ids' },
{ key: 'status', type: 'switch', label: 'Condition', note: 'Armour, hunger, thirst and mood' }, { key: 'status', type: 'switch', label: 'Condition', note: 'Armour, hunger, thirst and mood' },
{ key: 'money', type: 'switch', label: 'Wallet' }, { key: 'money', type: 'switch', label: 'Wallet' },
{ key: 'vehicle', type: 'switch', label: 'Vehicle panel', note: 'Only ever shown while you are in a car' } { key: 'vehicle', type: 'switch', label: 'Vehicle panel', note: 'Only ever shown while you are in a car' },
{ key: 'voice', type: 'switch', label: 'Voice range', note: 'How far you carry. Z changes it' }
]}, ]},
{ group: 'Server card', items: [ { group: 'Server card', items: [
{ key: 'seconds', type: 'switch', label: 'Show seconds' } { key: 'seconds', type: 'switch', label: 'Show seconds' }
@@ -215,7 +237,7 @@ const CONTROLS = [
{ key: 'numbers', type: 'switch', label: 'Show numbers', note: 'Print the percentage under each ring' }, { key: 'numbers', type: 'switch', label: 'Show numbers', note: 'Print the percentage under each ring' },
{ key: 'dimFull', type: 'switch', label: 'Fade what is full', note: 'Quietens needs you do not have to think about' }, { key: 'dimFull', type: 'switch', label: 'Fade what is full', note: 'Quietens needs you do not have to think about' },
{ key: 'place', type: 'segmented', label: 'Position', { key: 'place', type: 'segmented', label: 'Position',
options: [['radar', 'Above radar'], ['bottom', 'Bottom'], ['top', 'Top left']] } options: [['radar', 'Beside radar'], ['bottom', 'Bottom'], ['top', 'Top left']] }
]}, ]},
{ group: 'Vehicle', items: [ { group: 'Vehicle', items: [
{ key: 'units', type: 'segmented', label: 'Speed', options: [['kmh', 'km/h'], ['mph', 'mph']] } { key: 'units', type: 'segmented', label: 'Speed', options: [['kmh', 'km/h'], ['mph', 'mph']] }
@@ -347,8 +369,18 @@ window.addEventListener('message', (event) => {
return; return;
} }
if (msg.action === 'voice') {
setVoice(msg.data);
return;
}
if (msg.action === 'settings') { if (msg.action === 'settings') {
$('settings').classList.toggle('is-open', !!msg.value); $('settings').classList.toggle('is-open', !!msg.value);
// A hidden panel has no rect, so the custom scrollbar can only size itself
// once the panel is actually on screen.
if (msg.value) {
requestAnimationFrame(() => window.dispatchEvent(new Event('fiverp:relayout')));
}
} }
}); });
+19 -2
View File
@@ -31,9 +31,25 @@
</div> </div>
</section> </section>
<!-- the body: only what the game does not already draw --> <!-- bottom left, beside the radar: condition and voice -->
<div class="left-stack">
<section class="status" data-module="status" id="status"></section> <section class="status" data-module="status" id="status"></section>
<section class="voice" data-module="voice" id="voice">
<span class="voice__icon">
<svg viewBox="0 0 24 24">
<rect x="9" y="3" width="6" height="11" rx="3"/>
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0"/>
<path d="M12 18v3"/>
</svg>
</span>
<span class="voice__text">
<b class="voice__range" id="voiceRange">Normal</b>
<i class="voice__steps" id="voiceSteps"></i>
</span>
</section>
</div>
<!-- bottom right: the wallet, and the car when there is one --> <!-- bottom right: the wallet, and the car when there is one -->
<div class="right-stack"> <div class="right-stack">
<section class="money" data-module="money"> <section class="money" data-module="money">
@@ -102,7 +118,7 @@
<button class="close" id="settingsClose" type="button">Done</button> <button class="close" id="settingsClose" type="button">Done</button>
</header> </header>
<div class="settings__body" id="settingsBody"></div> <div class="settings__body scroll-y" id="settingsBody" data-scroll></div>
<footer class="settings__foot"> <footer class="settings__foot">
<button class="ghost" id="settingsReset" type="button">Reset to defaults</button> <button class="ghost" id="settingsReset" type="button">Reset to defaults</button>
@@ -112,5 +128,6 @@
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
<script src="scrollbar.js"></script>
</body> </body>
</html> </html>
+123
View File
@@ -0,0 +1,123 @@
/* A scrollbar we actually own.
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
drawing a stepper stub at each end whatever display, size or background it is
given. So the native bar is hidden outright and this draws the replacement.
Attach with data-scroll on any scrolling element. The bar is position:fixed
and measured from the element's own rect, so it needs no particular ancestor
and works inside a modal that moves. */
(() => {
const MIN_THUMB = 28;
function attach(host) {
const bar = document.createElement('div');
bar.className = 'sbar';
const thumb = document.createElement('i');
thumb.className = 'sbar__thumb';
bar.appendChild(thumb);
document.body.appendChild(bar);
let dragging = false;
let grabOffset = 0;
// The bar is appended to the body, not to the host, so nothing about the
// host being faded out reaches it. Walk the ancestors and hide with them —
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
function hostVisible() {
let el = host;
while (el && el !== document.body) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (Number(style.opacity) < 0.05) return false;
el = el.parentElement;
}
return true;
}
function layout() {
const box = host.getBoundingClientRect();
const scrollable = host.scrollHeight - host.clientHeight;
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
// control that does nothing.
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
bar.style.display = 'none';
return;
}
bar.style.display = 'block';
bar.style.top = box.top + 'px';
bar.style.height = box.height + 'px';
bar.style.left = (box.right - 12) + 'px';
const ratio = host.clientHeight / host.scrollHeight;
const h = Math.max(MIN_THUMB, box.height * ratio);
const travel = box.height - h;
const progress = host.scrollTop / scrollable;
thumb.style.height = h + 'px';
thumb.style.transform = `translateY(${travel * progress}px)`;
}
function onDrag(event) {
if (!dragging) return;
const box = host.getBoundingClientRect();
const h = thumb.getBoundingClientRect().height;
const travel = box.height - h;
if (travel <= 0) return;
const wanted = event.clientY - box.top - grabOffset;
const progress = Math.max(0, Math.min(1, wanted / travel));
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
}
thumb.addEventListener('pointerdown', (event) => {
dragging = true;
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
bar.classList.add('is-held');
event.preventDefault();
});
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
// wide target mid-drag and the drag has to survive that.
window.addEventListener('pointermove', onDrag);
window.addEventListener('pointerup', () => {
dragging = false;
bar.classList.remove('is-held');
});
// Clicking the empty track jumps a page, the way a real scrollbar does.
bar.addEventListener('pointerdown', (event) => {
if (event.target === thumb) return;
const box = thumb.getBoundingClientRect();
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
});
host.addEventListener('scroll', layout);
window.addEventListener('resize', layout);
// The panel is rebuilt (settings reset, tab switch) without the element ever
// being replaced, so the size has to be re-read when its contents change.
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
layout();
return layout;
}
const layouts = [];
function scan() {
document.querySelectorAll('[data-scroll]').forEach((el) => {
if (el.dataset.scrollBound) return;
el.dataset.scrollBound = '1';
layouts.push(attach(el));
});
}
// Re-measure when a panel is opened: a hidden element has no useful rect.
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
scan();
}
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
-- FiveRP interact — hold-to-act markers.
-- One shared system so every interaction on the server behaves the same way:
-- a dial appears on the thing, you hold E, a ring fills, and only when it is
-- full does anything happen. A tap does nothing on purpose — nothing important
-- should be one twitch away.
--
-- exports['fiverp-interact']:add({
-- id = 'atm:14',
-- coords = vector3(...), -- or entity = pedHandle
-- label = 'ATM',
-- distance = 1.6, -- how close before it can be used
-- hold = 600, -- ms
-- onSelect = function() ... end
-- })
-- exports['fiverp-interact']:remove('atm:14')
local points = {} -- id -> point
local order = {} -- stable iteration, so ties resolve the same way
local active = nil -- id under the cursor right now
local holding = false
local holdStart = 0
local lastX, lastY = -1, -1
local SHOW_RANGE = 12.0 -- how far away the dial appears at all
local KEY_E = 38 -- INPUT_CONTEXT
local function coordsOf(point)
if point.entity and DoesEntityExist(point.entity) then
return GetEntityCoords(point.entity)
end
return point.coords
end
-- ------------------------------------------------------------------ exports
function AddPoint(spec)
if not spec or not spec.id then return end
if not points[spec.id] then order[#order + 1] = spec.id end
points[spec.id] = {
id = spec.id,
coords = spec.coords,
entity = spec.entity,
label = spec.label or '',
key = spec.key or 'E',
distance = spec.distance or 2.0,
hold = spec.hold or 600,
onSelect = spec.onSelect,
canUse = spec.canUse
}
end
function RemovePoint(id)
if not points[id] then return end
points[id] = nil
for i, key in ipairs(order) do
if key == id then table.remove(order, i) break end
end
-- If the point under the cursor is the one going away, the marker has to be
-- told. Clearing `active` on its own left the dial drawn at its last screen
-- position with nothing left to update it — which is why cutting a bush
-- pinned an E to the middle of the screen and it stayed there.
if active == id then
active = nil
holding = false
lastX, lastY = -1, -1
SendNUIMessage({ action = 'release', done = false })
SendNUIMessage({ action = 'hide' })
end
end
exports('add', function(spec) AddPoint(spec) end)
exports('remove', function(id) RemovePoint(id) end)
exports('isBusy', function() return holding end)
-- ------------------------------------------------------------------- search
-- The closest point that is in range, in front of the camera, and willing.
local function pick(me)
local best, bestDist = nil, SHOW_RANGE
for _, id in ipairs(order) do
local point = points[id]
if point then
local at = coordsOf(point)
if at then
local d = #(me - at)
if d < bestDist then
if not point.canUse or point.canUse() then
best, bestDist = point, d
end
end
end
end
end
return best, bestDist
end
-- ------------------------------------------------------------------- render
local function send(msg) SendNUIMessage(msg) end
local function stopHold(done)
if not holding then return end
holding = false
send({ action = 'release', done = done })
end
local function clear()
if active then
stopHold(false)
active = nil
lastX, lastY = -1, -1
send({ action = 'hide' })
end
end
CreateThread(function()
while true do
local wait = 250
local ped = PlayerPedId()
-- Nothing to point at while dead, in a car, or with a menu holding focus.
if IsPedDeadOrDying(ped, true) or IsPedInAnyVehicle(ped, false) or IsNuiFocused() then
clear()
else
local me = GetEntityCoords(ped)
local point, dist = pick(me)
if not point then
clear()
else
wait = 0
local at = coordsOf(point)
local onScreen, sx, sy = GetScreenCoordFromWorldCoord(at.x, at.y, at.z)
if not onScreen then
clear()
else
if active ~= point.id then
stopHold(false)
active = point.id
end
local w, h = GetActiveScreenResolution()
local px = math.floor(sx * w)
local py = math.floor(sy * h)
-- Only post when it actually moved: this runs every frame and the
-- NUI does not need a message that changes nothing.
if px ~= lastX or py ~= lastY then
lastX, lastY = px, py
send({
action = 'show',
x = px, y = py,
label = point.label,
key = point.key,
near = dist <= point.distance
})
end
-- Close enough to act, so the key starts meaning something.
if dist <= point.distance then
if IsControlPressed(0, KEY_E) then
if not holding then
holding = true
holdStart = GetGameTimer()
send({ action = 'hold', ms = point.hold })
elseif GetGameTimer() - holdStart >= point.hold then
stopHold(true)
-- Cleared before the callback: opening a menu takes focus, and
-- the marker should already be gone by then.
local fn = point.onSelect
clear()
if fn then fn(point.id) end
Wait(250)
end
else
stopHold(false)
end
else
stopHold(false)
end
end
end
end
Wait(wait)
end
end)
AddEventHandler('onResourceStop', function(name)
if name == GetCurrentResourceName() then send({ action = 'hide' }) end
end)
+20
View File
@@ -0,0 +1,20 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-interact'
author 'FiveRP'
description 'Hold-to-interact markers'
version '0.1.0'
ui_page 'html/index.html'
client_scripts { 'client/cl_interact.lua' }
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+76
View File
@@ -0,0 +1,76 @@
/* FiveRP interact — the marker.
Draws one dial at a screen position Lua projected for it. It measures nothing
and decides nothing: the ring's fill is a CSS transition whose duration Lua
sets, and Lua times the real hold itself, so what the ring shows and what the
game accepts cannot drift apart. */
const $ = (id) => document.getElementById(id);
const marker = $('marker');
const arc = $('arc');
const label = $('label');
const key = $('key');
let visible = false;
function place(msg) {
marker.style.left = msg.x + 'px';
marker.style.top = msg.y + 'px';
if (msg.label !== undefined && label.textContent !== msg.label) {
label.textContent = msg.label;
}
if (msg.key !== undefined && key.textContent !== msg.key) {
key.textContent = msg.key;
}
marker.classList.toggle('is-near', !!msg.near);
if (!visible) {
visible = true;
marker.classList.add('is-shown');
}
}
function hide() {
if (!visible) return;
visible = false;
marker.classList.remove('is-shown', 'is-holding', 'is-done');
reset();
}
function reset() {
arc.classList.remove('is-filling');
arc.classList.add('is-reset');
// Force the browser to apply the reset before any new transition starts,
// otherwise the next fill animates from wherever the last one stopped.
void arc.offsetWidth;
arc.classList.remove('is-reset');
}
function startHold(ms) {
reset();
marker.classList.add('is-holding');
marker.classList.remove('is-done');
arc.style.setProperty('--hold', ms + 'ms');
// Same reason as above: the class has to land on a fresh layout.
requestAnimationFrame(() => arc.classList.add('is-filling'));
}
function endHold(done) {
marker.classList.remove('is-holding');
if (done) {
marker.classList.add('is-done');
setTimeout(() => marker.classList.remove('is-done'), 220);
}
reset();
}
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'show') { place(msg); return; }
if (msg.action === 'hide') { hide(); return; }
if (msg.action === 'hold') { startHold(msg.ms || 600); return; }
if (msg.action === 'release') { endHold(!!msg.done); }
});
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP Interact</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="marker" class="marker">
<div class="dial">
<svg viewBox="0 0 44 44">
<circle class="dial__track" cx="22" cy="22" r="19"></circle>
<circle class="dial__fill" id="arc" cx="22" cy="22" r="19"></circle>
</svg>
<span class="dial__key" id="key">E</span>
</div>
<span class="marker__label" id="label"></span>
</div>
<script src="app.js"></script>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
/* FiveRP — chat.
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
Sits over the game, so the page itself stays transparent and only the
panels take a fill. No blur, no shadow, no gradient. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: rgba(24, 24, 24, 1);
--card-bg: rgba(30, 30, 30, 1);
--btn-bg: rgba(51, 51, 51, 1);
--btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--sans: 'Unbounded', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--sans);
user-select: none;
pointer-events: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body { color: var(--text-primary); }
/* The marker lives at a projected world point, so Lua owns left/top and this
file owns everything about how it looks. translate(-50%,-50%) keeps the dial
centred on the point rather than hanging off it. */
.marker {
position: fixed;
left: 0;
top: 0;
display: none;
flex-direction: column;
align-items: center;
gap: 8px;
transform: translate(-50%, -50%);
}
.marker.is-shown { display: flex; }
/* ────────────────────────────────────────────────────────────────── dial */
.dial {
position: relative;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
/* Scale is the only motion: a marker that slides would fight the world point
it is supposed to be pinned to. */
transform: scale(0.86);
transition: transform 0.2s ease;
}
.marker.is-near .dial { transform: scale(1); }
.dial svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.dial svg circle {
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
.dial__track { stroke: var(--btn-bg); }
/* r=19 in a 44 box: circumference 119.38. The arc is drawn by moving the dash
offset from full to zero, and the duration is set from Lua so the ring and
the hold it represents always take the same time. */
.dial__fill {
stroke: var(--accent);
stroke-dasharray: 119.38;
stroke-dashoffset: 119.38;
}
.dial__fill.is-filling {
stroke-dashoffset: 0;
transition: stroke-dashoffset linear;
transition-duration: var(--hold, 600ms);
}
/* Released early: snap back rather than unwind, so a cancelled hold reads as
cancelled and not as a second, slower interaction. */
.dial__fill.is-reset {
stroke-dashoffset: 119.38;
transition: none;
}
/* The disc behind the letter. Solid, because the world behind it is arbitrary
and a letter on grass has to stay readable. */
.dial::before {
content: '';
position: absolute;
inset: 5px;
border-radius: 50%;
background-color: var(--card-bg);
border: 1px solid var(--border);
}
.dial__key {
position: relative;
font-size: 15px;
font-weight: 900;
line-height: 1;
color: var(--text-primary);
transition: color 0.2s ease;
}
.marker.is-holding .dial__key { color: var(--accent); }
/* Held long enough: the whole dial goes accent for a beat so the press is
acknowledged even if the menu takes a moment to open. */
.marker.is-done .dial::before {
background-color: var(--accent);
border-color: var(--accent);
}
.marker.is-done .dial__key { color: var(--bg); }
/* ───────────────────────────────────────────────────────────────── label */
.marker__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-primary);
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 12px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s ease;
}
/* The label only earns its space once you are close enough to act. */
.marker.is-near .marker__label { opacity: 1; }
@@ -0,0 +1,318 @@
-- FiveRP inventory — the client half.
-- Opens the panel, forwards what the player did, and draws the piles lying on
-- the ground. It stores nothing: the state it renders arrives from the server
-- whole, every time, so there is no local copy that can drift out of step.
-- The registry as the server knows it, which is the merged one: shared/items.lua
-- plus whatever other resources registered. ItemDef() alone would only ever see
-- this resource's own half.
local registry = {}
local function defOf(id)
return registry[id] or ItemDef(id)
end
local isOpen = false
local inWorld = false -- spawned, as opposed to still on the slot screen
local groundProps = {} -- dropId -> entity
-- ------------------------------------------------------------------ panel
local function close()
if not isOpen then return end
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
local function open()
if isOpen then return end
if IsPedDeadOrDying(PlayerPedId(), true) then return end
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
TriggerServerEvent('fiverp-inventory:request')
end
RegisterCommand('fiverp:inventory', function()
if isOpen then close() else open() end
end, false)
RegisterKeyMapping('fiverp:inventory', 'Inventory: open', 'keyboard', 'i')
RegisterCommand('inventory', function() open() end, false)
TriggerEvent('chat:addSuggestion', '/inventory', 'Open your bag')
-- ------------------------------------------------------------------ worn
-- The five worn slots drive real ped components. Reading them is how the
-- inventory learns what the creator put on the character; writing them is what
-- makes taking a shirt off visible on the body.
local function isFemale(ped)
return GetEntityModel(ped) == GetHashKey('mp_f_freemode_01')
end
local function bareFor(ped, component)
local set = BARE[isFemale(ped) and 'female' or 'male']
return set and set[component] or 0
end
-- What is on the body right now, in the shape the server stores.
local function readWorn()
local ped = PlayerPedId()
local out = {}
for slot, map in pairs(WEAR_COMPONENTS) do
if map.prop then
local drawable = GetPedPropIndex(ped, map.index)
-- -1 is "no prop", which is an empty slot rather than a hat of index -1.
if drawable >= 0 then
out[slot] = { drawable = drawable, texture = GetPedPropTextureIndex(ped, map.index) }
end
else
local drawable = GetPedDrawableVariation(ped, map.index)
local bare = bareFor(ped, map.index)
-- Wearing the bare drawable means wearing nothing, so there is no
-- garment to adopt: otherwise every character would start with an
-- "Undershirt" that is actually their skin.
if drawable ~= bare then
local piece = {
drawable = drawable,
texture = GetPedTextureVariation(ped, map.index)
}
if map.also then
piece.arms = GetPedDrawableVariation(ped, map.also)
piece.armsTexture = GetPedTextureVariation(ped, map.also)
end
out[slot] = piece
end
end
end
return out
end
-- Put the body into the state the rows describe. Every slot is written, not
-- only the filled ones: an empty slot has to actively become bare skin, or
-- removing a shirt would change a row and nothing else.
local function applyWorn(wear)
-- Not while the creator owns the ped: it is mid-dress there, and writing
-- components underneath it would fight the preview.
if not inWorld then return end
local ped = PlayerPedId()
if not DoesEntityExist(ped) then return end
for slot, map in pairs(WEAR_COMPONENTS) do
local row = wear and wear[slot]
local meta = row and row.meta
if map.prop then
if meta and meta.drawable and meta.drawable >= 0 then
SetPedPropIndex(ped, map.index, meta.drawable, meta.texture or 0, true)
else
ClearPedProp(ped, map.index)
end
elseif meta and meta.drawable then
SetPedComponentVariation(ped, map.index, meta.drawable, meta.texture or 0, 0)
if map.also and meta.arms then
SetPedComponentVariation(ped, map.also, meta.arms, meta.armsTexture or 0, 0)
end
elseif row then
-- Something is in the slot but it does not say what it looks like. Leave
-- the body alone: stripping it would be acting on a row we cannot read,
-- and the player would watch their clothes vanish for no visible reason.
else
SetPedComponentVariation(ped, map.index, bareFor(ped, map.index), 0, 0)
if map.also then
SetPedComponentVariation(ped, map.also, bareFor(ped, map.also), 0, 0)
end
end
end
end
RegisterNetEvent('fiverp-inventory:registry', function(items)
registry = items or {}
end)
RegisterNetEvent('fiverp-inventory:state', function(state)
if state and state.items then registry = state.items end
SendNUIMessage({ action = 'state', data = state })
-- The rows are the truth about what is worn, so the body follows them rather
-- than the other way round.
if state and state.wear then applyWorn(state.wear) end
end)
RegisterNetEvent('fiverp-inventory:refused', function(payload)
SendNUIMessage({ action = 'refused', data = payload })
end)
RegisterNUICallback('close', function(_, cb) close(); cb('ok') end)
RegisterNUICallback('move', function(data, cb)
TriggerServerEvent('fiverp-inventory:move', {
fromContainer = data.fromContainer, fromSlot = data.fromSlot,
toContainer = data.toContainer, toSlot = data.toSlot
})
cb('ok')
end)
-- Wearing something is not doing something: it happens on the spot and the
-- panel stays open, because the next thing you do is usually to the slot beside
-- it.
RegisterNUICallback('toggleWear', function(data, cb)
TriggerServerEvent('fiverp-inventory:toggleWear', data.container, data.slot)
cb('ok')
end)
RegisterNUICallback('use', function(data, cb)
-- Using something is doing something, and doing it happens in the world.
-- Closing also removes the way to press the same item twenty times while the
-- first press is still playing out.
close()
TriggerServerEvent('fiverp-inventory:use', data.container, data.slot)
cb('ok')
end)
RegisterNUICallback('drop', function(data, cb)
TriggerServerEvent('fiverp-inventory:drop', data.container, data.slot, data.count)
cb('ok')
end)
-- ----------------------------------------------------------------- ground
-- A dropped item is a real object in the world, which is the whole point of the
-- model field in the registry. No model, no prop — the interact marker still
-- appears, so nothing becomes unrecoverable because an entry is incomplete.
-- Anything dropped has to be visible. An item with no model of its own, or one
-- whose model turns out not to exist, becomes a small box.
--
-- Small is checked rather than assumed: the first pick here was a shipping
-- crate taller than the player standing next to it. GetModelDimensions gives
-- the bounding box, so a model that is nothing like the size of a dropped
-- object can be rejected instead of discovered in a screenshot.
local FALLBACK_MODELS = {
'prop_cs_cardbox_01',
'prop_paper_bag_01',
'prop_food_bs_bag_01',
'prop_cs_box_clothes',
'prop_box_ammo01a'
}
local MAX_DROP_SIZE = 0.75 -- metres on the longest side
local function modelFits(hash)
local min, max = GetModelDimensions(hash)
if not min or not max then return true end
local size = max - min
return math.max(size.x, size.y, size.z) <= MAX_DROP_SIZE
end
local function tryModel(name, checkSize)
if not name then return nil end
local hash = GetHashKey(name)
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
RequestModel(hash)
local timeout = GetGameTimer() + 4000
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
if not HasModelLoaded(hash) then return nil end
if checkSize and not modelFits(hash) then
SetModelAsNoLongerNeeded(hash)
return nil
end
return hash
end
local function spawnProp(drop)
local def = defOf(drop.item)
-- The item's own model is taken as authored, whatever its size; the fallbacks
-- are size-checked because those are ours to get wrong.
local hash = tryModel(def and def.model, false)
if not hash then
for _, name in ipairs(FALLBACK_MODELS) do
hash = tryModel(name, true)
if hash then break end
end
end
if not hash then return end
local prop = CreateObject(hash, drop.coords[1], drop.coords[2], drop.coords[3], false, false, false)
PlaceObjectOnGroundProperly(prop)
FreezeEntityPosition(prop, true)
SetEntityCollision(prop, false, false)
SetModelAsNoLongerNeeded(hash)
groundProps[drop.id] = prop
end
local function addGround(drop)
if not drop or not drop.id then return end
spawnProp(drop)
local def = defOf(drop.item)
local label = def and def.label or drop.item
if drop.count > 1 then label = label .. ' x' .. drop.count end
exports['fiverp-interact']:add({
id = 'drop:' .. drop.id,
coords = vector3(drop.coords[1], drop.coords[2], drop.coords[3] + 0.35),
label = label,
distance = 1.8,
hold = 400,
onSelect = function()
TriggerServerEvent('fiverp-inventory:pickup', drop.id)
end
})
end
local function removeGround(id)
exports['fiverp-interact']:remove('drop:' .. id)
local prop = groundProps[id]
if prop and DoesEntityExist(prop) then DeleteEntity(prop) end
groundProps[id] = nil
end
RegisterNetEvent('fiverp-inventory:groundAdd', function(drop)
removeGround(drop.id) -- a partial pickup re-sends the same id
addGround(drop)
end)
RegisterNetEvent('fiverp-inventory:groundGone', function(id) removeGround(id) end)
RegisterNetEvent('fiverp-inventory:groundAll', function(list)
for _, drop in ipairs(list or {}) do addGround(drop) end
end)
-- ----------------------------------------------------------------- wiring
local function onSpawn()
inWorld = true
-- A beat after the spawn: applyAppearance runs in the same frame and reading
-- the ped before it has finished would adopt whatever it was wearing first.
CreateThread(function()
Wait(2500)
TriggerServerEvent('fiverp-inventory:adoptWorn', readWorn())
TriggerServerEvent('fiverp-inventory:request')
TriggerServerEvent('fiverp-inventory:groundRequest')
end)
end
AddEventHandler('fiverp-chars:spawn', onSpawn)
RegisterNetEvent('fiverp-chars:spawn', onSpawn)
-- Back to the menus: the bag must not sit on top of them.
local function onBegin()
inWorld = false
close()
end
AddEventHandler('fiverp-chars:begin', onBegin)
RegisterNetEvent('fiverp-chars:begin', onBegin)
AddEventHandler('onResourceStop', function(name)
if name ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
for id in pairs(groundProps) do removeGround(id) end
end)
+33
View File
@@ -0,0 +1,33 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-inventory'
author 'FiveRP'
description 'Backpack, worn clothing and what ends up on the pavement'
version '0.1.0'
dependency 'fiverp-interact'
ui_page 'html/index.html'
shared_script 'shared/items.lua'
-- items.js first: the runtime has no require(), so order is how these two share
-- anything at all.
server_scripts {
'shared/items.js',
'server/sv_inventory.js'
}
client_scripts { 'client/cl_inventory.lua' }
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/scrollbar.js',
'html/images/*.png',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+281
View File
@@ -0,0 +1,281 @@
/* FiveRP inventory — the panel.
It draws whatever state the server last sent and asks for changes. It never
applies one locally: a slot that moved on screen before the server agreed
would be a slot the player believes in and the world does not. */
const RES = 'fiverp-inventory';
const $ = (id) => document.getElementById(id);
let state = {
bag: {}, wear: {}, items: {},
weight: 0, maxWeight: 30, slots: 16,
wearSlots: ['hat', 'top', 'undershirt', 'pants', 'shoes']
};
const WEAR_LABELS = {
hat: 'Headwear',
top: 'Outerwear',
undershirt: 'Undershirt',
pants: 'Trousers',
shoes: 'Shoes'
};
let drag = null; // { container, slot, item }
function post(name, data) {
fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
function escape(text) {
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function def(id) {
return state.items[id] || null;
}
// Two letters of the label, which is what stands in until an item has artwork.
function initials(id) {
const d = def(id);
const label = (d && d.label) || id || '?';
const words = label.split(/\s+/).filter(Boolean);
return (words.length > 1 ? words[0][0] + words[1][0] : label.slice(0, 2)).toUpperCase();
}
function rowAt(container, slot) {
const box = state[container];
return (box && box[String(slot)]) || null;
}
/* --------------------------------------------------------------- drawing */
function slotMarkup(container, slot, row) {
const d = row ? def(row.item) : null;
const name = d ? d.label : (row ? row.item : '');
const art = row
? (d && d.image
? `<img src="images/${escape(d.image)}" alt="">`
: `<span class="slot__initials">${escape(initials(row.item))}</span>`)
: '';
const count = row && row.count > 1
? `<span class="slot__count">${row.count}</span>` : '';
const weight = row && d
? `<span class="slot__weight">${(d.weight * row.count).toFixed(1)}</span>` : '';
const label = row
? `<span class="slot__name">${escape(name)}</span>`
: (container === 'wear'
? `<span class="slot__placeholder">${escape(WEAR_LABELS[slot] || slot)}</span>`
: '');
const title = row && d && d.description
? ` title="${escape(d.label)} — ${escape(d.description)}"` : '';
return `<div class="slot${row ? ' has-item' : ''}"
data-container="${container}" data-slot="${slot}"${title}>
<div class="slot__art">${art}</div>
${label}${count}${weight}
</div>`;
}
function draw() {
$('bag').innerHTML = Array.from({ length: state.slots }, (_, i) =>
slotMarkup('bag', i, rowAt('bag', i))).join('');
$('wear').innerHTML = state.wearSlots.map((slot) =>
slotMarkup('wear', slot, rowAt('wear', slot))).join('');
const pct = Math.min(100, (state.weight / state.maxWeight) * 100);
$('weight').textContent = Number(state.weight).toFixed(1);
$('maxWeight').textContent = state.maxWeight;
$('loadFill').style.width = pct + '%';
$('loadFill').classList.toggle('is-heavy', pct >= 85);
bindSlots();
}
/* ------------------------------------------------------------- dragging */
function markTargets(on) {
if (!on || !drag) {
document.querySelectorAll('.slot').forEach((el) =>
el.classList.remove('is-blocked', 'is-target'));
return;
}
const d = def(drag.item);
document.querySelectorAll('.slot').forEach((el) => {
if (el.dataset.container !== 'wear') return;
// A clothing slot that cannot take this item is dimmed up front, rather
// than accepting the drop and bouncing back when the server refuses.
const fits = d && d.wear && d.wear === el.dataset.slot;
el.classList.toggle('is-blocked', !fits);
});
}
function startDrag(el, event) {
const container = el.dataset.container;
const slot = el.dataset.slot;
const row = rowAt(container, slot);
if (!row) return;
drag = { container, slot, item: row.item };
el.classList.add('is-dragging');
// The ghost carries the item's picture, not its initials. Dragging a thing
// you cannot see is dragging a rectangle, and the slot it came from is
// already dimmed — there is nothing else on screen saying what is in hand.
const ghost = $('ghost');
const d = def(row.item);
ghost.innerHTML = d && d.image
? `<img src="images/${escape(d.image)}" alt="">`
: escape(initials(row.item));
ghost.classList.add('is-shown');
moveGhost(event);
markTargets(true);
}
function moveGhost(event) {
const ghost = $('ghost');
ghost.style.left = event.clientX + 'px';
ghost.style.top = event.clientY + 'px';
}
function endDrag(event) {
if (!drag) return;
const ghost = $('ghost');
ghost.classList.remove('is-shown');
document.querySelectorAll('.slot').forEach((el) => el.classList.remove('is-dragging'));
$('dropzone').classList.remove('is-target');
const under = document.elementFromPoint(event.clientX, event.clientY);
const target = under && under.closest('.slot');
const zone = under && under.closest('#dropzone');
if (zone) {
post('drop', { container: drag.container, slot: drag.slot });
} else if (target && !target.classList.contains('is-blocked')) {
const toContainer = target.dataset.container;
const toSlot = target.dataset.slot;
if (toContainer !== drag.container || toSlot !== drag.slot) {
post('move', {
fromContainer: drag.container, fromSlot: drag.slot,
toContainer, toSlot
});
}
}
drag = null;
markTargets(false);
}
function bindSlots() {
document.querySelectorAll('.slot').forEach((el) => {
el.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
startDrag(el, event);
});
// Right-click and double-click both mean "use", because both are what
// people already try.
el.addEventListener('contextmenu', (event) => {
event.preventDefault();
use(el);
});
el.addEventListener('dblclick', () => use(el));
});
}
function use(el) {
const container = el.dataset.container;
const slot = el.dataset.slot;
const row = rowAt(container, slot);
if (!row) return;
const d = def(row.item);
// Clothing is put on where it lies and the panel stays; anything else is
// used, which happens out in the world, so the panel gets out of the way.
if (d && d.wear) {
post('toggleWear', { container, slot });
return;
}
post('use', { container, slot });
}
document.addEventListener('mousemove', (event) => {
if (!drag) return;
moveGhost(event);
const under = document.elementFromPoint(event.clientX, event.clientY);
const target = under && under.closest('.slot');
const zone = under && under.closest('#dropzone');
document.querySelectorAll('.slot').forEach((el) =>
el.classList.toggle('is-target', el === target && !el.classList.contains('is-blocked')));
$('dropzone').classList.toggle('is-target', !!zone);
});
document.addEventListener('mouseup', endDrag);
/* ---------------------------------------------------------------- notice */
let noticeTimer = null;
function notice(text) {
const el = $('notice');
el.textContent = text || '';
el.classList.toggle('is-shown', !!text);
clearTimeout(noticeTimer);
if (text) noticeTimer = setTimeout(() => el.classList.remove('is-shown'), 2600);
}
/* ----------------------------------------------------------- open, close */
function close() {
$('root').classList.remove('is-open');
post('close', {});
}
$('close').addEventListener('click', close);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' || event.key.toLowerCase() === 'i') close();
});
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'open') {
$('root').classList.add('is-open');
notice('');
return;
}
if (msg.action === 'close') {
$('root').classList.remove('is-open');
return;
}
if (msg.action === 'state') {
state = Object.assign(state, msg.data || {});
draw();
return;
}
if (msg.action === 'refused') {
notice((msg.data && msg.data.message) || 'That did not work');
}
});
draw();
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP — Inventory</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root" class="root">
<div class="panel">
<header class="head">
<div>
<p class="eyebrow">Carrying</p>
<h1 class="title">Inventory</h1>
</div>
<div class="load">
<div class="load__top">
<span class="load__value" id="weight">0.0</span>
<span class="load__max">/ <b id="maxWeight">30</b> kg</span>
</div>
<div class="load__track"><i class="load__fill" id="loadFill"></i></div>
</div>
<button class="close" id="close" type="button">Close</button>
</header>
<div class="body">
<!-- worn -->
<section class="wear">
<p class="group">Worn</p>
<div class="wear__slots" id="wear"></div>
</section>
<!-- backpack -->
<section class="bag">
<p class="group">Bag</p>
<div class="bag__grid" id="bag"></div>
</section>
</div>
<footer class="foot">
<p class="hint">
Drag to move. Double-click or right-click to use.
Drag onto the ground strip to drop.
</p>
<div class="dropzone" id="dropzone">
<span>Drop here</span>
</div>
</footer>
<p class="notice" id="notice"></p>
</div>
</div>
<!-- Follows the cursor while dragging, so the slot itself never moves. -->
<div class="ghost" id="ghost"></div>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,123 @@
/* A scrollbar we actually own.
CEF will not let the native one go quietly: ::-webkit-scrollbar-button keeps
drawing a stepper stub at each end whatever display, size or background it is
given. So the native bar is hidden outright and this draws the replacement.
Attach with data-scroll on any scrolling element. The bar is position:fixed
and measured from the element's own rect, so it needs no particular ancestor
and works inside a modal that moves. */
(() => {
const MIN_THUMB = 28;
function attach(host) {
const bar = document.createElement('div');
bar.className = 'sbar';
const thumb = document.createElement('i');
thumb.className = 'sbar__thumb';
bar.appendChild(thumb);
document.body.appendChild(bar);
let dragging = false;
let grabOffset = 0;
// The bar is appended to the body, not to the host, so nothing about the
// host being faded out reaches it. Walk the ancestors and hide with them —
// otherwise a chat that has quietly gone away leaves its scrollbar behind.
function hostVisible() {
let el = host;
while (el && el !== document.body) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (Number(style.opacity) < 0.05) return false;
el = el.parentElement;
}
return true;
}
function layout() {
const box = host.getBoundingClientRect();
const scrollable = host.scrollHeight - host.clientHeight;
// Nothing to scroll, or nothing to scroll it on: either way the bar is a
// control that does nothing.
if (scrollable <= 1 || box.height <= 0 || !hostVisible()) {
bar.style.display = 'none';
return;
}
bar.style.display = 'block';
bar.style.top = box.top + 'px';
bar.style.height = box.height + 'px';
bar.style.left = (box.right - 12) + 'px';
const ratio = host.clientHeight / host.scrollHeight;
const h = Math.max(MIN_THUMB, box.height * ratio);
const travel = box.height - h;
const progress = host.scrollTop / scrollable;
thumb.style.height = h + 'px';
thumb.style.transform = `translateY(${travel * progress}px)`;
}
function onDrag(event) {
if (!dragging) return;
const box = host.getBoundingClientRect();
const h = thumb.getBoundingClientRect().height;
const travel = box.height - h;
if (travel <= 0) return;
const wanted = event.clientY - box.top - grabOffset;
const progress = Math.max(0, Math.min(1, wanted / travel));
host.scrollTop = progress * (host.scrollHeight - host.clientHeight);
}
thumb.addEventListener('pointerdown', (event) => {
dragging = true;
grabOffset = event.clientY - thumb.getBoundingClientRect().top;
bar.classList.add('is-held');
event.preventDefault();
});
// Listen on the window, not the thumb: the pointer regularly leaves a 4px
// wide target mid-drag and the drag has to survive that.
window.addEventListener('pointermove', onDrag);
window.addEventListener('pointerup', () => {
dragging = false;
bar.classList.remove('is-held');
});
// Clicking the empty track jumps a page, the way a real scrollbar does.
bar.addEventListener('pointerdown', (event) => {
if (event.target === thumb) return;
const box = thumb.getBoundingClientRect();
host.scrollTop += (event.clientY < box.top ? -1 : 1) * host.clientHeight * 0.9;
});
host.addEventListener('scroll', layout);
window.addEventListener('resize', layout);
// The panel is rebuilt (settings reset, tab switch) without the element ever
// being replaced, so the size has to be re-read when its contents change.
new MutationObserver(layout).observe(host, { childList: true, subtree: true });
layout();
return layout;
}
const layouts = [];
function scan() {
document.querySelectorAll('[data-scroll]').forEach((el) => {
if (el.dataset.scrollBound) return;
el.dataset.scrollBound = '1';
layouts.push(attach(el));
});
}
// Re-measure when a panel is opened: a hidden element has no useful rect.
window.addEventListener('fiverp:relayout', () => layouts.forEach((fn) => fn()));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
scan();
}
})();
+441
View File
@@ -0,0 +1,441 @@
/* FiveRP — chat.
Design system: CLAUDE.md — Clean UI / Dark Minimalism.
Sits over the game, so the page itself stays transparent and only the
panels take a fill. No blur, no shadow, no gradient. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 900;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: rgba(24, 24, 24, 1);
--card-bg: rgba(30, 30, 30, 1);
--btn-bg: rgba(51, 51, 51, 1);
--btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--sans: 'Unbounded', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--sans);
user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body { color: var(--text-primary); }
.root {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background-color: rgba(24, 24, 24, 0.86);
}
.root.is-open { display: flex; }
.panel {
position: relative;
width: min(880px, 92vw);
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
}
/* ────────────────────────────────────────────────────────────────── head */
.head {
display: flex;
align-items: center;
gap: 25px;
padding: 25px;
border-bottom: 1px solid var(--border);
}
.eyebrow {
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 6px;
}
.title {
font-size: 20px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
}
/* Weight is the only real constraint on a bag, so it gets the space a number
that changes deserves rather than a line of small print. */
.load {
flex: 1;
min-width: 0;
}
.load__top {
display: flex;
align-items: baseline;
gap: 6px;
justify-content: flex-end;
margin-bottom: 8px;
}
.load__value {
font-size: 18px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.load__max {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
}
.load__max b { font-weight: 700; }
.load__track {
height: 4px;
border-radius: 2px;
background-color: var(--btn-bg);
overflow: hidden;
}
.load__fill {
display: block;
height: 100%;
width: 0;
border-radius: 2px;
background-color: var(--text-primary);
transition: width 0.2s ease;
}
/* Near the limit the bar becomes the one accent thing on screen, because at
that point it is the only number that will stop you doing something. */
.load__fill.is-heavy { background-color: var(--accent); }
.close {
flex-shrink: 0;
border: 1px solid var(--border);
cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-primary);
background-color: var(--btn-bg);
border-radius: 20px;
padding: 10px 18px;
transition: all 0.2s ease;
}
.close:hover { background-color: var(--btn-hover); }
/* ────────────────────────────────────────────────────────────────── body */
.body {
display: grid;
/* minmax(0, 1fr), not 1fr: a grid track's automatic minimum is its content's
min-content width, and four square slots with labels are wider than the
panel — the bag pushed the whole thing past its own edge. */
grid-template-columns: 220px minmax(0, 1fr);
gap: 25px;
padding: 25px;
}
.body > section { min-width: 0; }
.group {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 14px;
}
.wear__slots {
display: flex;
flex-direction: column;
gap: 8px;
}
.bag__grid {
display: grid;
/* Same trap one level down: each 1fr track still has an automatic minimum of
its content, and a nowrap label like "Handheld radio" is wider than a
quarter of the panel — so four of them pushed the grid past its column.
minmax(0, …) lets the track shrink and the label ellipsis do its job. */
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
/* ───────────────────────────────────────────────────────────────── slots */
.slot {
position: relative;
aspect-ratio: 1;
background-color: var(--btn-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 9px;
display: flex;
flex-direction: column;
justify-content: flex-end;
cursor: default;
transition: all 0.2s ease;
}
.slot.has-item { cursor: grab; }
.slot.has-item:hover {
background-color: var(--btn-hover);
transform: translateY(-2px);
}
/* The slot being dragged stays put and dims; the ghost is what moves. */
.slot.is-dragging { opacity: 0.35; }
.slot.is-target {
border-color: var(--accent);
background-color: var(--btn-hover);
}
/* A clothing slot that cannot take what is being dragged says so before the
move is attempted, rather than after the server refuses it. */
.slot.is-blocked { opacity: 0.35; }
/* Worn slots are wide rather than square: they are a list of five named places,
not a grid of interchangeable ones. */
.wear .slot {
aspect-ratio: auto;
height: 58px;
flex-direction: row;
align-items: center;
gap: 11px;
padding: 9px 12px;
}
.slot__art {
position: absolute;
inset: 9px;
bottom: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.wear .slot__art {
position: static;
inset: auto;
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 12px;
background-color: var(--card-bg);
}
.slot__art img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* No artwork yet for anything, so the initials stand in. It reads as a
placeholder rather than as a broken image. */
.slot__initials {
font-size: 15px;
font-weight: 900;
letter-spacing: 1px;
color: var(--text-secondary);
}
.wear .slot__initials { font-size: 12px; }
.slot__name {
position: relative;
/* Room for the weight, which is pinned to the same bottom corner. Without it
a long label runs straight through the number. */
padding-right: 26px;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wear .slot__name { padding-right: 0; }
.wear .slot__name { flex: 1; min-width: 0; }
/* The empty worn slot still says what belongs in it. */
.slot__placeholder {
font-size: 10px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
}
.slot__count {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--bg);
background-color: var(--accent);
border-radius: 20px;
padding: 3px 7px;
}
.wear .slot__count { top: 50%; right: 12px; transform: translateY(-50%); }
.slot__weight {
position: absolute;
bottom: 8px;
right: 9px;
font-size: 9px;
font-weight: 500;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.wear .slot__weight { display: none; }
/* ───────────────────────────────────────────────────────────────── foot */
.foot {
display: flex;
align-items: center;
gap: 25px;
padding: 0 25px 25px;
}
.hint {
flex: 1;
font-size: 10px;
font-weight: 400;
line-height: 1.5;
color: var(--text-secondary);
}
.dropzone {
flex-shrink: 0;
width: 220px;
border: 1px dashed var(--btn-bg);
border-radius: 12px;
padding: 16px;
text-align: center;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.dropzone.is-target {
border-style: solid;
border-color: var(--accent);
color: var(--accent);
}
/* ─────────────────────────────────────────────────────────────── notice */
.notice {
position: absolute;
left: 25px;
bottom: 25px;
display: none;
font-size: 11px;
font-weight: 500;
color: var(--accent);
background-color: var(--btn-bg);
border-radius: 12px;
padding: 11px 15px;
}
.notice.is-shown { display: block; }
/* ──────────────────────────────────────────────────────────────── ghost */
.ghost {
position: fixed;
z-index: 90;
display: none;
pointer-events: none;
width: 74px;
height: 74px;
border-radius: 12px;
background-color: var(--btn-hover);
border: 1px solid var(--accent);
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 900;
letter-spacing: 1px;
color: var(--text-primary);
transform: translate(-50%, -50%);
padding: 8px;
}
.ghost img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.ghost.is-shown { display: flex; }
body.no-anim *,
body.no-anim *::before,
body.no-anim *::after {
animation: none !important;
transition: none !important;
}
@@ -0,0 +1,582 @@
// FiveRP inventory — the server half.
//
// It owns every slot. A client may ask to move, use, drop or pick something up;
// it never states what it is holding. Each request is checked against the row
// the database has, not against anything that arrived over the wire, and the
// answer sent back is the whole inventory rather than a delta — a client that
// missed one update would otherwise diverge quietly and stay wrong.
// shared/items.js is loaded before this file by the manifest and leaves its
// definitions as globals; the runtime has no require().
const sql = (method, query, params) =>
exports.oxmysql[`${method}_async`](query, params);
// character_id -> { bag: {slot: {item,count,meta}}, wear: {...} }
const held = new Map();
// source -> character_id, so a request can be traced to rows without trusting it
const owner = new Map();
// Dropped items waiting on the ground. Kept in memory on purpose: a bag left in
// an alley should not survive a restart, and persisting it would make the world
// fill up with everything anyone ever discarded.
const ground = new Map();
let nextDrop = 1;
function ask(resource, method, fallback, ...args) {
try {
const target = exports[resource];
const result = target[method](...args);
return result === undefined ? fallback : result;
} catch (err) {
return fallback;
}
}
function charOf(src) {
return owner.get(Number(src)) || null;
}
function blank() {
return { bag: {}, wear: {} };
}
function weightOf(store) {
let total = 0;
for (const container of ['bag', 'wear']) {
for (const slot of Object.keys(store[container])) {
const row = store[container][slot];
total += itemWeight(row.item, row.count);
}
}
return Math.round(total * 100) / 100;
}
// ------------------------------------------------------------------- load
async function load(charId) {
const rows = await sql('query',
'SELECT id, container, slot, item, count, meta FROM character_items WHERE character_id = ?',
[charId]) || [];
const store = blank();
const junk = [];
for (const row of rows) {
// An item that has been removed from the registry since it was stored is
// dropped on load rather than shown as a slot nobody can explain.
if (!itemDef(row.item)) { junk.push(row); continue; }
const meta = row.meta ? safeParse(row.meta) : null;
// A garment is its drawable. A worn_* row without one describes no actual
// clothing, so it would sit in the slot looking equipped while the body
// stayed bare — which is exactly what rows written before meta survived a
// drop now look like. Discard them rather than keep an item that cannot be
// put on.
if (String(row.item).startsWith('worn_') &&
(!meta || typeof meta.drawable !== 'number')) {
junk.push(row);
continue;
}
store[row.container][String(row.slot)] = {
item: row.item,
count: Number(row.count),
meta
};
}
await Promise.all(junk.map((row) =>
sql('update', 'DELETE FROM character_items WHERE id = ?', [row.id])));
held.set(charId, store);
return store;
}
function safeParse(text) {
try { return JSON.parse(text); } catch (err) { return null; }
}
function storeOf(charId) {
return held.get(charId) || blank();
}
// ------------------------------------------------------------------ write
// The row is the truth, so every change goes to the database before the client
// hears about it. Slots are written individually: a wholesale delete-and-insert
// would lose everything if the insert half failed.
async function persistSlot(charId, container, slot, row) {
if (!row) {
await sql('update',
'DELETE FROM character_items WHERE character_id = ? AND container = ? AND slot = ?',
[charId, container, String(slot)]);
return;
}
await sql('update',
`INSERT INTO character_items (character_id, container, slot, item, count, meta)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE item = VALUES(item), count = VALUES(count), meta = VALUES(meta)`,
[charId, container, String(slot), row.item, row.count,
row.meta ? JSON.stringify(row.meta) : null]);
}
function push(src) {
const charId = charOf(src);
if (!charId) return;
const store = storeOf(charId);
emitNet('fiverp-inventory:state', src, {
bag: store.bag,
wear: store.wear,
weight: weightOf(store),
maxWeight: MAX_WEIGHT,
slots: BAG_SLOTS,
wearSlots: WEAR_SLOTS,
// The registry travels with the state so the panel can label and weigh what
// it draws without a second round trip.
items: ITEMS
});
}
function refuse(src, why) {
emitNet('fiverp-inventory:refused', src, { message: why });
}
// ------------------------------------------------------------------ moves
function firstFreeBagSlot(store) {
for (let i = 0; i < BAG_SLOTS; i++) {
if (!store.bag[String(i)]) return String(i);
}
return null;
}
// Add to an existing stack first, then to a free slot. Returns what could not
// be placed, so a caller can decide whether a partial add is acceptable.
//
// An item carrying meta is never merged into a stack. Meta is what makes one
// instance different from another — the drawable a jacket remembers — and
// stacking two of them would keep one and silently destroy the other's.
function addTo(store, item, count, meta) {
const def = itemDef(item);
if (!def) return count;
let left = count;
const stack = itemStack(item);
if (stack > 1 && !meta) {
for (const slot of Object.keys(store.bag)) {
const row = store.bag[slot];
if (row.item !== item || row.count >= stack) continue;
const room = stack - row.count;
const take = Math.min(room, left);
row.count += take;
left -= take;
if (left <= 0) return 0;
}
}
while (left > 0) {
const slot = firstFreeBagSlot(store);
if (!slot) break;
// With meta there is one instance per slot, by definition.
const take = meta ? 1 : Math.min(stack, left);
store.bag[slot] = { item, count: take, meta: meta || null };
left -= take;
}
return left;
}
async function saveAll(charId, store) {
const jobs = [];
for (const container of ['bag', 'wear']) {
for (const slot of Object.keys(store[container])) {
jobs.push(persistSlot(charId, container, slot, store[container][slot]));
}
}
await Promise.all(jobs);
}
// ---------------------------------------------------------------- lifecycle
on('fiverp:characterSelected', async (src, charId) => {
src = Number(src);
charId = Number(charId);
if (!src || !charId) return;
owner.set(src, charId);
await load(charId);
push(src);
});
on('playerDropped', () => {
const src = Number(global.source);
const charId = owner.get(src);
owner.delete(src);
// The cache is dropped with the player; the rows stay where they are.
if (charId) held.delete(charId);
});
// ------------------------------------------------------------------ events
onNet('fiverp-inventory:request', () => {
push(Number(global.source));
});
// Move one slot onto another: swap, merge or split-free move, decided here.
onNet('fiverp-inventory:move', async (req) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId || !req) return;
const store = storeOf(charId);
const from = { c: req.fromContainer, s: String(req.fromSlot) };
const to = { c: req.toContainer, s: String(req.toSlot) };
if (!['bag', 'wear'].includes(from.c) || !['bag', 'wear'].includes(to.c)) return;
const moving = store[from.c][from.s];
if (!moving) return refuse(src, 'Nothing there');
if (from.c === to.c && from.s === to.s) return;
if (!fitsSlot(moving.item, to.c, to.s)) {
return refuse(src, to.c === 'wear'
? 'That does not go there'
: 'No such slot');
}
const target = store[to.c][to.s];
if (target) {
// Same item and room in the stack: merge rather than swap.
const stack = itemStack(moving.item);
if (target.item === moving.item && stack > 1 && target.count < stack) {
const take = Math.min(stack - target.count, moving.count);
target.count += take;
moving.count -= take;
if (moving.count <= 0) delete store[from.c][from.s];
} else {
// Swap, but only if the displaced item is allowed where it would land.
if (!fitsSlot(target.item, from.c, from.s)) {
return refuse(src, 'Nothing to swap it with');
}
store[to.c][to.s] = moving;
store[from.c][from.s] = target;
}
} else {
store[to.c][to.s] = moving;
delete store[from.c][from.s];
}
await Promise.all([
persistSlot(charId, from.c, from.s, store[from.c][from.s] || null),
persistSlot(charId, to.c, to.s, store[to.c][to.s] || null)
]);
push(src);
});
// Use whatever is in a slot. This resource does not know what "drink" means; it
// announces the action and lets whoever owns that effect handle it.
onNet('fiverp-inventory:use', async (container, slot) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId) return;
const store = storeOf(charId);
const row = store[String(container)] && store[String(container)][String(slot)];
if (!row) return;
const def = itemDef(row.item);
if (!def || !def.action) return refuse(src, 'Nothing happens');
emit('fiverp-inventory:used', src, row.item, def.action, row.meta);
});
// Put a garment on, or take it off, without the player having to drag it. The
// destination is derived from the item's own wear slot, so there is nothing for
// a client to choose and nothing for it to get wrong.
onNet('fiverp-inventory:toggleWear', async (container, slot) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId) return;
const store = storeOf(charId);
container = String(container);
slot = String(slot);
const row = store[container] && store[container][slot];
if (!row) return;
const def = itemDef(row.item);
if (!def || !def.wear) return refuse(src, 'That is not something you wear');
if (container === 'wear') {
// Taking off: into the bag, wherever there is room.
const free = firstFreeBagSlot(store);
if (!free) return refuse(src, 'No room in your bag');
store.bag[free] = row;
delete store.wear[slot];
await Promise.all([
persistSlot(charId, 'wear', slot, null),
persistSlot(charId, 'bag', free, store.bag[free])
]);
} else {
// Putting on: whatever is already in that slot swaps into the vacated one,
// so wearing a second jacket does not silently destroy the first.
const target = def.wear;
const worn = store.wear[target] || null;
store.wear[target] = row;
if (worn) {
store.bag[slot] = worn;
} else {
delete store.bag[slot];
}
await Promise.all([
persistSlot(charId, 'wear', target, store.wear[target]),
persistSlot(charId, 'bag', slot, store.bag[slot] || null)
]);
}
push(src);
});
// Drop to the ground. The count is clamped to what is actually in the slot, so
// a client asking to drop more than it has drops what it has.
onNet('fiverp-inventory:drop', async (container, slot, count) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId) return;
const store = storeOf(charId);
container = String(container);
slot = String(slot);
const row = store[container] && store[container][slot];
if (!row) return;
const take = Math.max(1, Math.min(Number(count) || row.count, row.count));
const ped = GetPlayerPed(String(src));
if (!ped) return;
const c = GetEntityCoords(ped);
row.count -= take;
if (row.count <= 0) delete store[container][slot];
const id = nextDrop++;
ground.set(id, {
id,
item: row.item,
count: take,
// Carried through the drop. Without it a jacket picked back up is a jacket
// that has forgotten which jacket it was, and putting it on does nothing.
meta: row.meta || null,
coords: [c[0], c[1], c[2] - 0.9],
at: GetGameTimer()
});
await persistSlot(charId, container, slot, store[container][slot] || null);
push(src);
emitNet('fiverp-inventory:groundAdd', -1, ground.get(id));
});
onNet('fiverp-inventory:pickup', async (dropId) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId) return;
const drop = ground.get(Number(dropId));
if (!drop) return;
// Distance is measured here, from the server's own copy of both positions.
const ped = GetPlayerPed(String(src));
if (!ped) return;
const c = GetEntityCoords(ped);
const dx = c[0] - drop.coords[0], dy = c[1] - drop.coords[1], dz = c[2] - drop.coords[2];
if (Math.sqrt(dx * dx + dy * dy + dz * dz) > 3.0) return refuse(src, 'Too far away');
const store = storeOf(charId);
if (weightOf(store) + itemWeight(drop.item, drop.count) > MAX_WEIGHT) {
return refuse(src, 'Too heavy to carry');
}
const left = addTo(store, drop.item, drop.count, drop.meta);
if (left === drop.count) return refuse(src, 'No room');
// Whatever would not fit stays on the ground rather than vanishing.
if (left > 0) {
drop.count = left;
emitNet('fiverp-inventory:groundAdd', -1, drop);
} else {
ground.delete(drop.id);
emitNet('fiverp-inventory:groundGone', -1, drop.id);
}
await saveAll(charId, store);
push(src);
});
// A joining client needs the piles that are already lying around.
onNet('fiverp-inventory:groundRequest', () => {
emitNet('fiverp-inventory:groundAll', Number(global.source), [...ground.values()]);
});
// -------------------------------------------------------------- registry
// Other resources add their own items here rather than editing shared/items.
// A job's berries belong to the job, not to the inventory: the registry file
// stays the contract, and content stays with whatever owns it.
global.exports('registerItems', (defs) => {
if (!defs || typeof defs !== 'object') return false;
for (const id of Object.keys(defs)) {
const def = defs[id];
if (!def || typeof def !== 'object' || !def.label) continue;
ITEMS[id] = {
label: def.label,
description: def.description || '',
weight: Number(def.weight) || 0,
stack: Math.max(1, Math.floor(Number(def.stack) || 1)),
action: def.action || null,
model: def.model || null,
image: def.image || null,
wear: def.wear || null
};
}
// Clients need the definitions to draw and to spawn a dropped prop, and a
// player already in the world will not get them any other way.
emitNet('fiverp-inventory:registry', -1, ITEMS);
return true;
});
on('playerJoining', () => {
emitNet('fiverp-inventory:registry', Number(global.source), ITEMS);
});
// ------------------------------------------------------------------ worn
// A character arrives dressed by the creator, and those clothes are ped
// components rather than items. This adopts them once, so the worn slots show
// what is actually on the body instead of five empty boxes.
//
// The client reads the drawables because only the client has the ped; the
// server decides whether to accept them, and after that the rows are the truth.
// A slot that is already filled is left alone: re-adopting would resurrect a
// shirt the player had taken off.
onNet('fiverp-inventory:adoptWorn', async (worn) => {
const src = Number(global.source);
const charId = charOf(src);
if (!charId || !worn || typeof worn !== 'object') return;
const store = storeOf(charId);
const jobs = [];
for (const slot of WEAR_SLOTS) {
if (store.wear[slot]) continue;
const piece = worn[slot];
if (!piece || typeof piece !== 'object') continue;
const drawable = Math.floor(Number(piece.drawable));
const texture = Math.floor(Number(piece.texture));
if (!Number.isFinite(drawable) || drawable < 0) continue;
store.wear[slot] = {
item: 'worn_' + slot,
count: 1,
// Everything needed to put it back on, and nothing the client could
// profit from lying about: a drawable index is not a value.
meta: {
drawable,
texture: Number.isFinite(texture) && texture >= 0 ? texture : 0,
arms: piece.arms === undefined ? null : Math.floor(Number(piece.arms)),
armsTexture: piece.armsTexture === undefined ? null : Math.floor(Number(piece.armsTexture))
}
};
jobs.push(persistSlot(charId, 'wear', slot, store.wear[slot]));
}
if (!jobs.length) return;
await Promise.all(jobs);
push(src);
});
// ----------------------------------------------------------------- exports
// What other resources use. All of them take a source and go through the same
// checks a player's own request would.
global.exports('giveItem', async (src, item, count, meta) => {
const charId = charOf(src);
if (!charId) return { ok: false, error: 'no character' };
if (!itemDef(item)) return { ok: false, error: 'unknown item' };
const n = Math.max(1, Math.floor(Number(count) || 1));
const store = storeOf(charId);
if (weightOf(store) + itemWeight(item, n) > MAX_WEIGHT) {
return { ok: false, error: 'too heavy' };
}
const left = addTo(store, item, n, meta || null);
await saveAll(charId, store);
push(src);
return { ok: left === 0, placed: n - left, left };
});
global.exports('removeItem', async (src, item, count) => {
const charId = charOf(src);
if (!charId) return { ok: false, error: 'no character' };
const store = storeOf(charId);
let left = Math.max(1, Math.floor(Number(count) || 1));
for (const container of ['bag', 'wear']) {
for (const slot of Object.keys(store[container])) {
if (left <= 0) break;
const row = store[container][slot];
if (row.item !== item) continue;
const take = Math.min(row.count, left);
row.count -= take;
left -= take;
if (row.count <= 0) {
delete store[container][slot];
await persistSlot(charId, container, slot, null);
} else {
await persistSlot(charId, container, slot, row);
}
}
}
push(src);
return { ok: left === 0, removed: (Number(count) || 1) - left };
});
// A plain function as well as an export: an export cannot call itself, and
// hasItem is just a question about this count.
function countItem(src, item) {
const charId = charOf(src);
if (!charId) return 0;
const store = storeOf(charId);
let total = 0;
for (const container of ['bag', 'wear']) {
for (const slot of Object.keys(store[container])) {
if (store[container][slot].item === item) total += store[container][slot].count;
}
}
return total;
}
global.exports('countItem', countItem);
global.exports('hasItem', (src, item, count) =>
countItem(src, item) >= (Number(count) || 1));
global.exports('getInventory', (src) => {
const charId = charOf(src);
return charId ? storeOf(charId) : null;
});
@@ -0,0 +1,80 @@
// The item registry, server side.
//
// Same content as shared/items.lua and for the same reason: the server has to
// answer "what does this weigh" and "may this go in that slot" without asking a
// client, and the client needs the labels to draw anything at all. Two runtimes
// means two copies; they are kept side by side so a change to one is an obvious
// omission if the other is not touched.
//
// Empty on purpose. The shape an entry takes:
//
// water: {
// label: 'Water bottle',
// description: 'Half a litre, still.',
// weight: 0.5, // kilograms, per unit
// stack: 12, // how many fit in one slot; 1 = no stacking
// action: 'drink', // null means it cannot be used
// model: 'prop_ld_flow_bottle', // the real object, spawned when dropped
// image: 'water.png',
// wear: null // 'hat'|'top'|'undershirt'|'pants'|'shoes'
// }
// Globals rather than module exports: the FiveM JS runtime has no require(),
// so files share state by being listed in order in the manifest.
const ITEMS = {};
// The five garment carriers — see items.lua for why they exist. Kept identical
// on purpose: the server decides what may sit in a worn slot, and it cannot ask
// a client.
[
['hat', 'Headwear', 0.3],
['top', 'Outerwear', 0.8],
['undershirt', 'Undershirt', 0.3],
['pants', 'Trousers', 0.6],
['shoes', 'Shoes', 0.9]
].forEach(([slot, label, weight]) => {
ITEMS['worn_' + slot] = {
label,
description: 'Taken off, it goes in your bag like anything else.',
weight,
stack: 1,
action: null,
model: null,
image: 'worn_' + slot + '.png',
wear: slot
};
});
const WEAR_SLOTS = ['hat', 'top', 'undershirt', 'pants', 'shoes'];
const BAG_SLOTS = 16;
const MAX_WEIGHT = 30.0;
function itemDef(id) {
return Object.prototype.hasOwnProperty.call(ITEMS, id) ? ITEMS[id] : null;
}
function itemWeight(id, count) {
const def = itemDef(id);
if (!def) return 0;
return (def.weight || 0) * (count || 1);
}
function itemStack(id) {
const def = itemDef(id);
return (def && def.stack) || 1;
}
// A clothing item may only sit in the slot it was made for; everything else may
// only sit in the bag.
function fitsSlot(id, container, slot) {
const def = itemDef(id);
if (!def) return false;
if (container === 'wear') {
return !!def.wear && def.wear === slot;
}
const index = Number(slot);
return Number.isInteger(index) && index >= 0 && index < BAG_SLOTS;
}
+113
View File
@@ -0,0 +1,113 @@
-- The item registry.
--
-- Deliberately empty. Everything below describes the shape an entry has to
-- take, because an inventory that accepts loosely-shaped items becomes
-- impossible to reason about once there are two hundred of them.
--
-- The server treats this file as the only truth about what an item is. A client
-- can ask to move or use something; it can never tell the server what that
-- something weighs or does.
--
-- ITEMS['water'] = {
-- label = 'Water bottle', -- what the player reads
-- description = 'Half a litre, still.',-- one or two lines, shown on hover
-- weight = 0.5, -- kilograms, per unit
-- stack = 12, -- how many fit in one slot (1 = no stacking)
-- action = 'drink', -- what using it does; nil means it cannot be used
-- model = 'prop_ld_flow_bottle', -- the real object, spawned when dropped
-- image = 'water.png', -- html/images/, falls back to initials
-- wear = nil -- 'hat'|'top'|'undershirt'|'pants'|'shoes'
-- -- set only for clothing; restricts which slot accepts it
-- }
--
-- `action` is a name, not code. What it does lives in the resource that owns
-- the effect: fiverp-inventory emits fiverp-inventory:use with the action and
-- the item, and whoever cares listens. That keeps food, weapons and paperwork
-- out of this file.
ITEMS = {}
-- The five garment carriers.
--
-- These are not content, they are the mechanism: a character arrives already
-- dressed by the creator, and those clothes are ped components, not items. One
-- carrier per slot holds whichever drawable is on the character, in its meta.
-- Without them the worn slots could only ever be empty, because the registry
-- has nothing that may sit in them.
local function garment(slot, label, weight)
ITEMS['worn_' .. slot] = {
label = label,
description = 'Taken off, it goes in your bag like anything else.',
weight = weight,
stack = 1,
action = nil,
model = nil,
-- One picture per slot rather than per garment: the registry stores which
-- slot a thing belongs in, not what it looks like. A jacket and a shirt
-- share worn_top and therefore share the icon.
image = 'worn_' .. slot .. '.png',
wear = slot
}
end
garment('hat', 'Headwear', 0.3)
garment('top', 'Outerwear', 0.8)
garment('undershirt', 'Undershirt', 0.3)
garment('pants', 'Trousers', 0.6)
garment('shoes', 'Shoes', 0.9)
-- Which ped component each slot drives.
--
-- `prop` means it is a prop index rather than a component; hats are the only
-- one of the five. `also` is dragged along with the parent: the arms drawable
-- belongs to the torso, and leaving it behind puts sleeves on a bare chest.
WEAR_COMPONENTS = {
hat = { prop = true, index = 0 },
top = { index = 11, also = 3 },
undershirt = { index = 8 },
pants = { index = 4 },
shoes = { index = 6 }
}
-- What the body looks like with that piece removed. Underwear, not nothing:
-- these are the drawables the game itself uses for an undressed freemode ped.
BARE = {
male = {
[11] = 15, [3] = 15, [8] = 15, [4] = 21, [6] = 34
},
female = {
[11] = 15, [3] = 15, [8] = 15, [4] = 17, [6] = 35
}
}
-- The five places clothing can go, in the order the panel draws them.
WEAR_SLOTS = { 'hat', 'top', 'undershirt', 'pants', 'shoes' }
WEAR_LABELS = {
hat = 'Headwear',
top = 'Outerwear',
undershirt = 'Undershirt',
pants = 'Trousers',
shoes = 'Shoes'
}
BAG_SLOTS = 16 -- the backpack grid
MAX_WEIGHT = 30.0 -- kilograms a character can carry
-- Everything the rest of the resource asks about an item goes through here, so
-- an unknown id fails the same way everywhere instead of at whichever line
-- happened to touch it first.
function ItemDef(id)
return ITEMS[id]
end
function ItemWeight(id, count)
local def = ITEMS[id]
if not def then return 0.0 end
return (def.weight or 0.0) * (count or 1)
end
function ItemStack(id)
local def = ITEMS[id]
return (def and def.stack) or 1
end
@@ -8,6 +8,13 @@ version '1.0.0'
loadscreen 'html/index.html' loadscreen 'html/index.html'
-- Without this the engine owns the loading screen's lifetime and tears it down
-- on spawn — which never happens here, because auth holds spawning shut until
-- the player has an identity. The screen then outlives its purpose and covers
-- the world, and a manual ShutdownLoadingScreenNui() is ignored. Declaring
-- manual shutdown makes that call authoritative; fiverp-auth makes it.
loadscreen_manual_shutdown 'yes'
files { files {
'html/index.html', 'html/index.html',
'html/style.css', 'html/style.css',
@@ -7,15 +7,6 @@
</head> </head>
<body> <body>
<!-- Slow drifting colour behind frosted white. Nothing here is interactive. -->
<div class="sky" aria-hidden="true">
<span class="blob blob-a"></span>
<span class="blob blob-b"></span>
<span class="blob blob-c"></span>
<span class="blob blob-d"></span>
</div>
<div class="veil" aria-hidden="true"></div>
<main class="stage"> <main class="stage">
<div class="mark fade-up"> <div class="mark fade-up">
<span class="mark-glyph">FR</span> <span class="mark-glyph">FR</span>
+192 -219
View File
@@ -1,12 +1,11 @@
/* FiveRP — loading screen. /* FiveRP — loading screen.
Design system: /opt/fivem/server-data/CLAUDE.md Design system: /opt/fivem/server-data/CLAUDE.md — Clean UI / Dark Minimalism.
This page owns the whole screen (no game behind it), so unlike the in-game Flat surfaces, solid fills, one accent. No gradients, no blur, no shadows. */
panels it is allowed an opaque background and real shadows. */
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal; font-style: normal;
font-weight: 300 800; font-weight: 300 900;
font-display: block; font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2'); src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
@@ -16,7 +15,7 @@
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal; font-style: normal;
font-weight: 300 800; font-weight: 300 900;
font-display: block; font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2'); src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
@@ -26,247 +25,177 @@
@font-face { @font-face {
font-family: 'Unbounded'; font-family: 'Unbounded';
font-style: normal; font-style: normal;
font-weight: 300 800; font-weight: 300 900;
font-display: block; font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2'); src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
:root { :root {
--bg: #f5f5f7; --bg: rgba(24, 24, 24, 1);
--bg-deep: #e8e8ed; --card-bg: rgba(30, 30, 30, 1);
--surface: rgba(255, 255, 255, 0.72); --btn-bg: rgba(51, 51, 51, 1);
--surface-solid: #ffffff; --btn-hover: rgba(70, 70, 70, 1);
--accent: rgba(255, 119, 22, 1);
--text-primary: rgba(241, 241, 241, 1);
--text-secondary: rgba(153, 153, 153, 1);
--border: rgba(51, 51, 51, 0.5);
--fg: #1d1d1f; --sans: 'Unbounded', sans-serif;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--sans);
user-select: none;
}
html, body { html, body {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
cursor: none;
} }
/* ---------------------------------------------------------------------- sky */ body {
/* Four wide, heavily blurred washes drifting on long loops. Kept pale so the background-color: var(--bg);
page still reads as white — colour is a hint, not a gradient poster. */ color: var(--text-primary);
.sky { position: fixed; inset: -20%; z-index: 0; filter: blur(90px); }
.blob {
position: absolute;
display: block;
border-radius: 50%;
will-change: transform;
}
.blob-a {
width: 46vw; height: 46vw; top: 4%; left: 8%;
background: radial-gradient(circle, rgba(0,113,227,0.30), rgba(0,113,227,0) 70%);
animation: driftA 34s var(--ease) infinite alternate;
}
.blob-b {
width: 40vw; height: 40vw; top: 32%; left: 52%;
background: radial-gradient(circle, rgba(90,200,250,0.32), rgba(90,200,250,0) 70%);
animation: driftB 41s var(--ease) infinite alternate;
}
.blob-c {
width: 34vw; height: 34vw; top: 56%; left: 18%;
background: radial-gradient(circle, rgba(201,182,255,0.28), rgba(201,182,255,0) 70%);
animation: driftC 47s var(--ease) infinite alternate;
}
.blob-d {
width: 30vw; height: 30vw; top: 6%; left: 68%;
background: radial-gradient(circle, rgba(255,214,165,0.30), rgba(255,214,165,0) 70%);
animation: driftD 38s var(--ease) infinite alternate;
}
@keyframes driftA { to { transform: translate3d(9vw, 7vh, 0) scale(1.14); } }
@keyframes driftB { to { transform: translate3d(-11vw, -6vh, 0) scale(1.08); } }
@keyframes driftC { to { transform: translate3d(7vw, -9vh, 0) scale(1.18); } }
@keyframes driftD { to { transform: translate3d(-8vw, 8vh, 0) scale(1.10); } }
/* A milky sheet over the colour: this is what turns four blobs into frosted
white rather than a lava lamp. */
.veil {
position: fixed;
inset: 0;
z-index: 1;
background:
radial-gradient(110% 85% at 50% 42%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0.72) 46%, rgba(255,255,255,0.88) 100%),
linear-gradient(180deg, rgba(245,245,247,0.20), rgba(232,232,237,0.42));
}
/* -------------------------------------------------------------------- stage */
.stage {
position: relative;
z-index: 2;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 32px 96px; }
/* ─────────────────────────────────────────────────────────── centre stage */
.stage {
display: flex;
flex-direction: column;
align-items: center;
width: min(560px, 78vw);
text-align: center; text-align: center;
} }
/* App-icon style badge: the one saturated object on an otherwise white page. */ /* The mark is a plain square with the accent as its only colour: at this size
an outline would read as a hairline artefact on a 1080p projector. */
.mark { .mark {
width: 84px; width: 72px;
height: 84px; height: 72px;
display: grid; border-radius: 16px;
place-items: center; background-color: var(--accent);
border-radius: 24px; display: flex;
background-image: linear-gradient(160deg, #35a0ff 0%, var(--accent) 52%, #0059b8 100%); align-items: center;
margin-bottom: 26px; justify-content: center;
margin-bottom: 28px;
} }
.mark-glyph { .mark-glyph {
font-size: 27px; font-size: 24px;
font-weight: 800; font-weight: 900;
letter-spacing: -0.045em; letter-spacing: 1px;
color: #fff; color: var(--bg);
text-shadow: 0 1px 2px rgba(0,0,0,0.22);
} }
.eyebrow { .eyebrow {
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 500;
letter-spacing: 0.20em; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--fg-tertiary); color: var(--text-secondary);
margin-bottom: 14px; margin-bottom: 12px;
} }
.wordmark { .wordmark {
font-size: clamp(52px, 6.4vw, 84px); font-size: 46px;
font-weight: 800; font-weight: 900;
letter-spacing: -0.045em; letter-spacing: 1px;
text-transform: uppercase;
line-height: 1; line-height: 1;
color: var(--fg); margin-bottom: 14px;
margin-bottom: 16px;
} }
.tagline { .tagline {
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 400;
color: var(--fg-secondary); color: var(--text-secondary);
margin-bottom: 46px; margin-bottom: 44px;
} }
/* -------------------------------------------------------------------- meter */ /* ───────────────────────────────────────────────────────── progress meter */
/* Left-aligned: the phase and detail lines change constantly, and centred text
that reflows on every update reads as jitter. */ .meter {
.meter { width: min(560px, 84vw); text-align: left; } width: 100%;
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 22px 25px;
text-align: left;
}
.meter-top { .meter-top {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; margin-bottom: 14px;
} }
.phase { .phase {
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 700;
letter-spacing: 0.09em; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--fg-secondary);
} }
.pct { .pct {
font-size: 34px; font-size: 20px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.045em; color: var(--accent);
color: var(--fg);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.pct i { .pct i {
font-style: normal; font-style: normal;
font-size: 15px; font-size: 12px;
font-weight: 600; font-weight: 500;
color: var(--fg-tertiary); margin-left: 2px;
margin-left: 3px;
} }
.track { .track {
position: relative; height: 6px;
height: 8px; border-radius: 3px;
border-radius: 999px; background-color: var(--btn-bg);
/* Inset rather than flat grey: on a near-white page a 7%-black bar reads as
a smudge, a hairline-bordered groove reads as a control. */
background: rgba(0, 0, 0, 0.055);
overflow: hidden; overflow: hidden;
} }
.fill { .fill {
position: absolute; height: 100%;
inset: 0 auto 0 0; width: 0;
width: 0%; border-radius: 3px;
border-radius: 999px; background-color: var(--accent);
background-image: linear-gradient(90deg, #5ac8fa, var(--accent)); transition: width 0.2s ease;
transition: width 520ms var(--ease);
} }
/* Travelling highlight so a stalled bar still looks alive. */
.fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg,
rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%);
transform: translateX(-100%);
animation: sweep 1.9s var(--ease) infinite;
}
@keyframes sweep { to { transform: translateX(100%); } }
.detail { .detail {
margin-top: 14px; margin-top: 14px;
height: 14px;
font-family: var(--mono);
font-size: 11px; font-size: 11px;
letter-spacing: 0; font-weight: 400;
color: var(--fg-tertiary); color: var(--text-secondary);
white-space: nowrap; min-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity .3s ease;
} }
/* --------------------------------------------------------------------- dock */ /* ──────────────────────────────────────────────────────────── bottom dock */
.dock { .dock {
position: fixed; position: fixed;
z-index: 2; left: 0;
left: 0; right: 0; bottom: 0; right: 0;
bottom: 0;
padding: 26px 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
padding: 26px 32px;
} }
.tip { .tip {
@@ -274,73 +203,117 @@ transition: width 520ms var(--ease);
align-items: center; align-items: center;
gap: 12px; gap: 12px;
max-width: 62vw; max-width: 62vw;
padding: 12px 18px 12px 12px; transition: opacity 0.2s ease;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-strong);
} }
.tip.is-swapping { opacity: 0; }
.tip-label { .tip-label {
flex: none; flex-shrink: 0;
padding: 5px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--bg);
background-color: var(--accent);
border-radius: 20px;
padding: 5px 12px;
} }
.tip-body { .tip-body {
font-size: 12.5px; font-size: 11px;
font-weight: 400; font-weight: 400;
color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
color: var(--fg-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity .45s ease, transform .45s var(--ease);
} }
.tip-body.is-swapping { opacity: 0; transform: translateY(-6px); }
.badge { .badge {
flex: none; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 11px 18px; font-size: 11px;
border-radius: 999px; font-weight: 500;
background: var(--surface); color: var(--text-secondary);
border: 1px solid var(--line-strong); background-color: var(--card-bg);
font-family: var(--mono); border: 1px solid var(--border);
font-size: 11.5px; border-radius: 20px;
color: var(--fg-secondary); padding: 9px 16px;
letter-spacing: 0;
} }
.badge i { font-style: normal; color: var(--fg-tertiary); }
.badge .dot { .badge i {
width: 7px; height: 7px; font-style: normal;
color: var(--text-secondary);
opacity: 0.6;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--success, #34c759); background-color: var(--accent);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .45; } }
/* ------------------------------------------------------------------- motion */
.fade-up { animation: fadeUp .7s cubic-bezier(.22,1,.36,1) both; }
.delay-1 { animation-delay: .07s; }
.delay-2 { animation-delay: .14s; }
.delay-3 { animation-delay: .21s; }
.delay-4 { animation-delay: .30s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
to { opacity: 1; transform: none; filter: blur(0); }
} }
@media (prefers-reduced-motion: reduce) { /* ───────────────────────────────────────────────────────────── entrances */
*, *::before, *::after {
animation-duration: .001ms !important; .fade-up {
animation-iteration-count: 1 !important; animation: fade-up 0.4s ease both;
transition-duration: .001ms !important;
} }
.delay-1 { animation-delay: 0.06s; }
.delay-2 { animation-delay: 0.12s; }
.delay-3 { animation-delay: 0.18s; }
.delay-4 { animation-delay: 0.24s; }
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* A still frame for screenshots: kills entrance animation so a headless
capture does not photograph the first, invisible frame. */
body.no-anim *,
body.no-anim *::before,
body.no-anim *::after {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
/* ─────────────────────────────────────────────────────── custom scrollbar */
/* The native bar is hidden rather than styled: CEF keeps drawing a stepper stub
at each end no matter what ::-webkit-scrollbar-button is told. scrollbar.js
draws the replacement, which is a plain element and therefore ours. */
.scroll-y {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scroll-y::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.sbar {
position: fixed;
width: 4px;
border-radius: 20px;
background-color: var(--btn-bg);
z-index: 50;
display: none;
}
.sbar__thumb {
display: block;
width: 100%;
border-radius: 20px;
background-color: var(--text-secondary);
transition: background-color 0.2s ease;
}
.sbar:hover .sbar__thumb,
.sbar.is-held .sbar__thumb {
background-color: var(--accent);
} }
@@ -0,0 +1,23 @@
-- Toasts.
--
-- exports['fiverp-notify']:show('Berries', '3 × Blackberries', 'good')
-- exports['fiverp-notify']:show(nil, 'Too far away', 'bad')
--
-- kind is 'good' | 'bad' | 'info'. Wrap a number in **stars** to set it apart
-- from the sentence around it.
local function show(title, text, kind, duration)
if not text or text == '' then return end
SendNUIMessage({
action = 'show',
data = { title = title, text = text, kind = kind or 'info', duration = duration }
})
end
exports('show', show)
exports('clear', function() SendNUIMessage({ action = 'clear' }) end)
RegisterNetEvent('fiverp-notify:show', function(payload)
if type(payload) ~= 'table' then return end
show(payload.title, payload.text, payload.kind, payload.duration)
end)
+21
View File
@@ -0,0 +1,21 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-notify'
author 'FiveRP'
description 'Toasts, so the chat can stay a place people talk'
version '0.1.0'
ui_page 'html/index.html'
client_scripts { 'client/cl_notify.lua' }
server_scripts { 'server/sv_notify.js' }
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
+47
View File
@@ -0,0 +1,47 @@
/* FiveRP notify — the toasts.
Deliberately dumb: it renders what it is handed and forgets it on a timer.
Anything that has to be remembered belongs in the chat, which is why the two
are separate resources rather than one with a flag. */
const stack = document.getElementById('stack');
const MAX = 5;
function escape(text) {
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// **bold** for the one number in a line that is worth reading on its own.
function markup(text) {
return escape(text).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
}
function drop(el) {
if (!el || el.classList.contains('is-going')) return;
el.classList.add('is-going');
setTimeout(() => el.remove(), 220);
}
function show(data) {
const kind = ['good', 'bad', 'info'].includes(data.kind) ? data.kind : 'info';
const el = document.createElement('div');
el.className = 'toast toast--' + kind;
el.innerHTML =
(data.title ? `<div class="toast__title">${escape(data.title)}</div>` : '') +
`<div class="toast__text">${markup(data.text || '')}</div>`;
stack.appendChild(el);
// Oldest first: a burst should push the stale ones out rather than grow past
// the edge of the screen.
while (stack.childElementCount > MAX) drop(stack.firstElementChild);
const life = Math.max(1200, Math.min(15000, Number(data.duration) || 4200));
setTimeout(() => drop(el), life);
}
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'show') show(msg.data || {});
if (msg.action === 'clear') stack.innerHTML = '';
});

Some files were not shown because too many files have changed in this diff Show More