Compare commits
240
Commits
7e3bc38da4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e1d8c0f4 | ||
|
|
de05c7215f | ||
|
|
864f357fd2 | ||
|
|
986012a80d | ||
|
|
4f0f2f13e3 | ||
|
|
1c5d1f372a | ||
|
|
9ad12796e1 | ||
|
|
7980b7bc23 | ||
|
|
95d3f6e2ce | ||
|
|
bfcf2a8d9a | ||
|
|
b5373c8c62 | ||
|
|
3f1e706741 | ||
|
|
20278f588f | ||
|
|
38a7140f26 | ||
|
|
445436f943 | ||
|
|
e140bc30fc | ||
|
|
4446cad274 | ||
|
|
681927fc23 | ||
|
|
ff656930c8 | ||
|
|
78f611fd8c | ||
|
|
0dbe349da5 | ||
|
|
429199f462 | ||
|
|
b8f877fb45 | ||
|
|
ec6edefd24 | ||
|
|
dac62506b6 | ||
|
|
4bb2188564 | ||
|
|
de0977f7a4 | ||
|
|
8a90adb515 | ||
|
|
cad48d8856 | ||
|
|
7da4ab8103 | ||
|
|
80cb1ffb07 | ||
|
|
9b61acd61a | ||
|
|
b0bc8135bb | ||
|
|
1510bf10a3 | ||
|
|
75f1820e83 | ||
|
|
8f57dedfe6 | ||
|
|
afb4dc4cca | ||
|
|
956d6c506e | ||
|
|
329b954e31 | ||
|
|
b837a90f49 | ||
|
|
d4cf789906 | ||
|
|
4a8f6a5013 | ||
|
|
f5c796631f | ||
|
|
623766b535 | ||
|
|
69ac96522c | ||
|
|
8ba5c6c9e2 | ||
|
|
75faa787fd | ||
|
|
1e98c01cf3 | ||
|
|
0c4c955ddf | ||
|
|
ebe72d9ee5 | ||
|
|
e31a597f82 | ||
|
|
330da8bb17 | ||
|
|
42e11b1d7e | ||
|
|
4c2426370d | ||
|
|
3595ff48e9 | ||
|
|
fc2eea4681 | ||
|
|
caa632fc18 | ||
|
|
e48b3fb512 | ||
|
|
0135e66636 | ||
|
|
05579a8fef | ||
|
|
8ea2989441 | ||
|
|
803788cb72 | ||
|
|
1b6410ebfd | ||
|
|
22b592a533 | ||
|
|
d3ff69a5eb | ||
|
|
cbce3fab46 | ||
|
|
e3af1799bf | ||
|
|
cfa617ed93 | ||
|
|
dfec5ec57a | ||
|
|
6d8f55c21a | ||
|
|
e34f06e1bc | ||
|
|
3aa4017525 | ||
|
|
549de045b0 | ||
|
|
f3be4066e8 | ||
|
|
a66e32acef | ||
|
|
bd36b8fc4f | ||
|
|
228e7af4c9 | ||
|
|
8263491301 | ||
|
|
c7ff9b27c7 | ||
|
|
1bc3677f50 | ||
|
|
2a0eb04b12 | ||
|
|
9ec5436fb8 | ||
|
|
61b696b08a | ||
|
|
7556795a65 | ||
|
|
9eb20f9e15 | ||
|
|
1258b774c3 | ||
|
|
430b560347 | ||
|
|
738cb32eb1 | ||
|
|
74ce60805a | ||
|
|
bc95fce37a | ||
|
|
bd363b3bde | ||
|
|
fac922ddd0 | ||
|
|
4c04651c52 | ||
|
|
f136a2a3c4 | ||
|
|
58be6c2387 | ||
|
|
1aab2394ab | ||
|
|
be1aab1cf8 | ||
|
|
64fe6ba6c5 | ||
|
|
36a10a1a67 | ||
|
|
a938cba25e | ||
|
|
4f1671cc48 | ||
|
|
e5328d4178 | ||
|
|
50231812c8 | ||
|
|
11081486dc | ||
|
|
8d0579f2c3 | ||
|
|
d05d5747de | ||
|
|
9b6b0b1204 | ||
|
|
8058077454 | ||
|
|
4a060f8a91 | ||
|
|
17a2cfbfa5 | ||
|
|
c59b8d9140 | ||
|
|
ba77879350 | ||
|
|
6363a40ba3 | ||
|
|
15f8ad539b | ||
|
|
dd028afd3e | ||
|
|
4fbef903ec | ||
|
|
f79ac6f3b9 | ||
|
|
844b181644 | ||
|
|
6f90df026a | ||
|
|
0091d2a099 | ||
|
|
55acf50692 | ||
|
|
8b07425d25 | ||
|
|
04dee47571 | ||
|
|
d0716bf89a | ||
|
|
c278865a24 | ||
|
|
b76a189d22 | ||
|
|
069bf39ad2 | ||
|
|
7e18f99796 | ||
|
|
eda38810cd | ||
|
|
91c71fd9c8 | ||
|
|
55f28caa14 | ||
|
|
71dc1d1b1d | ||
|
|
2e6872a824 | ||
|
|
fa18e8c1ed | ||
|
|
a199fdce0c | ||
|
|
5bc0a3deab | ||
|
|
b51a6539d3 | ||
|
|
583da23d32 | ||
|
|
f43f0be1a8 | ||
|
|
a47c4ba716 | ||
|
|
1897fc9905 | ||
|
|
7ec439f248 | ||
|
|
8faa1b131f | ||
|
|
6caae5d3b4 | ||
|
|
dbbc194052 | ||
|
|
d3ea0bfc89 | ||
|
|
bafa5e0bce | ||
|
|
fe86565b55 | ||
|
|
f564890ac0 | ||
|
|
ef17e3b1d9 | ||
|
|
909cdc7412 | ||
|
|
07b4185cc3 | ||
|
|
dd57a34a6d | ||
|
|
0db237c1a5 | ||
|
|
2723bbd842 | ||
|
|
76293f2cc1 | ||
|
|
bfc066a1b3 | ||
|
|
09d09cbc52 | ||
|
|
97ed243b34 | ||
|
|
63d8f633d3 | ||
|
|
daa6c9bc5a | ||
|
|
850e2a136a | ||
|
|
48bc94ad76 | ||
|
|
508ff12e57 | ||
|
|
30a2548b2a | ||
|
|
00391580ce | ||
|
|
103d4b4ac2 | ||
|
|
59812479cf | ||
|
|
34b8846976 | ||
|
|
f52f28d2e9 | ||
|
|
4a02faeee4 | ||
|
|
45246e8399 | ||
|
|
f5a65d53cb | ||
|
|
d800baf9a9 | ||
|
|
ffbe30fa78 | ||
|
|
1462e2e9f1 | ||
|
|
ef4eea8147 | ||
|
|
da27a9ca02 | ||
|
|
e040e3b986 | ||
|
|
6dd70e4cae | ||
|
|
450c371e43 | ||
|
|
8bbe20ba25 | ||
|
|
1487f1bdcd | ||
|
|
63360022c8 | ||
|
|
9d6f912f13 | ||
|
|
c8daa086e1 | ||
|
|
d2b4f62949 | ||
|
|
0450ecd05a | ||
|
|
e9c1e344b8 | ||
|
|
32b3465c46 | ||
|
|
0bf4779903 | ||
|
|
ba09caf66f | ||
|
|
70ebafa31d | ||
|
|
742e071434 | ||
|
|
11d94d38f0 | ||
|
|
d1d9d51d5d | ||
|
|
ed59c6ae86 | ||
|
|
4c93f0471f | ||
|
|
9c9a2697a8 | ||
|
|
7b6f0b75f9 | ||
|
|
c7839bb2ef | ||
|
|
29c8034bf0 | ||
|
|
b7c6a1c264 | ||
|
|
6fb4392438 | ||
|
|
92c23f574f | ||
|
|
4a4897ffb0 | ||
|
|
1ea59b5d70 | ||
|
|
e805f97a4c | ||
|
|
bdc8d311be | ||
|
|
9d512f5abc | ||
|
|
8293fdca22 | ||
|
|
a21d5a4371 | ||
|
|
b4b6d22113 | ||
|
|
7ec64c39a3 | ||
|
|
9d4b712d8b | ||
|
|
8ecfa5753e | ||
|
|
c31dcca280 | ||
|
|
bfa3e1ce65 | ||
|
|
59f26dc5af | ||
|
|
bb6b34c1b6 | ||
|
|
302bb2b764 | ||
|
|
85336f109f | ||
|
|
a5385a53a7 | ||
|
|
90722d1c04 | ||
|
|
22dca72f39 | ||
|
|
27f415d4af | ||
|
|
b8f2422433 | ||
|
|
62ebb27d25 | ||
|
|
979470f306 | ||
|
|
18f31b322a | ||
|
|
321102cc72 | ||
|
|
948e16cf16 | ||
|
|
994c1a00ab | ||
|
|
6973c517b4 | ||
|
|
166145631b | ||
|
|
0846c017ae | ||
|
|
b4e5846dff | ||
|
|
3afd892487 | ||
|
|
cc1005c99e | ||
|
|
c998f8fda0 |
No files matched your search
@@ -13,3 +13,6 @@
|
|||||||
# Blender files
|
# Blender files
|
||||||
*.blend
|
*.blend
|
||||||
*.blend1
|
*.blend1
|
||||||
|
|
||||||
|
# Agent worktree state (scripts/wt)
|
||||||
|
/.wt/
|
||||||
@@ -8,28 +8,39 @@ and stake them on wells. When a stake finishes (drill cycles are seconds:
|
|||||||
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
||||||
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
||||||
items (Refinery section: timed batches in slots; refined items sell for more and are parts
|
items (Refinery section: timed batches in slots; refined items sell for more and are parts
|
||||||
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
|
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
|
||||||
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
|
its claim per cycle, the other shortens its cycle, a level at a time, per tool); advisors
|
||||||
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
|
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
|
||||||
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
|
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
|
||||||
banned players left out).
|
banned players left out).
|
||||||
|
|
||||||
## How the parts fit
|
## How the parts fit
|
||||||
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
|
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
|
||||||
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
|
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
|
||||||
Details: `server/AGENTS.md`.
|
authority on game state. Details: `server/AGENTS.md`.
|
||||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
||||||
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
||||||
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
|
what it sends. It is the only game client, and its moon generator
|
||||||
Details: `client/AGENTS.md`.
|
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
|
||||||
|
`client/AGENTS.md`.
|
||||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
||||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
|
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
|
||||||
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
|
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
|
||||||
- Agent worktrees go in the workspace, next to `game/`:
|
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
|
||||||
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
|
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
|
||||||
|
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
|
||||||
|
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
|
||||||
|
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||||
|
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||||
|
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||||
|
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||||
|
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||||
|
database (`moonminers`): it holds the author's game.
|
||||||
|
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||||
|
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||||
commit it.
|
commit it.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||||
|
|
||||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
|
||||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||||
|
|
||||||
## Server and web client
|
## Server
|
||||||
|
|
||||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||||
`server/README.md`).
|
`server/README.md`).
|
||||||
@@ -14,22 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
|||||||
podman-compose up -d db # or: docker compose up -d db
|
podman-compose up -d db # or: docker compose up -d db
|
||||||
cd server
|
cd server
|
||||||
make migrate
|
make migrate
|
||||||
make dev # http://localhost:3000
|
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
||||||
|
starting the server: it applies new migrations and syncs the tools and prices with the
|
||||||
|
config (the server alone doesn't).
|
||||||
|
|
||||||
## Defold client
|
## Defold client
|
||||||
|
|
||||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
||||||
is optional: https://defold.com/download/
|
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
||||||
|
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
||||||
With the server running:
|
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd client
|
||||||
make run # desktop build, plays against http://localhost:3000
|
make run # desktop build, plays against http://localhost:3000
|
||||||
make web && make serve-web # browser build: open http://localhost:8080
|
make web # browser build into dist/web/MoonMiners/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server serves the browser build at http://localhost:3000 (from
|
||||||
|
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
||||||
|
after the first `make web`, restart the server; later builds only need a page reload.
|
||||||
|
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
||||||
|
players.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||||
+189
-527
@@ -1,538 +1,200 @@
|
|||||||
# AGENTS.md — Moon Miners client (Defold)
|
# AGENTS.md — Moon Miners client (Defold)
|
||||||
|
|
||||||
## What this is
|
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
||||||
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
|
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
|
||||||
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
|
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||||
(header with sections, resources and credits; moon list on the left; the current section
|
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
||||||
in the middle; aside with the moon viewport and the selected well), made game-native.
|
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
|
||||||
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
|
gamepad later. Details live in each file's header comment; this file has the rules and a
|
||||||
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later.
|
map.
|
||||||
|
|
||||||
The server is the authority. The client shows the state the server sends and sends
|
## Rules
|
||||||
actions. It may show an action's result before the answer (prediction, see Conventions),
|
- The server is the authority. Show the state it sends, send actions. Prediction may show
|
||||||
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
|
an action's result early, computed only from numbers the server sent (`take`, the tool's
|
||||||
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
|
`cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
|
||||||
rules in Lua. Countdowns may tick locally as display only.
|
replaces it. No other game rules in Lua. Countdowns tick locally as display only.
|
||||||
|
- Keep changes small; don't touch `../server` from here.
|
||||||
|
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
|
||||||
|
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
|
||||||
|
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
|
||||||
|
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
|
||||||
|
label); animations own what's inside (CSS layout vs transform). Never give a box a node
|
||||||
|
that something animates by position or size. Widgets built at a size (ui.button, bars,
|
||||||
|
dashed edges) are built after the solve at their box's rect; text is cut after it
|
||||||
|
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
|
||||||
|
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
|
||||||
|
a button changes its position (reserve room: fixed heights, placeholders), and when a
|
||||||
|
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
|
||||||
|
cards), so repeated clicks at one place keep working.
|
||||||
|
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
|
||||||
|
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
|
||||||
|
whole gui script, so closing a dialog left the game deaf). Don't post
|
||||||
|
acquire/release_input_focus anywhere else. The dialog's input invariant
|
||||||
|
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
|
||||||
|
`ERROR:MM`.
|
||||||
|
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
|
||||||
|
One Druid instance per gui_script (the dialog has its own). Interaction goes through
|
||||||
|
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
|
||||||
|
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
|
||||||
|
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
|
||||||
|
keys, fx and the test driver depend on it.
|
||||||
|
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
|
||||||
|
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||||
|
layout is top-left based (x right, y down).
|
||||||
|
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||||
|
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||||
|
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||||
|
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||||
|
writes each part once per state, by content): never change `g.state` in place; a
|
||||||
|
prediction changes its copy before it is shown. What must survive
|
||||||
|
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||||
|
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||||
|
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||||
|
`blocks.bound(fn)`.
|
||||||
|
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
|
||||||
|
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
||||||
|
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
|
||||||
|
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
||||||
|
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
|
||||||
|
server text is safe to pass. Add missing characters to the `.font` (make check names them).
|
||||||
|
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
||||||
|
doesn't render (make check catches the warning); stencils and images split batches.
|
||||||
|
Outline textures use slice9 2.
|
||||||
|
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||||
|
children: fade content in from under a veil, not by fading a subtree. Every new
|
||||||
|
animation honors reduced motion (`anim.reduced`). Effects for action results go in
|
||||||
|
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
||||||
|
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
|
||||||
|
below 1250 wide the aside gets its own row and the page scrolls. Content caps
|
||||||
|
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
|
||||||
|
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
|
||||||
|
browser console (WebGL feedback loops, Firefox target resizes).
|
||||||
|
- Native code (`mmwindow/`, `mmterrain/`): user rule, C only (`.c`, Lua C API); C++ only
|
||||||
|
where the dmSDK forces it, as a C-style shim (free functions and plain structs: no
|
||||||
|
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
|
||||||
|
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
|
||||||
|
files. Changing a native extension (or the dependencies) sends a remote engine build.
|
||||||
|
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
|
||||||
|
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
|
||||||
|
`display.width/height`.
|
||||||
|
- Never kill processes by name; scripts stop the game by its pid.
|
||||||
|
|
||||||
## Stack
|
## Testing
|
||||||
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
|
||||||
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
|
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
|
||||||
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
|
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
|
||||||
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
|
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
|
||||||
native extension, so a build sends the engine to Defold's build server once (needs
|
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
||||||
network; later builds reuse it until the dependencies change). Dependencies are fetched
|
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
|
||||||
only when `game.project` changes (`make resolve` forces it).
|
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
|
||||||
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
|
says what it checks and needs). Run `make check` (with `MM_API` set to your test
|
||||||
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
|
server; it makes a fixture player there) before calling it done.
|
||||||
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
|
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
|
||||||
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
|
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
|
||||||
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
|
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
|
||||||
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
|
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
|
||||||
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
|
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
|
||||||
`~/Development/Refold/Defold` (read-only).
|
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
|
||||||
|
server, which `wt up` starts).
|
||||||
## Game screen: core, regions, sections
|
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
|
||||||
|
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
|
||||||
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
|
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
|
||||||
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
|
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
|
||||||
state: every new state (a response, a prediction) or a window/UI-scale change sets
|
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
|
||||||
`g.dirty`, and the next update drops the Druid instance and every layout node and builds
|
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
|
||||||
them again. `g` is the gui script's `self`.
|
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
|
||||||
|
click_check to a feature's scenario when it has buttons people click repeatedly.
|
||||||
Core (`screens/game/core/`):
|
|
||||||
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
|
|
||||||
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
|
|
||||||
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
|
|
||||||
the build orchestration (banner, header, moon list, main panel, aside; widths from
|
|
||||||
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
|
|
||||||
The aside shows only when the view selects something for it (`M.aside_shown`, the
|
|
||||||
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
|
|
||||||
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
|
|
||||||
the web's boot and suspended pages) shows until the first state.
|
|
||||||
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
|
|
||||||
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
|
|
||||||
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
|
|
||||||
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
|
|
||||||
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
|
|
||||||
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
|
|
||||||
words ("Something went wrong", "Can't reach the server") with the error sound; a
|
|
||||||
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
|
|
||||||
suspended screen.
|
|
||||||
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
|
|
||||||
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
|
|
||||||
(eased number); keyed so values carry over a rebuild.
|
|
||||||
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
|
|
||||||
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
|
|
||||||
`on_submit` and the refinery's quantity; reset every build), back / forward
|
|
||||||
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
|
|
||||||
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
|
|
||||||
scroll area under the mouse (else the focused one's, else the biggest;
|
|
||||||
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
|
|
||||||
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
|
|
||||||
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
|
|
||||||
Ctrl +/-/0 the UI scale.
|
|
||||||
`session.lua` keeps the view history (`g.history`, like the web's pushState).
|
|
||||||
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
|
|
||||||
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
|
|
||||||
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
|
|
||||||
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
|
|
||||||
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
|
|
||||||
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
|
|
||||||
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
|
|
||||||
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
|
|
||||||
clicks while it pops, and the claim predictor counts a drill whose end has passed on
|
|
||||||
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
|
|
||||||
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
|
|
||||||
|
|
||||||
Regions (one module each, so agents can work on them separately): `banner.lua`,
|
|
||||||
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
|
|
||||||
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
|
|
||||||
in the server's words, counted from its `end`),
|
|
||||||
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
|
|
||||||
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
|
|
||||||
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
|
|
||||||
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
|
|
||||||
fullscreen),
|
|
||||||
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
|
|
||||||
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
|
|
||||||
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
|
|
||||||
`advisors.lua`, the new-moon teaser in `reveal.lua`.
|
|
||||||
|
|
||||||
**Module contract** (regions and sections):
|
|
||||||
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
|
|
||||||
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
|
|
||||||
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
|
|
||||||
center their own capped content. Regions get their box; the header and banner return
|
|
||||||
their height. `H` = `g.h`.
|
|
||||||
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
|
|
||||||
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
|
|
||||||
for content under the viewport.
|
|
||||||
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before every rebuild
|
|
||||||
and when the screen goes away (cancel timers, drop references to nodes).
|
|
||||||
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
|
|
||||||
again every build), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
|
|
||||||
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
|
|
||||||
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
|
|
||||||
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
|
|
||||||
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
|
|
||||||
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
|
|
||||||
builds.
|
|
||||||
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
|
|
||||||
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
|
|
||||||
|
|
||||||
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
|
|
||||||
when the well or drill changes, both on the first state and after the reveal closes
|
|
||||||
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
|
|
||||||
staggered. Not on other rebuilds.
|
|
||||||
|
|
||||||
## Components (`components/`)
|
|
||||||
Build new UI from these instead of coordinate math. Each file's header documents its API.
|
|
||||||
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
|
|
||||||
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
|
|
||||||
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
|
|
||||||
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
|
|
||||||
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
|
|
||||||
or link button has hover and a test id. After a rebuild the button under the mouse
|
|
||||||
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
|
|
||||||
button with a test id: `button.carry`, the one with the same id under the pointer is
|
|
||||||
pressed in the new build), so the release still clicks it.
|
|
||||||
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
|
|
||||||
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
|
|
||||||
instance: survives rebuilds, runs actions in the game script, drops page hovers
|
|
||||||
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
|
|
||||||
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
|
|
||||||
only way to show a dialog (the Monarch popup screen is gone). Input invariant
|
|
||||||
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
|
|
||||||
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
|
|
||||||
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
|
|
||||||
a log with `ERROR:MM`).
|
|
||||||
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
|
|
||||||
- `card.lua`: image column + text column on Druid Layout (measured text, `title`,
|
|
||||||
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
|
|
||||||
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
|
|
||||||
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
|
|
||||||
advisor). Store, Equipment and Advisors.
|
|
||||||
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
|
|
||||||
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
|
|
||||||
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
|
|
||||||
Header and moon list.
|
|
||||||
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
|
|
||||||
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
|
|
||||||
history).
|
|
||||||
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
|
|
||||||
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
|
|
||||||
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
|
|
||||||
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
|
|
||||||
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
|
|
||||||
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
|
|
||||||
`bar.ready_label` (gold "Ready" with a pulsing glow).
|
|
||||||
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
|
|
||||||
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
|
|
||||||
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
|
|
||||||
and time on g so it picks up on the rebuilt row).
|
|
||||||
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
|
|
||||||
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
|
|
||||||
window, hidden by a click or a dialog; on its own layer. Give information-only text a
|
|
||||||
tooltip like the web does.
|
|
||||||
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
|
|
||||||
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
|
|
||||||
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
|
|
||||||
card once complete). A long list (components/list with `focus_id`) is walked item by
|
|
||||||
item, scrolling, since its rows out of view don't exist. So anything interactive must register
|
|
||||||
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
|
|
||||||
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
|
|
||||||
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
|
|
||||||
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
|
|
||||||
|
|
||||||
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
|
|
||||||
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
|
|
||||||
widths yourself); refresh innermost layouts first, synchronously, after building
|
|
||||||
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
|
|
||||||
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
|
|
||||||
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
|
|
||||||
so make a clickable container before the buttons on it.
|
|
||||||
|
|
||||||
## Animation (`modules/anim.lua`)
|
|
||||||
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
|
|
||||||
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
|
|
||||||
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
|
|
||||||
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
|
|
||||||
`scale_about` (scale a pivot-NW node about its middle, like CSS),
|
|
||||||
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
|
|
||||||
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
|
|
||||||
content fades in from under a veil and rises, stagger), `group` (invisible container),
|
|
||||||
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
|
|
||||||
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
|
|
||||||
(scan line), `count`.
|
|
||||||
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
|
|
||||||
translucent fills), so content fades in from under a veil (a box in the background
|
|
||||||
color, `theme.color.veil` for panels) instead of fading a subtree.
|
|
||||||
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
|
|
||||||
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
|
|
||||||
saved in the settings file unless save is false): every helper skips movement and keeps
|
|
||||||
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
|
|
||||||
ticks); the reveal skips the charge and shows the card complete; the ticker stands
|
|
||||||
still. New animations must honor it.
|
|
||||||
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
|
|
||||||
quick repeats of the same gain merge into one number; others stack past it; wallet
|
|
||||||
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
|
|
||||||
error shakes the button it came from, found after the rebuild by its test id
|
|
||||||
(`fx:button`, registered by `H.action`)).
|
|
||||||
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
|
|
||||||
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
|
|
||||||
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
|
|
||||||
|
|
||||||
## Text and markup
|
|
||||||
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
|
|
||||||
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `<` `&`). `ui.style_text` (so
|
|
||||||
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
|
|
||||||
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
|
|
||||||
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
|
|
||||||
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
|
|
||||||
`resource.get_text_metrics`) understands markup.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
- `game.project`: settings; `[mm]` holds the client's own config (see below).
|
|
||||||
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
|
|
||||||
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
|
|
||||||
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
|
|
||||||
components.
|
|
||||||
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
|
|
||||||
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
|
|
||||||
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
|
|
||||||
browser's Fullscreen API is used instead (`modules/screen.lua`).
|
|
||||||
- `web/index.html`: the web build's page (`[html5] htmlfile`; see Web build).
|
|
||||||
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
|
|
||||||
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
|
|
||||||
`chart.lua`
|
|
||||||
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
|
|
||||||
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
|
|
||||||
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
|
|
||||||
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
|
|
||||||
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
|
|
||||||
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
|
|
||||||
unseat, dismiss; an unhired one's details on a card click),
|
|
||||||
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
|
|
||||||
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
|
|
||||||
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
|
|
||||||
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
|
|
||||||
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
|
|
||||||
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
|
|
||||||
whenever the shown state changes: at the click for a predicted action, in the request
|
|
||||||
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
|
|
||||||
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
|
|
||||||
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
|
|
||||||
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
|
|
||||||
moon sale register with `fx:box(node)` while built; give a new card-like container one.
|
|
||||||
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
|
|
||||||
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
|
|
||||||
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
|
|
||||||
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
|
|
||||||
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
|
|
||||||
moon). View links carry the current moon like the web's (`View.ToSection`): without
|
|
||||||
one the server picks the first moon.
|
|
||||||
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
|
|
||||||
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
|
|
||||||
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
|
|
||||||
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
|
|
||||||
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
|
|
||||||
file),
|
|
||||||
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
|
|
||||||
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
|
|
||||||
(hot reload).
|
|
||||||
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
|
|
||||||
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
|
|
||||||
clones them (`ui.clone`).
|
|
||||||
- `widgets/moon_row.lua`: the moon list row (Druid widget).
|
|
||||||
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
|
|
||||||
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
|
|
||||||
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
|
|
||||||
fade, ring, spark, speaker, sun and moon icons; tinted per node.
|
|
||||||
- The moon viewport: `modules/moon/terrain.lua` ports the server's `terrain.js`
|
|
||||||
(`scripts/moon-check.mjs` compares them), `modules/moon/moon.lua` is the API
|
|
||||||
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`),
|
|
||||||
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
|
|
||||||
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
|
|
||||||
HDR target with bloom; the viewport node shows it through the `moon` gui material.
|
|
||||||
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
|
|
||||||
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
|
|
||||||
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
|
|
||||||
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
|
|
||||||
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
|
|
||||||
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
|
|
||||||
`zzfx-render.mjs`, `serve-web.py` and `web-shots.mjs` (Web build).
|
|
||||||
|
|
||||||
## Web build
|
|
||||||
`make web` bundles for the browser (`wasm-web`, release; `WEB_VARIANT=debug` keeps logs
|
|
||||||
and the test hook) into `dist/web/MoonMiners/` (release ~4.3 MB, ~2.4 MB gzipped: engine wasm 2.9 MB,
|
|
||||||
archive 1.1 MB, page and loader).
|
|
||||||
`make serve-web` (`scripts/serve-web.py`, `UPSTREAM=` default http://localhost:3000,
|
|
||||||
`WEB_PORT=` 8080) serves it and proxies `/api/*` and `/static/*` (everything the client
|
|
||||||
fetches) to the server, so the game talks to its own origin; the server stays unchanged.
|
|
||||||
- The page (`web/index.html`): the canvas fills the window in device pixels and follows
|
|
||||||
its size (the layout is native-pixel like desktop); dark loading screen; "WebGL 2
|
|
||||||
needed" without WebGL 2 (the moon's shaders don't build for WebGL 1). `[html5]` in
|
|
||||||
game.project: `stretch`, no Defold buttons or banner, heap 256 MB.
|
|
||||||
- On html5 (`if html5 then`): `api.base` is the page's origin (`?api=<url>` picks another
|
|
||||||
server, which must allow CORS); the player id and settings are saved with sys.save
|
|
||||||
(IndexedDB), X-MM-User sent as on desktop (the cookie works too, same origin).
|
|
||||||
Fullscreen (F11, display dialog) through the browser's Fullscreen API, not saved or
|
|
||||||
restored (the browser allows it only after a click or key). Moon generation is plain
|
|
||||||
Lua there (no LuaJIT): grid capped at 96 (`WEB_RESOLUTION`) and 12 ms a frame, a few
|
|
||||||
seconds per moon. Moon targets fall back to 8 bits without EXT_color_buffer_float.
|
|
||||||
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
|
|
||||||
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
|
|
||||||
depth buffer: the render script binds every bloom sampler and makes targets again at a
|
|
||||||
new size. Check the console after render changes.
|
|
||||||
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
|
|
||||||
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
|
|
||||||
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
|
|
||||||
by real mouse clicks (element rects from the test hook: the page pushes commands onto
|
|
||||||
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
|
|
||||||
claim; fails on console errors.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
|
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
|
||||||
bob's output; `build/x86_64-linux/dmengine` is the engine with our extension).
|
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
|
||||||
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
|
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
|
||||||
- `make web`, `make serve-web`: the browser build (Web build).
|
from `build/default` in the foreground (loose files, engine service on), so `make reload`
|
||||||
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
|
works on it.
|
||||||
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
|
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
|
||||||
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
|
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
|
||||||
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
|
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
|
||||||
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
|
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
|
||||||
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
|
`node scripts/web-shots.mjs` (Playwright; header of the script).
|
||||||
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
|
|
||||||
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
|
|
||||||
- `make check`: checks (below).
|
|
||||||
- Before/after screenshots of every section (works on any build, also one from before a
|
|
||||||
change): `scripts/fixture.py <api> > fx.json`, then
|
|
||||||
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
|
|
||||||
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
|
|
||||||
after each other (the fixture's drills and market move on).
|
|
||||||
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
|
|
||||||
`mm.fake_reject`, shots via `SHOTS_DIR`).
|
|
||||||
- Never kill processes by name (scripts stop the game by its pid).
|
|
||||||
|
|
||||||
## Test driver (`modules/test.lua`, `scripts/drive.py`)
|
## Hot reload (`make reload`, `scripts/dev.py`)
|
||||||
The engine's debug HTTP service (debug builds; port from the log line `Engine service
|
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
|
||||||
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
|
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
|
||||||
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
|
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
|
||||||
runs in the same Lua state as the gui script but outside it, so it only pushes commands
|
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
|
||||||
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
|
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
|
||||||
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
|
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
|
||||||
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
|
too; restart if one looks wrong.
|
||||||
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
|
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
|
||||||
`$MM_DRIVE_DATA`), never the player's own settings.
|
collections, game objects, `main.script`, materials, shaders, the render script; modules
|
||||||
- Elements: every interactive component registers a stable id while it's built:
|
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
|
||||||
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
|
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
|
||||||
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
|
native extensions and dependencies (a new engine).
|
||||||
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
|
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
|
||||||
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
|
|
||||||
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
|
|
||||||
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
|
|
||||||
over three frames through Druid: real input, hover included), `hover(id)`,
|
|
||||||
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
|
|
||||||
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
|
|
||||||
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
|
|
||||||
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
|
|
||||||
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
|
|
||||||
`restart()` (quit and start the game again, same options and saved files).
|
|
||||||
Those go through the test hook, which calls the screen's input function directly, so
|
|
||||||
they work even when the engine sends the game no input. Real input through the X server
|
|
||||||
(xdotool on the driver's Xvfb; key and button held 80 ms, since the engine polls once a
|
|
||||||
frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
|
|
||||||
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
|
|
||||||
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
|
|
||||||
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
|
|
||||||
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
|
|
||||||
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
|
|
||||||
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
|
|
||||||
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
|
|
||||||
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
|
|
||||||
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
|
|
||||||
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
|
|
||||||
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
|
|
||||||
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
|
|
||||||
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
|
|
||||||
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
|
|
||||||
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
|
|
||||||
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
|
|
||||||
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
|
|
||||||
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
|
|
||||||
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
|
|
||||||
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
|
|
||||||
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
|
|
||||||
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
|
|
||||||
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
|
|
||||||
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
|
|
||||||
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
|
|
||||||
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
|
|
||||||
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
|
|
||||||
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
|
|
||||||
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
|
|
||||||
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
|
|
||||||
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
|
|
||||||
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
|
|
||||||
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
|
|
||||||
page at once; the invariant's repair; args `reduced`, and run it with `--config
|
|
||||||
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
|
|
||||||
moon; the Moons tab returns to the last moon after other sections, back / forward, a
|
|
||||||
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
|
|
||||||
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
|
|
||||||
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
|
|
||||||
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
|
|
||||||
folder (`<out>/data`), never the real settings file. Run with `--config
|
|
||||||
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
|
|
||||||
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
|
|
||||||
grants through `/utils/players`); prints its ids as JSON.
|
|
||||||
|
|
||||||
## Fast iteration (hot reload)
|
|
||||||
`make dev` builds once and starts the game from the build folder (engine
|
|
||||||
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
|
|
||||||
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
|
|
||||||
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
|
|
||||||
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
|
|
||||||
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
|
|
||||||
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
|
|
||||||
registered first (a `run_script` listing them), then `.luac` files, then the game's
|
|
||||||
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
|
|
||||||
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
|
|
||||||
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
|
|
||||||
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
|
|
||||||
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
|
|
||||||
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
|
|
||||||
to those, to collections, game objects, `main.script`, materials, shaders or the render
|
|
||||||
script need a restart: `make dev-stop dev` (reload says so).
|
|
||||||
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
|
|
||||||
(core/main.lua) or lazily.
|
(core/main.lua) or lazily.
|
||||||
- `make reload` fails loudly on a compile error (and keeps the old code running).
|
|
||||||
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
|
|
||||||
|
|
||||||
## Checks (`make check`, scripts/check.py)
|
## Map
|
||||||
Fails on: Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
|
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
|
||||||
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
|
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
|
||||||
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
|
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
|
||||||
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
|
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
||||||
a fixture player in every section: symbols in sym, the rest in the text fonts); and
|
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
|
||||||
engine warnings in a `sections` scenario log (font batches, characters, text entries,
|
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
|
||||||
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
|
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
|
||||||
against `MM_API` (default http://localhost:3000) when it answers; otherwise the newest
|
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
||||||
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
|
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
|
||||||
|
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
||||||
|
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
|
||||||
|
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
|
||||||
|
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
||||||
|
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
|
||||||
|
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
||||||
|
(shared by card sections).
|
||||||
|
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
|
||||||
|
|
||||||
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
|
Components (`components/`, API in each header; build new UI from these): `button`,
|
||||||
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per
|
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
||||||
server.
|
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
|
||||||
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
|
|
||||||
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
|
|
||||||
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
|
|
||||||
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
|
|
||||||
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
|
|
||||||
- `view`: the view to open, `key=value&...` of `state.view` keys
|
|
||||||
(`section=equipment&drill=<id>`).
|
|
||||||
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
|
|
||||||
- `theme`: `light` or `dark` (default: the saved setting, else dark).
|
|
||||||
- `test`: `0` turns the test hook off in a debug build (it's never in release).
|
|
||||||
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
|
|
||||||
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
|
|
||||||
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
|
|
||||||
scale; not saved). Default: the saved setting (the display dialog), else 1.
|
|
||||||
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
|
|
||||||
--config mm.display_scale=2` looks like 1280x800).
|
|
||||||
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
|
|
||||||
the others on the command line.
|
|
||||||
|
|
||||||
## Conventions
|
Modules (`modules/`):
|
||||||
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
|
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
||||||
not screens.
|
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
|
||||||
- Every gui_script uses one Druid instance (`druid.new(self)` and the
|
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
|
||||||
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
|
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
|
||||||
interaction (buttons, hover, scroll, input, layout, data list) through the components.
|
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
|
||||||
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
|
{ lines = 2 })` (at most n lines, cut with …).
|
||||||
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
|
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
|
||||||
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
|
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
|
||||||
colors come from `theme.lua`.
|
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
||||||
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
|
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
||||||
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
|
`hot.lua` (hot reload).
|
||||||
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
|
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
|
||||||
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
|
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
|
||||||
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
|
|
||||||
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
|
Elsewhere:
|
||||||
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
|
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
||||||
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
|
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
||||||
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
|
`render/mm.render_script`: HDR moon pass with bloom.
|
||||||
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
|
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
|
||||||
1280x800 every cap is above what fits; keep it that way (compare shots).
|
game's only one (it began as a C port of the web client's terrain.js, now deleted).
|
||||||
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
|
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
||||||
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
|
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
||||||
is a token: no hex or vector colors in code (template node colors via `theme.template`,
|
- `web/index.html`: the web build's page.
|
||||||
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
|
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
|
||||||
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
|
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
|
||||||
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
|
|
||||||
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
|
## Stack
|
||||||
tells you which).
|
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
||||||
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
|
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
|
||||||
`max_characters` raised in `game.project`. Text past the font batch limit silently
|
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
|
||||||
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
|
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
|
||||||
dialog) and images split font batches.
|
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
|
||||||
- Outline textures use slice9 2 (not 1).
|
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
|
||||||
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
|
|
||||||
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
|
## Config (`--config=mm.<key>=<value>`)
|
||||||
- The server is the only source of truth: every response's `state` replaces ours and
|
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
|
||||||
`state.view` becomes our view; actions send the current view plus their params and
|
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
|
||||||
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
|
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
|
||||||
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
|
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
|
||||||
when `nextEnd` is due.
|
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
|
||||||
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
|
`reveal_freeze`, `sfx_debug`.
|
||||||
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
|
|
||||||
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
|
|
||||||
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
|
|
||||||
refine, collect. Others (and predictors that return false) show a busy button.
|
|
||||||
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
|
|
||||||
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
|
|
||||||
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
|
|
||||||
counts through fx.
|
|
||||||
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
|
||||||
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
|
|
||||||
it. Run `make check` before calling it done.
|
|
||||||
- Keep changes small; don't touch the server (`../server`) from here.
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Flex layout: notes from the first port
|
||||||
|
|
||||||
|
Solver `modules/flex.lua` (430 lines incl. docs), apply helper `modules/flex_gui.lua` (96),
|
||||||
|
tests `scripts/flex_test.lua` (53 checks, in `make check`).
|
||||||
|
|
||||||
|
## Lines, before -> after
|
||||||
|
|
||||||
|
| File | Lines | Hand-placed offsets (`x + `, `y + `, `+ 8`, `set_position`) |
|
||||||
|
|---|---|---|
|
||||||
|
| `screens/game/refinery.lua` | 524 -> 595 | 35 -> 10 |
|
||||||
|
| `components/card.lua` | 298 -> 325 | Druid Layout gone; one tree replaces three branches of padding math |
|
||||||
|
| `screens/game/header.lua` (wallet only) | 243 -> 256 | running `cx` cursor gone |
|
||||||
|
|
||||||
|
The code is longer, not shorter: every text is now a box with its line-box height, and
|
||||||
|
the tree literals add structure lines. What it gains: no running cursors, no "measure, then
|
||||||
|
come back and move it" (`gui.set_position(mx, vec(w - mw, -fy))`, the slot label's probe
|
||||||
|
node, the Collect all offset `67.9 - 44`). Spacing is in one place per container, and the
|
||||||
|
heading and stock now grow with their content (before: fixed 67.9 px, chips wrapped by
|
||||||
|
hand).
|
||||||
|
|
||||||
|
Screenshots (`shots/c-flex/before|after`, 1280x800 dark and light, 1024x640): Refinery
|
||||||
|
(ready, idle, running slots, buy slot, recipe footers), Store, Equipment, Advisors and the
|
||||||
|
header match pixel for pixel. The only differences are animations caught at another
|
||||||
|
moment (Claim all, Ready glow, pulsing buttons) and fixture data. Scenarios smoke, cards,
|
||||||
|
keys and finalcheck pass (keys timed out once on the Market tab, then passed; the baseline
|
||||||
|
passes too).
|
||||||
|
|
||||||
|
## Easier
|
||||||
|
|
||||||
|
- Rows with something pinned right (time left, "have" counts, Max button), centered
|
||||||
|
labels, and footers pinned to the bottom: `justify`, a `grow = 1` spacer.
|
||||||
|
- Wrapping chips and equal-height slot rows: `wrap = true` plus the default stretch.
|
||||||
|
- Text cut to the space left: a shrinking leaf, then `ui.fit_text(node, box.w)`.
|
||||||
|
|
||||||
|
## Harder
|
||||||
|
|
||||||
|
- Matching the old pixel offsets exactly. The old code centers text at hand-picked middles
|
||||||
|
(12 for one label, 11 for the one beside it), so some rows need leaves of different
|
||||||
|
heights inside a fixed-height row. A new screen built with flex from the start won't
|
||||||
|
have this.
|
||||||
|
- Widgets built at a size (ui.button, the cell bar, dashed edges, the quantity field):
|
||||||
|
solve with an empty box, then build at its rect. Fine, but it splits a component in two.
|
||||||
|
- Creation order is focus and Druid input order, so nodes must still be built in the old
|
||||||
|
order; only their placement moves to the end.
|
||||||
|
- bob follows `require` strings, and flex.apply -> flex_gui -> flex is a cycle; it uses a
|
||||||
|
non-literal name.
|
||||||
|
|
||||||
|
## What the solver does that CSS doesn't (or does differently)
|
||||||
|
|
||||||
|
- border-box sizes, `min-width: 0` by default (CSS's auto min size would stop labels from
|
||||||
|
being cut), auto size = fit-content even at the root, align-content always start, no
|
||||||
|
margins (spacer boxes), no baseline alignment.
|
||||||
|
- No pixel snapping. One label (buy slot) is solved, then placed on a whole pixel by hand,
|
||||||
|
as the old code did; a `round` option could do it.
|
||||||
|
- CSS grid's `minmax(200px, 1fr)` isn't flex: the columns are computed (as before) and
|
||||||
|
given to wrapped items as widths. A small `grid` helper would cover card.grid and the
|
||||||
|
slots.
|
||||||
|
|
||||||
|
## Also fixed
|
||||||
|
|
||||||
|
- `card.grid` rebuilt a kept card whose row height changed without the card options
|
||||||
|
(a recipe card would have gotten an image column).
|
||||||
|
|
||||||
|
## Recommendation for the rest
|
||||||
|
|
||||||
|
Port section by section when a section is touched anyway, not as one sweep: equipment's
|
||||||
|
tool cards and the drill panel, store and advisors (already on Card), market's ticket and
|
||||||
|
stats, the header's tab row (it has a "second row when it doesn't fit" rule, which is a
|
||||||
|
wrap). Keep kept blocks as they are: solve what a build makes. Leave lists (Data List)
|
||||||
|
and the scroll areas alone. Add a `grid` helper and rounding before the next big port.
|
||||||
+41
-16
@@ -1,5 +1,17 @@
|
|||||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
||||||
# python3 and luajit for shot, play, drive, dev and check.
|
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
||||||
|
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
||||||
|
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
||||||
|
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
||||||
|
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
||||||
|
# for shot, play, drive, dev and check (Linux).
|
||||||
|
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
||||||
|
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
||||||
|
$(USERPROFILE)/Defold C:/Defold
|
||||||
|
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
||||||
|
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
||||||
|
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
||||||
|
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
||||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||||
BOB := tools/bob.jar
|
BOB := tools/bob.jar
|
||||||
@@ -11,32 +23,33 @@ SHOT ?= dist/shot.png
|
|||||||
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
|
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
|
||||||
ARGS ?=
|
ARGS ?=
|
||||||
|
|
||||||
.PHONY: build run shot play clean resolve dev reload dev-stop drive check web serve-web
|
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
|
||||||
|
|
||||||
$(BOB):
|
$(BOB):
|
||||||
mkdir -p tools
|
mkdir -p tools
|
||||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||||
|
|
||||||
resolve: $(BOB)
|
resolve: $(BOB)
|
||||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||||
|
|
||||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
||||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
.internal/lib/dependencies.json: game.project | $(BOB)
|
||||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||||
touch $@
|
touch $@
|
||||||
|
|
||||||
build: $(BOB) .internal/lib/dependencies.json
|
build: $(BOB) .internal/lib/dependencies.json
|
||||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
--archive --bundle-output dist/$(PLATFORM) build bundle
|
||||||
|
|
||||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
||||||
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
||||||
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
|
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
||||||
|
# with /api and /static proxied to UPSTREAM (a test server).
|
||||||
WEB_VARIANT ?= release
|
WEB_VARIANT ?= release
|
||||||
UPSTREAM ?= http://localhost:3000
|
UPSTREAM ?= http://localhost:3000
|
||||||
WEB_PORT ?= 8080
|
WEB_PORT ?= 8080
|
||||||
web: $(BOB) .internal/lib/dependencies.json
|
web: $(BOB) .internal/lib/dependencies.json
|
||||||
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||||
--archive --bundle-output dist/web build bundle
|
--archive --bundle-output dist/web build bundle
|
||||||
|
|
||||||
serve-web:
|
serve-web:
|
||||||
@@ -50,9 +63,9 @@ dev: build
|
|||||||
|
|
||||||
reload: $(BOB)
|
reload: $(BOB)
|
||||||
@mkdir -p dist/dev
|
@mkdir -p dist/dev
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||||
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
||||||
scripts/dev.py reload
|
@scripts/dev.py reload
|
||||||
|
|
||||||
dev-stop:
|
dev-stop:
|
||||||
scripts/dev.py stop
|
scripts/dev.py stop
|
||||||
@@ -63,28 +76,40 @@ SCENARIO ?= smoke
|
|||||||
DRIVE ?= --fixture
|
DRIVE ?= --fixture
|
||||||
drive: $(BOB) .internal/lib/dependencies.json
|
drive: $(BOB) .internal/lib/dependencies.json
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||||
|
|
||||||
|
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
|
||||||
|
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||||
|
lint: $(BOB) .internal/lib/dependencies.json
|
||||||
|
@mkdir -p dist
|
||||||
|
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||||
|
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||||
|
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||||
|
|
||||||
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
|
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
|
||||||
# scenario run (sections) against MM_API (default http://localhost:3000) if it answers.
|
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||||
check: $(BOB) .internal/lib/dependencies.json
|
check: $(BOB) .internal/lib/dependencies.json
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||||
scripts/check.py
|
scripts/check.py
|
||||||
|
|
||||||
|
# The game from build/default (loose files, engine service on), so `make reload` works on it.
|
||||||
|
# The bundle in dist/ is for shot, play and shipping.
|
||||||
run: build
|
run: build
|
||||||
$(BIN) $(ARGS)
|
scripts/dev.py run $(ARGS)
|
||||||
|
|
||||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||||
shot: build
|
shot: build
|
||||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||||
|
|
||||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||||
|
# Needs MM_API (a test server): it makes a new player there.
|
||||||
play: build
|
play: build
|
||||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
||||||
|
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build dist .internal/cache
|
rm -rf build dist .internal/cache
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 877 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 806 B |
@@ -27,7 +27,8 @@ The claim whir, counter ticks, claim blips, credit blips and the moon reveal sou
|
|||||||
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
|
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
|
||||||
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
|
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
|
||||||
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
|
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
|
||||||
web build's parameters (server/web/static/js/sfx.js).
|
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
|
||||||
|
reveal's build-up and burst, client only) are synthesized by the same script.
|
||||||
|
|
||||||
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
|
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
|
||||||
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
|
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -31,9 +31,18 @@ images {
|
|||||||
images {
|
images {
|
||||||
image: "/assets/images/sun.png"
|
image: "/assets/images/sun.png"
|
||||||
}
|
}
|
||||||
|
images {
|
||||||
|
image: "/assets/images/gear.png"
|
||||||
|
}
|
||||||
images {
|
images {
|
||||||
image: "/assets/images/moon.png"
|
image: "/assets/images/moon.png"
|
||||||
}
|
}
|
||||||
|
images {
|
||||||
|
image: "/assets/images/pentagon.png"
|
||||||
|
}
|
||||||
|
images {
|
||||||
|
image: "/assets/images/pentagon_rim.png"
|
||||||
|
}
|
||||||
margin: 2
|
margin: 2
|
||||||
extrude_borders: 2
|
extrude_borders: 2
|
||||||
inner_padding: 0
|
inner_padding: 0
|
||||||
@@ -29,7 +29,6 @@ local theme = require("modules.theme")
|
|||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local anim = require("modules.anim")
|
local anim = require("modules.anim")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
local helper = require("druid.helper")
|
|
||||||
local druid_button = require("druid.base.button")
|
local druid_button = require("druid.base.button")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
@@ -144,23 +143,28 @@ function M.release()
|
|||||||
carry.pressed = nil
|
carry.pressed = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- After a rebuild, with the mouse still down at (x, y) (screen) on a button of the build
|
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
|
||||||
-- before: press the new button with its id under the pointer, so the release clicks it.
|
-- before: press the new button with its id under the pointer, so the release clicks it.
|
||||||
function M.carry(x, y)
|
function M.carry(x, y)
|
||||||
local p = carry.pressed
|
local p = carry.pressed
|
||||||
if not p or not p.id then
|
if not p or not p.id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- (not only one under the pointer now: a full build plays the page's entrance, so the
|
||||||
|
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
|
||||||
|
local found
|
||||||
for _, b in ipairs(carry.made) do
|
for _, b in ipairs(carry.made) do
|
||||||
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true)
|
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
|
||||||
and helper.pick_node(b.node, x, y, b.click_zone) then
|
|
||||||
b.can_action = true
|
b.can_action = true
|
||||||
b.last_pressed_time = p.t
|
b.last_pressed_time = p.t
|
||||||
b.hover:set_hover(true)
|
b.hover:set_hover(true)
|
||||||
carry.pressed = { node = b.node, id = p.id, t = p.t }
|
found = b
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if found then
|
||||||
|
carry.pressed = { node = found.node, id = p.id, t = p.t }
|
||||||
|
return true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw and bind. Returns { root, nodes, w, h, button }.
|
-- Draw and bind. Returns { root, nodes, w, h, button }.
|
||||||
|
|||||||
+133
-54
@@ -1,12 +1,12 @@
|
|||||||
-- Card (the web's .card / .store-card): an image column on the left and a text column
|
-- Card (the web's .card / .store-card): an image column on the left and a text column
|
||||||
-- that hugs its content, built on Druid Layout (vertical, measured text: a paragraph's
|
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
|
||||||
-- height comes from its wrapped lines), with an optional footer block pinned to the
|
-- paragraph's height comes from its wrapped lines), with an optional footer block pinned to the
|
||||||
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
|
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
|
||||||
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
|
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
|
||||||
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
|
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
|
||||||
-- heights per row, and
|
-- heights per row, and
|
||||||
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
|
-- registers them for the panel entrance (g.frames, staggered). Store and
|
||||||
-- Advisors use it.
|
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
|
||||||
--
|
--
|
||||||
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
|
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
|
||||||
-- c:image(item.image)
|
-- c:image(item.image)
|
||||||
@@ -20,10 +20,9 @@
|
|||||||
-- c.edge_hover = { "ready", 0.7 } -- its hover
|
-- c.edge_hover = { "ready", 0.7 } -- its hover
|
||||||
-- end)
|
-- end)
|
||||||
--
|
--
|
||||||
-- Rules from druid-notes.md: the layout node is an invisible box (no slice9), pivot NW;
|
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
|
||||||
-- it's refreshed synchronously once the card is built (Layout never resizes children:
|
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
|
||||||
-- widths are set here); text nodes are measured by their text (pivot W lines, wrapped
|
-- what's inside a slot is placed by hand, relative to it.
|
||||||
-- NW paragraphs).
|
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
@@ -31,6 +30,9 @@ local images = require("modules.images")
|
|||||||
local anim = require("modules.anim")
|
local anim = require("modules.anim")
|
||||||
local button = require("components.button")
|
local button = require("components.button")
|
||||||
local panel = require("components.panel")
|
local panel = require("components.panel")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local flex_gui = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -58,23 +60,32 @@ function M.media_size(cw)
|
|||||||
return math.max(120, math.min(156, math.floor(cw * 0.3)))
|
return math.max(120, math.min(156, math.floor(cw * 0.3)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none).
|
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none: just
|
||||||
|
-- the text column), pad (the text column's padding on every side; default 14 across,
|
||||||
|
-- 12 above, 14 below).
|
||||||
function M.new(g, parent, x, y, w, opts)
|
function M.new(g, parent, x, y, w, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local stacked = opts.media == nil and w < S.card_min_w
|
local stacked = opts.media == nil and w < S.card_min_w
|
||||||
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
|
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
|
||||||
local c = setmetatable({ g = g, w = w, m = m, footer_h = 0, stacked = stacked }, M)
|
local c = setmetatable({ g = g, x = x, y = y, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad, items = {} }, M)
|
||||||
c.root = ui.rect(parent, x, y, w, 1, "panel")
|
c.root = ui.rect(parent, x, y, w, 1, "panel")
|
||||||
|
if m > 0 then
|
||||||
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
|
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
|
||||||
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
|
|
||||||
if stacked then
|
|
||||||
c.bw = w - PAD * 2
|
|
||||||
c.body = anim.group(c.root, PAD, m + 14, c.bw, 1)
|
|
||||||
else
|
|
||||||
c.bw = w - m - PAD * 2
|
|
||||||
c.body = anim.group(c.root, m + PAD, 12, c.bw, 1)
|
|
||||||
end
|
end
|
||||||
c.layout = g.druid:new_layout(c.body, "vertical"):set_padding(0, 0, 0, 0):set_margin(0, 0):set_hug_content(false, true)
|
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
|
||||||
|
-- The text column's padding (top, right, bottom, left) and width.
|
||||||
|
if m == 0 then
|
||||||
|
local side = opts.pad or PAD
|
||||||
|
c.padding = { opts.pad or 12, side, opts.pad or 14, side }
|
||||||
|
c.bw = w - side * 2
|
||||||
|
elseif stacked then
|
||||||
|
c.padding = { 14, PAD, 14, PAD }
|
||||||
|
c.bw = w - PAD * 2
|
||||||
|
else
|
||||||
|
c.padding = { 12, PAD, 14, PAD }
|
||||||
|
c.bw = w - m - PAD * 2
|
||||||
|
end
|
||||||
|
c.body = anim.group(c.root, 0, 0, c.bw, 1)
|
||||||
return c
|
return c
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -86,29 +97,29 @@ function M:image(url)
|
|||||||
self.image_node = node
|
self.image_node = node
|
||||||
end
|
end
|
||||||
|
|
||||||
local function add(self, node, gap)
|
-- A box in the text column (flex: modules/flex.lua).
|
||||||
|
local function add(self, box, gap)
|
||||||
if gap and gap > 0 then
|
if gap and gap > 0 then
|
||||||
self:gap(gap)
|
self:gap(gap)
|
||||||
end
|
end
|
||||||
self.layout:add(node)
|
self.items[#self.items + 1] = box
|
||||||
return node
|
return box.node
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Vertical space.
|
-- Vertical space.
|
||||||
function M:gap(px)
|
function M:gap(px)
|
||||||
local n = anim.group(self.body, 0, 0, 1, px)
|
self.items[#self.items + 1] = { style = { height = px } }
|
||||||
self.layout:add(n)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
|
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
|
||||||
-- line boxes), cut with … to the column. style: theme.text name or table.
|
-- line boxes), cut with ... to the column. style: theme.text name or table.
|
||||||
function M:text(str, style, h)
|
function M:text(str, style, h)
|
||||||
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
|
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
|
||||||
local t = ui.label(slot, str, style or "body", 0, 8)
|
local t = ui.label(slot, str, style or "body", 0, 8)
|
||||||
if not ui.needs_rich(str) then
|
if not ui.needs_rich(str) then
|
||||||
ui.fit_text(t, self.bw)
|
ui.fit_text(t, self.bw)
|
||||||
end
|
end
|
||||||
self.layout:add(slot)
|
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
|
||||||
return t
|
return t
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -117,20 +128,29 @@ function M:title(str)
|
|||||||
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
|
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A paragraph that wraps at the column width (its height measured by the layout).
|
-- A paragraph that wraps at the column width (its height measured).
|
||||||
function M:para(str, style, gap)
|
function M:para(str, style, gap)
|
||||||
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
|
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
|
||||||
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
|
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
|
||||||
gui.set_line_break(n, true)
|
gui.set_line_break(n, true)
|
||||||
local s = gui.get_scale(n).x
|
local s = gui.get_scale(n).x
|
||||||
gui.set_size(n, vec(self.bw / s, 10))
|
gui.set_size(n, vec(self.bw / s, 10))
|
||||||
return add(self, n, gap)
|
return add(self, { node = n, size = false, style = { width = self.bw }, measure = flex_gui.text_measure(n) }, gap)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Any node (its size is what the layout counts; pivot NW or W).
|
-- Any node (its size, or a text's metrics, is what counts; pivot NW or W).
|
||||||
function M:node(n, gap)
|
function M:node(n, gap)
|
||||||
gui.set_parent(n, self.body, false)
|
gui.set_parent(n, self.body, false)
|
||||||
return add(self, n, gap)
|
local measure
|
||||||
|
if gui.get_text(n) then
|
||||||
|
measure = flex_gui.text_measure(n)
|
||||||
|
else
|
||||||
|
measure = function()
|
||||||
|
local sz, sc = gui.get_size(n), gui.get_scale(n)
|
||||||
|
return sz.x * sc.x, sz.y * sc.y
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return add(self, { node = n, size = false, style = { align_self = "start" }, measure = measure }, gap)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
|
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
|
||||||
@@ -141,10 +161,7 @@ function M:block(h, fn, gap)
|
|||||||
end
|
end
|
||||||
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
|
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
|
||||||
local used = fn(n, self.bw)
|
local used = fn(n, self.bw)
|
||||||
if not h then
|
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
|
||||||
gui.set_size(n, vec(self.bw, used or 0))
|
|
||||||
end
|
|
||||||
return add(self, n, gap)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Something clickable inside the card's text (a link): fn runs once the card's own click
|
-- Something clickable inside the card's text (a link): fn runs once the card's own click
|
||||||
@@ -160,27 +177,48 @@ function M:footer(h, fn)
|
|||||||
self.footer_h, self.footer_fn = h, fn
|
self.footer_h, self.footer_fn = h, fn
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The card as a flex tree (modules/flex): the image column (or strip) and the text
|
||||||
|
-- column, whose items stack from the top and whose footer a growing spacer pushes to the
|
||||||
|
-- bottom; h: the card's height (nil: its content's, at least the image column's).
|
||||||
|
function M:tree(h)
|
||||||
|
local kids = {}
|
||||||
|
for i, b in ipairs(self.items) do
|
||||||
|
kids[i] = b
|
||||||
|
end
|
||||||
|
kids[#kids + 1] = { style = { grow = 1 } }
|
||||||
|
self.footer_box = { style = { width = self.bw, height = self.footer_h } }
|
||||||
|
kids[#kids + 1] = self.footer_box
|
||||||
|
local body = { node = self.body, style = { direction = "column", align = "start", grow = 1, padding = self.padding }, children = kids }
|
||||||
|
local root = { node = self.root, style = { width = self.w, height = h }, children = { body } }
|
||||||
|
if self.stacked then
|
||||||
|
root.style.direction = "column"
|
||||||
|
table.insert(root.children, 1, { node = self.media, style = { height = self.m } })
|
||||||
|
elseif self.m > 0 then
|
||||||
|
root.style.min_height = self.m
|
||||||
|
table.insert(root.children, 1, { node = self.media, style = { width = self.m } })
|
||||||
|
else
|
||||||
|
root.style.direction = "column"
|
||||||
|
end
|
||||||
|
return flex.solve(root, self.x, self.y)
|
||||||
|
end
|
||||||
|
|
||||||
-- Lay out the text column; returns the card's natural height.
|
-- Lay out the text column; returns the card's natural height.
|
||||||
function M:refresh()
|
function M:refresh()
|
||||||
self.layout:refresh_layout(true)
|
local t = self:tree()
|
||||||
local body_h = self.layout:get_size().y
|
flex.apply(t)
|
||||||
if self.stacked then
|
self.natural = t.h
|
||||||
self.natural = self.m + 14 + body_h + self.footer_h + 14
|
|
||||||
else
|
|
||||||
self.natural = math.max(self.m, 12 + body_h + self.footer_h + 14)
|
|
||||||
end
|
|
||||||
return self.natural
|
return self.natural
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The card's final height (the tallest in its row): sizes the box and places the footer.
|
-- The card's final height (the tallest in its row): sizes the box and places the footer.
|
||||||
function M:set_height(h)
|
function M:set_height(h)
|
||||||
self.h = h
|
self.h = h
|
||||||
gui.set_size(self.root, vec(self.w, h))
|
local t = self:tree(h)
|
||||||
|
flex.apply(t)
|
||||||
gui.set_size(self.edge, vec(self.w, h))
|
gui.set_size(self.edge, vec(self.w, h))
|
||||||
if self.stacked then
|
if self.stacked then
|
||||||
ui.rect(self.root, 0, self.m, self.w, 1, "line")
|
ui.rect(self.root, 0, self.m, self.w, 1, "line")
|
||||||
else
|
elseif self.m > 0 then
|
||||||
gui.set_size(self.media, vec(self.m, h))
|
|
||||||
ui.rect(self.root, self.m, 0, 1, h, "line")
|
ui.rect(self.root, self.m, 0, 1, h, "line")
|
||||||
end
|
end
|
||||||
if self.selected then
|
if self.selected then
|
||||||
@@ -203,14 +241,18 @@ function M:set_height(h)
|
|||||||
fn()
|
fn()
|
||||||
end
|
end
|
||||||
if self.footer_fn then
|
if self.footer_fn then
|
||||||
local f = anim.group(self.root, self.stacked and PAD or (self.m + PAD), h - 14 - self.footer_h, self.bw, self.footer_h)
|
local fb = self.footer_box
|
||||||
|
local f = anim.group(self.root, fb.x - self.x, fb.y - self.y, fb.w, fb.h)
|
||||||
self.footer_fn(f, self.bw)
|
self.footer_fn(f, self.bw)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A grid of cards from y; returns the y under it. make(c, item) fills one card.
|
-- A grid of cards from y; returns the y under it. make(c, item) fills one card. opts: the
|
||||||
function M.grid(g, parent, x, y, inner, items, make)
|
-- cards' (M.new); opts.key(item): keep the card (modules/blocks) while opts.sig(item) (what
|
||||||
|
-- make shows besides the item; default the item alone) and its place stay the same.
|
||||||
|
function M.grid(g, parent, x, y, inner, items, make, opts)
|
||||||
local cols, cw = M.columns(inner)
|
local cols, cw = M.columns(inner)
|
||||||
|
local keyed = opts and opts.key
|
||||||
local i = 1
|
local i = 1
|
||||||
while i <= #items do
|
while i <= #items do
|
||||||
local row = {}
|
local row = {}
|
||||||
@@ -219,18 +261,55 @@ function M.grid(g, parent, x, y, inner, items, make)
|
|||||||
if not item then
|
if not item then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
local c = M.new(g, parent, x + (col - 1) * (cw + S.gap), y, cw)
|
local r = { item = item, x = x + (col - 1) * (cw + S.gap) }
|
||||||
make(c, item)
|
if keyed then
|
||||||
c:refresh()
|
r.key = opts.key(item)
|
||||||
row[#row + 1] = c
|
r.sig = blocks.sig(r.x, y, cw, item, opts.sig and opts.sig(item) or false)
|
||||||
|
r.old = blocks.peek(g, r.key, r.sig) -- (kept, if its row's height stays the same)
|
||||||
|
end
|
||||||
|
if r.old then
|
||||||
|
r.natural = r.old.card.natural
|
||||||
|
else
|
||||||
|
if r.key then
|
||||||
|
r.blk = blocks.open(g, r.key, r.sig, parent)
|
||||||
|
end
|
||||||
|
r.c = M.new(g, r.blk and r.blk.root or parent, r.x, y, cw, opts)
|
||||||
|
make(r.c, item)
|
||||||
|
r.natural = r.c:refresh()
|
||||||
|
if r.blk then
|
||||||
|
r.blk.card = r.c
|
||||||
|
blocks.close(g, r.blk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row[#row + 1] = r
|
||||||
i = i + 1
|
i = i + 1
|
||||||
end
|
end
|
||||||
local rh = 0
|
local rh = 0
|
||||||
for _, c in ipairs(row) do
|
for _, r in ipairs(row) do
|
||||||
rh = math.max(rh, c.natural)
|
rh = math.max(rh, r.natural)
|
||||||
end
|
end
|
||||||
for _, c in ipairs(row) do
|
for _, r in ipairs(row) do
|
||||||
c:set_height(rh)
|
if r.old and r.old.card.h == rh then
|
||||||
|
r.blk = blocks.open(g, r.key, r.sig, parent) -- (kept)
|
||||||
|
r.c = r.blk.card
|
||||||
|
else
|
||||||
|
if r.old then
|
||||||
|
-- The row's height changed: made again at it.
|
||||||
|
blocks.drop(g, r.old)
|
||||||
|
r.blk = blocks.open(g, r.key, r.sig, parent)
|
||||||
|
r.c = M.new(g, r.blk.root, r.x, y, cw, opts)
|
||||||
|
make(r.c, r.item)
|
||||||
|
r.c:refresh()
|
||||||
|
r.blk.card = r.c
|
||||||
|
elseif r.blk then
|
||||||
|
blocks.reopen(g, r.blk) -- (its hover and footer are the card's)
|
||||||
|
end
|
||||||
|
r.c:set_height(rh)
|
||||||
|
if r.blk then
|
||||||
|
blocks.close(g, r.blk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local c = r.c
|
||||||
if g.frames then
|
if g.frames then
|
||||||
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
|
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
|
-- Chip / badge (the web's .badge, level chips, advisor tags): a 1 px outlined box with
|
||||||
-- a short label, optionally filled. Returns the box node and its width.
|
-- a short label, optionally filled. Returns the box node and its width.
|
||||||
--
|
--
|
||||||
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
-- chip.new(parent, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
||||||
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
||||||
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
||||||
--
|
--
|
||||||
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
|
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
|
||||||
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
|
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
|
||||||
-- pad (6 each side), min_w, right (x is the right edge).
|
-- pad (6 each side), min_w, right (x is the right edge).
|
||||||
|
--
|
||||||
|
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
|
||||||
@@ -26,11 +28,27 @@ function M.new(parent, label, x, y, opts)
|
|||||||
local left = opts.right and (x - w) or x
|
local left = opts.right and (x - w) or x
|
||||||
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
|
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
|
||||||
if opts.fill then
|
if opts.fill then
|
||||||
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
|
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
|
||||||
gui.move_below(f, box)
|
-- move the fill out to box's parent, at its 0, 0).
|
||||||
|
local edge = box
|
||||||
|
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
|
||||||
|
gui.set_parent(edge, box, false)
|
||||||
|
gui.set_position(edge, vmath.vector3(0, 0, 0))
|
||||||
end
|
end
|
||||||
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
||||||
return box, w
|
return box, w
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Moon grade badges: edge/text color per grade, cheapest to best.
|
||||||
|
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
|
||||||
|
M.GRADE_W = 18
|
||||||
|
|
||||||
|
function M.grade(parent, grade, x, y, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local color = M.GRADE[grade] or "line_hi"
|
||||||
|
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
|
||||||
|
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
|
||||||
|
right = opts.right })
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
-- cancel = "Cancel" (the other button; "Close" without ok),
|
-- cancel = "Cancel" (the other button; "Close" without ok),
|
||||||
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
|
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
|
||||||
-- title_x = 24 }) -- the title's x (room for an image left of it)
|
-- title_x = 24 }) -- the title's x (room for an image left of it)
|
||||||
|
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
|
||||||
|
-- backdrop (a click outside still closes it), no title or buttons, its top-right
|
||||||
|
-- corner `right` from the page's right edge and `top` down; build makes the content.
|
||||||
-- g.dialog:close("ok" | "cancel")
|
-- g.dialog:close("ok" | "cancel")
|
||||||
-- g.dialog:is_open()
|
-- g.dialog:is_open()
|
||||||
--
|
--
|
||||||
@@ -29,6 +32,8 @@ local anim = require("modules.anim")
|
|||||||
local sfx = require("modules.sfx")
|
local sfx = require("modules.sfx")
|
||||||
local button = require("components.button")
|
local button = require("components.button")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -105,12 +110,13 @@ function M:open(opts)
|
|||||||
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
|
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
|
||||||
local root = ui.rect(self.layer, 0, 0, W, H, "none")
|
local root = ui.rect(self.layer, 0, 0, W, H, "none")
|
||||||
self.root = root
|
self.root = root
|
||||||
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
|
local menu = opts.menu
|
||||||
|
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
|
||||||
self.scrim = scrim
|
self.scrim = scrim
|
||||||
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
|
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
|
||||||
gui.delete_node(probe)
|
gui.delete_node(probe)
|
||||||
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
||||||
local holder = anim.group(root, W / 2, H / 2, 1, 1)
|
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
|
||||||
gui.set_pivot(holder, gui.PIVOT_CENTER)
|
gui.set_pivot(holder, gui.PIVOT_CENTER)
|
||||||
self.holder = holder
|
self.holder = holder
|
||||||
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
|
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
|
||||||
@@ -137,27 +143,46 @@ function M:open(opts)
|
|||||||
d:close("cancel")
|
d:close("cancel")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
if menu then
|
||||||
|
opts.build(self, box, w, h)
|
||||||
|
test.set_scope("page")
|
||||||
|
sfx.play("toggle")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Title, message (or custom content), buttons: three groups that rise in turn.
|
-- Title, message (or custom content), buttons: three groups that rise in turn.
|
||||||
local g_title = anim.group(box, 0, 0)
|
local g_title = anim.group(box, 0, 0)
|
||||||
local g_body = anim.group(box, 0, 0)
|
local g_body = anim.group(box, 0, 0)
|
||||||
local g_actions = anim.group(box, 0, 0)
|
local g_actions = anim.group(box, 0, 0)
|
||||||
ui.label(g_title, opts.title or "Notice", "h2", opts.title_x or 24, 34)
|
-- The panel's layout (geometry only: each group builds into its own part): the title
|
||||||
|
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
|
||||||
|
-- left of Cancel (Cancel is made first).
|
||||||
|
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
|
||||||
|
local ok = opts.ok
|
||||||
|
local danger = ok and ok.danger
|
||||||
|
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
|
||||||
|
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
|
||||||
|
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
|
||||||
|
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
|
||||||
|
{ style = { height = 20 }, id = "title" },
|
||||||
|
flex.box({ grow = 1 }),
|
||||||
|
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
|
||||||
|
})
|
||||||
|
flex.solve(tree, 0, 0)
|
||||||
|
local title = tree.children[1]
|
||||||
|
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
|
||||||
if opts.build then
|
if opts.build then
|
||||||
opts.build(self, g_body, w, h)
|
opts.build(self, g_body, w, h)
|
||||||
else
|
else
|
||||||
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, 58, w - 48)
|
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
|
||||||
end
|
end
|
||||||
local by = h - 24 - S.button_h
|
local by = cancel_box.y
|
||||||
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
|
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
|
||||||
local cb, _, cw = self:button(g_actions, cancel_label, 0, 0, {
|
|
||||||
id = "dialog:cancel", on_click = function() d:close("cancel") end,
|
id = "dialog:cancel", on_click = function() d:close("cancel") end,
|
||||||
})
|
})
|
||||||
gui.set_position(cb, vec(w - 24 - cw, -by))
|
if ok then
|
||||||
if opts.ok then
|
local ob
|
||||||
local ok = opts.ok
|
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
|
||||||
local danger = ok.danger
|
|
||||||
local ob, _, ow
|
|
||||||
ob, _, ow = self:button(g_actions, ok.label or "OK", 0, 0, {
|
|
||||||
id = "dialog:ok", price = ok.price,
|
id = "dialog:ok", price = ok.price,
|
||||||
variant = danger and "danger" or "primary",
|
variant = danger and "danger" or "primary",
|
||||||
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
|
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
|
||||||
@@ -173,7 +198,6 @@ function M:open(opts)
|
|||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
gui.set_position(ob, vec(w - 24 - cw - 10 - ow, -by))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
self.by = by
|
self.by = by
|
||||||
@@ -296,8 +320,13 @@ function M:layout(W, H)
|
|||||||
gui.set_size(self.root, vec(W, H))
|
gui.set_size(self.root, vec(W, H))
|
||||||
gui.set_size(self.scrim, vec(W, H))
|
gui.set_size(self.scrim, vec(W, H))
|
||||||
gui.cancel_animations(self.holder, "position.y")
|
gui.cancel_animations(self.holder, "position.y")
|
||||||
|
local menu = self.opts and self.opts.menu
|
||||||
|
if menu then
|
||||||
|
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
|
||||||
|
else
|
||||||
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function M:update(dt)
|
function M:update(dt)
|
||||||
if self.druid then
|
if self.druid then
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
-- local l = list.new(g, parent, x, y, w, h, {
|
-- local l = list.new(g, parent, x, y, w, h, {
|
||||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
||||||
-- create = function(item, index) ... return root[, druid component] end,
|
-- create = function(item, index) ... return root[, druid component] end,
|
||||||
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
|
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||||
-- -- id its row registers: Tab walks
|
-- -- id its row registers: Tab walks
|
||||||
-- -- every row, scrolling (modules/focus)
|
-- -- every row, scrolling (modules/focus)
|
||||||
|
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||||
|
-- -- its node is deleted (not when the
|
||||||
|
-- -- list itself goes in a rebuild)
|
||||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
||||||
--
|
--
|
||||||
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
||||||
-- places it. A Druid component returned with it (a widget) is removed with the row.
|
-- places it. A Druid component returned with it (a widget) is removed with the row.
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
M.__index = M
|
M.__index = M
|
||||||
@@ -24,7 +28,7 @@ local function vec(x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M.new(g, parent, x, y, w, h, opts)
|
function M.new(g, parent, x, y, w, h, opts)
|
||||||
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = opts.create,
|
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = blocks.bound(opts.create), -- (rows made as it scrolls are its block's)
|
||||||
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
|
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
|
||||||
g.scroll_pos = g.scroll_pos or {}
|
g.scroll_pos = g.scroll_pos or {}
|
||||||
l.view = ui.rect(parent, x, y, w, h, "text")
|
l.view = ui.rect(parent, x, y, w, h, "text")
|
||||||
@@ -42,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
|||||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||||
return l.create(item, index)
|
return l.create(item, index)
|
||||||
end)
|
end)
|
||||||
|
if opts.on_remove then
|
||||||
|
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||||
|
if not g.tearing_down then
|
||||||
|
opts.on_remove(node, item)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
l.scroll.on_scroll:subscribe(function(_, pos)
|
||||||
if g.tearing_down then
|
if g.tearing_down then
|
||||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
return -- (the old list emptying itself during a rebuild: keep the offset)
|
||||||
@@ -49,21 +60,21 @@ function M.new(g, parent, x, y, w, h, opts)
|
|||||||
g.scroll_pos[l.key] = pos.y
|
g.scroll_pos[l.key] = pos.y
|
||||||
l:place_thumb(pos.y)
|
l:place_thumb(pos.y)
|
||||||
end)
|
end)
|
||||||
g.scroll_areas = g.scroll_areas or {}
|
blocks.push(g, "scroll_areas", { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll })
|
||||||
g.scroll_areas[#g.scroll_areas + 1] = { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll }
|
|
||||||
return l
|
return l
|
||||||
end
|
end
|
||||||
|
|
||||||
function M:set_data(items)
|
function M:set_data(items)
|
||||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
local saved = self.g.scroll_pos[self.key] or 0
|
||||||
self.items = items
|
self.items = items
|
||||||
self.data:set_data(items)
|
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
-- rows there, not the top ones only to drop them again.
|
||||||
-- scroll event).
|
|
||||||
local s = self.scroll
|
local s = self.scroll
|
||||||
|
s:set_size(self.grid:get_size_for(#items))
|
||||||
s.target_position.y = saved
|
s.target_position.y = saved
|
||||||
s:_set_scroll_position(s.position.x, saved)
|
s:_set_scroll_position(s.position.x, saved)
|
||||||
s.target_position.y = s.position.y
|
s.target_position.y = s.position.y
|
||||||
|
self.data:set_data(items)
|
||||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
||||||
for _, a in ipairs(self.g.scroll_areas) do
|
for _, a in ipairs(self.g.scroll_areas) do
|
||||||
if a.list == self then
|
if a.list == self then
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
-- ... build into area.content (top-left based, like any parent) ...
|
-- ... build into area.content (top-left based, like any parent) ...
|
||||||
-- area:finish(content_height)
|
-- area:finish(content_height)
|
||||||
--
|
--
|
||||||
-- Screens rebuild their layout on every new state, so the offset is kept per key in
|
-- A section built again makes its scroll area again, so the offset is kept per key in
|
||||||
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
|
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
|
||||||
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
|
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
|
||||||
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
|
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
|
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -75,8 +76,7 @@ function Area:finish(content_h)
|
|||||||
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
|
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
|
||||||
end)
|
end)
|
||||||
local s = self_.druid:new_scroll(self.view, self.content)
|
local s = self_.druid:new_scroll(self.view, self.content)
|
||||||
self_.scroll_areas = self_.scroll_areas or {}
|
blocks.push(self_, "scroll_areas", { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s })
|
||||||
self_.scroll_areas[#self_.scroll_areas + 1] = { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s }
|
|
||||||
|
|
||||||
s:set_horizontal_scroll(false)
|
s:set_horizontal_scroll(false)
|
||||||
s:set_extra_stretch_size(0)
|
s:set_extra_stretch_size(0)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
|
||||||
|
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
|
||||||
|
-- exactly (screen position mapped into the track, not summed deltas).
|
||||||
|
--
|
||||||
|
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
|
||||||
|
-- on_change = fn(v), on_release = fn(v) })
|
||||||
|
-- s:set(v) -- move it without on_change (another control changed the value)
|
||||||
|
--
|
||||||
|
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
|
||||||
|
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
|
||||||
|
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
|
||||||
|
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
|
||||||
|
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local test = require("modules.test")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
M.__index = M
|
||||||
|
|
||||||
|
local TOUCH = hash("touch")
|
||||||
|
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function snap(v, o)
|
||||||
|
local n = math.floor((v - o.min) / o.step + 0.5)
|
||||||
|
v = o.min + n * o.step
|
||||||
|
-- (decimal steps: drop float noise like 0.30000000000000004)
|
||||||
|
v = tonumber(string.format("%.6f", v))
|
||||||
|
return math.max(o.min, math.min(o.max, v))
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.new(g, parent, x, y, w, h, opts)
|
||||||
|
local self = setmetatable({ opts = opts, w = w }, M)
|
||||||
|
local hit = ui.rect(parent, x, y, w, h, "none")
|
||||||
|
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
|
||||||
|
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
|
||||||
|
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
|
||||||
|
gui.set_pivot(knob, gui.PIVOT_CENTER)
|
||||||
|
gui.set_position(knob, vec(0, -h / 2))
|
||||||
|
self.knob = knob
|
||||||
|
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
|
||||||
|
local v = snap(opts.min + f * (opts.max - opts.min), opts)
|
||||||
|
self:_fill(f)
|
||||||
|
if v ~= self.value then
|
||||||
|
self.value = v
|
||||||
|
if opts.on_change then
|
||||||
|
opts.on_change(v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
self.s = s
|
||||||
|
local zone = opts.zone
|
||||||
|
s.on_input = function(sl, action_id, action)
|
||||||
|
if action_id ~= TOUCH or not action.screen_x then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not sl.is_drag then
|
||||||
|
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
|
||||||
|
and (not zone or gui.pick_node(zone, action.x, action.y))) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
sl.is_drag = true
|
||||||
|
end
|
||||||
|
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
|
||||||
|
local f = math.max(0, math.min(1, p.x / w))
|
||||||
|
if f ~= sl.value then
|
||||||
|
sl:set(f)
|
||||||
|
end
|
||||||
|
if action.released then
|
||||||
|
sl.is_drag = false
|
||||||
|
if opts.on_release then
|
||||||
|
opts.on_release(self.value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
self:set(opts.value)
|
||||||
|
test.register(opts.id, hit, "slider", opts.label, zone, true)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:_fill(f)
|
||||||
|
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:set(v)
|
||||||
|
local o = self.opts
|
||||||
|
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
|
||||||
|
self.value = v
|
||||||
|
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
|
||||||
|
self.s:set(f, true)
|
||||||
|
self:_fill(f)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -50,6 +50,16 @@ embedded_instances {
|
|||||||
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"embedded_components {\n"
|
"embedded_components {\n"
|
||||||
|
" id: \"rumble\"\n"
|
||||||
|
" type: \"sound\"\n"
|
||||||
|
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||||
|
"}\n"
|
||||||
|
"embedded_components {\n"
|
||||||
|
" id: \"reveal\"\n"
|
||||||
|
" type: \"sound\"\n"
|
||||||
|
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||||
|
"}\n"
|
||||||
|
"embedded_components {\n"
|
||||||
" id: \"close\"\n"
|
" id: \"close\"\n"
|
||||||
" type: \"sound\"\n"
|
" type: \"sound\"\n"
|
||||||
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ end
|
|||||||
|
|
||||||
function init(self)
|
function init(self)
|
||||||
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
|
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
|
||||||
|
-- In a browser the frames come from requestAnimationFrame at the display's rate; with
|
||||||
|
-- display.update_frequency set the engine skips callbacks to approach it (judder on
|
||||||
|
-- 120/144 Hz displays, dropped frames on jittery 60 Hz ones). Variable rate there:
|
||||||
|
-- every callback is a frame, dt is the real time.
|
||||||
|
if html5 then
|
||||||
|
sys.set_update_frequency(0)
|
||||||
|
end
|
||||||
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
|
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
|
||||||
if w then
|
if w then
|
||||||
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
|
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
name: "MMTerrain"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// The two calls mmterrain.c needs from the engine's C++-only buffer API, as C functions:
|
||||||
|
// the float32 x3 streams of the buffer at a Lua stack index, and marking it changed.
|
||||||
|
#include <dmsdk/sdk.h>
|
||||||
|
|
||||||
|
static float* stream(dmBuffer::HBuffer buf, const char* name, uint32_t* count, uint32_t* stride)
|
||||||
|
{
|
||||||
|
void* data = 0;
|
||||||
|
uint32_t components = 0;
|
||||||
|
if (dmBuffer::GetStream(buf, dmHashString64(name), &data, count, &components, stride) != dmBuffer::RESULT_OK)
|
||||||
|
return 0;
|
||||||
|
if (components != 3)
|
||||||
|
return 0;
|
||||||
|
dmBuffer::ValueType type;
|
||||||
|
if (dmBuffer::GetStreamType(buf, dmHashString64(name), &type, &components) != dmBuffer::RESULT_OK
|
||||||
|
|| type != dmBuffer::VALUE_TYPE_FLOAT32)
|
||||||
|
return 0;
|
||||||
|
return (float*)data;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride)
|
||||||
|
{
|
||||||
|
dmBuffer::HBuffer buf = dmScript::CheckBufferUnpack(L, index);
|
||||||
|
uint32_t c[3], s[3];
|
||||||
|
*pos = stream(buf, "position", &c[0], &s[0]);
|
||||||
|
*nor = stream(buf, "normal", &c[1], &s[1]);
|
||||||
|
*col = stream(buf, "color", &c[2], &s[2]);
|
||||||
|
if (!*pos || !*nor || !*col || s[0] != s[1] || s[0] != s[2])
|
||||||
|
return 0;
|
||||||
|
*count = c[0];
|
||||||
|
*stride = s[0];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void mm_buffer_touch(lua_State* L, int index)
|
||||||
|
{
|
||||||
|
dmBuffer::UpdateContentVersion(dmScript::CheckBufferUnpack(L, index));
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/* mmterrain: the moon generator (terrain.c) for Lua. Plain C; the engine's buffer API is
|
||||||
|
* C++ only, so buffer_shim.cpp reaches a buffer's streams for mmterrain.fill.
|
||||||
|
*
|
||||||
|
* local gen = mmterrain.new(terrain.native_params(p)) -- p: terrain.resolve's result
|
||||||
|
* mmterrain.start(gen) -> true: it runs on a thread of its own (desktop);
|
||||||
|
* false: step it (web)
|
||||||
|
* mmterrain.step(gen, secs) -> true when done (works about secs; 0: to the end)
|
||||||
|
* mmterrain.done(gen) -> true when done (threaded or stepped)
|
||||||
|
* mmterrain.vertices(gen) -> the triangle strip's length (terrain.strip)
|
||||||
|
* mmterrain.fill(gen, buf [, from, to]) fills buf's position, normal and color
|
||||||
|
* streams (float32 x3, at least vertices(gen) long) with the
|
||||||
|
* strip: its vertices from..to (1-based, inclusive; default
|
||||||
|
* all)
|
||||||
|
* mmterrain.result(gen) -> { n, count, positions, normals, colors, min_h, max_h }
|
||||||
|
* mmterrain.free(gen) frees it now (else the garbage collector does)
|
||||||
|
* Don't call anything but done/free on a started generator until done is true.
|
||||||
|
*/
|
||||||
|
#include <dmsdk/extension/extension.h>
|
||||||
|
#include <dmsdk/dlib/align.h>
|
||||||
|
|
||||||
|
#include "terrain.h"
|
||||||
|
|
||||||
|
#define META "mmterrain.gen"
|
||||||
|
|
||||||
|
/* The extender compiles .c files in C++ mode (clang++, "treating 'c' input as 'c++'"), so
|
||||||
|
* this file and terrain.c are valid as both; what links across files is extern "C". */
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
/* buffer_shim.cpp: a buffer's three float32 x3 streams, or 0. */
|
||||||
|
int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride);
|
||||||
|
void mm_buffer_touch(lua_State* L, int index);
|
||||||
|
void MMTerrain(void);
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static mt_gen** check_slot(lua_State* L)
|
||||||
|
{
|
||||||
|
return (mt_gen**)luaL_checkudata(L, 1, META);
|
||||||
|
}
|
||||||
|
|
||||||
|
static mt_gen* check_gen(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen** slot = check_slot(L);
|
||||||
|
if (!*slot)
|
||||||
|
luaL_error(L, "mmterrain: generator already freed");
|
||||||
|
return *slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
static mt_gen* check_done(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen* g = check_gen(L);
|
||||||
|
if (!mt_done(g))
|
||||||
|
luaL_error(L, "mmterrain: generation not done");
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int New(lua_State* L)
|
||||||
|
{
|
||||||
|
double a[MT_PARAM_COUNT];
|
||||||
|
mt_params p;
|
||||||
|
mt_gen** slot;
|
||||||
|
int i;
|
||||||
|
luaL_checktype(L, 1, LUA_TTABLE);
|
||||||
|
for (i = 0; i < MT_PARAM_COUNT; ++i) {
|
||||||
|
lua_rawgeti(L, 1, i + 1);
|
||||||
|
a[i] = luaL_checknumber(L, -1);
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
mt_params_from_array(a, MT_PARAM_COUNT, &p);
|
||||||
|
slot = (mt_gen**)lua_newuserdata(L, sizeof(mt_gen*));
|
||||||
|
*slot = 0;
|
||||||
|
luaL_getmetatable(L, META);
|
||||||
|
lua_setmetatable(L, -2);
|
||||||
|
*slot = mt_new(&p);
|
||||||
|
if (!*slot)
|
||||||
|
return luaL_error(L, "mmterrain: out of memory");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Start(lua_State* L)
|
||||||
|
{
|
||||||
|
lua_pushboolean(L, mt_start(check_gen(L)));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Step(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen* g = check_gen(L);
|
||||||
|
lua_pushboolean(L, mt_done(g) || mt_step(g, luaL_optnumber(L, 2, 0)));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Done(lua_State* L)
|
||||||
|
{
|
||||||
|
lua_pushboolean(L, mt_done(check_gen(L)));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Vertices(lua_State* L)
|
||||||
|
{
|
||||||
|
lua_pushinteger(L, mt_strip_len(mt_n(check_gen(L))));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Fill(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen* g = check_done(L);
|
||||||
|
float *pos, *nor, *col;
|
||||||
|
unsigned count, stride;
|
||||||
|
if (!mm_buffer_streams(L, 2, &pos, &nor, &col, &count, &stride))
|
||||||
|
return luaL_error(L, "mmterrain.fill: needs position, normal and color streams (float32 x3)");
|
||||||
|
int len = mt_strip_len(mt_n(g));
|
||||||
|
int from = (int)luaL_optinteger(L, 3, 1) - 1, to = (int)luaL_optinteger(L, 4, len);
|
||||||
|
if (count < (unsigned)len)
|
||||||
|
return luaL_error(L, "mmterrain.fill: buffer too short");
|
||||||
|
mt_fill(g, pos, nor, col, (int)stride, from < 0 ? 0 : from, to > len ? len : to);
|
||||||
|
mm_buffer_touch(L, 2);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void push_array(lua_State* L, const double* a, int n)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
lua_createtable(L, n, 0);
|
||||||
|
for (i = 0; i < n; ++i) {
|
||||||
|
lua_pushnumber(L, a[i]);
|
||||||
|
lua_rawseti(L, -2, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Result(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen* g = check_done(L);
|
||||||
|
int c = mt_count(g);
|
||||||
|
lua_createtable(L, 0, 7);
|
||||||
|
lua_pushinteger(L, mt_n(g));
|
||||||
|
lua_setfield(L, -2, "n");
|
||||||
|
lua_pushinteger(L, c);
|
||||||
|
lua_setfield(L, -2, "count");
|
||||||
|
push_array(L, mt_positions(g), c * 3);
|
||||||
|
lua_setfield(L, -2, "positions");
|
||||||
|
push_array(L, mt_normals(g), c * 3);
|
||||||
|
lua_setfield(L, -2, "normals");
|
||||||
|
push_array(L, mt_colors(g), c * 3);
|
||||||
|
lua_setfield(L, -2, "colors");
|
||||||
|
lua_pushnumber(L, mt_min_h(g));
|
||||||
|
lua_setfield(L, -2, "min_h");
|
||||||
|
lua_pushnumber(L, mt_max_h(g));
|
||||||
|
lua_setfield(L, -2, "max_h");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Free(lua_State* L)
|
||||||
|
{
|
||||||
|
mt_gen** slot = check_slot(L);
|
||||||
|
mt_free(*slot); /* (joins a running thread) */
|
||||||
|
*slot = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const luaL_Reg Module_methods[] = {
|
||||||
|
{ "new", New },
|
||||||
|
{ "start", Start },
|
||||||
|
{ "step", Step },
|
||||||
|
{ "done", Done },
|
||||||
|
{ "vertices", Vertices },
|
||||||
|
{ "fill", Fill },
|
||||||
|
{ "result", Result },
|
||||||
|
{ "free", Free },
|
||||||
|
{ 0, 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
static ExtensionResult Initialize(ExtensionParams* params)
|
||||||
|
{
|
||||||
|
lua_State* L = params->m_L;
|
||||||
|
int top = lua_gettop(L);
|
||||||
|
luaL_newmetatable(L, META);
|
||||||
|
lua_pushcfunction(L, Free);
|
||||||
|
lua_setfield(L, -2, "__gc");
|
||||||
|
lua_pop(L, 1);
|
||||||
|
luaL_register(L, "mmterrain", Module_methods);
|
||||||
|
lua_pop(L, 1);
|
||||||
|
(void)top;
|
||||||
|
return EXTENSION_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ExtensionResult Finalize(ExtensionParams* params)
|
||||||
|
{
|
||||||
|
(void)params;
|
||||||
|
return EXTENSION_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DM_DECLARE_EXTENSION, written out for C (the macro's function is extern "C"). */
|
||||||
|
static uint8_t DM_ALIGNED(16) g_Desc[128]; /* ExtensionDescBufferSize (a const, not a C constant) */
|
||||||
|
|
||||||
|
void MMTerrain(void)
|
||||||
|
{
|
||||||
|
ExtensionRegister((void*)g_Desc, sizeof(g_Desc), "MMTerrain", 0, 0, Initialize, Finalize, 0, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
/* The moon generator (see terrain.h), a port of the server's web/static/js/terrain.js:
|
||||||
|
* keep the two in sync (scripts/moon-check.mjs compares them). */
|
||||||
|
#if !defined(_WIN32) && !defined(_POSIX_C_SOURCE)
|
||||||
|
#define _POSIX_C_SOURCE 199309L /* clock_gettime */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "terrain.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#if MT_THREADS
|
||||||
|
#include <pthread.h>
|
||||||
|
#endif
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <time.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* The same rounding as the JS: no fused multiply-adds. */
|
||||||
|
#if defined(__clang__)
|
||||||
|
#pragma clang fp contract(off)
|
||||||
|
#elif defined(__GNUC__)
|
||||||
|
#pragma GCC optimize("fp-contract=off")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define MAX_OCTAVES 10
|
||||||
|
#define MAX_CRATERS 2000
|
||||||
|
#define GRID 12
|
||||||
|
|
||||||
|
static double clock_secs(void)
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
LARGE_INTEGER f, c;
|
||||||
|
QueryPerformanceFrequency(&f);
|
||||||
|
QueryPerformanceCounter(&c);
|
||||||
|
return (double)c.QuadPart / (double)f.QuadPart;
|
||||||
|
#else
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (double)ts.tv_sec + ts.tv_nsec * 1e-9;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static double dmin(double a, double b) { return a < b ? a : b; }
|
||||||
|
static double dmax(double a, double b) { return a > b ? a : b; }
|
||||||
|
static double lround_(double x) { return floor(x + 0.5); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ random */
|
||||||
|
|
||||||
|
/* mulberry32, uniform in [0, 1). */
|
||||||
|
typedef struct { uint32_t a; } mt_rng;
|
||||||
|
|
||||||
|
static mt_rng rng_new(double seed)
|
||||||
|
{
|
||||||
|
mt_rng r;
|
||||||
|
/* bit.tobit: the number modulo 2^32 */
|
||||||
|
r.a = (uint32_t)(int64_t)fmod(seed, 4294967296.0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double rng_next(mt_rng* r)
|
||||||
|
{
|
||||||
|
uint32_t t;
|
||||||
|
r->a += 0x6d2b79f5u;
|
||||||
|
t = (r->a ^ (r->a >> 15)) * (1u | r->a);
|
||||||
|
t = (t + (t ^ (t >> 7)) * (61u | t)) ^ t;
|
||||||
|
return (double)(t ^ (t >> 14)) / 4294967296.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ noise */
|
||||||
|
|
||||||
|
static const double GRAD3[36] = {
|
||||||
|
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
|
||||||
|
};
|
||||||
|
static int PERM[512];
|
||||||
|
static double GX[256], GY[256], GZ[256];
|
||||||
|
static int perm_ready = 0;
|
||||||
|
|
||||||
|
static void perm_init(void)
|
||||||
|
{
|
||||||
|
int p[256], i;
|
||||||
|
mt_rng r;
|
||||||
|
if (perm_ready)
|
||||||
|
return;
|
||||||
|
for (i = 0; i < 256; ++i)
|
||||||
|
p[i] = i;
|
||||||
|
r = rng_new(1337);
|
||||||
|
for (i = 255; i >= 1; --i) {
|
||||||
|
int j = (int)floor(rng_next(&r) * (i + 1));
|
||||||
|
int t = p[i];
|
||||||
|
p[i] = p[j];
|
||||||
|
p[j] = t;
|
||||||
|
}
|
||||||
|
for (i = 0; i < 512; ++i)
|
||||||
|
PERM[i] = p[i & 255];
|
||||||
|
for (i = 0; i < 256; ++i) {
|
||||||
|
int g = (i % 12) * 3;
|
||||||
|
GX[i] = GRAD3[g];
|
||||||
|
GY[i] = GRAD3[g + 1];
|
||||||
|
GZ[i] = GRAD3[g + 2];
|
||||||
|
}
|
||||||
|
perm_ready = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define F3 (1.0 / 3.0)
|
||||||
|
#define G3 (1.0 / 6.0)
|
||||||
|
|
||||||
|
/* floor without a libm call (x86_64's baseline has no rounding instruction); the same
|
||||||
|
* value for |x| < 2^53. */
|
||||||
|
static double ffloor(double x)
|
||||||
|
{
|
||||||
|
double t = (double)(int64_t)x;
|
||||||
|
return t > x ? t - 1 : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* terrain.lua simplex3 (branch-free, the same values as the reference's if/else chains,
|
||||||
|
* and faster here too: the corner order is unpredictable). */
|
||||||
|
static double simplex3(double x, double y, double z)
|
||||||
|
{
|
||||||
|
const int* P = PERM;
|
||||||
|
double s = (x + y + z) * F3;
|
||||||
|
double i = ffloor(x + s), j = ffloor(y + s), k = ffloor(z + s);
|
||||||
|
double t = (i + j + k) * G3;
|
||||||
|
double x0 = x - (i - t), y0 = y - (j - t), z0 = z - (k - t);
|
||||||
|
double a = x0 >= y0 ? 1 : 0; /* 1 + floor(x0 - y0) in the Lua: |x0 - y0| < 1 */
|
||||||
|
double b = y0 >= z0 ? 1 : 0;
|
||||||
|
double c = x0 >= z0 ? 1 : 0;
|
||||||
|
double i1 = a * (b + (1 - b) * c);
|
||||||
|
double j1 = (1 - a) * b;
|
||||||
|
double k1 = 1 - i1 - j1;
|
||||||
|
double i2 = a + (1 - a) * b * c;
|
||||||
|
double j2 = a * b + 1 - a;
|
||||||
|
double k2 = 2 - i2 - j2;
|
||||||
|
int ii = (int)((int64_t)i & 255), jj = (int)((int64_t)j & 255), kk = (int)((int64_t)k & 255);
|
||||||
|
int ii1 = (int)i1, jj1 = (int)j1, kk1 = (int)k1, ii2 = (int)i2, jj2 = (int)j2, kk2 = (int)k2;
|
||||||
|
double tt, n, x1, y1, z1, x2, y2, z2, x3, y3, z3;
|
||||||
|
int g;
|
||||||
|
tt = dmax(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0);
|
||||||
|
g = P[ii + P[jj + P[kk]]];
|
||||||
|
tt = tt * tt;
|
||||||
|
n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0);
|
||||||
|
x1 = x0 - i1 + G3; y1 = y0 - j1 + G3; z1 = z0 - k1 + G3;
|
||||||
|
tt = dmax(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1);
|
||||||
|
g = P[ii + ii1 + P[jj + jj1 + P[kk + kk1]]];
|
||||||
|
tt = tt * tt;
|
||||||
|
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1);
|
||||||
|
x2 = x0 - i2 + 2 * G3; y2 = y0 - j2 + 2 * G3; z2 = z0 - k2 + 2 * G3;
|
||||||
|
tt = dmax(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2);
|
||||||
|
g = P[ii + ii2 + P[jj + jj2 + P[kk + kk2]]];
|
||||||
|
tt = tt * tt;
|
||||||
|
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2);
|
||||||
|
x3 = x0 - 1 + 3 * G3; y3 = y0 - 1 + 3 * G3; z3 = z0 - 1 + 3 * G3;
|
||||||
|
tt = dmax(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3);
|
||||||
|
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]];
|
||||||
|
tt = tt * tt;
|
||||||
|
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3);
|
||||||
|
return 32 * n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double fbm(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
|
||||||
|
{
|
||||||
|
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
|
||||||
|
for (o = 1; o <= top; o += 1) {
|
||||||
|
sum = sum + simplex3(x * f, y * f, z * f) * amp;
|
||||||
|
norm = norm + amp;
|
||||||
|
amp = amp * persistence;
|
||||||
|
f = f * lacunarity;
|
||||||
|
}
|
||||||
|
return sum / norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double billow(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
|
||||||
|
{
|
||||||
|
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
|
||||||
|
for (o = 1; o <= top; o += 1) {
|
||||||
|
sum = sum + fabs(simplex3(x * f, y * f, z * f)) * amp;
|
||||||
|
norm = norm + amp;
|
||||||
|
amp = amp * persistence;
|
||||||
|
f = f * lacunarity;
|
||||||
|
}
|
||||||
|
return sum / norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double ridged(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity,
|
||||||
|
double sharpness, double gain)
|
||||||
|
{
|
||||||
|
double sum = 0, amp = 1, norm = 0, weight = 1, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
|
||||||
|
for (o = 1; o <= top; o += 1) {
|
||||||
|
double v = 1 - fabs(simplex3(x * f, y * f, z * f));
|
||||||
|
v = pow(v, sharpness) * weight;
|
||||||
|
weight = dmin(1, dmax(0, v * gain));
|
||||||
|
sum = sum + v * amp;
|
||||||
|
norm = norm + amp;
|
||||||
|
amp = amp * persistence;
|
||||||
|
f = f * lacunarity;
|
||||||
|
}
|
||||||
|
return sum / norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double smooth_min(double a, double b, double k)
|
||||||
|
{
|
||||||
|
double h;
|
||||||
|
if (k <= 0)
|
||||||
|
return dmin(a, b);
|
||||||
|
h = dmin(1, dmax(0, (b - a + k) / (2 * k)));
|
||||||
|
return a * h + b * (1 - h) - k * h * (1 - h);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double smooth_max(double a, double b, double k)
|
||||||
|
{
|
||||||
|
return -smooth_min(-a, -b, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double smoothstep(double e0, double e1, double x)
|
||||||
|
{
|
||||||
|
double d = e1 - e0, t;
|
||||||
|
if (d == 0)
|
||||||
|
d = 1e-6;
|
||||||
|
t = dmin(1, dmax(0, (x - e0) / d));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ colors */
|
||||||
|
|
||||||
|
int mt_params_from_array(const double* a, int n, mt_params* p)
|
||||||
|
{
|
||||||
|
int i = 0, k;
|
||||||
|
if (n < MT_PARAM_COUNT)
|
||||||
|
return 0;
|
||||||
|
p->resolution = a[i++];
|
||||||
|
p->technique = (int)a[i++];
|
||||||
|
p->shape_strength = a[i++]; p->shape_frequency = a[i++]; p->shape_octaves = a[i++];
|
||||||
|
p->shape_persistence = a[i++]; p->shape_lacunarity = a[i++]; p->shape_min = a[i++];
|
||||||
|
p->ridge_strength = a[i++]; p->ridge_frequency = a[i++]; p->ridge_octaves = a[i++];
|
||||||
|
p->ridge_persistence = a[i++]; p->ridge_lacunarity = a[i++]; p->ridge_sharpness = a[i++];
|
||||||
|
p->ridge_gain = a[i++]; p->ridge_min = a[i++];
|
||||||
|
p->warp_strength = a[i++]; p->warp_frequency = a[i++]; p->warp_octaves = a[i++];
|
||||||
|
p->crater_count = a[i++]; p->crater_min_radius = a[i++]; p->crater_max_radius = a[i++];
|
||||||
|
p->crater_size_bias = a[i++]; p->crater_depth = a[i++]; p->crater_rim_width = a[i++];
|
||||||
|
p->crater_rim_steepness = a[i++]; p->crater_floor = a[i++]; p->crater_smoothness = a[i++];
|
||||||
|
p->crater_ejecta = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->low[k] = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->mid[k] = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->high[k] = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->steep[k] = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->patch[k] = a[i++];
|
||||||
|
p->steep_threshold = a[i++]; p->steep_blend = a[i++]; p->patch_amount = a[i++];
|
||||||
|
p->patch_frequency = a[i++]; p->grain = a[i++];
|
||||||
|
for (k = 0; k < 3; ++k) p->offset[k] = a[i++];
|
||||||
|
p->crater_seed = a[i++];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ generator */
|
||||||
|
|
||||||
|
typedef struct crater {
|
||||||
|
double x, y, z, r, reach;
|
||||||
|
} crater;
|
||||||
|
|
||||||
|
enum { ST_DIRS, ST_CRATERS, ST_HEIGHTS, ST_MEAN, ST_POSITIONS, ST_NORMALS, ST_SEAMS, ST_NORMALIZE, ST_COLORS, ST_DONE };
|
||||||
|
|
||||||
|
struct mt_gen {
|
||||||
|
mt_params p;
|
||||||
|
#if MT_THREADS
|
||||||
|
pthread_t thread;
|
||||||
|
int threaded;
|
||||||
|
int finished; /* (set by the thread, read with atomics) */
|
||||||
|
#endif
|
||||||
|
int n, row, per, count;
|
||||||
|
int stage, i;
|
||||||
|
double* dirs;
|
||||||
|
int* keys;
|
||||||
|
double *heights, *ejecta, *positions, *normals, *colors;
|
||||||
|
crater* craters;
|
||||||
|
int n_craters;
|
||||||
|
int* cell_start; /* GRID^3 + 1 offsets into cell_list */
|
||||||
|
int* cell_list;
|
||||||
|
double x_max, mean, min_h, max_h;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const double FACES[6][9] = {
|
||||||
|
{ 1, 0, 0, 0, 1, 0, 0, 0, 1 },
|
||||||
|
{ -1, 0, 0, 0, 0, 1, 0, 1, 0 },
|
||||||
|
{ 0, 1, 0, 0, 0, 1, 1, 0, 0 },
|
||||||
|
{ 0, -1, 0, 1, 0, 0, 0, 0, 1 },
|
||||||
|
{ 0, 0, 1, 1, 0, 0, 0, 1, 0 },
|
||||||
|
{ 0, 0, -1, 0, 1, 0, 1, 0, 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mt_gen* mt_new(const mt_params* p)
|
||||||
|
{
|
||||||
|
mt_gen* g = (mt_gen*)calloc(1, sizeof(mt_gen));
|
||||||
|
size_t c3;
|
||||||
|
if (!g)
|
||||||
|
return 0;
|
||||||
|
perm_init();
|
||||||
|
g->p = *p;
|
||||||
|
g->n = (int)dmax(8, dmin(320, lround_(p->resolution)));
|
||||||
|
g->row = g->n + 1;
|
||||||
|
g->per = g->row * g->row;
|
||||||
|
g->count = 6 * g->per;
|
||||||
|
c3 = (size_t)g->count * 3;
|
||||||
|
g->dirs = (double*)malloc(c3 * sizeof(double));
|
||||||
|
g->keys = (int*)malloc((size_t)g->count * sizeof(int));
|
||||||
|
g->heights = (double*)malloc((size_t)g->count * sizeof(double));
|
||||||
|
g->ejecta = (double*)malloc((size_t)g->count * sizeof(double));
|
||||||
|
g->positions = (double*)malloc(c3 * sizeof(double));
|
||||||
|
g->normals = (double*)calloc(c3, sizeof(double));
|
||||||
|
g->colors = (double*)malloc(c3 * sizeof(double));
|
||||||
|
if (!g->dirs || !g->keys || !g->heights || !g->ejecta || !g->positions || !g->normals || !g->colors) {
|
||||||
|
mt_free(g);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
g->stage = ST_DIRS;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mt_free(mt_gen* g)
|
||||||
|
{
|
||||||
|
if (!g)
|
||||||
|
return;
|
||||||
|
#if MT_THREADS
|
||||||
|
if (g->threaded)
|
||||||
|
pthread_join(g->thread, 0);
|
||||||
|
#endif
|
||||||
|
free(g->dirs);
|
||||||
|
free(g->keys);
|
||||||
|
free(g->heights);
|
||||||
|
free(g->ejecta);
|
||||||
|
free(g->positions);
|
||||||
|
free(g->normals);
|
||||||
|
free(g->colors);
|
||||||
|
free(g->craters);
|
||||||
|
free(g->cell_start);
|
||||||
|
free(g->cell_list);
|
||||||
|
free(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dir_at(mt_gen* g, int v)
|
||||||
|
{
|
||||||
|
int n = g->n, row = g->row, f = v / g->per, r = v % g->per, j = r / row, i = r % row;
|
||||||
|
const double* F = FACES[f];
|
||||||
|
double s = ((double)i / n) * 2 - 1;
|
||||||
|
double t = ((double)j / n) * 2 - 1;
|
||||||
|
double cx = F[0] + s * F[3] + t * F[6];
|
||||||
|
double cy = F[1] + s * F[4] + t * F[7];
|
||||||
|
double cz = F[2] + s * F[5] + t * F[8];
|
||||||
|
double x2 = cx * cx, y2 = cy * cy, z2 = cz * cz;
|
||||||
|
g->dirs[v * 3] = cx * sqrt(1 - y2 / 2 - z2 / 2 + (y2 * z2) / 3);
|
||||||
|
g->dirs[v * 3 + 1] = cy * sqrt(1 - x2 / 2 - z2 / 2 + (x2 * z2) / 3);
|
||||||
|
g->dirs[v * 3 + 2] = cz * sqrt(1 - x2 / 2 - y2 / 2 + (x2 * y2) / 3);
|
||||||
|
if (i == 0 || j == 0 || i == n || j == n) {
|
||||||
|
int kx = (int)lround_(((cx + 1) / 2) * n), ky = (int)lround_(((cy + 1) / 2) * n), kz = (int)lround_(((cz + 1) / 2) * n);
|
||||||
|
g->keys[v] = (kx * row + ky) * row + kz;
|
||||||
|
} else {
|
||||||
|
g->keys[v] = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cell_at(double v)
|
||||||
|
{
|
||||||
|
double cell = 2.0 / GRID;
|
||||||
|
return (int)dmin(GRID - 1, dmax(0, floor((v + 1) / cell)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* terrain.lua make_craters: the list, then each grid cell's craters in list order. */
|
||||||
|
static int make_craters(mt_gen* g)
|
||||||
|
{
|
||||||
|
const mt_params* p = &g->p;
|
||||||
|
int field = p->technique == MT_CRATER_FIELD;
|
||||||
|
double min_r = field ? p->crater_min_radius * 0.8 : p->crater_min_radius;
|
||||||
|
double max_r = field ? p->crater_max_radius * 0.6 : p->crater_max_radius;
|
||||||
|
double scale = field ? 3 : 1;
|
||||||
|
int count = (int)lround_(dmin(p->crater_count, MAX_CRATERS) * scale), i, c, x, y, z, total;
|
||||||
|
mt_rng r = rng_new(p->crater_seed);
|
||||||
|
int cells = GRID * GRID * GRID;
|
||||||
|
int* fill;
|
||||||
|
if (count < 0)
|
||||||
|
count = 0;
|
||||||
|
g->x_max = dmax(1 + p->crater_rim_width, sqrt(1 + dmax(0, p->crater_smoothness)));
|
||||||
|
g->craters = (crater*)malloc((size_t)(count > 0 ? count : 1) * sizeof(crater));
|
||||||
|
g->cell_start = (int*)calloc((size_t)cells + 1, sizeof(int));
|
||||||
|
if (!g->craters || !g->cell_start)
|
||||||
|
return 0;
|
||||||
|
for (i = 0; i < count; ++i) {
|
||||||
|
double zz = rng_next(&r) * 2 - 1;
|
||||||
|
double a = rng_next(&r) * M_PI * 2;
|
||||||
|
double q = sqrt(1 - zz * zz);
|
||||||
|
double radius = min_r + (max_r - min_r) * pow(rng_next(&r), p->crater_size_bias);
|
||||||
|
crater* k = &g->craters[i];
|
||||||
|
k->x = q * cos(a);
|
||||||
|
k->y = q * sin(a);
|
||||||
|
k->z = zz;
|
||||||
|
k->r = radius;
|
||||||
|
k->reach = radius * g->x_max;
|
||||||
|
}
|
||||||
|
g->n_craters = count;
|
||||||
|
/* Two passes: counts per cell, then the lists. */
|
||||||
|
for (c = 0; c < count; ++c) {
|
||||||
|
crater* k = &g->craters[c];
|
||||||
|
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
|
||||||
|
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
|
||||||
|
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z)
|
||||||
|
g->cell_start[(x * GRID + y) * GRID + z + 1]++;
|
||||||
|
}
|
||||||
|
for (i = 0; i < cells; ++i)
|
||||||
|
g->cell_start[i + 1] += g->cell_start[i];
|
||||||
|
total = g->cell_start[cells];
|
||||||
|
g->cell_list = (int*)malloc((size_t)(total > 0 ? total : 1) * sizeof(int));
|
||||||
|
fill = (int*)calloc((size_t)cells, sizeof(int));
|
||||||
|
if (!g->cell_list || !fill) {
|
||||||
|
free(fill);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (c = 0; c < count; ++c) {
|
||||||
|
crater* k = &g->craters[c];
|
||||||
|
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
|
||||||
|
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
|
||||||
|
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z) {
|
||||||
|
int cell = (x * GRID + y) * GRID + z;
|
||||||
|
g->cell_list[g->cell_start[cell] + fill[cell]++] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(fill);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void height_at(mt_gen* g, int i)
|
||||||
|
{
|
||||||
|
const mt_params* p = &g->p;
|
||||||
|
int field = p->technique == MT_CRATER_FIELD;
|
||||||
|
double x = g->dirs[i * 3], y = g->dirs[i * 3 + 1], z = g->dirs[i * 3 + 2];
|
||||||
|
double px = x + p->offset[0], py = y + p->offset[1], pz = z + p->offset[2];
|
||||||
|
double value, h, crater_h = 0, glow = 0;
|
||||||
|
double shape_strength = field ? p->shape_strength * 0.4 : p->shape_strength;
|
||||||
|
double smooth = dmax(0, p->crater_smoothness);
|
||||||
|
double ejecta_reach = 1 + p->crater_rim_width * 2.5;
|
||||||
|
double x_max = g->x_max;
|
||||||
|
int cell, c, end;
|
||||||
|
switch (p->technique) {
|
||||||
|
case MT_RIDGED:
|
||||||
|
value = ridged(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity,
|
||||||
|
p->ridge_sharpness, p->ridge_gain);
|
||||||
|
break;
|
||||||
|
case MT_BILLOW:
|
||||||
|
value = billow(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity);
|
||||||
|
break;
|
||||||
|
case MT_WARPED: {
|
||||||
|
double wf = p->warp_frequency;
|
||||||
|
double wx = fbm(px + 5.2, py + 1.3, pz, p->warp_octaves, wf, 0.5, 2);
|
||||||
|
double wy = fbm(px, py + 9.7, pz + 2.8, p->warp_octaves, wf, 0.5, 2);
|
||||||
|
double wz = fbm(px + 3.1, py, pz + 7.4, p->warp_octaves, wf, 0.5, 2);
|
||||||
|
double s = p->warp_strength;
|
||||||
|
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, p->shape_octaves, p->shape_frequency, p->shape_persistence,
|
||||||
|
p->shape_lacunarity) + 1) / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
value = (fbm(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity) + 1) / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h = dmax(0, value - p->shape_min) * shape_strength;
|
||||||
|
if (p->ridge_strength != 0) {
|
||||||
|
double r = ridged(px - 11.3, py + 4.1, pz - 7.9, p->ridge_octaves, p->ridge_frequency, p->ridge_persistence,
|
||||||
|
p->ridge_lacunarity, p->ridge_sharpness, p->ridge_gain);
|
||||||
|
h = h + dmax(0, r - p->ridge_min) * p->ridge_strength;
|
||||||
|
}
|
||||||
|
cell = (cell_at(x) * GRID + cell_at(y)) * GRID + cell_at(z);
|
||||||
|
end = g->cell_start[cell + 1];
|
||||||
|
for (c = g->cell_start[cell]; c < end; ++c) {
|
||||||
|
const crater* k = &g->craters[g->cell_list[c]];
|
||||||
|
double dx = x - k->x, dy = y - k->y, dz = z - k->z;
|
||||||
|
double d2 = dx * dx + dy * dy + dz * dz;
|
||||||
|
double reach = dmax(k->reach, k->r * ejecta_reach);
|
||||||
|
if (d2 <= reach * reach) {
|
||||||
|
double dist = sqrt(d2) / k->r;
|
||||||
|
if (dist < x_max) {
|
||||||
|
double cavity = dist * dist - 1;
|
||||||
|
double rim_x = dmin(dist - 1 - p->crater_rim_width, 0);
|
||||||
|
double rim = p->crater_rim_steepness * rim_x * rim_x;
|
||||||
|
double shape = smooth_max(cavity, p->crater_floor, smooth);
|
||||||
|
shape = smooth_min(shape, rim, smooth);
|
||||||
|
crater_h = crater_h + shape * k->r;
|
||||||
|
}
|
||||||
|
if (dist > 0.8 && dist < ejecta_reach)
|
||||||
|
glow = glow + 1 - smoothstep(1, ejecta_reach, dist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g->heights[i] = h + crater_h * p->crater_depth;
|
||||||
|
g->ejecta[i] = dmin(1, glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tri(mt_gen* g, int a, int b, int c)
|
||||||
|
{
|
||||||
|
const double* P = g->positions;
|
||||||
|
double* N = g->normals;
|
||||||
|
double e1x, e1y, e1z, e2x, e2y, e2z, nx, ny, nz;
|
||||||
|
a *= 3;
|
||||||
|
b *= 3;
|
||||||
|
c *= 3;
|
||||||
|
e1x = P[b] - P[a]; e1y = P[b + 1] - P[a + 1]; e1z = P[b + 2] - P[a + 2];
|
||||||
|
e2x = P[c] - P[a]; e2y = P[c + 1] - P[a + 1]; e2z = P[c + 2] - P[a + 2];
|
||||||
|
nx = e1y * e2z - e1z * e2y;
|
||||||
|
ny = e1z * e2x - e1x * e2z;
|
||||||
|
nz = e1x * e2y - e1y * e2x;
|
||||||
|
N[a] += nx; N[a + 1] += ny; N[a + 2] += nz;
|
||||||
|
N[b] += nx; N[b + 1] += ny; N[b + 2] += nz;
|
||||||
|
N[c] += nx; N[c + 1] += ny; N[c + 2] += nz;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct seam {
|
||||||
|
int key, index;
|
||||||
|
} seam;
|
||||||
|
|
||||||
|
static int seam_cmp(const void* a, const void* b)
|
||||||
|
{
|
||||||
|
const seam* x = (const seam*)a;
|
||||||
|
const seam* y = (const seam*)b;
|
||||||
|
if (x->key != y->key)
|
||||||
|
return x->key < y->key ? -1 : 1;
|
||||||
|
return x->index < y->index ? -1 : (x->index > y->index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faces duplicate their edge vertices: each copy gets the sum (in index order, like the
|
||||||
|
* Lua's ipairs over its group). */
|
||||||
|
static int seams(mt_gen* g)
|
||||||
|
{
|
||||||
|
int count = g->count, i, m = 0, a, b, k;
|
||||||
|
seam* list = (seam*)malloc((size_t)count * sizeof(seam));
|
||||||
|
if (!list)
|
||||||
|
return 0;
|
||||||
|
for (i = 0; i < count; ++i)
|
||||||
|
if (g->keys[i] >= 0) {
|
||||||
|
list[m].key = g->keys[i];
|
||||||
|
list[m].index = i;
|
||||||
|
++m;
|
||||||
|
}
|
||||||
|
qsort(list, (size_t)m, sizeof(seam), seam_cmp);
|
||||||
|
for (a = 0; a < m; a = b) {
|
||||||
|
double sx = 0, sy = 0, sz = 0;
|
||||||
|
for (b = a; b < m && list[b].key == list[a].key; ++b) {
|
||||||
|
}
|
||||||
|
if (b - a < 2)
|
||||||
|
continue;
|
||||||
|
for (k = a; k < b; ++k) {
|
||||||
|
int o = list[k].index * 3;
|
||||||
|
sx = sx + g->normals[o];
|
||||||
|
sy = sy + g->normals[o + 1];
|
||||||
|
sz = sz + g->normals[o + 2];
|
||||||
|
}
|
||||||
|
for (k = a; k < b; ++k) {
|
||||||
|
int o = list[k].index * 3;
|
||||||
|
g->normals[o] = sx;
|
||||||
|
g->normals[o + 1] = sy;
|
||||||
|
g->normals[o + 2] = sz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(list);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void color_at(mt_gen* g, int i)
|
||||||
|
{
|
||||||
|
const mt_params* p = &g->p;
|
||||||
|
int o = i * 3;
|
||||||
|
double x = g->dirs[o], y = g->dirs[o + 1], z = g->dirs[o + 2];
|
||||||
|
double ox = p->offset[0], oy = p->offset[1], oz = p->offset[2];
|
||||||
|
double range = g->max_h - g->min_h;
|
||||||
|
double e, te, c1, c2, c3, gg, steepness, ws, gr;
|
||||||
|
const double *lo, *hi;
|
||||||
|
if (range == 0)
|
||||||
|
range = 1;
|
||||||
|
e = (g->heights[i] - g->min_h) / range;
|
||||||
|
if (e < 0.5) {
|
||||||
|
lo = p->low; hi = p->mid; te = e * 2;
|
||||||
|
} else {
|
||||||
|
lo = p->mid; hi = p->high; te = (e - 0.5) * 2;
|
||||||
|
}
|
||||||
|
c1 = lo[0] + (hi[0] - lo[0]) * te;
|
||||||
|
c2 = lo[1] + (hi[1] - lo[1]) * te;
|
||||||
|
c3 = lo[2] + (hi[2] - lo[2]) * te;
|
||||||
|
if (p->patch_amount > 0) {
|
||||||
|
double pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, p->patch_frequency, 0.5, 2);
|
||||||
|
double w = smoothstep(-0.05, 0.4, pn) * p->patch_amount * (1 - e * 0.6);
|
||||||
|
c1 = c1 + (p->patch[0] - c1) * w;
|
||||||
|
c2 = c2 + (p->patch[1] - c2) * w;
|
||||||
|
c3 = c3 + (p->patch[2] - c3) * w;
|
||||||
|
}
|
||||||
|
gg = g->ejecta[i] * p->crater_ejecta;
|
||||||
|
c1 = c1 + (p->high[0] - c1) * gg;
|
||||||
|
c2 = c2 + (p->high[1] - c2) * gg;
|
||||||
|
c3 = c3 + (p->high[2] - c3) * gg;
|
||||||
|
steepness = 1 - (g->normals[o] * x + g->normals[o + 1] * y + g->normals[o + 2] * z);
|
||||||
|
ws = smoothstep(p->steep_threshold - p->steep_blend, p->steep_threshold + p->steep_blend, steepness);
|
||||||
|
c1 = c1 + (p->steep[0] - c1) * ws;
|
||||||
|
c2 = c2 + (p->steep[1] - c2) * ws;
|
||||||
|
c3 = c3 + (p->steep[2] - c3) * ws;
|
||||||
|
gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * p->grain;
|
||||||
|
g->colors[o] = c1 * gr;
|
||||||
|
g->colors[o + 1] = c2 * gr;
|
||||||
|
g->colors[o + 2] = c3 * gr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mt_step(mt_gen* g, double budget)
|
||||||
|
{
|
||||||
|
double deadline = clock_secs() + budget;
|
||||||
|
int count = g->count, every = 0;
|
||||||
|
#define DUE() (budget > 0 && (++every & 31) == 0 && clock_secs() > deadline)
|
||||||
|
while (g->stage != ST_DONE) {
|
||||||
|
switch (g->stage) {
|
||||||
|
case ST_DIRS:
|
||||||
|
while (g->i < count) {
|
||||||
|
dir_at(g, g->i++);
|
||||||
|
if (DUE())
|
||||||
|
goto out;
|
||||||
|
}
|
||||||
|
g->stage = ST_CRATERS;
|
||||||
|
break;
|
||||||
|
case ST_CRATERS:
|
||||||
|
if (!make_craters(g)) {
|
||||||
|
g->stage = ST_DONE; /* (out of memory: a flat moon) */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
g->stage = ST_HEIGHTS;
|
||||||
|
g->i = 0;
|
||||||
|
break;
|
||||||
|
case ST_HEIGHTS:
|
||||||
|
while (g->i < count) {
|
||||||
|
height_at(g, g->i++);
|
||||||
|
if (DUE())
|
||||||
|
goto out;
|
||||||
|
}
|
||||||
|
g->stage = ST_MEAN;
|
||||||
|
break;
|
||||||
|
case ST_MEAN: {
|
||||||
|
double mean = 0;
|
||||||
|
int i;
|
||||||
|
for (i = 0; i < count; ++i)
|
||||||
|
mean = mean + g->heights[i];
|
||||||
|
g->mean = mean / count;
|
||||||
|
g->min_h = HUGE_VAL;
|
||||||
|
g->max_h = -HUGE_VAL;
|
||||||
|
g->stage = ST_POSITIONS;
|
||||||
|
g->i = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ST_POSITIONS: {
|
||||||
|
int i;
|
||||||
|
for (i = 0; i < count; ++i) {
|
||||||
|
double hh = g->heights[i] - g->mean, r;
|
||||||
|
g->heights[i] = hh;
|
||||||
|
r = 1 + hh;
|
||||||
|
g->positions[i * 3] = g->dirs[i * 3] * r;
|
||||||
|
g->positions[i * 3 + 1] = g->dirs[i * 3 + 1] * r;
|
||||||
|
g->positions[i * 3 + 2] = g->dirs[i * 3 + 2] * r;
|
||||||
|
if (hh < g->min_h)
|
||||||
|
g->min_h = hh;
|
||||||
|
if (hh > g->max_h)
|
||||||
|
g->max_h = hh;
|
||||||
|
}
|
||||||
|
g->stage = ST_NORMALS;
|
||||||
|
g->i = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ST_NORMALS: {
|
||||||
|
/* g->i: the row (face * n + y) */
|
||||||
|
int n = g->n, rows = 6 * n;
|
||||||
|
while (g->i < rows) {
|
||||||
|
int f = g->i / n, y = g->i % n, x;
|
||||||
|
int base = f * g->per;
|
||||||
|
for (x = 0; x < n; ++x) {
|
||||||
|
int i = base + y * g->row + x;
|
||||||
|
tri(g, i, i + 1, i + n + 2);
|
||||||
|
tri(g, i, i + n + 2, i + n + 1);
|
||||||
|
}
|
||||||
|
++g->i;
|
||||||
|
if (budget > 0 && clock_secs() > deadline)
|
||||||
|
goto out;
|
||||||
|
}
|
||||||
|
g->stage = ST_SEAMS;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ST_SEAMS:
|
||||||
|
seams(g);
|
||||||
|
g->stage = ST_NORMALIZE;
|
||||||
|
break;
|
||||||
|
case ST_NORMALIZE: {
|
||||||
|
int i;
|
||||||
|
for (i = 0; i < count; ++i) {
|
||||||
|
int o = i * 3;
|
||||||
|
double nx = g->normals[o], ny = g->normals[o + 1], nz = g->normals[o + 2];
|
||||||
|
double len = sqrt(nx * nx + ny * ny + nz * nz);
|
||||||
|
if (len == 0)
|
||||||
|
len = 1;
|
||||||
|
g->normals[o] = nx / len;
|
||||||
|
g->normals[o + 1] = ny / len;
|
||||||
|
g->normals[o + 2] = nz / len;
|
||||||
|
}
|
||||||
|
g->stage = ST_COLORS;
|
||||||
|
g->i = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ST_COLORS:
|
||||||
|
while (g->i < count) {
|
||||||
|
color_at(g, g->i++);
|
||||||
|
if (DUE())
|
||||||
|
goto out;
|
||||||
|
}
|
||||||
|
g->stage = ST_DONE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out:
|
||||||
|
#undef DUE
|
||||||
|
return g->stage == ST_DONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mt_n(const mt_gen* g) { return g->n; }
|
||||||
|
int mt_count(const mt_gen* g) { return g->count; }
|
||||||
|
const double* mt_positions(const mt_gen* g) { return g->positions; }
|
||||||
|
const double* mt_normals(const mt_gen* g) { return g->normals; }
|
||||||
|
const double* mt_colors(const mt_gen* g) { return g->colors; }
|
||||||
|
double mt_min_h(const mt_gen* g) { return g->min_h; }
|
||||||
|
double mt_max_h(const mt_gen* g) { return g->max_h; }
|
||||||
|
|
||||||
|
int mt_strip_len(int n)
|
||||||
|
{
|
||||||
|
/* per face n rows of 2 (n + 1), joined by 2 repeated vertices (none before the first) */
|
||||||
|
return 6 * n * 2 * (n + 1) + 2 * (6 * n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void mt_strip(int n, int* out)
|
||||||
|
{
|
||||||
|
int row = n + 1, per = row * row, c = 0, f, j, i;
|
||||||
|
for (f = 0; f < 6; ++f) {
|
||||||
|
int base = f * per;
|
||||||
|
for (j = 0; j < n; ++j) {
|
||||||
|
if (c > 0) {
|
||||||
|
out[c] = out[c - 1];
|
||||||
|
out[c + 1] = base + (j + 1) * row;
|
||||||
|
c += 2;
|
||||||
|
}
|
||||||
|
for (i = 0; i <= n; ++i) {
|
||||||
|
out[c] = base + (j + 1) * row + i;
|
||||||
|
out[c + 1] = base + j * row + i;
|
||||||
|
c += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to)
|
||||||
|
{
|
||||||
|
int n = g->n, row = n + 1, per = row * row, k = 0, f, j, i, last = -1;
|
||||||
|
/* the strip of mt_strip, walked without the index array */
|
||||||
|
#define PUT(v) \
|
||||||
|
do { \
|
||||||
|
int v3 = (v) * 3; \
|
||||||
|
if (k < from || k >= to) { \
|
||||||
|
last = (v); \
|
||||||
|
++k; \
|
||||||
|
break; \
|
||||||
|
} \
|
||||||
|
float* pp = pos ? pos + (size_t)k * stride : 0; \
|
||||||
|
float* nn = nor ? nor + (size_t)k * stride : 0; \
|
||||||
|
float* cc = col ? col + (size_t)k * stride : 0; \
|
||||||
|
if (pp) { pp[0] = (float)g->positions[v3]; pp[1] = (float)g->positions[v3 + 1]; pp[2] = (float)g->positions[v3 + 2]; } \
|
||||||
|
if (nn) { nn[0] = (float)g->normals[v3]; nn[1] = (float)g->normals[v3 + 1]; nn[2] = (float)g->normals[v3 + 2]; } \
|
||||||
|
if (cc) { cc[0] = (float)g->colors[v3]; cc[1] = (float)g->colors[v3 + 1]; cc[2] = (float)g->colors[v3 + 2]; } \
|
||||||
|
last = (v); \
|
||||||
|
++k; \
|
||||||
|
} while (0)
|
||||||
|
for (f = 0; f < 6; ++f) {
|
||||||
|
int base = f * per;
|
||||||
|
for (j = 0; j < n; ++j) {
|
||||||
|
if (k > 0) {
|
||||||
|
int prev = last;
|
||||||
|
PUT(prev);
|
||||||
|
PUT(base + (j + 1) * row);
|
||||||
|
}
|
||||||
|
for (i = 0; i <= n; ++i) {
|
||||||
|
PUT(base + (j + 1) * row + i);
|
||||||
|
PUT(base + j * row + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#undef PUT
|
||||||
|
}
|
||||||
|
|
||||||
|
#if MT_THREADS
|
||||||
|
static void* run_thread(void* arg)
|
||||||
|
{
|
||||||
|
mt_gen* g = (mt_gen*)arg;
|
||||||
|
mt_step(g, 0);
|
||||||
|
__atomic_store_n(&g->finished, 1, __ATOMIC_RELEASE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int mt_start(mt_gen* g)
|
||||||
|
{
|
||||||
|
#if MT_THREADS
|
||||||
|
if (g->threaded || g->stage == ST_DONE)
|
||||||
|
return g->threaded;
|
||||||
|
if (pthread_create(&g->thread, 0, run_thread, g) != 0)
|
||||||
|
return 0;
|
||||||
|
g->threaded = 1;
|
||||||
|
return 1;
|
||||||
|
#else
|
||||||
|
(void)g;
|
||||||
|
return 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
int mt_done(mt_gen* g)
|
||||||
|
{
|
||||||
|
#if MT_THREADS
|
||||||
|
if (g->threaded)
|
||||||
|
return __atomic_load_n(&g->finished, __ATOMIC_ACQUIRE);
|
||||||
|
#endif
|
||||||
|
return g->stage == ST_DONE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* The moon generator in C: a port of the server's web/static/js/terrain.js generation.
|
||||||
|
* Same random draws (mulberry32, the simplex permutation) and the same arithmetic in the
|
||||||
|
* same order, in doubles, so the output matches terrain.js within its Float32Array
|
||||||
|
* rounding.
|
||||||
|
*
|
||||||
|
* Plain C, no engine or Lua headers: mmterrain.c binds it for the engine, and
|
||||||
|
* scripts/moon-check.mjs loads it into luajit through the FFI (a host build) to compare
|
||||||
|
* it with terrain.js. The work runs in steps with a time budget (mt_step), so a slow
|
||||||
|
* target (wasm) can spread it over frames.
|
||||||
|
*/
|
||||||
|
#ifndef MM_TERRAIN_H
|
||||||
|
#define MM_TERRAIN_H
|
||||||
|
|
||||||
|
#ifndef MT_THREADS
|
||||||
|
#if defined(__EMSCRIPTEN__) || defined(_WIN32)
|
||||||
|
#define MT_THREADS 0
|
||||||
|
#else
|
||||||
|
#define MT_THREADS 1
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Techniques, in terrain.lua's TECHNIQUES order. */
|
||||||
|
enum { MT_FBM = 0, MT_RIDGED = 1, MT_WARPED = 2, MT_BILLOW = 3, MT_CRATER_FIELD = 4 };
|
||||||
|
|
||||||
|
/* The resolved parameters of one moon (terrain.lua resolve), colors already linear RGB. */
|
||||||
|
typedef struct mt_params {
|
||||||
|
double resolution;
|
||||||
|
int technique;
|
||||||
|
double shape_strength, shape_frequency, shape_octaves, shape_persistence, shape_lacunarity, shape_min;
|
||||||
|
double ridge_strength, ridge_frequency, ridge_octaves, ridge_persistence, ridge_lacunarity,
|
||||||
|
ridge_sharpness, ridge_gain, ridge_min;
|
||||||
|
double warp_strength, warp_frequency, warp_octaves;
|
||||||
|
double crater_count, crater_min_radius, crater_max_radius, crater_size_bias, crater_depth,
|
||||||
|
crater_rim_width, crater_rim_steepness, crater_floor, crater_smoothness, crater_ejecta;
|
||||||
|
double low[3], mid[3], high[3], steep[3], patch[3];
|
||||||
|
double steep_threshold, steep_blend, patch_amount, patch_frequency, grain;
|
||||||
|
double offset[3];
|
||||||
|
double crater_seed;
|
||||||
|
} mt_params;
|
||||||
|
|
||||||
|
typedef struct mt_gen mt_gen;
|
||||||
|
|
||||||
|
/* A generator for p (copied). NULL when out of memory. */
|
||||||
|
mt_gen* mt_new(const mt_params* p);
|
||||||
|
void mt_free(mt_gen* g);
|
||||||
|
/* Works for about `budget` seconds (<= 0: until done); returns 1 when done. */
|
||||||
|
int mt_step(mt_gen* g, double budget);
|
||||||
|
/* Where there are threads (desktop: MT_THREADS), runs the whole generation on a thread of
|
||||||
|
* its own and returns 1 (then only mt_done and, once done, the results may be used);
|
||||||
|
* else returns 0 (step it instead). */
|
||||||
|
int mt_start(mt_gen* g);
|
||||||
|
/* 1 once the generation is complete (threaded or stepped). */
|
||||||
|
int mt_done(mt_gen* g);
|
||||||
|
/* The grid size, its vertex count, and (when done) the flat xyz arrays over the grid
|
||||||
|
* (vertex v at [3v..3v+2]) and the height range, like terrain.js generateMoon's. */
|
||||||
|
int mt_n(const mt_gen* g);
|
||||||
|
int mt_count(const mt_gen* g);
|
||||||
|
const double* mt_positions(const mt_gen* g);
|
||||||
|
const double* mt_normals(const mt_gen* g);
|
||||||
|
const double* mt_colors(const mt_gen* g);
|
||||||
|
double mt_min_h(const mt_gen* g);
|
||||||
|
double mt_max_h(const mt_gen* g);
|
||||||
|
|
||||||
|
/* The triangle strip over the grid (terrain.lua strip): its length, and its 0-based grid
|
||||||
|
* indices into out (mt_strip_len(n) ints). */
|
||||||
|
int mt_strip_len(int n);
|
||||||
|
void mt_strip(int n, int* out);
|
||||||
|
/* Writes the strip's positions, normals and colors as floats, its vertices k in
|
||||||
|
* [from, to): vertex k at pos[k * stride ..], nor[k * stride ..], col[k * stride ..] (any
|
||||||
|
* may be NULL). */
|
||||||
|
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
|
||||||
|
|
||||||
|
/* The parameters from a flat array in terrain.lua native_params order (MT_PARAM_COUNT
|
||||||
|
* numbers); returns 0 when it is too short. */
|
||||||
|
#define MT_PARAM_COUNT 53
|
||||||
|
int mt_params_from_array(const double* a, int n, mt_params* p);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
// mmwindow.set_fullscreen(true | false)
|
// mmwindow.set_fullscreen(true | false)
|
||||||
// mmwindow.is_fullscreen() -> bool
|
// mmwindow.is_fullscreen() -> bool
|
||||||
//
|
//
|
||||||
|
// Also sticky mouse buttons and keys: the engine polls button and key states once a frame,
|
||||||
|
// so a press and release between two polls (a quick click or a touchpad tap at a low frame
|
||||||
|
// rate) was lost; sticky, a press released before the poll still reads as pressed once.
|
||||||
|
//
|
||||||
// The GLFW functions are the engine's (statically linked); declared here, no header.
|
// The GLFW functions are the engine's (statically linked); declared here, no header.
|
||||||
|
|
||||||
#define EXTENSION_NAME MMWindow
|
#define EXTENSION_NAME MMWindow
|
||||||
@@ -32,8 +36,12 @@ void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
|
|||||||
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
|
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
|
||||||
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
|
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
|
||||||
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
|
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
|
||||||
|
void glfwSetInputMode(GLFWwindow* window, int mode, int value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#define MM_GLFW_STICKY_KEYS 0x00033002
|
||||||
|
#define MM_GLFW_STICKY_MOUSE_BUTTONS 0x00033003
|
||||||
|
|
||||||
static int g_X, g_Y, g_W, g_H;
|
static int g_X, g_Y, g_W, g_H;
|
||||||
static bool g_Saved = false;
|
static bool g_Saved = false;
|
||||||
|
|
||||||
@@ -128,9 +136,27 @@ static dmExtension::Result Initialize(dmExtension::Params* params)
|
|||||||
return dmExtension::RESULT_OK;
|
return dmExtension::RESULT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sticky input on the engine's window, once it exists (see the top).
|
||||||
|
static bool g_Sticky = false;
|
||||||
|
|
||||||
|
static dmExtension::Result Update(dmExtension::Params* params)
|
||||||
|
{
|
||||||
|
#if defined(MM_GLFW)
|
||||||
|
if (!g_Sticky) {
|
||||||
|
GLFWwindow* win = glfwGetCurrentContext();
|
||||||
|
if (win) {
|
||||||
|
glfwSetInputMode(win, MM_GLFW_STICKY_MOUSE_BUTTONS, 1);
|
||||||
|
glfwSetInputMode(win, MM_GLFW_STICKY_KEYS, 1);
|
||||||
|
g_Sticky = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return dmExtension::RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
static dmExtension::Result Finalize(dmExtension::Params* params)
|
static dmExtension::Result Finalize(dmExtension::Params* params)
|
||||||
{
|
{
|
||||||
return dmExtension::RESULT_OK;
|
return dmExtension::RESULT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, 0, 0, Finalize)
|
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, Update, 0, Finalize)
|
||||||
+63
-7
@@ -15,8 +15,9 @@
|
|||||||
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
|
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
|
||||||
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
|
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
|
||||||
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
|
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
|
||||||
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
|
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
|
||||||
-- decaying noise shake (sell power-down, reveal burst)
|
-- reveal burst) or anim.noise_shake for a
|
||||||
|
-- decaying value-noise shake (sell power-down)
|
||||||
-- anim.flash(node, color, dur) tint to a color and back
|
-- anim.flash(node, color, dur) tint to a color and back
|
||||||
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
|
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
|
||||||
-- anim.count(node, from, to, dur, fmt) a number counting
|
-- anim.count(node, from, to, dur, fmt) a number counting
|
||||||
@@ -159,6 +160,7 @@ end
|
|||||||
|
|
||||||
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
||||||
local running = {}
|
local running = {}
|
||||||
|
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
|
||||||
|
|
||||||
local function now()
|
local function now()
|
||||||
return socket.gettime()
|
return socket.gettime()
|
||||||
@@ -207,6 +209,7 @@ end
|
|||||||
-- Drop every frame-driven tween (a screen going away).
|
-- Drop every frame-driven tween (a screen going away).
|
||||||
function M.clear()
|
function M.clear()
|
||||||
running = {}
|
running = {}
|
||||||
|
shaking = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- fading content
|
---------------------------------------------------------------- fading content
|
||||||
@@ -342,21 +345,39 @@ end
|
|||||||
|
|
||||||
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
||||||
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
||||||
|
-- A shake that starts while another runs on the node replaces it and keeps its resting
|
||||||
|
-- place (read mid-shake, the position is off by up to amp and would stay so).
|
||||||
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
||||||
function M.shake(node, amp, dur, delay)
|
function M.shake(node, amp, dur, delay)
|
||||||
if M.reduced then
|
if M.reduced then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
amp, dur = amp or 6, dur or 0.4
|
amp, dur = amp or 6, dur or 0.4
|
||||||
local p
|
local rest = {}
|
||||||
return M.tween(node, dur, function(k, n)
|
local keep = {}
|
||||||
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
for _, s in ipairs(shaking) do
|
||||||
|
if not s.t.dead then
|
||||||
|
local ok, same = pcall(function() return s.node == node end)
|
||||||
|
if ok and same then
|
||||||
|
M.cancel(s.t)
|
||||||
|
rest = s.rest
|
||||||
|
else
|
||||||
|
keep[#keep + 1] = s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
shaking = keep
|
||||||
|
local t = M.tween(node, dur, function(k, n)
|
||||||
|
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||||
|
local p = rest.p
|
||||||
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
||||||
end, delay or 0, function()
|
end, delay or 0, function()
|
||||||
if p then
|
if rest.p then
|
||||||
pcall(gui.set_position, node, p)
|
pcall(gui.set_position, node, rest.p)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
|
||||||
|
return t
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 1D value noise (smooth), for shakes.
|
-- 1D value noise (smooth), for shakes.
|
||||||
@@ -389,6 +410,41 @@ function M.noise_shake(node, amp, dur, freq)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
|
||||||
|
-- screen shake.
|
||||||
|
local function grad(i, seed)
|
||||||
|
local s = math.sin((i + seed) * 12.9898) * 43758.5453
|
||||||
|
return (s - math.floor(s)) * 2 - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function perlin1(x, seed)
|
||||||
|
local i = math.floor(x)
|
||||||
|
local f = x - i
|
||||||
|
local u = f * f * f * (f * (f * 6 - 15) + 10)
|
||||||
|
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
|
||||||
|
return (a + (b - a) * u) * 2
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.perlin(x, seed)
|
||||||
|
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
|
||||||
|
function M.perlin_shake(node, amp, dur, freq)
|
||||||
|
if M.reduced or not node then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local p = gui.get_position(node)
|
||||||
|
local seed = math.random() * 100
|
||||||
|
M.tween(node, dur, function(k, n)
|
||||||
|
local a = amp * (1 - k) ^ 2
|
||||||
|
local t = k * dur * freq
|
||||||
|
gui.set_position(n, v3(p.x + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * a, p.z))
|
||||||
|
end, 0, function()
|
||||||
|
pcall(gui.set_position, node, p)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Tint to `color` and back over dur (a flash).
|
-- Tint to `color` and back over dur (a flash).
|
||||||
function M.flash(node, color, dur)
|
function M.flash(node, color, dur)
|
||||||
local c = gui.get_color(node)
|
local c = gui.get_color(node)
|
||||||
|
|||||||
+19
-1
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
|
|||||||
err = "network error"
|
err = "network error"
|
||||||
elseif status < 200 or status >= 300 then
|
elseif status < 200 or status >= 300 then
|
||||||
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
||||||
elseif data == nil then
|
elseif data == nil and status ~= 204 and method == "POST" then
|
||||||
|
err = "bad JSON"
|
||||||
|
elseif data == nil and method == "GET" and response.response ~= "null" then
|
||||||
err = "bad JSON"
|
err = "bad JSON"
|
||||||
end
|
end
|
||||||
done(err, data, status, recv)
|
done(err, data, status, recv)
|
||||||
@@ -179,6 +181,22 @@ function M.get_state(params, cb)
|
|||||||
request("GET", "/api/state", params, cb)
|
request("GET", "/api/state", params, cb)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
|
||||||
|
-- itself comes only from the save-code action; restore is the action restore (code).
|
||||||
|
function M.get_save_code(cb)
|
||||||
|
request("GET", "/api/save-code", nil, cb)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
|
||||||
|
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
|
||||||
|
function M.get_moon_settings(cb)
|
||||||
|
request("GET", "/api/moon-settings", nil, cb)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.save_moon_settings(settings, cb)
|
||||||
|
request("POST", "/api/moon-settings", settings, cb)
|
||||||
|
end
|
||||||
|
|
||||||
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
||||||
function M.act(name, params, cb)
|
function M.act(name, params, cb)
|
||||||
if reject_name ~= "" and name == reject_name then
|
if reject_name ~= "" and name == reject_name then
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
-- Blocks: parts of the layout that a rebuild keeps when what they show hasn't changed.
|
||||||
|
-- Every new state rebuilds the screen (core/main.lua); a block is built once into a
|
||||||
|
-- container node and kept, with its nodes and Druid components, by the next builds while
|
||||||
|
-- its signature (M.sig of everything it shows) stays the same. Regions are blocks
|
||||||
|
-- (header, moon list, main panel, aside), and so are costly parts inside them (the
|
||||||
|
-- market's chart, ticker and sparklines, cards).
|
||||||
|
--
|
||||||
|
-- local b, kept = blocks.open(g, key, sig, parent) -- build into b.root unless kept
|
||||||
|
-- if not kept then ... build into b.root ... end
|
||||||
|
-- local ret = blocks.close(g, b, ...) -- what it returns, kept or not
|
||||||
|
-- -- or: blocks.keep(g, key, sig, parent, function(root) ... return ... end)
|
||||||
|
--
|
||||||
|
-- Build registrations (test ids and keyboard focus, tooltips, tickers, fields, scroll
|
||||||
|
-- areas, effect anchors, ...) are reset by every build; those made while a block builds
|
||||||
|
-- are noted (M.note) and replayed in their order when it's kept, so a kept block is in
|
||||||
|
-- every registry like a rebuilt one. Druid components made while a block builds are its
|
||||||
|
-- own: they go when it goes (the screen's Druid instance lives on; core/main.lua).
|
||||||
|
-- A keyed block inside one that is rebuilt survives it (set aside until the new build
|
||||||
|
-- asks for it); blocks no build asked for go at the end of the build (M.finish).
|
||||||
|
|
||||||
|
local helper = require("druid.helper")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local stack = {} -- blocks being built, innermost last
|
||||||
|
local replaying = false
|
||||||
|
local orphans = {} -- components made outside any block's build (not a region's)
|
||||||
|
|
||||||
|
local function pack(...)
|
||||||
|
return { n = select("#", ...), ... }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function alive(node)
|
||||||
|
return node ~= nil and pcall(gui.get_id, node)
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- signatures
|
||||||
|
|
||||||
|
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||||
|
-- as a token of their content, worked out once per table: two tables with the same
|
||||||
|
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||||
|
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||||
|
-- state, not once per block and build. (A frozen table must not change.)
|
||||||
|
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||||
|
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||||
|
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||||
|
|
||||||
|
local ser
|
||||||
|
|
||||||
|
local function token_of(v)
|
||||||
|
local tok = frozen[v]
|
||||||
|
if tok ~= true then
|
||||||
|
return tok
|
||||||
|
end
|
||||||
|
local buf = {}
|
||||||
|
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||||
|
ser(v, buf, true)
|
||||||
|
local s = table.concat(buf)
|
||||||
|
tok = tokens[s]
|
||||||
|
if not tok then
|
||||||
|
if token_bytes > TOKEN_BYTES then
|
||||||
|
tokens, token_bytes = {}, 0
|
||||||
|
end
|
||||||
|
token_n = token_n + 1
|
||||||
|
tok = "#" .. token_n
|
||||||
|
tokens[s], token_bytes = tok, token_bytes + #s
|
||||||
|
end
|
||||||
|
frozen[v] = tok
|
||||||
|
return tok
|
||||||
|
end
|
||||||
|
|
||||||
|
local function key_lt(a, b)
|
||||||
|
return tostring(a) < tostring(b)
|
||||||
|
end
|
||||||
|
|
||||||
|
ser = function(v, buf, own)
|
||||||
|
local t = type(v)
|
||||||
|
if t == "table" then
|
||||||
|
if not own and frozen[v] then
|
||||||
|
buf[#buf + 1] = token_of(v)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
buf[#buf + 1] = "{"
|
||||||
|
local n = #v
|
||||||
|
for i = 1, n do
|
||||||
|
ser(v[i], buf)
|
||||||
|
buf[#buf + 1] = ","
|
||||||
|
end
|
||||||
|
local keys, mixed
|
||||||
|
for k in pairs(v) do
|
||||||
|
if type(k) ~= "number" or k < 1 or k > n or k % 1 ~= 0 then
|
||||||
|
keys = keys or {}
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
mixed = mixed or type(k) ~= "string"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if keys then
|
||||||
|
if mixed then
|
||||||
|
table.sort(keys, key_lt)
|
||||||
|
else
|
||||||
|
table.sort(keys)
|
||||||
|
end
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
buf[#buf + 1] = tostring(k)
|
||||||
|
buf[#buf + 1] = "="
|
||||||
|
ser(v[k], buf)
|
||||||
|
buf[#buf + 1] = ";"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
buf[#buf + 1] = "}"
|
||||||
|
elseif t == "string" then
|
||||||
|
buf[#buf + 1] = #v
|
||||||
|
buf[#buf + 1] = ":"
|
||||||
|
buf[#buf + 1] = v
|
||||||
|
elseif t == "function" then
|
||||||
|
buf[#buf + 1] = "f"
|
||||||
|
else
|
||||||
|
buf[#buf + 1] = tostring(v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||||
|
-- frozen ones by their token, the same for the same content).
|
||||||
|
function M.sig(...)
|
||||||
|
local buf = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
ser((select(i, ...)), buf)
|
||||||
|
buf[#buf + 1] = "|"
|
||||||
|
end
|
||||||
|
return table.concat(buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||||
|
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||||
|
function M.freeze(t)
|
||||||
|
if type(t) ~= "table" then
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
if frozen[t] == nil then
|
||||||
|
frozen[t] = true
|
||||||
|
end
|
||||||
|
for _, v in pairs(t) do
|
||||||
|
if type(v) == "table" and frozen[v] == nil then
|
||||||
|
frozen[v] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- registrations
|
||||||
|
|
||||||
|
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||||
|
-- keeps the block). The registry functions call this; it does nothing outside a block.
|
||||||
|
function M.note(fn, ...)
|
||||||
|
local b = stack[#stack]
|
||||||
|
if replaying or not b then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local j = b.journal
|
||||||
|
j[#j + 1] = { fn = fn, n = select("#", ...), ... }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- t[field] (a list reset by every build) gets value, noted.
|
||||||
|
local function push(t, field, value)
|
||||||
|
local l = t[field]
|
||||||
|
if not l then
|
||||||
|
l = {}
|
||||||
|
t[field] = l
|
||||||
|
end
|
||||||
|
l[#l + 1] = value
|
||||||
|
end
|
||||||
|
function M.push(t, field, value)
|
||||||
|
push(t, field, value)
|
||||||
|
M.note(push, t, field, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- t[field][k] = value (a table reset by every build), noted.
|
||||||
|
local function put(t, field, k, value)
|
||||||
|
local m = t[field]
|
||||||
|
if not m then
|
||||||
|
m = {}
|
||||||
|
t[field] = m
|
||||||
|
end
|
||||||
|
m[k] = value
|
||||||
|
end
|
||||||
|
function M.put(t, field, k, value)
|
||||||
|
put(t, field, k, value)
|
||||||
|
M.note(put, t, field, k, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local replay
|
||||||
|
|
||||||
|
-- A kept block inside the one being built: noted in it, its registrations made again.
|
||||||
|
local function adopt(reg, b)
|
||||||
|
local top = stack[#stack]
|
||||||
|
if top then
|
||||||
|
top.journal[#top.journal + 1] = { block = b }
|
||||||
|
end
|
||||||
|
replay(reg, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (the keyed blocks inside a kept one are used in this build too, or finish() would
|
||||||
|
-- destroy them: a kept Equipment section lost its cards)
|
||||||
|
replay = function(reg, b)
|
||||||
|
for _, e in ipairs(b.journal) do
|
||||||
|
if e.block then
|
||||||
|
local c = e.block
|
||||||
|
if not c.dead then
|
||||||
|
if c.key and reg.by_key[c.key] == c then
|
||||||
|
reg.used[c.key] = true
|
||||||
|
end
|
||||||
|
replay(reg, c)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
replaying = true
|
||||||
|
pcall(e.fn, unpack(e, 1, e.n)) -- (a node that went since: a list row scrolled out)
|
||||||
|
replaying = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fn, made to run inside the block being built now when it's called later, outside a
|
||||||
|
-- build (a list making rows as it scrolls, a field's on_change building again): what it
|
||||||
|
-- makes and registers is that block's.
|
||||||
|
function M.bound(fn)
|
||||||
|
local b = stack[#stack]
|
||||||
|
if not b then
|
||||||
|
return fn
|
||||||
|
end
|
||||||
|
return function(...)
|
||||||
|
if #stack > 0 or b.dead then
|
||||||
|
return fn(...)
|
||||||
|
end
|
||||||
|
stack[1] = b
|
||||||
|
local r = pack(pcall(fn, ...))
|
||||||
|
stack = {}
|
||||||
|
if not r[1] then
|
||||||
|
error(r[2], 0)
|
||||||
|
end
|
||||||
|
return unpack(r, 2, r.n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- components
|
||||||
|
|
||||||
|
-- Components of g.druid made while a block builds belong to it (core/main.lua hooks the
|
||||||
|
-- instance's new once per instance).
|
||||||
|
function M.hook(druid_instance)
|
||||||
|
local new = druid_instance.new
|
||||||
|
druid_instance.new = function(self, component, ...)
|
||||||
|
local c = new(self, component, ...)
|
||||||
|
if not c._meta.parent then -- (a widget's own components go with it)
|
||||||
|
local b = stack[#stack]
|
||||||
|
if b then
|
||||||
|
b.comps[#b.comps + 1] = c
|
||||||
|
else
|
||||||
|
orphans[#orphans + 1] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After a kept block moved under another parent: Druid buttons and hovers clip their
|
||||||
|
-- clicks to the stencil above them, found when they were made (perhaps deleted with the
|
||||||
|
-- old parent; a deleted node still answers until the frame ends, so always look again).
|
||||||
|
local function rezone(b)
|
||||||
|
for _, c in ipairs(b.comps) do
|
||||||
|
if c.node and c.set_click_zone and (c.hover or c.on_mouse_hover) then
|
||||||
|
pcall(c.set_click_zone, c, helper.get_closest_stencil_node(c.node))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, e in ipairs(b.journal) do
|
||||||
|
if e.block and not e.block.dead then
|
||||||
|
rezone(e.block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||||
|
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||||
|
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||||
|
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||||
|
-- clicks first, as a rebuilt one would.
|
||||||
|
local new_uid = require("druid.component").create_uid
|
||||||
|
local function renew(c)
|
||||||
|
local cc = c._component
|
||||||
|
if cc and cc._uid then
|
||||||
|
cc._uid = new_uid()
|
||||||
|
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||||
|
end
|
||||||
|
local kids = c.get_childrens and c:get_childrens() or {}
|
||||||
|
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||||
|
local k = kids[i]._component
|
||||||
|
if k and k._uid then
|
||||||
|
k._uid = new_uid()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function reorder(b)
|
||||||
|
for _, c in ipairs(b.comps) do
|
||||||
|
pcall(renew, c)
|
||||||
|
end
|
||||||
|
for _, e in ipairs(b.journal) do
|
||||||
|
if e.block and not e.block.dead then
|
||||||
|
reorder(e.block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- True while a kept block's registrations are made again (test.register looks for its
|
||||||
|
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||||
|
function M.replaying()
|
||||||
|
return replaying
|
||||||
|
end
|
||||||
|
|
||||||
|
local function node_of(c)
|
||||||
|
return c.node or c.root or c.view_node
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- lifetime
|
||||||
|
|
||||||
|
local function blocks_of(g)
|
||||||
|
g.blocks = g.blocks or { by_key = {}, aside = {}, used = {} }
|
||||||
|
return g.blocks
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Taken by the build going on (kept or made again under a key).
|
||||||
|
local function taken(reg, b)
|
||||||
|
return b.key ~= nil and reg.used[b.key] and reg.by_key[b.key] == b
|
||||||
|
end
|
||||||
|
|
||||||
|
local destroy
|
||||||
|
|
||||||
|
-- Removes a block: keyed blocks inside it are set aside (detached, still whole) for the
|
||||||
|
-- build going on to take back; the rest goes.
|
||||||
|
destroy = function(g, b, set_aside)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
for _, e in ipairs(b.journal) do
|
||||||
|
local c = e.block
|
||||||
|
if c and not c.dead and not taken(reg, c) then
|
||||||
|
if c.key and set_aside and alive(c.root) then
|
||||||
|
gui.set_parent(c.root, reg.limbo, false)
|
||||||
|
reg.aside[#reg.aside + 1] = c
|
||||||
|
else
|
||||||
|
destroy(g, c, set_aside)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
g.tearing_down = true -- (components going away fire events: core/list, scroll)
|
||||||
|
for i = #b.comps, 1, -1 do
|
||||||
|
pcall(g.druid.remove, g.druid, b.comps[i])
|
||||||
|
end
|
||||||
|
g.tearing_down = false
|
||||||
|
b.comps = {}
|
||||||
|
if alive(b.root) then
|
||||||
|
gui.delete_node(b.root)
|
||||||
|
end
|
||||||
|
if b.key and reg.by_key[b.key] == b then
|
||||||
|
reg.by_key[b.key] = nil
|
||||||
|
end
|
||||||
|
b.dead = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Components made outside blocks whose nodes went (the moon list's search filtering
|
||||||
|
-- rows again, ...).
|
||||||
|
local function sweep(g)
|
||||||
|
local keep = {}
|
||||||
|
for _, c in ipairs(orphans) do
|
||||||
|
local n = node_of(c)
|
||||||
|
if n and not alive(n) then
|
||||||
|
pcall(g.druid.remove, g.druid, c)
|
||||||
|
elseif n then
|
||||||
|
keep[#keep + 1] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
orphans = keep
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Start of a build. full: nothing is kept (core/main.lua makes the layout and the Druid
|
||||||
|
-- instance again).
|
||||||
|
function M.begin(g, full)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
stack = {}
|
||||||
|
replaying = false
|
||||||
|
if full then
|
||||||
|
reg.by_key, reg.aside = {}, {}
|
||||||
|
orphans = {}
|
||||||
|
end
|
||||||
|
reg.used = {}
|
||||||
|
if not alive(reg.limbo) then
|
||||||
|
reg.limbo = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
|
||||||
|
gui.set_enabled(reg.limbo, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Open a block under parent. Returns the block (build into b.root) and whether it was
|
||||||
|
-- kept: the last one with this key had the same sig. key nil: never kept (still a
|
||||||
|
-- block, so keyed blocks inside it can be).
|
||||||
|
function M.open(g, key, sig, parent)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
if key and reg.used[key] then
|
||||||
|
key = nil -- (asked for twice in one build: the second isn't kept)
|
||||||
|
end
|
||||||
|
local old = key and reg.by_key[key]
|
||||||
|
if old and old.dead then
|
||||||
|
old = nil
|
||||||
|
end
|
||||||
|
if old and old.sig == sig and alive(old.root) then
|
||||||
|
reg.used[key] = true
|
||||||
|
gui.set_parent(old.root, parent, false)
|
||||||
|
rezone(old)
|
||||||
|
reorder(old)
|
||||||
|
adopt(reg, old)
|
||||||
|
return old, true
|
||||||
|
end
|
||||||
|
if old then
|
||||||
|
destroy(g, old, true)
|
||||||
|
end
|
||||||
|
local root = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
|
||||||
|
gui.set_pivot(root, gui.PIVOT_NW)
|
||||||
|
gui.set_parent(root, parent, false)
|
||||||
|
gui.set_visible(root, false) -- (only a container: its children draw)
|
||||||
|
local b = { key = key, sig = sig, root = root, journal = {}, comps = {} }
|
||||||
|
if key then
|
||||||
|
reg.by_key[key] = b
|
||||||
|
reg.used[key] = true
|
||||||
|
end
|
||||||
|
local top = stack[#stack]
|
||||||
|
if top then
|
||||||
|
top.journal[#top.journal + 1] = { block = b }
|
||||||
|
end
|
||||||
|
stack[#stack + 1] = b
|
||||||
|
return b, false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The block open() would keep (same key and sig), without taking it; nil if none.
|
||||||
|
function M.peek(g, key, sig)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
if not key or reg.used[key] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local b = reg.by_key[key]
|
||||||
|
if b and not b.dead and b.sig == sig and alive(b.root) then
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Close block b: ... is what its build returns; returns that (kept: what the build that
|
||||||
|
-- made it returned).
|
||||||
|
function M.close(g, b, ...)
|
||||||
|
for i = #stack, 1, -1 do
|
||||||
|
if stack[i] == b then
|
||||||
|
for j = #stack, i, -1 do
|
||||||
|
stack[j] = nil
|
||||||
|
end
|
||||||
|
b.ret = { n = select("#", ...), ... }
|
||||||
|
return ...
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if b.ret then
|
||||||
|
return unpack(b.ret, 1, b.ret.n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Open b again to add to it (a card's height, known after its row): what is made until
|
||||||
|
-- close() is its.
|
||||||
|
function M.reopen(g, b)
|
||||||
|
stack[#stack + 1] = b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop block b now (a kept card that has to be built again).
|
||||||
|
function M.drop(g, b)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
if b.key and reg.used[b.key] and reg.by_key[b.key] == b then
|
||||||
|
reg.used[b.key] = nil
|
||||||
|
end
|
||||||
|
if not b.dead then
|
||||||
|
destroy(g, b, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- open + build + close: fn(root) builds; returns what it returns (and that again while
|
||||||
|
-- the block is kept).
|
||||||
|
function M.keep(g, key, sig, parent, fn)
|
||||||
|
local b, kept = M.open(g, key, sig, parent)
|
||||||
|
if kept then
|
||||||
|
return M.close(g, b)
|
||||||
|
end
|
||||||
|
return M.close(g, b, fn(b.root))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Destroy a keyed block now (a region not shown this build).
|
||||||
|
function M.remove(g, key)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
local b = reg.by_key[key]
|
||||||
|
if b then
|
||||||
|
destroy(g, b, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- End of a build: blocks it didn't ask for go.
|
||||||
|
function M.finish(g)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
stack = {}
|
||||||
|
for k, b in pairs(reg.by_key) do
|
||||||
|
if not reg.used[k] then
|
||||||
|
destroy(g, b, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, b in ipairs(reg.aside) do
|
||||||
|
if not b.dead and not taken(reg, b) then
|
||||||
|
destroy(g, b, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
reg.aside = {}
|
||||||
|
sweep(g)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Forget every block (the screen goes away, or before a full rebuild deletes the layout).
|
||||||
|
function M.clear(g)
|
||||||
|
local reg = blocks_of(g)
|
||||||
|
reg.by_key, reg.aside, reg.used = {}, {}, {}
|
||||||
|
stack, orphans = {}, {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -53,9 +54,8 @@ function M.new(g, parent, key, x, y, w, h, opts)
|
|||||||
end
|
end
|
||||||
local input = g.druid:new_input(box, txt, opts.keyboard or gui.KEYBOARD_TYPE_DEFAULT)
|
local input = g.druid:new_input(box, txt, opts.keyboard or gui.KEYBOARD_TYPE_DEFAULT)
|
||||||
test.register("field:" .. key, box, "field", opts.hint)
|
test.register("field:" .. key, box, "field", opts.hint)
|
||||||
g.inputs = g.inputs or {} -- (core/keys: Enter submits; other keys skip while editing)
|
blocks.push(g, "inputs", { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
|
||||||
g.inputs[#g.inputs + 1] = { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
|
esc_clears = opts.esc_clears })
|
||||||
esc_clears = opts.esc_clears }
|
|
||||||
-- (no "<" or "&": the text node would read them as markup)
|
-- (no "<" or "&": the text node would read them as markup)
|
||||||
input:set_allowed_characters(opts.allowed or "[^<&]")
|
input:set_allowed_characters(opts.allowed or "[^<&]")
|
||||||
if opts.max_len then
|
if opts.max_len then
|
||||||
|
|||||||
@@ -0,0 +1,692 @@
|
|||||||
|
-- Flex: a pure flexbox layout solver (no Defold calls; plain Lua 5.1+/LuaJIT). It only
|
||||||
|
-- computes rects; modules/flex_gui.lua puts persistent nodes there.
|
||||||
|
--
|
||||||
|
-- local flex = require("modules.flex")
|
||||||
|
-- local tree = flex.box({ direction = "column", gap = 8, padding = 12, width = 300 }, {
|
||||||
|
-- flex.box({ height = 22 }), -- fixed
|
||||||
|
-- flex.leaf(function(avail_w) return text_size(avail_w) end), -- measured (wraps)
|
||||||
|
-- flex.box({ direction = "row", justify = "space-between" }, { a, b }),
|
||||||
|
-- })
|
||||||
|
-- flex.solve(tree, 0, 0) -- or flex.solve(tree, x, y, w, h)
|
||||||
|
-- tree.children[2].x, .y, .w, .h -- absolute rect of every box
|
||||||
|
--
|
||||||
|
-- Coordinates: top-left origin, y grows DOWN (like CSS and like this client's ui.* calls,
|
||||||
|
-- which take (x, y) and place nodes at vec(x, -y)). Rects are absolute (relative to the
|
||||||
|
-- origin passed to solve); box.rx/ry are relative to the parent box. flex.gui_pos(box,
|
||||||
|
-- pivot) gives the Defold GUI position (y up) of a node with that pivot in the box.
|
||||||
|
--
|
||||||
|
-- A box: { style = {...}, children = {...}, measure = fn(avail_w) -> w, h, id = any }.
|
||||||
|
-- measure (leaves: text, images) gets the content width it may use (math.huge when
|
||||||
|
-- unbounded) and returns its content size; text should wrap to avail_w. Style keys
|
||||||
|
-- (CSS names, snake_case):
|
||||||
|
-- direction "row" (default) | "column"
|
||||||
|
-- wrap false (default) | true ("wrap")
|
||||||
|
-- gap, row_gap, column_gap px
|
||||||
|
-- padding n | {t, r, b, l} | {v, h} | {t, h, b}; padding_top/_right/_bottom/_left
|
||||||
|
-- justify "start" (default) | "end" | "center" | "space-between" | "space-around"
|
||||||
|
-- | "space-evenly" (justify-content)
|
||||||
|
-- align "stretch" (default) | "start" | "end" | "center" (align-items)
|
||||||
|
-- align_self overrides the parent's align for one child
|
||||||
|
-- grow, shrink (default 0, 1), basis (px | "N%" | "auto")
|
||||||
|
-- width, height px | "N%" (of the parent's content box) | "auto"
|
||||||
|
-- min_width, max_width, min_height, max_height px | "N%"
|
||||||
|
-- display = "none" leaves the box out (no rect: x, y, w, h = nil).
|
||||||
|
-- aspect_ratio w / h: the missing size follows the given one (a width, or a column's
|
||||||
|
-- stretch); in a row it isn't stretched (its height follows its width).
|
||||||
|
-- position = "absolute": out of the flow, placed in its parent's box by left, right,
|
||||||
|
-- top, bottom (px | "N%"), translate = {fx, fy} (fractions of its own size);
|
||||||
|
-- flex.anchor places a box the same way against any solved rect (badges,
|
||||||
|
-- floating numbers, bursts on another tree's box).
|
||||||
|
-- columns = { tracks }: a grid instead of flex (px, "N%", "auto", "Nfr"; cells' span,
|
||||||
|
-- rows, justify_items, justify_self): see grid() below. Tables.
|
||||||
|
-- overflow = "scroll": children don't shrink along the main axis (a scroll area's
|
||||||
|
-- content); flex.content_size(box) gives the scrolled size.
|
||||||
|
--
|
||||||
|
-- Deviations from CSS (on purpose):
|
||||||
|
-- - box-sizing is border-box: width/height include the padding. There are no borders or
|
||||||
|
-- margins (use padding, gap or spacer boxes).
|
||||||
|
-- - min-width/min-height default to 0, not auto (CSS's flex items won't shrink below
|
||||||
|
-- their min-content; here they do, like `min-width: 0`: labels get cut with ... instead
|
||||||
|
-- of overflowing).
|
||||||
|
-- - An auto-sized box is fit-content (max-content, capped by the available width), also
|
||||||
|
-- at the root and for a column's child that isn't stretched; CSS's block-level flex
|
||||||
|
-- container would fill the width. Give the root a width.
|
||||||
|
-- - align-content is always start (lines packed at the top); with wrap and the default
|
||||||
|
-- align = "stretch", each line's items stretch to the line's tallest one, as in CSS
|
||||||
|
-- (equal-height rows of cards). No baseline alignment, no order, no *-reverse, no
|
||||||
|
-- absolute positioning in the flow (position = "absolute" only against the parent).
|
||||||
|
-- - A % size against an auto (indefinite) parent size is treated as auto, like CSS.
|
||||||
|
-- - Sizes are not rounded (rects can be fractional; this client places at fractions).
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local HUGE = math.huge
|
||||||
|
local EMPTY = {}
|
||||||
|
|
||||||
|
-- Constructors (plain tables work too).
|
||||||
|
function M.box(style, children, id)
|
||||||
|
return { style = style or {}, children = children or {}, id = id }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.leaf(measure, style, id)
|
||||||
|
return { style = style or {}, measure = measure, id = id }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function len(v, ref)
|
||||||
|
if v == nil or v == "auto" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(v) == "number" then
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
local p = type(v) == "string" and v:match("^([%d%.%-]+)%%$")
|
||||||
|
if p then
|
||||||
|
return ref and ref ~= HUGE and ref * tonumber(p) / 100 or nil
|
||||||
|
end
|
||||||
|
error("flex: bad length " .. tostring(v))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clamp(v, lo, hi)
|
||||||
|
if hi and v > hi then
|
||||||
|
v = hi
|
||||||
|
end
|
||||||
|
if lo and v < lo then
|
||||||
|
v = lo
|
||||||
|
end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pads(s)
|
||||||
|
local p = s.padding
|
||||||
|
local t, r, b, l = 0, 0, 0, 0
|
||||||
|
if type(p) == "number" then
|
||||||
|
t, r, b, l = p, p, p, p
|
||||||
|
elseif type(p) == "table" then
|
||||||
|
if #p == 1 then
|
||||||
|
t, r, b, l = p[1], p[1], p[1], p[1]
|
||||||
|
elseif #p == 2 then
|
||||||
|
t, r, b, l = p[1], p[2], p[1], p[2]
|
||||||
|
elseif #p == 3 then
|
||||||
|
t, r, b, l = p[1], p[2], p[3], p[2]
|
||||||
|
else
|
||||||
|
t, r, b, l = p[1], p[2], p[3], p[4]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return s.padding_top or t, s.padding_right or r, s.padding_bottom or b, s.padding_left or l
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Width/height limits of a box: min, max (px or nil) against the parent's content size.
|
||||||
|
local function limits(s, cw, ch)
|
||||||
|
return len(s.min_width, cw) or 0, len(s.max_width, cw), len(s.min_height, ch) or 0, len(s.max_height, ch)
|
||||||
|
end
|
||||||
|
|
||||||
|
local layout, place_abs, grid
|
||||||
|
|
||||||
|
-- Children in the flow, and the absolutely positioned ones (nil when none).
|
||||||
|
local function in_flow(kids)
|
||||||
|
if not kids then
|
||||||
|
return EMPTY, nil
|
||||||
|
end
|
||||||
|
local abs
|
||||||
|
for _, c in ipairs(kids) do
|
||||||
|
if c.style and c.style.position == "absolute" then
|
||||||
|
abs = abs or {}
|
||||||
|
abs[#abs + 1] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not abs then
|
||||||
|
return kids, nil
|
||||||
|
end
|
||||||
|
local flow = {}
|
||||||
|
for _, c in ipairs(kids) do
|
||||||
|
if not (c.style and c.style.position == "absolute") then
|
||||||
|
flow[#flow + 1] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return flow, abs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Absolutely positioned children of a box w x h (CSS position: absolute against the box's
|
||||||
|
-- border box): left/right/top/bottom (px or "N%"), width/height (else from both offsets,
|
||||||
|
-- else content), translate = {fx, fy} (fractions of its own size, CSS translate(-50%, ..)).
|
||||||
|
-- No offset on an axis: 0 (the start edge).
|
||||||
|
local function abs_one(c, w, h)
|
||||||
|
local cs = c.style
|
||||||
|
if cs.display == "none" then
|
||||||
|
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local l, r, t, bo = len(cs.left, w), len(cs.right, w), len(cs.top, h), len(cs.bottom, h)
|
||||||
|
local cw, ch = len(cs.width, w), len(cs.height, h)
|
||||||
|
if not cw and l and r then
|
||||||
|
cw = math.max(0, w - l - r)
|
||||||
|
end
|
||||||
|
if not ch and t and bo then
|
||||||
|
ch = math.max(0, h - t - bo)
|
||||||
|
end
|
||||||
|
local ww, hh = layout(c, cw, ch, w, h)
|
||||||
|
local x = l or (r and w - r - ww) or 0
|
||||||
|
local y = t or (bo and h - bo - hh) or 0
|
||||||
|
local tr = cs.translate
|
||||||
|
if tr then
|
||||||
|
x, y = x + (tr[1] or 0) * ww, y + (tr[2] or 0) * hh
|
||||||
|
end
|
||||||
|
c.rx, c.ry = x, y
|
||||||
|
end
|
||||||
|
|
||||||
|
function place_abs(abs, w, h)
|
||||||
|
for _, c in ipairs(abs or EMPTY) do
|
||||||
|
abs_one(c, w, h)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Grid layout (CSS grid subset): style.columns = list of tracks: px, "N%", "auto"
|
||||||
|
-- (max-content of its cells), "Nfr" (a share of what's left: minmax(0, Nfr); auto when
|
||||||
|
-- the width is indefinite). Cells fill rows left to right (style.span on a cell: columns
|
||||||
|
-- it covers); a row is as tall as its tallest cell (style.rows: fixed heights by row,
|
||||||
|
-- missing = auto). gap/row_gap/column_gap; align (align-items, default stretch) and
|
||||||
|
-- justify_items (default stretch), align_self/justify_self on a cell.
|
||||||
|
function grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
|
||||||
|
local pt, pr, pb, pl = pads(s)
|
||||||
|
local px, py = pl + pr, pt + pb
|
||||||
|
local cols = s.columns
|
||||||
|
local n = #cols
|
||||||
|
local cg = s.column_gap or s.gap or 0
|
||||||
|
local rg = s.row_gap or s.gap or 0
|
||||||
|
local inner_w = w and (w - px) or (avail_w ~= HUGE and avail_w or nil)
|
||||||
|
local cells, r, c = {}, 1, 1
|
||||||
|
for _, k in ipairs(kids) do
|
||||||
|
local ks = k.style or EMPTY
|
||||||
|
if ks.display == "none" then
|
||||||
|
k.x, k.y, k.w, k.h, k.rx, k.ry = nil, nil, nil, nil, nil, nil
|
||||||
|
else
|
||||||
|
local span = math.max(1, math.min(n, ks.span or 1))
|
||||||
|
if c + span - 1 > n then
|
||||||
|
r, c = r + 1, 1
|
||||||
|
end
|
||||||
|
cells[#cells + 1] = { box = k, s = ks, r = r, c = c, span = span }
|
||||||
|
c = c + span
|
||||||
|
if c > n then
|
||||||
|
r, c = r + 1, 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local nrows = #cells > 0 and cells[#cells].r or 0
|
||||||
|
local width, fr, frsum, auto = {}, {}, 0, {}
|
||||||
|
for i, t in ipairs(cols) do
|
||||||
|
local f = type(t) == "string" and t:match("^([%d%.]+)fr$")
|
||||||
|
if f then
|
||||||
|
fr[i] = tonumber(f)
|
||||||
|
frsum = frsum + fr[i]
|
||||||
|
if not inner_w then
|
||||||
|
auto[i] = true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
width[i] = len(t, inner_w)
|
||||||
|
if not width[i] then
|
||||||
|
auto[i] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local content = {}
|
||||||
|
for _, cl in ipairs(cells) do
|
||||||
|
if cl.span == 1 and auto[cl.c] then
|
||||||
|
local cw = layout(cl.box, len(cl.s.width, inner_w), nil, HUGE, nil)
|
||||||
|
content[cl.c] = math.max(content[cl.c] or 0, cw)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local used = cg * (n - 1)
|
||||||
|
for i = 1, n do
|
||||||
|
if auto[i] then
|
||||||
|
width[i] = content[i] or 0
|
||||||
|
end
|
||||||
|
if width[i] then
|
||||||
|
used = used + width[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if frsum > 0 and inner_w then
|
||||||
|
local free = math.max(0, inner_w - used) / math.max(frsum, 1)
|
||||||
|
for i = 1, n do
|
||||||
|
if fr[i] then
|
||||||
|
width[i] = free * fr[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local colx, x = {}, 0
|
||||||
|
for i = 1, n do
|
||||||
|
colx[i] = x
|
||||||
|
x = x + width[i] + cg
|
||||||
|
end
|
||||||
|
w = w or clamp(x - cg + px, minw, maxw)
|
||||||
|
-- Rows: each cell at its width, then the row's height.
|
||||||
|
local rows, rowh = s.rows or EMPTY, {}
|
||||||
|
for _, cl in ipairs(cells) do
|
||||||
|
local cw = colx[cl.c + cl.span - 1] + width[cl.c + cl.span - 1] - colx[cl.c]
|
||||||
|
local iw = len(cl.s.width, cw)
|
||||||
|
local js = cl.s.justify_self or s.justify_items or "stretch"
|
||||||
|
if not iw and js == "stretch" then
|
||||||
|
iw = cw
|
||||||
|
end
|
||||||
|
cl.cw, cl.iw, cl.js = cw, iw, js
|
||||||
|
local _, ih = layout(cl.box, iw, nil, cw, nil)
|
||||||
|
cl.ih = ih
|
||||||
|
local fixed = type(rows[cl.r]) == "number" and rows[cl.r]
|
||||||
|
rowh[cl.r] = fixed or math.max(rowh[cl.r] or 0, ih)
|
||||||
|
end
|
||||||
|
local rowy, y = {}, 0
|
||||||
|
for i = 1, nrows do
|
||||||
|
rowy[i] = y
|
||||||
|
y = y + (rowh[i] or 0) + rg
|
||||||
|
end
|
||||||
|
h = h or clamp((nrows > 0 and y - rg or 0) + py, minh, maxh)
|
||||||
|
for _, cl in ipairs(cells) do
|
||||||
|
local k, ks, rh = cl.box, cl.s, rowh[cl.r]
|
||||||
|
local al = ks.align_self or s.align or "stretch"
|
||||||
|
local auto_h = ks.height == nil or ks.height == "auto"
|
||||||
|
if al == "stretch" and auto_h and not ks.aspect_ratio and cl.ih ~= rh then
|
||||||
|
layout(k, k.w, rh, cl.cw, rh)
|
||||||
|
cl.ih = rh
|
||||||
|
end
|
||||||
|
local ox, oy = 0, 0
|
||||||
|
if cl.js == "end" then
|
||||||
|
ox = cl.cw - k.w
|
||||||
|
elseif cl.js == "center" then
|
||||||
|
ox = (cl.cw - k.w) / 2
|
||||||
|
end
|
||||||
|
if al == "end" then
|
||||||
|
oy = rh - cl.ih
|
||||||
|
elseif al == "center" then
|
||||||
|
oy = (rh - cl.ih) / 2
|
||||||
|
end
|
||||||
|
k.rx, k.ry = pl + colx[cl.c] + ox, pt + rowy[cl.r] + oy
|
||||||
|
end
|
||||||
|
b.w, b.h = w, h
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lay out b's content at border-box size w x h (either may be nil: sized by content),
|
||||||
|
-- in a parent content box cw x ch (nil/HUGE: unbounded). Sets b.w, b.h and children's
|
||||||
|
-- rx, ry (relative to b). Returns w, h.
|
||||||
|
function layout(b, w, h, cw, ch)
|
||||||
|
local s = b.style or EMPTY
|
||||||
|
local minw, maxw, minh, maxh = limits(s, cw, ch)
|
||||||
|
w = w or len(s.width, cw)
|
||||||
|
h = h or len(s.height, ch)
|
||||||
|
if w then
|
||||||
|
w = clamp(w, minw, maxw)
|
||||||
|
end
|
||||||
|
if h then
|
||||||
|
h = clamp(h, minh, maxh)
|
||||||
|
end
|
||||||
|
local ar = s.aspect_ratio
|
||||||
|
if ar then
|
||||||
|
if w and not h then
|
||||||
|
h = clamp(w / ar, minh, maxh)
|
||||||
|
elseif h and not w then
|
||||||
|
w = clamp(h * ar, minw, maxw)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local pt, pr, pb, pl = pads(s)
|
||||||
|
local px, py = pl + pr, pt + pb
|
||||||
|
-- The width content may take when w is auto: the parent's, capped by max-width.
|
||||||
|
local avail_w = w and (w - px) or (math.min(cw or HUGE, maxw or HUGE) - px)
|
||||||
|
local kids, abs = in_flow(b.children)
|
||||||
|
if #kids == 0 then
|
||||||
|
if not (w and h) then
|
||||||
|
local mw, mh = 0, 0
|
||||||
|
if b.measure then
|
||||||
|
mw, mh = b.measure(math.max(0, avail_w))
|
||||||
|
end
|
||||||
|
w = w or clamp(math.min(mw, math.max(0, avail_w)) + px, minw, maxw)
|
||||||
|
h = h or (ar and clamp(w / ar, minh, maxh)) or clamp(mh + py, minh, maxh)
|
||||||
|
end
|
||||||
|
b.w, b.h = w, h
|
||||||
|
place_abs(abs, w, h)
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
if s.columns then
|
||||||
|
w, h = grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
|
||||||
|
place_abs(abs, w, h)
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = (s.direction or "row") == "row"
|
||||||
|
local gap = s.gap or 0
|
||||||
|
local main_gap = row and (s.column_gap or gap) or (s.row_gap or gap)
|
||||||
|
local cross_gap = row and (s.row_gap or gap) or (s.column_gap or gap)
|
||||||
|
local inner_w = w and (w - px) or nil
|
||||||
|
local inner_h = h and (h - py) or nil
|
||||||
|
local inner_main, inner_cross = inner_w, inner_h -- (either may be nil: no and/or here)
|
||||||
|
if not row then
|
||||||
|
inner_main, inner_cross = inner_h, inner_w
|
||||||
|
end
|
||||||
|
-- Where lines break and what text wraps to.
|
||||||
|
local break_main = row and avail_w or (inner_h or HUGE)
|
||||||
|
local align = s.align or "stretch"
|
||||||
|
|
||||||
|
-- Flex base sizes (outer main size; padding counts: border-box).
|
||||||
|
local items = {}
|
||||||
|
for _, c in ipairs(kids) do
|
||||||
|
local cs = c.style or EMPTY
|
||||||
|
if cs.display == "none" then
|
||||||
|
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
|
||||||
|
else
|
||||||
|
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
|
||||||
|
local it = { box = c, s = cs, grow = cs.grow or 0, shrink = s.overflow == "scroll" and 0 or cs.shrink or 1 }
|
||||||
|
if row then
|
||||||
|
it.min, it.max = cminw, cmaxw
|
||||||
|
else
|
||||||
|
it.min, it.max = cminh, cmaxh
|
||||||
|
end
|
||||||
|
local self_align = cs.align_self or align
|
||||||
|
it.stretch = self_align == "stretch" and not (row and cs.aspect_ratio)
|
||||||
|
it.align = self_align
|
||||||
|
local main_style = cs.width
|
||||||
|
if not row then
|
||||||
|
main_style = cs.height
|
||||||
|
end
|
||||||
|
local base = len(cs.basis, inner_main) or len(main_style, inner_main)
|
||||||
|
if not base then
|
||||||
|
if row then
|
||||||
|
base = layout(c, nil, nil, avail_w, inner_h)
|
||||||
|
else
|
||||||
|
-- A column's child: its width first (stretched, set, or content), then
|
||||||
|
-- its height at that width.
|
||||||
|
local cwid = len(cs.width, inner_w)
|
||||||
|
if not cwid and it.stretch and inner_w then
|
||||||
|
cwid = clamp(inner_w, cminw, cmaxw)
|
||||||
|
end
|
||||||
|
local _, hh = layout(c, cwid, nil, avail_w, inner_h)
|
||||||
|
base = hh
|
||||||
|
end
|
||||||
|
end
|
||||||
|
it.base = base
|
||||||
|
it.hyp = clamp(base, it.min, it.max)
|
||||||
|
items[#items + 1] = it
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lines.
|
||||||
|
local lines, cur, used = {}, {}, 0
|
||||||
|
for _, it in ipairs(items) do
|
||||||
|
local add = it.hyp + (#cur > 0 and main_gap or 0)
|
||||||
|
if s.wrap and #cur > 0 and used + add > break_main + 1e-6 then
|
||||||
|
lines[#lines + 1] = cur
|
||||||
|
cur, used = {}, 0
|
||||||
|
add = it.hyp
|
||||||
|
end
|
||||||
|
cur[#cur + 1] = it
|
||||||
|
used = used + add
|
||||||
|
end
|
||||||
|
if #cur > 0 then
|
||||||
|
lines[#lines + 1] = cur
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Main sizes per line (CSS "resolve flexible lengths", with freezing at min/max).
|
||||||
|
local content_main = 0
|
||||||
|
for _, ln in ipairs(lines) do
|
||||||
|
local gaps = main_gap * (#ln - 1)
|
||||||
|
local hyp_sum = gaps
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
hyp_sum = hyp_sum + it.hyp
|
||||||
|
it.size, it.frozen = it.hyp, false
|
||||||
|
end
|
||||||
|
local space = inner_main
|
||||||
|
if not space and row and hyp_sum > avail_w then
|
||||||
|
space = avail_w -- (an auto-width row is fit-content: it shrinks to what's there)
|
||||||
|
end
|
||||||
|
if space then
|
||||||
|
local growing = hyp_sum < space
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
if (growing and it.grow == 0) or (not growing and it.shrink == 0)
|
||||||
|
or (growing and it.base > it.hyp) or (not growing and it.base < it.hyp) then
|
||||||
|
it.frozen = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _ = 1, #ln + 1 do
|
||||||
|
local free, sum = space - gaps, 0
|
||||||
|
local active = false
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
if it.frozen then
|
||||||
|
free = free - it.size
|
||||||
|
else
|
||||||
|
free = free - it.base
|
||||||
|
sum = sum + (growing and it.grow or it.shrink * it.base)
|
||||||
|
active = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not active then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if growing and sum < 1 then
|
||||||
|
free = free * sum -- (CSS: grow factors under 1 take that share)
|
||||||
|
end
|
||||||
|
local total_violation = 0
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
if not it.frozen then
|
||||||
|
local share = sum > 0 and (growing and it.grow or it.shrink * it.base) / sum or 0
|
||||||
|
local t = it.base + free * share
|
||||||
|
local c = clamp(t, it.min, it.max)
|
||||||
|
it.size, it.viol = c, c - t
|
||||||
|
total_violation = total_violation + it.viol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
if not it.frozen and (total_violation == 0 or (total_violation > 0 and it.viol > 0)
|
||||||
|
or (total_violation < 0 and it.viol < 0)) then
|
||||||
|
it.frozen = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local m = gaps
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
m = m + it.size
|
||||||
|
end
|
||||||
|
ln.main = m
|
||||||
|
content_main = math.max(content_main, m)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cross sizes: each item at its main size; line cross = its tallest item (a single
|
||||||
|
-- line in a box of definite cross size takes that size).
|
||||||
|
local content_cross = 0
|
||||||
|
for li, ln in ipairs(lines) do
|
||||||
|
local lc = 0
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
local c, cs = it.box, it.s
|
||||||
|
local cw2, ch2
|
||||||
|
if row then
|
||||||
|
cw2, ch2 = layout(c, it.size, nil, inner_w, inner_h)
|
||||||
|
it.cross = ch2
|
||||||
|
else
|
||||||
|
local cwid = len(cs.width, inner_w)
|
||||||
|
if not cwid and it.stretch and inner_w then
|
||||||
|
cwid = inner_w
|
||||||
|
end
|
||||||
|
cw2 = layout(c, cwid, it.size, inner_w, inner_h)
|
||||||
|
it.cross = cw2
|
||||||
|
end
|
||||||
|
lc = math.max(lc, it.cross)
|
||||||
|
end
|
||||||
|
if #lines == 1 and inner_cross then
|
||||||
|
lc = inner_cross
|
||||||
|
end
|
||||||
|
ln.cross = lc
|
||||||
|
content_cross = content_cross + lc + (li > 1 and cross_gap or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The box's own size.
|
||||||
|
if row then
|
||||||
|
w = w or clamp(content_main + px, minw, maxw)
|
||||||
|
h = h or clamp(content_cross + py, minh, maxh)
|
||||||
|
else
|
||||||
|
w = w or clamp(content_cross + px, minw, maxw)
|
||||||
|
h = h or clamp(content_main + py, minh, maxh)
|
||||||
|
end
|
||||||
|
inner_w, inner_h = w - px, h - py
|
||||||
|
inner_main = row and inner_w or inner_h
|
||||||
|
if #lines == 1 then
|
||||||
|
lines[1].cross = row and inner_h or inner_w
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Stretch, then place.
|
||||||
|
local cross_pos = 0
|
||||||
|
for _, ln in ipairs(lines) do
|
||||||
|
local free = inner_main - ln.main
|
||||||
|
local start, between = 0, main_gap
|
||||||
|
local j = s.justify or "start"
|
||||||
|
local n = #ln
|
||||||
|
if j == "end" then
|
||||||
|
start = free
|
||||||
|
elseif j == "center" then
|
||||||
|
start = free / 2
|
||||||
|
elseif j == "space-between" and n > 1 and free > 0 then
|
||||||
|
between = main_gap + free / (n - 1)
|
||||||
|
elseif j == "space-around" and free > 0 then
|
||||||
|
start, between = free / n / 2, main_gap + free / n
|
||||||
|
elseif j == "space-evenly" and free > 0 then
|
||||||
|
start, between = free / (n + 1), main_gap + free / (n + 1)
|
||||||
|
end
|
||||||
|
local pos = start
|
||||||
|
for _, it in ipairs(ln) do
|
||||||
|
local c, cs = it.box, it.s
|
||||||
|
local cross_style
|
||||||
|
if row then
|
||||||
|
cross_style = cs.height
|
||||||
|
else
|
||||||
|
cross_style = cs.width
|
||||||
|
end
|
||||||
|
local auto_cross = cross_style == nil or cross_style == "auto"
|
||||||
|
if it.stretch and auto_cross then
|
||||||
|
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
|
||||||
|
local target = row and clamp(ln.cross, cminh, cmaxh) or clamp(ln.cross, cminw, cmaxw)
|
||||||
|
if target ~= it.cross then
|
||||||
|
if row then
|
||||||
|
layout(c, it.size, target, inner_w, inner_h)
|
||||||
|
else
|
||||||
|
layout(c, target, it.size, inner_w, inner_h)
|
||||||
|
end
|
||||||
|
it.cross = target
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local off = 0
|
||||||
|
if it.align == "end" then
|
||||||
|
off = ln.cross - it.cross
|
||||||
|
elseif it.align == "center" then
|
||||||
|
off = (ln.cross - it.cross) / 2
|
||||||
|
end
|
||||||
|
if row then
|
||||||
|
c.rx, c.ry = pl + pos, pt + cross_pos + off
|
||||||
|
else
|
||||||
|
c.rx, c.ry = pl + cross_pos + off, pt + pos
|
||||||
|
end
|
||||||
|
pos = pos + it.size + between
|
||||||
|
end
|
||||||
|
cross_pos = cross_pos + ln.cross + cross_gap
|
||||||
|
end
|
||||||
|
b.w, b.h = w, h
|
||||||
|
place_abs(abs, w, h)
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function place(b, x, y)
|
||||||
|
b.x, b.y = x, y
|
||||||
|
for _, c in ipairs(b.children or EMPTY) do
|
||||||
|
if c.rx then
|
||||||
|
place(c, x + c.rx, y + c.ry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Solve the tree with its top-left at (x, y); w, h force the root's size (otherwise its
|
||||||
|
-- style's, or its content's, capped by avail_w when given). Returns the root.
|
||||||
|
function M.solve(root, x, y, w, h, avail_w)
|
||||||
|
root.rx, root.ry = 0, 0
|
||||||
|
layout(root, w, h, avail_w, nil)
|
||||||
|
place(root, x or 0, y or 0)
|
||||||
|
return root
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Anchor a box to an already solved rect (a box of another tree, a button's rect: x, y,
|
||||||
|
-- w, h, y down) like CSS position: absolute inside it: style left/right/top/bottom,
|
||||||
|
-- width/height, translate (see abs_one). Solves box (and its children) and returns it.
|
||||||
|
-- flex.anchor(button_box, flex.box({ top = -6, right = -6, translate = {0, 0} }, ...))
|
||||||
|
-- flex.anchor(r, flex.leaf(m, { left = "50%", bottom = "100%", translate = { -0.5, 0 } }))
|
||||||
|
function M.anchor(target, box)
|
||||||
|
box.style = box.style or {}
|
||||||
|
abs_one(box, target.w, target.h)
|
||||||
|
place(box, target.x + box.rx, target.y + box.ry)
|
||||||
|
return box
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The scrollable content size of a solved box (CSS scrollWidth/scrollHeight): its size, or
|
||||||
|
-- the extent of its descendants plus its right/bottom padding when they reach further.
|
||||||
|
-- A box whose content should overflow (a scroll area of fixed height) wants style
|
||||||
|
-- overflow = "scroll": its children then don't shrink along its main axis.
|
||||||
|
function M.content_size(b)
|
||||||
|
local mx, my = 0, 0
|
||||||
|
local function walk(p)
|
||||||
|
for _, c in ipairs(p.children or EMPTY) do
|
||||||
|
if c.x then
|
||||||
|
mx = math.max(mx, c.x + c.w - b.x)
|
||||||
|
my = math.max(my, c.y + c.h - b.y)
|
||||||
|
walk(c)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
walk(b)
|
||||||
|
local _, pr, pb = pads(b.style or EMPTY)
|
||||||
|
return math.max(b.w, mx + pr), math.max(b.h, my + pb)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Boxes by id (after solve).
|
||||||
|
function M.index(root, out)
|
||||||
|
out = out or {}
|
||||||
|
if root.id ~= nil then
|
||||||
|
out[root.id] = root
|
||||||
|
end
|
||||||
|
for _, c in ipairs(root.children or EMPTY) do
|
||||||
|
M.index(c, out)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Grid columns for a width (CSS grid's repeat(auto-fill, minmax(min, 1fr))): the count and
|
||||||
|
-- the column width, in whole pixels (what's left over stays at the right).
|
||||||
|
function M.columns(w, min, gap)
|
||||||
|
local cols = math.max(1, math.floor((w + gap) / (min + gap)))
|
||||||
|
return cols, math.floor((w - gap * (cols - 1)) / cols)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A grid as a wrapping row w wide: each child gets the column width (M.columns); with the
|
||||||
|
-- default align = "stretch" a row is as tall as its tallest item. style: more of the
|
||||||
|
-- row's (row_gap, ...).
|
||||||
|
function M.grid(w, min, gap, children, style)
|
||||||
|
local _, cw = M.columns(w, min, gap)
|
||||||
|
for _, c in ipairs(children) do
|
||||||
|
c.style = c.style or {}
|
||||||
|
c.style.width, c.style.shrink = cw, 0
|
||||||
|
end
|
||||||
|
local s = { direction = "row", wrap = true, gap = gap, width = w }
|
||||||
|
for k, v in pairs(style or EMPTY) do
|
||||||
|
s[k] = v
|
||||||
|
end
|
||||||
|
return M.box(s, children)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- flex.apply(tree, nodes, opts): put persistent GUI nodes on a solved tree (Defold only:
|
||||||
|
-- modules/flex_gui.lua, which documents it).
|
||||||
|
local GUI = "modules.flex_gui" -- (by a name bob doesn't follow: flex_gui requires flex)
|
||||||
|
function M.apply(tree, nodes, opts)
|
||||||
|
return require(GUI).apply(tree, nodes, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Where a node with pivot (0..8 as Defold's gui.PIVOT_*: CENTER, N, NE, E, SE, S, SW, W,
|
||||||
|
-- NW) sits for the rect x, y, w, h (y down): returns its GUI position x, y (y up).
|
||||||
|
local PIVOT = { [0] = { 0.5, 0.5 }, { 0.5, 0 }, { 1, 0 }, { 1, 0.5 }, { 1, 1 }, { 0.5, 1 }, { 0, 1 }, { 0, 0.5 }, { 0, 0 } }
|
||||||
|
function M.gui_pos(r, pivot, ox, oy)
|
||||||
|
local p = PIVOT[pivot or 8] or PIVOT[8]
|
||||||
|
return r.x - (ox or 0) + r.w * p[1], -(r.y - (oy or 0) + r.h * p[2])
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
-- Puts persistent GUI nodes where modules/flex solved their boxes. It never creates,
|
||||||
|
-- deletes, reparents or restyles a node; it sets position (by the node's pivot) and,
|
||||||
|
-- unless box.size == false, size (divided by the node's scale: a wrapping text node gets
|
||||||
|
-- its wrap width), and only when they changed.
|
||||||
|
--
|
||||||
|
-- Rule: layout owns an outer "slot" node per box (an anim.group, a card's root); an
|
||||||
|
-- animation owns the visual nodes inside it (like CSS transform vs layout). Give a box
|
||||||
|
-- only slot nodes, never one that something else animates (gui.animate on position or
|
||||||
|
-- size); boxes without a node are just geometry (their children's nodes are placed
|
||||||
|
-- relative to the nearest ancestor box that has one, or to the tree's origin).
|
||||||
|
--
|
||||||
|
-- flex.solve(tree, 0, 0, w)
|
||||||
|
-- flex_gui.apply(tree) -- box.node on the boxes
|
||||||
|
-- flex_gui.apply(tree, { head = n1, foot = n2 }) -- or nodes by box id
|
||||||
|
-- flex_gui.apply(tree, nil, { glide = 0.2 }) -- tween what moved (FLIP), default snap
|
||||||
|
--
|
||||||
|
-- glide: a node that already sits somewhere tweens from there to its new rect (eased
|
||||||
|
-- out, `glide` seconds); a later apply retargets it. Off under reduced motion.
|
||||||
|
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local anim = require("modules.anim")
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local EPS = 0.01
|
||||||
|
|
||||||
|
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
|
||||||
|
-- several labels).
|
||||||
|
local function copy(opts)
|
||||||
|
local s = {}
|
||||||
|
for k, v in pairs(opts or {}) do
|
||||||
|
s[k] = v
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function differs(a, b)
|
||||||
|
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
||||||
|
end
|
||||||
|
|
||||||
|
local function put(node, prop, value, cur, glide)
|
||||||
|
if not differs(cur, value) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if glide then
|
||||||
|
gui.animate(node, prop, value, gui.EASING_OUTQUAD, glide)
|
||||||
|
elseif prop == "position" then
|
||||||
|
gui.set_position(node, value)
|
||||||
|
else
|
||||||
|
gui.set_size(node, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function walk(b, nodes, ox, oy, glide)
|
||||||
|
if not b.x then
|
||||||
|
return -- (display = "none")
|
||||||
|
end
|
||||||
|
local node = b.node or (nodes and b.id ~= nil and nodes[b.id])
|
||||||
|
if node then
|
||||||
|
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
|
||||||
|
local pos = gui.get_position(node)
|
||||||
|
put(node, "position", vmath.vector3(px, py, pos.z), pos, glide)
|
||||||
|
if b.size ~= false then
|
||||||
|
local sc = gui.get_scale(node)
|
||||||
|
put(node, "size", vmath.vector3(b.w / sc.x, b.h / sc.y, 0), gui.get_size(node), glide)
|
||||||
|
end
|
||||||
|
ox, oy = b.x, b.y
|
||||||
|
end
|
||||||
|
for _, c in ipairs(b.children or {}) do
|
||||||
|
walk(c, nodes, ox, oy, glide)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply a solved tree. nodes: optional { [box id] = node }. opts.glide: seconds.
|
||||||
|
-- opts.origin: {x, y} the tree's coordinates of the root nodes' parent (default 0, 0).
|
||||||
|
function M.apply(tree, nodes, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local glide = opts.glide and opts.glide > 0 and not anim.reduced and opts.glide or nil
|
||||||
|
local o = opts.origin
|
||||||
|
walk(tree, nodes, o and o[1] or 0, o and o[2] or 0, glide)
|
||||||
|
return tree
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A measure function for a text node (flex.leaf): its metrics at the width it gets;
|
||||||
|
-- wrapping nodes (line break on) wrap to it. lh: line height in px (CSS line-height:
|
||||||
|
-- lines * lh), else the font's own line metrics.
|
||||||
|
function M.text_measure(node, lh)
|
||||||
|
return function(avail)
|
||||||
|
local s = gui.get_scale(node)
|
||||||
|
local font = gui.get_font_resource(gui.get_font(node))
|
||||||
|
local wrap = gui.get_line_break(node)
|
||||||
|
local opts = { tracking = gui.get_tracking(node), line_break = wrap, leading = lh and 1 or gui.get_leading(node) }
|
||||||
|
if wrap then
|
||||||
|
opts.width = avail / s.x
|
||||||
|
end
|
||||||
|
local m = resource.get_text_metrics(font, gui.get_text(node), opts)
|
||||||
|
if lh then
|
||||||
|
local one = resource.get_text_metrics(font, "Ag")
|
||||||
|
local lines = math.max(1, math.floor(m.height / (one.max_ascent + one.max_descent) + 0.5))
|
||||||
|
return m.width * s.x, lines * lh
|
||||||
|
end
|
||||||
|
return m.width * s.x, m.height * s.y
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Building a section from one tree (M.build): describe it with boxes, leaves made by
|
||||||
|
-- M.label / M.para (their text node is made now, under any parent, for its metrics) and
|
||||||
|
-- boxes with make = fn(parent, x, y, w, h) for what is built at a size (frames, buttons,
|
||||||
|
-- bars); flex.solve it; then M.build makes the nodes.
|
||||||
|
|
||||||
|
-- One line of text (ui.text, pivot W: its middle at the box's) in a line box h tall, as
|
||||||
|
-- wide as its text (trailing spaces count). opts: more box style; opts.fit cuts the text
|
||||||
|
-- with ... to the width it gets; opts.shrink_text = k (0..1) first scales it down to fit,
|
||||||
|
-- to k of its size at most (then cuts). Text with symbols (ui.needs_rich) goes through ui.rich
|
||||||
|
-- (not cut).
|
||||||
|
function M.label(parent, str, style, h, opts)
|
||||||
|
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
||||||
|
local n, w = ui.rich(parent, str, style, 0, 0)
|
||||||
|
gui.set_pivot(n, gui.PIVOT_W)
|
||||||
|
local s = copy(opts)
|
||||||
|
s.fit, s.height = nil, h
|
||||||
|
return { node = n, size = false, style = s, measure = function() return w, h end }
|
||||||
|
end
|
||||||
|
local n = ui.text(parent, str, style, 0, 0)
|
||||||
|
local w, t = ui.text_width(n), gui.get_text(n)
|
||||||
|
if t:match(" $") then -- (metrics leave trailing spaces out)
|
||||||
|
gui.set_text(n, t .. "|")
|
||||||
|
w = ui.text_width(n)
|
||||||
|
gui.set_text(n, "|")
|
||||||
|
w = w - ui.text_width(n)
|
||||||
|
gui.set_text(n, t)
|
||||||
|
end
|
||||||
|
local s = copy(opts)
|
||||||
|
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||||
|
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||||
|
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more
|
||||||
|
-- box style (max_width, ...); opts.lines = n: at most n lines, the rest cut with "…" (so
|
||||||
|
-- long text can't push what's under it).
|
||||||
|
function M.para(parent, str, style, lh, opts)
|
||||||
|
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
|
||||||
|
gui.set_line_break(n, true)
|
||||||
|
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
||||||
|
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
||||||
|
gui.set_leading(n, lh / line)
|
||||||
|
local s = copy(opts)
|
||||||
|
local lines = s.lines
|
||||||
|
s.lines = nil
|
||||||
|
local measure = M.text_measure(n, lh)
|
||||||
|
return { node = n, shift = (lh - line) / 2, style = s, clamp = lines and { lines, lh },
|
||||||
|
measure = lines and function(avail)
|
||||||
|
local w, h = measure(avail)
|
||||||
|
return w, math.min(h, lines * lh)
|
||||||
|
end or measure }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cut a wrapping text node's text with "…" (whole words, then letters) until it takes at
|
||||||
|
-- most n lines lh apart at width w.
|
||||||
|
function M.clamp_lines(node, n, lh, w)
|
||||||
|
local measure = M.text_measure(node, lh)
|
||||||
|
local text = gui.get_text(node)
|
||||||
|
local function over()
|
||||||
|
local _, h = measure(w)
|
||||||
|
return h > n * lh + 0.5
|
||||||
|
end
|
||||||
|
if not over() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
while #text > 1 do
|
||||||
|
local cut = text:gsub("%s*%S+%s*$", "")
|
||||||
|
if cut == "" or cut == text then -- (one word left: a letter at a time, whole UTF-8 chars)
|
||||||
|
cut = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", "")
|
||||||
|
end
|
||||||
|
text = cut:gsub("[%s,;:.]+$", "")
|
||||||
|
gui.set_text(node, text .. "…")
|
||||||
|
if not over() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fit one line of text to max_w: scaled down to `shrink` (0..1) of its size at most, if
|
||||||
|
-- given, then cut with "…" (ui.fit_text).
|
||||||
|
function M.fit_line(node, max_w, shrink)
|
||||||
|
local w = ui.text_width(node)
|
||||||
|
if shrink and w > max_w then
|
||||||
|
local sc = gui.get_scale(node)
|
||||||
|
local k = math.max(shrink, max_w / w)
|
||||||
|
gui.set_scale(node, vmath.vector3(sc.x * k, sc.y * k, sc.z))
|
||||||
|
end
|
||||||
|
ui.fit_text(node, max_w)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A reserved slot: a box of a fixed size whether its content shows or not (the "Need ₥
|
||||||
|
-- more" line under Buy, a note above cards, Claim all's place), so what shows in it can
|
||||||
|
-- never move what's around it. style: its size (width and/or height) and more box style;
|
||||||
|
-- kid: the content box, or nil (shown empty). The content is laid out inside the slot
|
||||||
|
-- and cut to it if it's bigger (clip text with fit/shrink/lines).
|
||||||
|
function M.reserve(style, kid)
|
||||||
|
style = style or {}
|
||||||
|
if style.shrink == nil then
|
||||||
|
style.shrink = 0
|
||||||
|
end
|
||||||
|
return M.box(style, { kid })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Tree pieces (shared by the flex-first sections). Children lists may have holes
|
||||||
|
-- (`cond and x or nil`); extra: more fields on the box (make, done, id, ...).
|
||||||
|
function M.box(style, kids, extra)
|
||||||
|
local list = {}
|
||||||
|
for i = 1, table.maxn(kids or {}) do
|
||||||
|
list[#list + 1] = kids[i]
|
||||||
|
end
|
||||||
|
local b = { style = style or {}, children = list }
|
||||||
|
for k, v in pairs(extra or {}) do
|
||||||
|
b[k] = v
|
||||||
|
end
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
function M.row(style, kids, extra) style.direction = "row"; return M.box(style, kids, extra) end
|
||||||
|
function M.col(style, kids, extra) style.direction = "column"; return M.box(style, kids, extra) end
|
||||||
|
function M.space(w, h) return { style = { width = w or 0, height = h or 0, shrink = 0 } } end
|
||||||
|
function M.fill() return { style = { grow = 1 } } end
|
||||||
|
-- A box with make = fn(parent, x, y, w, h) (built at its solved rect).
|
||||||
|
function M.made(style, make, kids) return M.box(style, kids, { make = make }) end
|
||||||
|
|
||||||
|
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
||||||
|
-- the box's middle, h tall, as wide as its text (set the text first).
|
||||||
|
function M.node_label(node, h, opts)
|
||||||
|
local s = copy(opts)
|
||||||
|
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||||
|
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||||
|
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the
|
||||||
|
-- width it gets. opts: more box style (max_width = S.text_max, ...).
|
||||||
|
function M.text_para(parent, str, style, opts)
|
||||||
|
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
|
||||||
|
gui.set_line_break(n, true)
|
||||||
|
return { node = n, style = opts or {}, measure = M.text_measure(n) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A component that places its own parts (cards.parts, a ui.credits amount): built by
|
||||||
|
-- fn(parent, x, y, w) -> h[, w] at its rect; its size is measured first by building it
|
||||||
|
-- on a probe under parent (deleted at once; once per width).
|
||||||
|
function M.measured(parent, fn, style)
|
||||||
|
local cache = {}
|
||||||
|
return { style = style or {}, measure = function(avail)
|
||||||
|
local finite = avail == avail and avail < math.huge
|
||||||
|
local k = finite and math.floor(avail + 0.5) or -1
|
||||||
|
if not cache[k] then
|
||||||
|
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
||||||
|
local h, w = fn(probe, 0, 0, finite and avail or 10000)
|
||||||
|
gui.delete_node(probe)
|
||||||
|
cache[k] = { w or (finite and avail or 0), h or 0 }
|
||||||
|
end
|
||||||
|
return cache[k][1], cache[k][2]
|
||||||
|
end, make = function(p, x, y, w)
|
||||||
|
fn(p, x, y, w)
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A one-line component that places itself at a middle y and returns its width (chips,
|
||||||
|
-- tags, stat.inline, ui.credits): a box h tall, as wide as fn measures on a probe
|
||||||
|
-- (fn(probe, 0, 0, true): register nothing then); made by fn(parent, x, y_middle).
|
||||||
|
-- style: more box style.
|
||||||
|
function M.inline(parent, h, fn, style)
|
||||||
|
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
||||||
|
local w = fn(probe, 0, 0, true) or 0
|
||||||
|
gui.delete_node(probe)
|
||||||
|
style = style or {}
|
||||||
|
style.width, style.height = style.width or w, h
|
||||||
|
if style.shrink == nil then
|
||||||
|
style.shrink = 0
|
||||||
|
end
|
||||||
|
return { style = style, make = function(p, x, y)
|
||||||
|
fn(p, x, y + h / 2)
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A page that mixes trees with components that lay themselves out and must be built in
|
||||||
|
-- place (card.grid's kept cards): parts top to bottom, w wide from (x, y); a part is a
|
||||||
|
-- box (solved at that width, built under parent) or fn(y) -> the y under it. Returns the
|
||||||
|
-- bottom.
|
||||||
|
function M.flow(parent, x, y, w, parts)
|
||||||
|
for i = 1, table.maxn(parts) do
|
||||||
|
local part = parts[i]
|
||||||
|
if type(part) == "function" then
|
||||||
|
y = part(y)
|
||||||
|
elseif part then
|
||||||
|
part.style = part.style or {}
|
||||||
|
part.style.width = part.style.width or w
|
||||||
|
M.build(flex.solve(part, x, y), parent)
|
||||||
|
y = part.y + part.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return y
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The width ui.button gives a label with these opts (a probe, deleted at once).
|
||||||
|
function M.button_w(parent, label, opts)
|
||||||
|
local b, _, w = ui.button(parent, label, 0, 0, opts)
|
||||||
|
ui.button_looks[b] = nil
|
||||||
|
gui.delete_node(b)
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A ui.button box: as wide as ui.button makes it (probe; style.width or grow override),
|
||||||
|
-- its height the button's; made at its rect, then bind(b, nodes, w) (H.action, ...).
|
||||||
|
function M.button(parent, label, opts, bind, style)
|
||||||
|
style = style or {}
|
||||||
|
style.height = style.height or opts.h or (opts.small and S.button_sm_h or S.button_h)
|
||||||
|
style.shrink = style.shrink or 0
|
||||||
|
if not style.width and not style.grow then
|
||||||
|
style.width = M.button_w(parent, label, opts)
|
||||||
|
end
|
||||||
|
return { style = style, make = function(p, x, y, w)
|
||||||
|
opts.w = w
|
||||||
|
local b, nodes = ui.button(p, label, x, y, opts)
|
||||||
|
if bind then
|
||||||
|
bind(b, nodes, w)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Make the nodes of a solved tree under parent (the tree's origin), in tree order (which
|
||||||
|
-- is focus and Druid input order). A box with make gets its rect rounded to whole pixels,
|
||||||
|
-- relative to the nearest node above it (top-left, y down, like ui.*); the node it
|
||||||
|
-- returns, if any, holds its children. A box with a node (M.label, M.para) is moved under
|
||||||
|
-- that node and placed by its pivot (sized unless size == false; fit: cut to its width).
|
||||||
|
-- Then box.done(node, box), if any.
|
||||||
|
function M.build(tree, parent)
|
||||||
|
local function walk(b, p, ox, oy)
|
||||||
|
if not b.x then
|
||||||
|
return -- (display = "none")
|
||||||
|
end
|
||||||
|
local node = b.node
|
||||||
|
if b.make then
|
||||||
|
local x, y = math.floor(b.x + 0.5), math.floor(b.y + 0.5)
|
||||||
|
local w, h = math.floor(b.x + b.w + 0.5) - x, math.floor(b.y + b.h + 0.5) - y
|
||||||
|
node = b.make(p, x - ox, y - oy, w, h)
|
||||||
|
b.node = node
|
||||||
|
if node then
|
||||||
|
p, ox, oy = node, x, y
|
||||||
|
end
|
||||||
|
elseif node then
|
||||||
|
gui.set_parent(node, p, false)
|
||||||
|
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
|
||||||
|
gui.set_position(node, vmath.vector3(px, py - (b.shift or 0), gui.get_position(node).z))
|
||||||
|
if b.size ~= false then
|
||||||
|
local sc = gui.get_scale(node)
|
||||||
|
gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
|
||||||
|
end
|
||||||
|
if b.fit then
|
||||||
|
M.fit_line(node, b.w, b.shrink)
|
||||||
|
end
|
||||||
|
if b.clamp then
|
||||||
|
M.clamp_lines(node, b.clamp[1], b.clamp[2], b.w)
|
||||||
|
end
|
||||||
|
p, ox, oy = node, b.x, b.y
|
||||||
|
end
|
||||||
|
if b.done then
|
||||||
|
b.done(node, b)
|
||||||
|
end
|
||||||
|
for _, c in ipairs(b.children or {}) do
|
||||||
|
walk(c, p, ox, oy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
walk(tree, parent, 0, 0)
|
||||||
|
return tree
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -24,7 +24,7 @@ local M = {}
|
|||||||
|
|
||||||
local entries = {} -- { key, node, kind, label, zone, scope }
|
local entries = {} -- { key, node, kind, label, zone, scope }
|
||||||
local scope = "page"
|
local scope = "page"
|
||||||
local seen = {} -- key -> count in this build (duplicate ids get "#n")
|
local seen = {} -- base -> entries with it (duplicate ids get "#n")
|
||||||
|
|
||||||
local function alive(node)
|
local function alive(node)
|
||||||
return pcall(gui.get_id, node)
|
return pcall(gui.get_id, node)
|
||||||
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
||||||
|
local key = base
|
||||||
|
if (seen[base] or 0) > 0 then
|
||||||
|
-- A row made again (a long list scrolled back, a search, a kept block replayed)
|
||||||
|
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
|
||||||
|
-- plain key that list_step and find look for.
|
||||||
|
local keep, taken = {}, {}
|
||||||
|
for _, e in ipairs(entries) do
|
||||||
|
if e.base ~= base then
|
||||||
|
keep[#keep + 1] = e
|
||||||
|
elseif alive(e.node) then
|
||||||
|
keep[#keep + 1] = e
|
||||||
|
taken[e.key] = true
|
||||||
|
else
|
||||||
|
seen[base] = seen[base] - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
entries = keep
|
||||||
|
local n = 1
|
||||||
|
while taken[key] do
|
||||||
|
n = n + 1
|
||||||
|
key = base .. "#" .. n
|
||||||
|
end
|
||||||
|
end
|
||||||
seen[base] = (seen[base] or 0) + 1
|
seen[base] = (seen[base] or 0) + 1
|
||||||
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
|
|
||||||
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -203,7 +225,7 @@ local function into_view(g, e)
|
|||||||
to = math.max(0, math.min(a.max or 0, to))
|
to = math.max(0, math.min(a.max or 0, to))
|
||||||
if to ~= pos then
|
if to ~= pos then
|
||||||
g.scroll_pos[a.key] = to
|
g.scroll_pos[a.key] = to
|
||||||
g.dirty = true
|
g.dirty, g.dirty_all = true, true
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -225,12 +247,16 @@ end
|
|||||||
|
|
||||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||||
-- when it moved the focus.
|
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||||
|
-- from that row (the one under it). Returns true when it moved the focus.
|
||||||
local function list_step(g, f, cur, e, dir, scope_)
|
local function list_step(g, f, cur, e, dir, scope_)
|
||||||
local a = list_of(g, cur)
|
local a = list_of(g, cur)
|
||||||
local items, i
|
local items, i
|
||||||
if a then
|
if a then
|
||||||
|
if list_of(g, e) == a then
|
||||||
|
return false -- (the next element is in view in the same list)
|
||||||
|
end
|
||||||
items = a.list.items
|
items = a.list.items
|
||||||
local id = cur.base:sub(#scope_ + 2)
|
local id = cur.base:sub(#scope_ + 2)
|
||||||
for n, item in ipairs(items) do
|
for n, item in ipairs(items) do
|
||||||
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
|||||||
i = n + dir
|
i = n + dir
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if not i and a.list.row_h then
|
||||||
|
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||||
|
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||||
|
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||||
|
end
|
||||||
if not i or i < 1 or i > #items then
|
if not i or i < 1 or i > #items then
|
||||||
return false -- (leaving the list: the next element on the page)
|
return false -- (leaving the list: the next element on the page)
|
||||||
end
|
end
|
||||||
@@ -266,7 +297,7 @@ local function list_step(g, f, cur, e, dir, scope_)
|
|||||||
to = math.max(0, math.min(a.max or 0, to))
|
to = math.max(0, math.min(a.max or 0, to))
|
||||||
if to ~= pos then
|
if to ~= pos then
|
||||||
g.scroll_pos[a.key] = to
|
g.scroll_pos[a.key] = to
|
||||||
g.dirty = true
|
g.dirty, g.dirty_all = true, true
|
||||||
end
|
end
|
||||||
local now = find(g, key)
|
local now = find(g, key)
|
||||||
f.tip = now and tooltip.show_for(now.node, M.rect(now.node)) or false
|
f.tip = now and tooltip.show_for(now.node, M.rect(now.node)) or false
|
||||||
|
|||||||
+128
-19
@@ -29,6 +29,7 @@ local ui = require("modules.ui")
|
|||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local sfx = require("modules.sfx")
|
local sfx = require("modules.sfx")
|
||||||
local anim = require("modules.anim")
|
local anim = require("modules.anim")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
M.__index = M
|
M.__index = M
|
||||||
@@ -39,17 +40,17 @@ local GOLD_HI = C.ready_hi
|
|||||||
local LOSS = C.loss
|
local LOSS = C.loss
|
||||||
local FLASH = C.flash
|
local FLASH = C.flash
|
||||||
|
|
||||||
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
|
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
|
||||||
equip = true, hire = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
||||||
|
|
||||||
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
|
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
|
||||||
local SOUND = {
|
local SOUND = {
|
||||||
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
|
["buy-tool"] = "buy",
|
||||||
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
|
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
|
||||||
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade",
|
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
|
||||||
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
|
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
|
||||||
refine = "refine", collect = "collect", error = "error",
|
refine = "refine", collect = "collect", error = "error",
|
||||||
-- (sell-tool and sell-attachment: the dialog's confirm, then the cash as the coins land)
|
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
|
||||||
}
|
}
|
||||||
|
|
||||||
local function v3(x, y)
|
local function v3(x, y)
|
||||||
@@ -101,16 +102,16 @@ end
|
|||||||
|
|
||||||
-- A box an action's sweep covers when its button is inside (fx.js: the button's .card,
|
-- A box an action's sweep covers when its button is inside (fx.js: the button's .card,
|
||||||
-- .line, row or heading): cards and list rows register while they're built.
|
-- .line, row or heading): cards and list rows register while they're built.
|
||||||
|
-- Sweeps cover the clicked button's box (fx.js closest(".card, .line, tr, ...")): cards,
|
||||||
|
-- rows and the moon sale register here while built; a new card-like container must too.
|
||||||
function M:box(node)
|
function M:box(node)
|
||||||
self.boxes = self.boxes or {}
|
blocks.push(self, "boxes", node)
|
||||||
self.boxes[#self.boxes + 1] = node
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- An action button of this build by its test id (H.action): effects that come after a
|
-- An action button of this build by its test id (H.action): effects that come after a
|
||||||
-- rebuild find the new node (the error shake, the ready burst).
|
-- rebuild find the new node (the error shake, the ready burst).
|
||||||
function M:button(id, node)
|
function M:button(id, node)
|
||||||
self.buttons = self.buttons or {}
|
blocks.put(self, "buttons", id, node)
|
||||||
self.buttons[id] = node
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The layout rect {x, y, w, h} (x right, y down, from the page's top-left) of a node with
|
-- The layout rect {x, y, w, h} (x right, y down, from the page's top-left) of a node with
|
||||||
@@ -193,8 +194,8 @@ end
|
|||||||
|
|
||||||
-- A header number node for `key` (box: its chip or frame, the target for particles).
|
-- A header number node for `key` (box: its chip or frame, the target for particles).
|
||||||
function M:bind(key, node, box)
|
function M:bind(key, node, box)
|
||||||
self.binds[key] = { node = node, box = box, color = gui.get_color(node), p = gui.get_position(node),
|
blocks.put(self, "binds", key, { node = node, box = box, color = gui.get_color(node), p = gui.get_position(node),
|
||||||
scale = gui.get_scale(node), w = ui.text_width(node) }
|
scale = gui.get_scale(node), w = ui.text_width(node) })
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drop_halo(c)
|
local function drop_halo(c)
|
||||||
@@ -740,7 +741,7 @@ function M:reticle(root, w, h, name)
|
|||||||
gui.set_pivot(glow, gui.PIVOT_CENTER)
|
gui.set_pivot(glow, gui.PIVOT_CENTER)
|
||||||
gui.set_blend_mode(glow, theme.glow_blend())
|
gui.set_blend_mode(glow, theme.glow_blend())
|
||||||
gui.move_below(glow, parts[1])
|
gui.move_below(glow, parts[1])
|
||||||
self.reticles[#self.reticles + 1] = { node = ret, w = w, h = h, name = name, arms = arms, ticks = ticks, glow = glow }
|
blocks.push(self, "reticles", { node = ret, w = w, h = h, name = name, arms = arms, ticks = ticks, glow = glow })
|
||||||
if self.shine_known and not self.shine_known[name] then
|
if self.shine_known and not self.shine_known[name] then
|
||||||
-- It appeared shining: the reticle locks on after the tab's entrance.
|
-- It appeared shining: the reticle locks on after the tab's entrance.
|
||||||
self.shine_known[name] = true
|
self.shine_known[name] = true
|
||||||
@@ -832,6 +833,16 @@ local function index(list)
|
|||||||
return m
|
return m
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A refined item's color (catalog.refined, as refinery.lua's item_color).
|
||||||
|
local function refined_color(st, name)
|
||||||
|
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
|
||||||
|
if r.name == name and r.color then
|
||||||
|
return theme.item(r.color)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return theme.resource[name] or C.violet
|
||||||
|
end
|
||||||
|
|
||||||
-- After a response's state is applied (g: the game screen's self). kind: the action
|
-- After a response's state is applied (g: the game screen's self). kind: the action
|
||||||
-- name, or "get".
|
-- name, or "get".
|
||||||
function M:on_response(g, kind, data, prev, st, opts)
|
function M:on_response(g, kind, data, prev, st, opts)
|
||||||
@@ -879,7 +890,25 @@ function M:on_response(g, kind, data, prev, st, opts)
|
|||||||
if credits > 0 then
|
if credits > 0 then
|
||||||
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
|
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
|
||||||
elseif credits < 0 then
|
elseif credits < 0 then
|
||||||
self:count("credits", self:value("credits", prev.credits), st.credits, { held = revealing or self:holding("credits") })
|
-- (a bought moon's price too: it counts down at the purchase, while the reveal plays)
|
||||||
|
self:count("credits", self:value("credits", prev.credits), st.credits, { held = self:holding("credits") })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Refined stock (the Refinery's recipe cards' owned numbers, keys "refined:<name>"):
|
||||||
|
-- what a collect brings is held until its motes land there; other changes count only if
|
||||||
|
-- one is running.
|
||||||
|
local refined = {}
|
||||||
|
if prev.refinery and st.refinery then
|
||||||
|
local rwas = by_name(prev.refinery.stock)
|
||||||
|
for _, r in ipairs(st.refinery.stock or {}) do
|
||||||
|
local key, old = "refined:" .. r.name, rwas[r.name] or 0
|
||||||
|
if r.amount > old and k == "collect" then
|
||||||
|
refined[#refined + 1] = { name = r.name, key = key, delta = r.amount - old, color = refined_color(st, r.name) }
|
||||||
|
self:count(key, self:value(key, old), r.amount, { held = not quiet or self:holding(key) })
|
||||||
|
elseif r.amount ~= old and self.counters[key] then
|
||||||
|
self:count(key, self:value(key, old), r.amount, { held = self:holding(key) })
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if quiet then
|
if quiet then
|
||||||
@@ -916,12 +945,97 @@ function M:on_response(g, kind, data, prev, st, opts)
|
|||||||
-- The rest needs the new layout's positions: after the next build.
|
-- The rest needs the new layout's positions: after the next build.
|
||||||
self.pending[#self.pending + 1] = function()
|
self.pending[#self.pending + 1] = function()
|
||||||
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||||
|
self:collected(g, refined, at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Refined items a collect brought (on_response): the amount pops up in the item's color
|
||||||
|
-- above the slot it came from (clear of the batch sliding into it), and motes stream from
|
||||||
|
-- the Collect button into the recipe card's owned number, which counts up as they land
|
||||||
|
-- (an arrival blip; the "collect" sound plays with the action). Without that number on
|
||||||
|
-- screen it just counts.
|
||||||
|
function M:collected(g, refined, at)
|
||||||
|
if #refined == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local from = at and center(at) or { x = g.size[1] / 2, y = g.size[2] / 2 }
|
||||||
|
local above = at and at.box and { x = at.box.x + at.box.w / 2, y = at.box.y - 8 } or { x = from.x, y = from.y - 10 }
|
||||||
|
local colors = {}
|
||||||
|
for i, rg in ipairs(refined) do
|
||||||
|
colors[#colors + 1] = rg.color
|
||||||
|
self:float_text("+" .. ui.money(rg.delta) .. " " .. rg.name, above.x, above.y - (i - 1) * 30, rg.color, (i - 1) * 0.09, false, -1,
|
||||||
|
{ key = "gain:" .. rg.key, amount = rg.delta, fmt = function(a) return "+" .. ui.money(a) .. " " .. rg.name end })
|
||||||
|
local r = self:box_of(rg.key)
|
||||||
|
if r then
|
||||||
|
local to = center(r)
|
||||||
|
self:stream(from, to, rg.color, 10, 120 + (i - 1) * 90, function()
|
||||||
|
sfx.arrive(i - 1, anim.reduced)
|
||||||
|
self:release(rg.key)
|
||||||
|
self:burst(to.x, to.y, { rg.color }, 12, 0.5)
|
||||||
|
self:glint(r)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
self:release(rg.key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
colors[#colors + 1] = GOLD_HI
|
||||||
|
self:burst(from.x, from.y, colors, 30, 0.8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Refinery batches that finished on our clock (core/ready), whichever section is open:
|
||||||
|
-- the ready sound (once for batches finishing together or with a drill), then, after the
|
||||||
|
-- build that shows them, with the Refinery open each slot's edge lights up (a gold pulse)
|
||||||
|
-- and sparks leave the full end of its bar (refinery.lua pops its Ready label and Collect
|
||||||
|
-- button and runs a light along the bar); elsewhere the Refinery tab pulses and sparks
|
||||||
|
-- leave its badge (header.lua pops the badge).
|
||||||
|
function M:batch_ready(g, jobs)
|
||||||
|
local t = now()
|
||||||
|
if not (self.ready_sound and t - self.ready_sound < 1) then
|
||||||
|
sfx.play("ready")
|
||||||
|
self.ready_sound = t
|
||||||
|
end
|
||||||
|
self.pending[#self.pending + 1] = function()
|
||||||
|
if g.view and g.view.section == "refinery" then
|
||||||
|
for _, j in ipairs(jobs) do
|
||||||
|
local slot = g.rf_slots and g.rf_slots[j.id]
|
||||||
|
if slot then
|
||||||
|
self:pulse(self:rect_of(slot.frame), GOLD, 0.9)
|
||||||
|
local b = self:rect_of(slot.bar)
|
||||||
|
self:burst(b.x + b.w, b.y + b.h / 2, { GOLD, GOLD_HI }, 18, 0.6)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local tab = g.tab_nodes and g.tab_nodes.refinery
|
||||||
|
if tab then
|
||||||
|
local r = self:rect_of(tab)
|
||||||
|
self:pulse(r, GOLD, 0.8)
|
||||||
|
self:burst(r.x + r.w - 9, r.y + 9, { GOLD, GOLD_HI }, 14, 0.5) -- (the badge on its top-right corner)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (a text node's position is its pivot point and its size not its text's: the box is the
|
||||||
|
-- text's own, round that point)
|
||||||
|
local PIVOT = { -- (fraction of the box left of / above the pivot point)
|
||||||
|
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||||
|
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||||
|
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||||
|
}
|
||||||
|
|
||||||
function M:box_of(key)
|
function M:box_of(key)
|
||||||
local b = self.binds[key]
|
local b = self.binds[key]
|
||||||
return b and self:rect_of(b.box or b.node)
|
if not b then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
|
||||||
|
return self:rect_of(b.box or b.node)
|
||||||
|
end
|
||||||
|
local r = self:rect_of(b.node)
|
||||||
|
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
|
||||||
|
local w = ui.text_width(b.node)
|
||||||
|
local h = r.h * gui.get_scale(b.node).y
|
||||||
|
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
|
||||||
end
|
end
|
||||||
|
|
||||||
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||||
@@ -1006,13 +1120,8 @@ function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
|||||||
{ key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
|
{ key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if revealing then
|
|
||||||
self.waiting.revealed = self.waiting.revealed or {}
|
|
||||||
table.insert(self.waiting.revealed, spend)
|
|
||||||
else
|
|
||||||
spend()
|
spend()
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
if BUY_KINDS[k] then
|
if BUY_KINDS[k] then
|
||||||
if at and not revealing then
|
if at and not revealing then
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- The moon in the aside viewport: the procedural moon of the web build (terrain.lua, a
|
-- The moon in the aside viewport: the procedural moon of the web build (mmterrain and terrain.lua, a
|
||||||
-- port of the server's terrain.js; look of scene.js), drawn in 3D behind a GUI node.
|
-- port of the server's terrain.js; look of scene.js), drawn in 3D behind a GUI node.
|
||||||
--
|
--
|
||||||
-- Use from a gui_script:
|
-- Use from a gui_script:
|
||||||
@@ -6,23 +6,26 @@
|
|||||||
-- call again after the layout moves the node (it reads the node's
|
-- call again after the layout moves the node (it reads the node's
|
||||||
-- screen rect). The node gets the "moon" material (listed in the
|
-- screen rect). The node gets the "moon" material (listed in the
|
||||||
-- .gui's materials), which draws the rendered moon.
|
-- .gui's materials), which draws the rendered moon.
|
||||||
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
|
-- moon.move(node[, quiet]) the node moved (the narrow layout's page scroll, the reveal's
|
||||||
|
-- window sliding and shaking): follow it; quiet: no log line.
|
||||||
-- moon.hide() no moon (the node keeps its own look).
|
-- moon.hide() no moon (the node keeps its own look).
|
||||||
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
|
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
|
||||||
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
|
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
|
||||||
-- appearing in front of the moon shown) charges up, then bursts
|
-- appearing in front of the moon shown) charges up, then bursts
|
||||||
-- into moon `id` once its mesh is built. moon.show(id, node) during
|
-- into moon `id` once its mesh is built. moon.show(id, node) during
|
||||||
-- it only moves the viewport. Progress in moon.revealing (below).
|
-- it only moves the viewport. Progress in moon.revealing (below).
|
||||||
|
-- moon.prefetch(id) start making moon `id`'s mesh ahead (a bought moon, before its
|
||||||
|
-- reveal is shown).
|
||||||
-- moon.skip_reveal() jump ahead: a charge bursts as soon as the moon is ready, a
|
-- moon.skip_reveal() jump ahead: a charge bursts as soon as the moon is ready, a
|
||||||
-- burst ends.
|
-- burst ends.
|
||||||
--
|
--
|
||||||
-- How it works: moon/moon.script (in moon/moon.collection, a collection instance in the
|
-- How it works: moon/moon.script (in moon/moon.collection, a collection instance in the
|
||||||
-- game screen) generates meshes (cached per id, spread over frames), spins and orbits the
|
-- game screen) generates meshes (cached per id; natively through the mmterrain extension:
|
||||||
-- camera (drag to rotate, wheel to zoom), and fills `moon.frame`; the render script
|
-- on a thread on desktop, a few ms a frame on the web), spins and orbits the camera (drag to rotate, wheel to zoom), and fills `moon.frame`; the render script
|
||||||
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
||||||
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
||||||
--
|
--
|
||||||
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
|
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
|
||||||
-- defaults like the web does; until the request answers nothing is generated.
|
-- defaults like the web does; until the request answers nothing is generated.
|
||||||
--
|
--
|
||||||
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
||||||
@@ -35,10 +38,17 @@ local M = {}
|
|||||||
M.id = nil
|
M.id = nil
|
||||||
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
||||||
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
||||||
|
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
|
||||||
|
|
||||||
-- Settings: nil until loaded (or failed: then the defaults).
|
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
|
||||||
|
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
|
||||||
M.settings = nil
|
M.settings = nil
|
||||||
|
|
||||||
|
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
|
||||||
|
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
|
||||||
|
M.building = false
|
||||||
|
M.last_build = nil
|
||||||
|
|
||||||
-- What the render script draws, written by moon.script every frame.
|
-- What the render script draws, written by moon.script every frame.
|
||||||
M.frame = { visible = false, has_mesh = false }
|
M.frame = { visible = false, has_mesh = false }
|
||||||
|
|
||||||
@@ -64,17 +74,20 @@ local function node_rect(node)
|
|||||||
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
||||||
local br = gui.get_screen_position(probe)
|
local br = gui.get_screen_position(probe)
|
||||||
gui.delete_node(probe)
|
gui.delete_node(probe)
|
||||||
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
|
-- The size is rounded once (not as the difference of two rounded corners), so it stays
|
||||||
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
|
-- the same while the node moves by fractions of a pixel: every new size remakes the
|
||||||
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
|
-- render targets.
|
||||||
|
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
|
||||||
|
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
|
||||||
|
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function set_node(node)
|
local function set_node(node, quiet)
|
||||||
if node then
|
if node then
|
||||||
local old = M.rect
|
local old = M.rect
|
||||||
M.rect, M.unit = node_rect(node)
|
M.rect, M.unit = node_rect(node)
|
||||||
local r = M.rect
|
local r = M.rect
|
||||||
if not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h then
|
if not quiet and (not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h) then
|
||||||
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
|
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
|
||||||
end
|
end
|
||||||
gui.set_material(node, "moon")
|
gui.set_material(node, "moon")
|
||||||
@@ -96,9 +109,9 @@ function M.show(id, node)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
|
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
|
||||||
function M.move(node)
|
function M.move(node, quiet)
|
||||||
if node and (M.id or M.mode) then
|
if node and (M.id or M.mode) then
|
||||||
set_node(node)
|
set_node(node, quiet)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,6 +139,17 @@ function M.reveal(id, node)
|
|||||||
set_node(node)
|
set_node(node)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Start making moon `id`'s mesh now (a moon just bought, before its reveal shows it), so
|
||||||
|
-- it's ready when asked for.
|
||||||
|
function M.prefetch(id)
|
||||||
|
if debug_id ~= "" then
|
||||||
|
id = debug_id
|
||||||
|
end
|
||||||
|
if id ~= nil and tostring(id) ~= M.id then
|
||||||
|
M.prefetch_id = tostring(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function M.skip_reveal()
|
function M.skip_reveal()
|
||||||
local r = M.revealing
|
local r = M.revealing
|
||||||
if r then
|
if r then
|
||||||
|
|||||||
+37
-427
@@ -1,7 +1,7 @@
|
|||||||
-- Procedural moon terrain: a port of the server's web/static/js/terrain.js (generation,
|
-- Moon terrain settings and helpers, a port of the server's web/static/js/terrain.js: the
|
||||||
-- deterministic from the moon id). Same seed and settings give the same moon as the web:
|
-- settings (defaults, merge, per-moon resolve, the description traits), the colors and
|
||||||
-- the integer hashing and random draws are bit-exact, the floats differ only by the JS
|
-- the cube-sphere grid and its triangle strip. The generation itself is native: the
|
||||||
-- Float32Array rounding (~1e-7).
|
-- mmterrain extension (mmterrain/src/terrain.c) takes native_params(resolve(...)).
|
||||||
--
|
--
|
||||||
-- Pure Lua (LuaJIT `bit`), no engine APIs, so it also runs in plain luajit for checks
|
-- Pure Lua (LuaJIT `bit`), no engine APIs, so it also runs in plain luajit for checks
|
||||||
-- (scripts/moon-check.mjs). Keep it in sync with terrain.js when that changes.
|
-- (scripts/moon-check.mjs). Keep it in sync with terrain.js when that changes.
|
||||||
@@ -131,146 +131,6 @@ function M.rng(seed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- noise
|
|
||||||
|
|
||||||
-- 3D simplex noise (Stefan Gustavson's reference implementation), roughly in [-1, 1].
|
|
||||||
-- 0-based tables (LuaJIT keeps index 0 in the array part). GX/GY/GZ[v] is the gradient of
|
|
||||||
-- permutation value v (GRAD3 at v % 12, i.e. terrain.js PERM12).
|
|
||||||
local GRAD3 = {
|
|
||||||
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
|
|
||||||
}
|
|
||||||
local PERM, GX, GY, GZ = {}, {}, {}, {}
|
|
||||||
do
|
|
||||||
local p = {}
|
|
||||||
for i = 0, 255 do
|
|
||||||
p[i] = i
|
|
||||||
end
|
|
||||||
local r = M.rng(1337)
|
|
||||||
for i = 255, 1, -1 do
|
|
||||||
local j = floor(r() * (i + 1))
|
|
||||||
p[i], p[j] = p[j], p[i]
|
|
||||||
end
|
|
||||||
for i = 0, 511 do
|
|
||||||
PERM[i] = p[band(i, 255)]
|
|
||||||
end
|
|
||||||
for v = 0, 255 do
|
|
||||||
local g = (v % 12) * 3
|
|
||||||
GX[v], GY[v], GZ[v] = GRAD3[g + 1], GRAD3[g + 2], GRAD3[g + 3]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
M.PERM = PERM
|
|
||||||
|
|
||||||
local F3 = 1 / 3
|
|
||||||
local G3 = 1 / 6
|
|
||||||
|
|
||||||
-- Branch-free (LuaJIT side traces of the reference's if/else chains abort on register
|
|
||||||
-- pressure): the simplex corner order comes from 0/1 flags, and corners outside their
|
|
||||||
-- radius get a zero weight. Bit-identical results: for floats, x >= y iff x - y >= 0, and
|
|
||||||
-- here |x - y| < 1, so 1 + floor(x - y) is that flag.
|
|
||||||
local function simplex3(x, y, z)
|
|
||||||
local P = PERM
|
|
||||||
local s = (x + y + z) * F3
|
|
||||||
local i, j, k = floor(x + s), floor(y + s), floor(z + s)
|
|
||||||
local t = (i + j + k) * G3
|
|
||||||
local x0, y0, z0 = x - (i - t), y - (j - t), z - (k - t)
|
|
||||||
local a = 1 + floor(x0 - y0) -- x0 >= y0
|
|
||||||
local b = 1 + floor(y0 - z0) -- y0 >= z0
|
|
||||||
local c = 1 + floor(x0 - z0) -- x0 >= z0
|
|
||||||
local i1 = a * (b + (1 - b) * c)
|
|
||||||
local j1 = (1 - a) * b
|
|
||||||
local k1 = 1 - i1 - j1
|
|
||||||
local i2 = a + (1 - a) * b * c
|
|
||||||
local j2 = a * b + 1 - a
|
|
||||||
local k2 = 2 - i2 - j2
|
|
||||||
local ii, jj, kk = band(i, 255), band(j, 255), band(k, 255)
|
|
||||||
local tt = max(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0)
|
|
||||||
local g = P[ii + P[jj + P[kk]]]
|
|
||||||
tt = tt * tt
|
|
||||||
local n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0)
|
|
||||||
local x1, y1, z1 = x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3
|
|
||||||
tt = max(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1)
|
|
||||||
g = P[ii + i1 + P[jj + j1 + P[kk + k1]]]
|
|
||||||
tt = tt * tt
|
|
||||||
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1)
|
|
||||||
local x2, y2, z2 = x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3
|
|
||||||
tt = max(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2)
|
|
||||||
g = P[ii + i2 + P[jj + j2 + P[kk + k2]]]
|
|
||||||
tt = tt * tt
|
|
||||||
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2)
|
|
||||||
local x3, y3, z3 = x0 - 1 + 3 * G3, y0 - 1 + 3 * G3, z0 - 1 + 3 * G3
|
|
||||||
tt = max(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3)
|
|
||||||
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]]
|
|
||||||
tt = tt * tt
|
|
||||||
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3)
|
|
||||||
return 32 * n
|
|
||||||
end
|
|
||||||
M.simplex3 = simplex3
|
|
||||||
|
|
||||||
-- Caps on saved settings, which come from the admin page.
|
|
||||||
local MAX_OCTAVES = 10
|
|
||||||
local MAX_CRATERS = 2000
|
|
||||||
|
|
||||||
-- Fractal (fBm) noise in [-1, 1].
|
|
||||||
local function fbm(x, y, z, octaves, frequency, persistence, lacunarity)
|
|
||||||
local sum, amp, norm, f = 0, 1, 0, frequency
|
|
||||||
for _ = 1, min(octaves, MAX_OCTAVES) do
|
|
||||||
sum = sum + simplex3(x * f, y * f, z * f) * amp
|
|
||||||
norm = norm + amp
|
|
||||||
amp = amp * persistence
|
|
||||||
f = f * lacunarity
|
|
||||||
end
|
|
||||||
return sum / norm
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Billow noise (fBm of |noise|) in [0, 1].
|
|
||||||
local function billow(x, y, z, octaves, frequency, persistence, lacunarity)
|
|
||||||
local sum, amp, norm, f = 0, 1, 0, frequency
|
|
||||||
for _ = 1, min(octaves, MAX_OCTAVES) do
|
|
||||||
sum = sum + abs(simplex3(x * f, y * f, z * f)) * amp
|
|
||||||
norm = norm + amp
|
|
||||||
amp = amp * persistence
|
|
||||||
f = f * lacunarity
|
|
||||||
end
|
|
||||||
return sum / norm
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Ridged multifractal in [0, 1]: sharp crests; each octave is weighted by the previous.
|
|
||||||
local function ridged(x, y, z, octaves, frequency, persistence, lacunarity, sharpness, gain)
|
|
||||||
local sum, amp, norm, weight, f = 0, 1, 0, 1, frequency
|
|
||||||
for _ = 1, min(octaves, MAX_OCTAVES) do
|
|
||||||
local v = 1 - abs(simplex3(x * f, y * f, z * f))
|
|
||||||
v = pow(v, sharpness) * weight
|
|
||||||
weight = min(1, max(0, v * gain))
|
|
||||||
sum = sum + v * amp
|
|
||||||
norm = norm + amp
|
|
||||||
amp = amp * persistence
|
|
||||||
f = f * lacunarity
|
|
||||||
end
|
|
||||||
return sum / norm
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Polynomial smooth min/max (Inigo Quilez), k = smoothing width.
|
|
||||||
local function smooth_min(a, b, k)
|
|
||||||
if k <= 0 then
|
|
||||||
return min(a, b)
|
|
||||||
end
|
|
||||||
local h = min(1, max(0, (b - a + k) / (2 * k)))
|
|
||||||
return a * h + b * (1 - h) - k * h * (1 - h)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smooth_max(a, b, k)
|
|
||||||
return -smooth_min(-a, -b, k)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smoothstep(e0, e1, x)
|
|
||||||
local d = e1 - e0
|
|
||||||
if d == 0 then
|
|
||||||
d = 1e-6
|
|
||||||
end
|
|
||||||
local t = min(1, max(0, (x - e0) / d))
|
|
||||||
return t * t * (3 - 2 * t)
|
|
||||||
end
|
|
||||||
|
|
||||||
---------------------------------------------------------------- colors
|
---------------------------------------------------------------- colors
|
||||||
|
|
||||||
local function round(x)
|
local function round(x)
|
||||||
@@ -410,6 +270,32 @@ function M.traits(settings, seed)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The resolved parameters as the flat number list the native generator takes
|
||||||
|
-- (mmterrain/src/terrain.h mt_params_from_array; colors linear, like generate's).
|
||||||
|
local TECHNIQUE_INDEX = { fbm = 0, ridged = 1, warped = 2, billow = 3, craterField = 4 }
|
||||||
|
function M.native_params(p)
|
||||||
|
local sh, rd, wp, cr, sd = p.shape, p.ridge, p.warp, p.craters, p.shading
|
||||||
|
local off = p.offset or { 0, 0, 0 }
|
||||||
|
local a = {
|
||||||
|
p.resolution, TECHNIQUE_INDEX[p.technique] or 0,
|
||||||
|
sh.strength, sh.frequency, sh.octaves, sh.persistence, sh.lacunarity, sh.minValue,
|
||||||
|
rd.strength, rd.frequency, rd.octaves, rd.persistence, rd.lacunarity, rd.sharpness, rd.gain, rd.minValue,
|
||||||
|
wp.strength, wp.frequency, wp.octaves,
|
||||||
|
cr.count, cr.minRadius, cr.maxRadius, cr.sizeBias, cr.depth, cr.rimWidth, cr.rimSteepness,
|
||||||
|
cr.floorHeight, cr.smoothness, cr.ejecta,
|
||||||
|
}
|
||||||
|
for _, key in ipairs({ "low", "mid", "high", "steep", "patch" }) do
|
||||||
|
local c = M.linear_color(sd[key])
|
||||||
|
a[#a + 1], a[#a + 2], a[#a + 3] = c[1], c[2], c[3]
|
||||||
|
end
|
||||||
|
local rest = { sd.steepThreshold, sd.steepBlend, sd.patchAmount, sd.patchFrequency, sd.grain,
|
||||||
|
off[1], off[2], off[3], p.craterSeed or 1 }
|
||||||
|
for _, v in ipairs(rest) do
|
||||||
|
a[#a + 1] = v
|
||||||
|
end
|
||||||
|
return a
|
||||||
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- mesh
|
---------------------------------------------------------------- mesh
|
||||||
|
|
||||||
-- Cube faces: outward normal and two tangent axes (a x b = up, so triangles face out).
|
-- Cube faces: outward normal and two tangent axes (a x b = up, so triangles face out).
|
||||||
@@ -424,7 +310,13 @@ local FACES = {
|
|||||||
|
|
||||||
-- Unit-sphere directions of the cube-sphere grid (6 faces of (n+1)^2 vertices, flat xyz,
|
-- Unit-sphere directions of the cube-sphere grid (6 faces of (n+1)^2 vertices, flat xyz,
|
||||||
-- vertex v at [3v+1..3v+3], v 0-based) and the shared key of each seam vertex (-1 inside).
|
-- vertex v at [3v+1..3v+3], v 0-based) and the shared key of each seam vertex (-1 inside).
|
||||||
|
-- (Read-only for callers: cached per n, like the strip.)
|
||||||
|
local sphere_cache = {}
|
||||||
function M.cube_sphere(n)
|
function M.cube_sphere(n)
|
||||||
|
local hit = sphere_cache[n]
|
||||||
|
if hit then
|
||||||
|
return hit[1], hit[2]
|
||||||
|
end
|
||||||
local row = n + 1
|
local row = n + 1
|
||||||
local dirs, keys = {}, {}
|
local dirs, keys = {}, {}
|
||||||
local v = 0
|
local v = 0
|
||||||
@@ -453,6 +345,7 @@ function M.cube_sphere(n)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
sphere_cache[n] = { dirs, keys }
|
||||||
return dirs, keys
|
return dirs, keys
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -488,287 +381,4 @@ function M.strip(n)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Craters scattered on the unit sphere, bucketed in a 3D grid for fast lookup.
|
|
||||||
local function make_craters(c, seed, count_scale)
|
|
||||||
local r = M.rng(seed)
|
|
||||||
local count = round(min(c.count, MAX_CRATERS) * count_scale)
|
|
||||||
local x_max = max(1 + c.rimWidth, sqrt(1 + max(0, c.smoothness)))
|
|
||||||
local list = {}
|
|
||||||
for i = 1, count do
|
|
||||||
local z = r() * 2 - 1
|
|
||||||
local a = r() * pi * 2
|
|
||||||
local q = sqrt(1 - z * z)
|
|
||||||
local radius = c.minRadius + (c.maxRadius - c.minRadius) * pow(r(), c.sizeBias)
|
|
||||||
list[i] = { x = q * cos(a), y = q * sin(a), z = z, r = radius, reach = radius * x_max }
|
|
||||||
end
|
|
||||||
local G = 12
|
|
||||||
local cell = 2 / G
|
|
||||||
local grid = {}
|
|
||||||
for i = 1, G * G * G do
|
|
||||||
grid[i] = {}
|
|
||||||
end
|
|
||||||
local function at(v)
|
|
||||||
return min(G - 1, max(0, floor((v + 1) / cell)))
|
|
||||||
end
|
|
||||||
for _, cr in ipairs(list) do
|
|
||||||
for x = at(cr.x - cr.reach), at(cr.x + cr.reach) do
|
|
||||||
for y = at(cr.y - cr.reach), at(cr.y + cr.reach) do
|
|
||||||
for z = at(cr.z - cr.reach), at(cr.z + cr.reach) do
|
|
||||||
local g = grid[(x * G + y) * G + z + 1]
|
|
||||||
g[#g + 1] = cr
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return {
|
|
||||||
cell_of = function(x, y, z)
|
|
||||||
return grid[(at(x) * G + at(y)) * G + at(z) + 1]
|
|
||||||
end,
|
|
||||||
x_max = x_max,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Builds the moon for resolved parameters (see M.resolve): flat arrays over the cube-sphere
|
|
||||||
-- grid (vertex v 0-based at [3v+1..3v+3]): positions (unit radius plus elevation), normals,
|
|
||||||
-- colors (linear RGB). `checkpoint` (optional) is called every few hundred vertices, e.g.
|
|
||||||
-- to yield a coroutine and spread the work over frames.
|
|
||||||
function M.generate(p, checkpoint)
|
|
||||||
checkpoint = checkpoint or function() end
|
|
||||||
local n = max(8, min(320, round(p.resolution)))
|
|
||||||
local row = n + 1
|
|
||||||
local per = row * row
|
|
||||||
local count = 6 * per
|
|
||||||
local dirs, keys = M.cube_sphere(n)
|
|
||||||
local heights, ejecta = {}, {}
|
|
||||||
|
|
||||||
local technique = p.technique
|
|
||||||
local sh, rd, wp, cr = p.shape, p.ridge, p.warp, p.craters
|
|
||||||
local off = p.offset or { 0, 0, 0 }
|
|
||||||
local ox, oy, oz = off[1], off[2], off[3]
|
|
||||||
local crater_field = technique == "craterField"
|
|
||||||
local cparams = cr
|
|
||||||
if crater_field then
|
|
||||||
cparams = clone(cr)
|
|
||||||
cparams.minRadius = cr.minRadius * 0.8
|
|
||||||
cparams.maxRadius = cr.maxRadius * 0.6
|
|
||||||
end
|
|
||||||
local craters = make_craters(cparams, p.craterSeed or 1, crater_field and 3 or 1)
|
|
||||||
local x_max = craters.x_max
|
|
||||||
local shape_strength = crater_field and sh.strength * 0.4 or sh.strength
|
|
||||||
local smooth = max(0, cr.smoothness)
|
|
||||||
local ejecta_reach = 1 + cr.rimWidth * 2.5
|
|
||||||
local rim_width, rim_steep, floor_h = cr.rimWidth, cr.rimSteepness, cr.floorHeight
|
|
||||||
local s_oct, s_freq, s_pers, s_lac, s_min = sh.octaves, sh.frequency, sh.persistence, sh.lacunarity, sh.minValue
|
|
||||||
local r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain, r_min, r_str =
|
|
||||||
rd.octaves, rd.frequency, rd.persistence, rd.lacunarity, rd.sharpness, rd.gain, rd.minValue, rd.strength
|
|
||||||
|
|
||||||
for i = 0, count - 1 do
|
|
||||||
local x, y, z = dirs[i * 3 + 1], dirs[i * 3 + 2], dirs[i * 3 + 3]
|
|
||||||
local px, py, pz = x + ox, y + oy, z + oz
|
|
||||||
|
|
||||||
-- Primary shape layer.
|
|
||||||
local value
|
|
||||||
if technique == "ridged" then
|
|
||||||
value = ridged(px, py, pz, s_oct, s_freq, s_pers, s_lac, r_sharp, r_gain)
|
|
||||||
elseif technique == "billow" then
|
|
||||||
value = billow(px, py, pz, s_oct, s_freq, s_pers, s_lac)
|
|
||||||
elseif technique == "warped" then
|
|
||||||
local wf = wp.frequency
|
|
||||||
local wx = fbm(px + 5.2, py + 1.3, pz, wp.octaves, wf, 0.5, 2)
|
|
||||||
local wy = fbm(px, py + 9.7, pz + 2.8, wp.octaves, wf, 0.5, 2)
|
|
||||||
local wz = fbm(px + 3.1, py, pz + 7.4, wp.octaves, wf, 0.5, 2)
|
|
||||||
local s = wp.strength
|
|
||||||
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, s_oct, s_freq, s_pers, s_lac) + 1) / 2
|
|
||||||
else
|
|
||||||
value = (fbm(px, py, pz, s_oct, s_freq, s_pers, s_lac) + 1) / 2
|
|
||||||
end
|
|
||||||
local h = max(0, value - s_min) * shape_strength
|
|
||||||
|
|
||||||
-- Ridge layer.
|
|
||||||
if r_str ~= 0 then
|
|
||||||
local r = ridged(px - 11.3, py + 4.1, pz - 7.9, r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain)
|
|
||||||
h = h + max(0, r - r_min) * r_str
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Craters: parabola cavity, flattened floor, smooth rim (smooth min/max blend them).
|
|
||||||
local crater_h, glow = 0, 0
|
|
||||||
local list = craters.cell_of(x, y, z)
|
|
||||||
for c = 1, #list do
|
|
||||||
local k = list[c]
|
|
||||||
local dx, dy, dz = x - k.x, y - k.y, z - k.z
|
|
||||||
local d2 = dx * dx + dy * dy + dz * dz
|
|
||||||
local reach = max(k.reach, k.r * ejecta_reach)
|
|
||||||
if d2 <= reach * reach then
|
|
||||||
local dist = sqrt(d2) / k.r
|
|
||||||
if dist < x_max then
|
|
||||||
local cavity = dist * dist - 1
|
|
||||||
local rim_x = min(dist - 1 - rim_width, 0)
|
|
||||||
local rim = rim_steep * rim_x * rim_x
|
|
||||||
local shape = smooth_max(cavity, floor_h, smooth)
|
|
||||||
shape = smooth_min(shape, rim, smooth)
|
|
||||||
crater_h = crater_h + shape * k.r
|
|
||||||
end
|
|
||||||
if dist > 0.8 and dist < ejecta_reach then
|
|
||||||
glow = glow + 1 - smoothstep(1, ejecta_reach, dist)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
heights[i + 1] = h + crater_h * cr.depth
|
|
||||||
ejecta[i + 1] = min(1, glow)
|
|
||||||
if i % 256 == 0 then
|
|
||||||
checkpoint()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Positions on a unit sphere plus elevation, re-centered so the mean radius stays 1.
|
|
||||||
local mean = 0
|
|
||||||
for i = 1, count do
|
|
||||||
mean = mean + heights[i]
|
|
||||||
end
|
|
||||||
mean = mean / count
|
|
||||||
local positions = {}
|
|
||||||
local min_h, max_h = math.huge, -math.huge
|
|
||||||
for i = 0, count - 1 do
|
|
||||||
local hh = heights[i + 1] - mean
|
|
||||||
heights[i + 1] = hh
|
|
||||||
local r = 1 + hh
|
|
||||||
positions[i * 3 + 1] = dirs[i * 3 + 1] * r
|
|
||||||
positions[i * 3 + 2] = dirs[i * 3 + 2] * r
|
|
||||||
positions[i * 3 + 3] = dirs[i * 3 + 3] * r
|
|
||||||
if hh < min_h then
|
|
||||||
min_h = hh
|
|
||||||
end
|
|
||||||
if hh > max_h then
|
|
||||||
max_h = hh
|
|
||||||
end
|
|
||||||
end
|
|
||||||
checkpoint()
|
|
||||||
|
|
||||||
-- Area-weighted vertex normals (the same two triangles per quad as terrain.js).
|
|
||||||
local normals = {}
|
|
||||||
for i = 1, count * 3 do
|
|
||||||
normals[i] = 0
|
|
||||||
end
|
|
||||||
local P = positions
|
|
||||||
local function tri(a, b, c)
|
|
||||||
a, b, c = a * 3, b * 3, c * 3
|
|
||||||
local e1x, e1y, e1z = P[b + 1] - P[a + 1], P[b + 2] - P[a + 2], P[b + 3] - P[a + 3]
|
|
||||||
local e2x, e2y, e2z = P[c + 1] - P[a + 1], P[c + 2] - P[a + 2], P[c + 3] - P[a + 3]
|
|
||||||
local nx = e1y * e2z - e1z * e2y
|
|
||||||
local ny = e1z * e2x - e1x * e2z
|
|
||||||
local nz = e1x * e2y - e1y * e2x
|
|
||||||
normals[a + 1] = normals[a + 1] + nx
|
|
||||||
normals[a + 2] = normals[a + 2] + ny
|
|
||||||
normals[a + 3] = normals[a + 3] + nz
|
|
||||||
normals[b + 1] = normals[b + 1] + nx
|
|
||||||
normals[b + 2] = normals[b + 2] + ny
|
|
||||||
normals[b + 3] = normals[b + 3] + nz
|
|
||||||
normals[c + 1] = normals[c + 1] + nx
|
|
||||||
normals[c + 2] = normals[c + 2] + ny
|
|
||||||
normals[c + 3] = normals[c + 3] + nz
|
|
||||||
end
|
|
||||||
for f = 0, 5 do
|
|
||||||
local base = f * per
|
|
||||||
for y = 0, n - 1 do
|
|
||||||
for x = 0, n - 1 do
|
|
||||||
local i = base + y * row + x
|
|
||||||
tri(i, i + 1, i + n + 2)
|
|
||||||
tri(i, i + n + 2, i + n + 1)
|
|
||||||
end
|
|
||||||
checkpoint()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- Faces duplicate their edge vertices; sum the copies so there are no lighting seams.
|
|
||||||
local seams = {}
|
|
||||||
for i = 0, count - 1 do
|
|
||||||
local key = keys[i + 1]
|
|
||||||
if key >= 0 then
|
|
||||||
local g = seams[key]
|
|
||||||
if g then
|
|
||||||
g[#g + 1] = i
|
|
||||||
else
|
|
||||||
seams[key] = { i }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for _, group in pairs(seams) do
|
|
||||||
if #group > 1 then
|
|
||||||
local sx, sy, sz = 0, 0, 0
|
|
||||||
for _, i in ipairs(group) do
|
|
||||||
sx, sy, sz = sx + normals[i * 3 + 1], sy + normals[i * 3 + 2], sz + normals[i * 3 + 3]
|
|
||||||
end
|
|
||||||
for _, i in ipairs(group) do
|
|
||||||
normals[i * 3 + 1], normals[i * 3 + 2], normals[i * 3 + 3] = sx, sy, sz
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i = 0, count - 1 do
|
|
||||||
local o = i * 3
|
|
||||||
local nx, ny, nz = normals[o + 1], normals[o + 2], normals[o + 3]
|
|
||||||
local len = sqrt(nx * nx + ny * ny + nz * nz)
|
|
||||||
if len == 0 then
|
|
||||||
len = 1
|
|
||||||
end
|
|
||||||
normals[o + 1], normals[o + 2], normals[o + 3] = nx / len, ny / len, nz / len
|
|
||||||
end
|
|
||||||
checkpoint()
|
|
||||||
|
|
||||||
-- Colors: elevation gradient, dark patches, steep slopes, crater halos, grain.
|
|
||||||
local sd = p.shading
|
|
||||||
local low, mid, high = M.linear_color(sd.low), M.linear_color(sd.mid), M.linear_color(sd.high)
|
|
||||||
local steep, patch = M.linear_color(sd.steep), M.linear_color(sd.patch)
|
|
||||||
local colors = {}
|
|
||||||
local range = max_h - min_h
|
|
||||||
if range == 0 then
|
|
||||||
range = 1
|
|
||||||
end
|
|
||||||
local patch_amount, patch_freq, grain = sd.patchAmount, sd.patchFrequency, sd.grain
|
|
||||||
local st_lo, st_hi = sd.steepThreshold - sd.steepBlend, sd.steepThreshold + sd.steepBlend
|
|
||||||
local cr_ejecta = cr.ejecta
|
|
||||||
for i = 0, count - 1 do
|
|
||||||
local o = i * 3
|
|
||||||
local x, y, z = dirs[o + 1], dirs[o + 2], dirs[o + 3]
|
|
||||||
local e = (heights[i + 1] - min_h) / range
|
|
||||||
local lo, hi, te
|
|
||||||
if e < 0.5 then
|
|
||||||
lo, hi, te = low, mid, e * 2
|
|
||||||
else
|
|
||||||
lo, hi, te = mid, high, (e - 0.5) * 2
|
|
||||||
end
|
|
||||||
local c1 = lo[1] + (hi[1] - lo[1]) * te
|
|
||||||
local c2 = lo[2] + (hi[2] - lo[2]) * te
|
|
||||||
local c3 = lo[3] + (hi[3] - lo[3]) * te
|
|
||||||
|
|
||||||
if patch_amount > 0 then
|
|
||||||
local pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, patch_freq, 0.5, 2)
|
|
||||||
-- Patches favor low ground, like maria.
|
|
||||||
local w = smoothstep(-0.05, 0.4, pn) * patch_amount * (1 - e * 0.6)
|
|
||||||
c1, c2, c3 = c1 + (patch[1] - c1) * w, c2 + (patch[2] - c2) * w, c3 + (patch[3] - c3) * w
|
|
||||||
end
|
|
||||||
|
|
||||||
local g = ejecta[i + 1] * cr_ejecta
|
|
||||||
c1, c2, c3 = c1 + (high[1] - c1) * g, c2 + (high[2] - c2) * g, c3 + (high[3] - c3) * g
|
|
||||||
|
|
||||||
local steepness = 1 - (normals[o + 1] * x + normals[o + 2] * y + normals[o + 3] * z)
|
|
||||||
local ws = smoothstep(st_lo, st_hi, steepness)
|
|
||||||
c1, c2, c3 = c1 + (steep[1] - c1) * ws, c2 + (steep[2] - c2) * ws, c3 + (steep[3] - c3) * ws
|
|
||||||
|
|
||||||
local gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * grain
|
|
||||||
colors[o + 1], colors[o + 2], colors[o + 3] = c1 * gr, c2 * gr, c3 * gr
|
|
||||||
if i % 512 == 0 then
|
|
||||||
checkpoint()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
n = n,
|
|
||||||
count = count,
|
|
||||||
positions = positions,
|
|
||||||
normals = normals,
|
|
||||||
colors = colors,
|
|
||||||
min_h = min_h,
|
|
||||||
max_h = max_h,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+322
-58
@@ -53,6 +53,12 @@ function M.copy(st)
|
|||||||
return copy(st, true)
|
return copy(st, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
|
||||||
|
local function same_copy(a, b)
|
||||||
|
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
|
||||||
|
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
|
||||||
|
end
|
||||||
|
|
||||||
local function find(list, id, key)
|
local function find(list, id, key)
|
||||||
key = key or "id"
|
key = key or "id"
|
||||||
for i, x in ipairs(list or {}) do
|
for i, x in ipairs(list or {}) do
|
||||||
@@ -168,7 +174,7 @@ function M.tool(st, id)
|
|||||||
return find(st.tools, id)
|
return find(st.tools, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
|
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
|
||||||
function M.idle_copy(st, id)
|
function M.idle_copy(st, id)
|
||||||
local t = find(st.tools, id)
|
local t = find(st.tools, id)
|
||||||
if not t then
|
if not t then
|
||||||
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
|
|||||||
return t
|
return t
|
||||||
end
|
end
|
||||||
for _, x in ipairs(st.tools) do
|
for _, x in ipairs(st.tools) do
|
||||||
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
|
if not x.stakeId and same_copy(x, t) then
|
||||||
return x
|
return x
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -189,18 +195,162 @@ local function yield_boost(st, resource)
|
|||||||
return b[resource] or 1
|
return b[resource] or 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- shared wells
|
||||||
|
|
||||||
|
-- Ports of the server's well split (game.go coverWells, splitClaim; views.go split,
|
||||||
|
-- project, takeAfter), api.md "Prediction": claims go oldest first, each from what the
|
||||||
|
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
|
||||||
|
local EPS = 1e-9
|
||||||
|
|
||||||
|
-- The wells a stake with this reach mines: its anchor first, then the next wells in the
|
||||||
|
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
|
||||||
|
local function cover_wells(anchor, supply, reach)
|
||||||
|
local n = #supply
|
||||||
|
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
|
||||||
|
local out = {}
|
||||||
|
local want = math.max(1, reach or 1)
|
||||||
|
for k = 0, n - 1 do
|
||||||
|
if #out >= want then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local i = (start - 1 + k) % n + 1
|
||||||
|
if supply[i] > 0 then
|
||||||
|
out[#out + 1] = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function well_index(moon, id)
|
||||||
|
for i, w in ipairs(moon.wells or {}) do
|
||||||
|
if w.id == id then
|
||||||
|
return i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What claiming a stake (wellId, amount, reach) takes from each well, given supply.
|
||||||
|
local function split(moon, s, supply)
|
||||||
|
local take = {}
|
||||||
|
for i = 1, #supply do
|
||||||
|
take[i] = 0
|
||||||
|
end
|
||||||
|
local anchor = well_index(moon, s.wellId)
|
||||||
|
if (s.reach or 1) <= 1 then
|
||||||
|
if anchor then
|
||||||
|
take[anchor] = math.max(0, math.min(s.amount, supply[anchor]))
|
||||||
|
end
|
||||||
|
return take
|
||||||
|
end
|
||||||
|
local amount = s.amount
|
||||||
|
for _, i in ipairs(cover_wells(anchor, supply, s.reach)) do
|
||||||
|
take[i] = math.max(0, math.min(amount, supply[i]))
|
||||||
|
amount = amount - take[i]
|
||||||
|
if amount <= 0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return take
|
||||||
|
end
|
||||||
|
|
||||||
|
local function supplies(moon)
|
||||||
|
local out = {}
|
||||||
|
for i, w in ipairs(moon.wells or {}) do
|
||||||
|
out[i] = w.supply
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sum(t)
|
||||||
|
local n = 0
|
||||||
|
for _, x in ipairs(t) do
|
||||||
|
n = n + x
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The moon's stakes, oldest first (ties in the server's order).
|
||||||
|
local function moon_stakes(st, moon)
|
||||||
|
local list = {}
|
||||||
|
for i, s in ipairs(st.stakes or {}) do
|
||||||
|
if s.moonId == moon.id then
|
||||||
|
list[#list + 1] = { s = s, i = i }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(list, function(a, b)
|
||||||
|
if a.s.start ~= b.s.start then
|
||||||
|
return a.s.start < b.s.start
|
||||||
|
end
|
||||||
|
return a.i < b.i
|
||||||
|
end)
|
||||||
|
for i, x in ipairs(list) do
|
||||||
|
list[i] = x.s
|
||||||
|
end
|
||||||
|
return list
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The server's projection (views.go project) of the moon's unclaimed drills.
|
||||||
|
function M.project(st, moon)
|
||||||
|
local n = #(moon.wells or {})
|
||||||
|
local p = { take = {}, gets = {}, demand = {}, remain = supplies(moon), claims = {}, dry_after = {}, dry_at = {} }
|
||||||
|
for i = 1, n do
|
||||||
|
p.demand[i], p.claims[i], p.dry_after[i] = 0, 0, 0
|
||||||
|
end
|
||||||
|
local current, last = supplies(moon), {}
|
||||||
|
for _, s in ipairs(moon_stakes(st, moon)) do
|
||||||
|
if (s.reach or 1) <= 1 then
|
||||||
|
local i = well_index(moon, s.wellId)
|
||||||
|
if i then
|
||||||
|
p.demand[i] = p.demand[i] + s.amount
|
||||||
|
p.claims[i] = p.claims[i] + 1
|
||||||
|
last[i] = math.max(last[i] or 0, s["end"])
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for i, t in ipairs(split(moon, s, current)) do
|
||||||
|
p.demand[i] = p.demand[i] + t
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local take = split(moon, s, p.remain)
|
||||||
|
p.take[s.id], p.gets[s.id] = take, sum(take)
|
||||||
|
for i, t in ipairs(take) do
|
||||||
|
if (s.reach or 1) > 1 and t > 0 then
|
||||||
|
p.claims[i] = p.claims[i] + 1
|
||||||
|
last[i] = math.max(last[i] or 0, s["end"])
|
||||||
|
end
|
||||||
|
p.remain[i] = p.remain[i] - t
|
||||||
|
if t > 0 and p.remain[i] <= EPS and p.dry_after[i] == 0 then
|
||||||
|
p.remain[i] = 0
|
||||||
|
p.dry_after[i], p.dry_at[i] = p.claims[i], last[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What a new stake (amount, reach) on the well would get after the moon's drills (the
|
||||||
|
-- server's takeAfter; 0: the server refuses it, "Nothing left to mine on this well").
|
||||||
|
function M.take_after(moon, p, well_id, amount, reach)
|
||||||
|
return sum(split(moon, { wellId = well_id, amount = amount, reach = reach }, p.remain))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Room for a new one-well stake of amount on the well (0 when fully committed); nil when
|
||||||
|
-- the well isn't on the selected moon (no projection to go by).
|
||||||
|
function M.room(st, well_id, amount, reach)
|
||||||
|
local moon = st.moon
|
||||||
|
if not moon or not well_index(moon, well_id) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return M.take_after(moon, M.project(st, moon), well_id, amount, reach)
|
||||||
|
end
|
||||||
|
|
||||||
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
|
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
|
||||||
-- group: the well panel's idle group for it (cycle and yield already for this well).
|
-- group: the well panel's idle group for it (cycle and yield already for this well).
|
||||||
function M.add_stake(st, tool, moon_id, well, ctx, group)
|
function M.add_stake(st, tool, moon_id, well, ctx, group)
|
||||||
local cycle = group and group.cycleSecs or tool.cycleSecs
|
local cycle = group and group.cycleSecs or tool.cycleSecs
|
||||||
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
|
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
|
||||||
local committed = 0
|
-- take: what claiming it now would get, as the server's stake.take (its share after
|
||||||
for _, s in ipairs(st.stakes) do
|
-- the other drills is derive's projection).
|
||||||
if s.wellId == well.id then
|
local take = math.max(0, math.min(amount, M.supply(st, well.id)))
|
||||||
committed = committed + (s.take or 0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
|
|
||||||
local s = {
|
local s = {
|
||||||
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
|
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
|
||||||
resource = well.resource, amount = amount, reach = tool.reach or 1,
|
resource = well.resource, amount = amount, reach = tool.reach or 1,
|
||||||
@@ -212,11 +362,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
|
|||||||
-- Off the selected well's idle list.
|
-- Off the selected well's idle list.
|
||||||
if st.well then
|
if st.well then
|
||||||
for i, g in ipairs(st.well.idle or {}) do
|
for i, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
if same_copy(g, tool) then
|
||||||
g.count = g.count - 1
|
g.count = g.count - 1
|
||||||
if g.toolId == tool.id then
|
if g.toolId == tool.id then
|
||||||
for _, x in ipairs(st.tools) do
|
for _, x in ipairs(st.tools) do
|
||||||
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
|
if not x.stakeId and same_copy(x, g) then
|
||||||
g.toolId = x.id
|
g.toolId = x.id
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -260,7 +410,7 @@ function M.remove_stake(st, s)
|
|||||||
if st.well and st.well.idle then
|
if st.well and st.well.idle then
|
||||||
local g = nil
|
local g = nil
|
||||||
for _, x in ipairs(st.well.idle) do
|
for _, x in ipairs(st.well.idle) do
|
||||||
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
|
if same_copy(x, tool) then
|
||||||
g = x
|
g = x
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -270,16 +420,17 @@ function M.remove_stake(st, s)
|
|||||||
local free = M.supply(st, st.well.id)
|
local free = M.supply(st, st.well.id)
|
||||||
local y = tool.yield * yield_boost(st, st.well.resource)
|
local y = tool.yield * yield_boost(st, st.well.resource)
|
||||||
st.well.idle[#st.well.idle + 1] = {
|
st.well.idle[#st.well.idle + 1] = {
|
||||||
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
|
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
|
||||||
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
|
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
|
||||||
short = free < y, attachments = tool.attachments or {},
|
short = free < y,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
|
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
|
||||||
-- buttons' amounts, next ends). Wide drills (reach > 1) are left as the server sent them.
|
-- buttons' amounts, next ends) and the selected moon's well projection (M.project: demand,
|
||||||
|
-- remain, full, notes, deploy cards). The wide group (reach > 1) is left as the server sent it.
|
||||||
function M.derive(st)
|
function M.derive(st)
|
||||||
local ready, idle, next_end = 0, 0, nil
|
local ready, idle, next_end = 0, 0, nil
|
||||||
local per_moon = {}
|
local per_moon = {}
|
||||||
@@ -309,39 +460,71 @@ function M.derive(st)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local mready, mnext = 0, nil
|
local mready, mnext = 0, nil
|
||||||
for _, w in ipairs(moon.wells or {}) do
|
local p = M.project(st, moon)
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
for wi, w in ipairs(moon.wells or {}) do
|
||||||
local old = {}
|
local old = {}
|
||||||
for _, d in ipairs(w.drills or {}) do
|
for _, d in ipairs(w.drills or {}) do
|
||||||
old[d.id] = d
|
old[d.id] = d
|
||||||
end
|
end
|
||||||
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0
|
local drills, wready, wnext = {}, 0, nil
|
||||||
for _, s in ipairs(st.stakes or {}) do
|
local supply = supplies(moon)
|
||||||
|
local amount = 0 -- the ready drills claimed in turn (the server's claimable)
|
||||||
|
for _, s in ipairs(moon_stakes(st, moon)) do
|
||||||
if s.wellId == w.id and (s.reach or 1) <= 1 then
|
if s.wellId == w.id and (s.reach or 1) <= 1 then
|
||||||
local d = old[s.id]
|
local d = old[s.id] or copy(s)
|
||||||
if not d then
|
d.gets = p.gets[s.id] or 0
|
||||||
d = copy(s)
|
d.here = d.gets
|
||||||
d.gets, d.here = s.take, s.take
|
d.state = d.gets <= EPS and "none" or (d.gets < s.amount - EPS and "short" or "ok")
|
||||||
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
|
|
||||||
end
|
|
||||||
drills[#drills + 1] = d
|
drills[#drills + 1] = d
|
||||||
demand = demand + (d.here or s.take or 0)
|
|
||||||
if s.ready then
|
if s.ready then
|
||||||
wready = wready + 1
|
wready = wready + 1
|
||||||
amount = amount + (s.take or 0)
|
local take = split(moon, s, supply)
|
||||||
|
for i, t in ipairs(take) do
|
||||||
|
supply[i] = supply[i] - t
|
||||||
|
amount = amount + t
|
||||||
|
end
|
||||||
elseif not wnext or s["end"] < wnext then
|
elseif not wnext or s["end"] < wnext then
|
||||||
wnext = s["end"]
|
wnext = s["end"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
table.sort(drills, function(a, b) return a.start < b.start end)
|
|
||||||
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
|
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
|
||||||
w.demand = demand
|
-- The well's projection fields, as the server words them (views.go WellRow).
|
||||||
w.remain = math.max(0, w.supply - demand)
|
local demand, remain = p.demand[wi], p.remain[wi]
|
||||||
|
w.demand, w.remain, w.dryAfter, w.dryAt = demand, remain, p.dry_after[wi], p.dry_at[wi]
|
||||||
|
local taken = math.max(0, w.supply - remain)
|
||||||
|
w.over = w.supply > 0 and demand > w.supply + EPS
|
||||||
|
w.full = w.supply > 0 and ((demand > 0 and demand >= w.supply - EPS) or (remain <= EPS and taken > 0))
|
||||||
|
w.fullyCommitted = w.supply > 0 and remain <= EPS
|
||||||
|
w.statusText = w.over and "Over-drilled — next claims will come up short"
|
||||||
|
or (w.full and "Fully committed" or "")
|
||||||
|
if demand == 0 then
|
||||||
|
w.noteText = ""
|
||||||
|
else
|
||||||
|
w.noteText = ui.money(w.supply) .. " left · " .. ui.money(demand) .. " committed · "
|
||||||
|
.. (w.dryAfter > 0 and ("runs dry after " .. w.dryAfter .. (w.dryAfter == 1 and " claim" or " claims"))
|
||||||
|
or (ui.money(remain) .. " free after"))
|
||||||
|
end
|
||||||
mready = mready + wready
|
mready = mready + wready
|
||||||
if wnext and (not mnext or wnext < mnext) then
|
if wnext and (not mnext or wnext < mnext) then
|
||||||
mnext = wnext
|
mnext = wnext
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- The deploy cards: what each idle tool would get here after the drills on the moon
|
||||||
|
-- (the server's here, free, short; blocked: here is 0, the stake is refused).
|
||||||
|
local wi = st.well and well_index(moon, st.well.id)
|
||||||
|
if wi then
|
||||||
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
|
g.here = M.take_after(moon, p, st.well.id, g.yield, g.reach)
|
||||||
|
g.free = 0
|
||||||
|
for _, j in ipairs(cover_wells(wi, p.remain, g.reach)) do
|
||||||
|
g.free = g.free + p.remain[j]
|
||||||
|
end
|
||||||
|
g.short = g.here < g.yield - EPS
|
||||||
|
g.blocked = g.here <= EPS
|
||||||
|
end
|
||||||
|
end
|
||||||
if moon.wide then
|
if moon.wide then
|
||||||
mready = mready + (moon.wide.ready or 0)
|
mready = mready + (moon.wide.ready or 0)
|
||||||
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
|
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
|
||||||
@@ -399,43 +582,77 @@ end
|
|||||||
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
||||||
-- count goes up (the server's LastWell).
|
-- count goes up (the server's LastWell).
|
||||||
M.register("claim", function(st, p, ctx)
|
M.register("claim", function(st, p, ctx)
|
||||||
if p["claim-wide"] then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
local list = {}
|
local list = {}
|
||||||
for _, s in ipairs(st.stakes) do
|
for _, s in ipairs(st.stakes) do
|
||||||
local wide = (s.reach or 1) > 1
|
local wide = (s.reach or 1) > 1
|
||||||
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
local scope
|
||||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
|
if p["claim-wide"] then
|
||||||
if wide then
|
scope = wide and s.moonId == p["claim-wide"]
|
||||||
return false -- multi-well drills: the server splits them
|
else
|
||||||
|
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||||
|
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
|
||||||
end
|
end
|
||||||
|
if done(s, ctx) and scope then
|
||||||
list[#list + 1] = s
|
list[#list + 1] = s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #list == 0 then
|
if #list == 0 then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
||||||
|
-- A multi-well drill takes from its well, then the moon's next ones (split).
|
||||||
|
table.sort(list, function(a, b) return a.start < b.start end)
|
||||||
|
local emptied = {}
|
||||||
for _, s in ipairs(list) do
|
for _, s in ipairs(list) do
|
||||||
local take = (s.take or 0) * M.skew
|
local moon = find(st.moons, s.moonId)
|
||||||
M.add_item(st, s.resource, take)
|
if (s.reach or 1) > 1 and moon then
|
||||||
M.add_supply(st, s.wellId, -(s.take or 0))
|
local sup = {}
|
||||||
M.remove_stake(st, s)
|
for i, w in ipairs(moon.wells or {}) do
|
||||||
|
sup[i] = M.supply(st, w.id)
|
||||||
|
end
|
||||||
|
for i, take in ipairs(split(moon, s, sup)) do
|
||||||
|
local w = moon.wells[i]
|
||||||
|
if take > 0 then
|
||||||
|
M.add_item(st, w.resource or s.resource, take * M.skew)
|
||||||
|
M.add_supply(st, w.id, -take)
|
||||||
|
if M.supply(st, w.id) <= 0 then
|
||||||
|
emptied[w.id] = true
|
||||||
end
|
end
|
||||||
M.refresh_market(st)
|
|
||||||
if not p.redeploy then
|
|
||||||
for _, s in ipairs(list) do
|
|
||||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
|
||||||
if detail then
|
|
||||||
detail.redrill = (detail.redrill or 0) + 1
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||||
|
M.add_item(st, s.resource, take * M.skew)
|
||||||
|
M.add_supply(st, s.wellId, -take)
|
||||||
|
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||||
|
emptied[s.wellId] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
||||||
|
end
|
||||||
|
-- A claim that empties a well stops the other one-well drills on it (stopTapped).
|
||||||
|
for i = #st.stakes, 1, -1 do
|
||||||
|
local s = st.stakes[i]
|
||||||
|
if emptied[s.wellId] and (s.reach or 1) <= 1 then
|
||||||
|
M.remove_stake(st, s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
M.refresh_market(st)
|
||||||
|
if p.redeploy then
|
||||||
for _, s in ipairs(list) do
|
for _, s in ipairs(list) do
|
||||||
local tool = M.tool(st, s.toolId)
|
local tool = M.tool(st, s.toolId)
|
||||||
local w = wells(st, s.wellId)[1]
|
local w = wells(st, s.wellId)[1]
|
||||||
if tool and not tool.stakeId and w and w.supply > 0 then
|
local group = nil
|
||||||
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
|
if tool and w and st.well and st.well.id == w.id then
|
||||||
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
|
if same_copy(g, tool) then
|
||||||
|
group = g
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||||
|
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||||
|
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
|
||||||
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
||||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||||
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
||||||
@@ -466,17 +683,22 @@ M.register("stake", function(st, p, ctx)
|
|||||||
return false -- "Deploy there": the view moves to the other well, wait for it
|
return false -- "Deploy there": the view moves to the other well, wait for it
|
||||||
end
|
end
|
||||||
local tool = M.idle_copy(st, p.tool)
|
local tool = M.idle_copy(st, p.tool)
|
||||||
if not tool or (tool.reach or 1) > 1 then
|
if not tool then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local group = nil
|
local group = nil
|
||||||
if st.well and st.well.id == w.id then
|
if st.well and st.well.id == w.id then
|
||||||
for _, g in ipairs(st.well.idle or {}) do
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
if same_copy(g, tool) then
|
||||||
group = g
|
group = g
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
||||||
|
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
||||||
|
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
|
||||||
|
return false
|
||||||
|
end
|
||||||
M.add_stake(st, tool, moon.id, w, ctx, group)
|
M.add_stake(st, tool, moon.id, w, ctx, group)
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
@@ -499,24 +721,60 @@ M.register("redrill", function(st, p, ctx)
|
|||||||
if #tools == 0 then
|
if #tools == 0 then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
for _, t in ipairs(tools) do
|
-- Until the well has no room left, like the server's redrill (game.go redrillPick):
|
||||||
local group = nil
|
-- the smallest drill that fills what is left alone, else the biggest and again; the
|
||||||
|
-- rest stay idle.
|
||||||
|
local function group_of(t)
|
||||||
if st.well and st.well.id == id then
|
if st.well and st.well.id == id then
|
||||||
for _, g in ipairs(st.well.idle or {}) do
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == t.storeItemId and g.tier == t.tier then
|
if same_copy(g, t) then
|
||||||
group = g
|
return g
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
local staked = 0
|
||||||
|
while #tools > 0 do
|
||||||
|
local left = M.room(st, id, 1e18, 1) or 0
|
||||||
|
if left <= EPS then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
-- What each takes a claim, with the well's yield event (the server's t.Amount).
|
||||||
|
local amounts = {}
|
||||||
|
for i, t in ipairs(tools) do
|
||||||
|
local g = group_of(t)
|
||||||
|
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
|
||||||
|
end
|
||||||
|
local pick
|
||||||
|
for i, a in ipairs(amounts) do
|
||||||
|
if a >= left - EPS and (not pick or a < amounts[pick]) then
|
||||||
|
pick = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not pick then
|
||||||
|
pick = 1
|
||||||
|
for i, a in ipairs(amounts) do
|
||||||
|
if a > amounts[pick] then
|
||||||
|
pick = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local t = table.remove(tools, pick)
|
||||||
|
local group = group_of(t)
|
||||||
M.add_stake(st, t, moon.id, w, ctx, group)
|
M.add_stake(st, t, moon.id, w, ctx, group)
|
||||||
|
staked = staked + 1
|
||||||
|
end
|
||||||
|
if staked == 0 then
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
if detail then
|
if detail then
|
||||||
detail.redrill = 0
|
detail.redrill = math.max(0, n - staked)
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- sell: amount × unpressed × the pressure average (api.md), or the whole balance.
|
-- sell: amount × unpressed (shared quote × advisors) × the pressure average (api.md), or
|
||||||
|
-- the whole balance.
|
||||||
M.register("sell", function(st, p, ctx)
|
M.register("sell", function(st, p, ctx)
|
||||||
local q = st.market and find(st.market.quotes, p.resource, "item")
|
local q = st.market and find(st.market.quotes, p.resource, "item")
|
||||||
if not q or not q.unpressed or not q.base then
|
if not q or not q.unpressed or not q.base then
|
||||||
@@ -534,8 +792,12 @@ M.register("sell", function(st, p, ctx)
|
|||||||
local after = math.min(pnow + d, rules.pressure.cap)
|
local after = math.min(pnow + d, rules.pressure.cap)
|
||||||
-- Kept as of st.now, like the server's value (pressure_at decays it from there).
|
-- Kept as of st.now, like the server's value (pressure_at decays it from there).
|
||||||
q.pressure = after / (0.5 ^ (math.max(0, ctx.now - st.now) / 1000 / rules.pressure.halfLifeSecs))
|
q.pressure = after / (0.5 ^ (math.max(0, ctx.now - st.now) / 1000 / rules.pressure.halfLifeSecs))
|
||||||
|
-- Personal: the shared quote (price, value) doesn't move; the payout does.
|
||||||
q.factor = M.pressure_avg(rules, after, 0)
|
q.factor = M.pressure_avg(rules, after, 0)
|
||||||
q.price = q.unpressed * q.factor
|
q.unit = q.unpressed * q.factor
|
||||||
|
local pc = math.floor((1 - q.factor) * 100 + 0.5)
|
||||||
|
q.pressed = q.factor < 0.995
|
||||||
|
q.pressureText = q.pressed and string.format("−%d%%", pc) or ""
|
||||||
M.add_item(st, q.item, -amount)
|
M.add_item(st, q.item, -amount)
|
||||||
M.refresh_market(st)
|
M.refresh_market(st)
|
||||||
return true
|
return true
|
||||||
@@ -597,7 +859,9 @@ M.register("refine", function(st, p, ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local rr = st.rules.refinery
|
local rr = st.rules.refinery
|
||||||
local secs = math.floor(r.seconds * qty ^ rr.bulkExponent / (rr.speed or 1) + 0.5)
|
-- (refinery.go bulkUnits: seconds × min(n, 1 + bulkLog × ln n))
|
||||||
|
local units = math.min(qty, 1 + (rr.bulkLog or 0) * math.log(math.max(1, qty)))
|
||||||
|
local secs = math.floor(r.seconds * units / (rr.speed or 1) + 0.5)
|
||||||
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
|
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
|
||||||
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
|
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
|
||||||
slot.job = job
|
slot.job = job
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ local GAIN = {
|
|||||||
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
|
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
|
||||||
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
|
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
|
||||||
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
|
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
|
||||||
|
rumble = 1, reveal = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Minimum time between two plays of the same sound, in seconds.
|
-- Minimum time between two plays of the same sound, in seconds.
|
||||||
@@ -96,6 +97,13 @@ function M.play(name, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Stops every play of a sound (the reveal's build-up when the burst comes early).
|
||||||
|
function M.stop(name)
|
||||||
|
if GAIN[name] then
|
||||||
|
pcall(sound.stop, "main:/sfx#" .. name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function semitones(n)
|
local function semitones(n)
|
||||||
return 2 ^ (n / 12)
|
return 2 ^ (n / 12)
|
||||||
end
|
end
|
||||||
|
|||||||
+276
-14
@@ -7,7 +7,10 @@
|
|||||||
-- Elements: every interactive component registers a stable id while the layout builds
|
-- Elements: every interactive component registers a stable id while the layout builds
|
||||||
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
|
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
|
||||||
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
|
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
|
||||||
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel". A
|
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
|
||||||
|
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
|
||||||
|
-- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), the gear "settings" (its menu: "mute", "theme", "motion",
|
||||||
|
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
|
||||||
-- component given opts.id uses that instead.
|
-- component given opts.id uses that instead.
|
||||||
--
|
--
|
||||||
-- Commands (args after the name):
|
-- Commands (args after the name):
|
||||||
@@ -21,11 +24,16 @@
|
|||||||
-- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
|
-- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
|
||||||
-- eval <lua expr> evaluate, reply with the value
|
-- eval <lua expr> evaluate, reply with the value
|
||||||
-- list the registered elements: id, kind, label, rect, enabled
|
-- list the registered elements: id, kind, label, rect, enabled
|
||||||
|
-- dump the screen's laid-out nodes (rects, kinds, texts; see dump below)
|
||||||
-- state a summary of the shown state (view, wallet, counts, busy)
|
-- state a summary of the shown state (view, wallet, counts, busy)
|
||||||
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key>
|
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key>
|
||||||
-- mark <name> print "mm: shot <name>" (scripts/shot.sh SHOTS_DIR picks it up)
|
-- mark <name> print "mm: shot <name>" (scripts/shot.sh SHOTS_DIR picks it up)
|
||||||
|
-- frames [reset] frame times since the last reset (the last 600 frames): fps, dt,
|
||||||
|
-- the game screen's update and its rebuilds, in ms (n, mean, p50,
|
||||||
|
-- p95, max); "reset" starts over
|
||||||
|
|
||||||
local focus = require("modules.focus")
|
local focus = require("modules.focus")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -70,15 +78,31 @@ end
|
|||||||
-- Register an interactive element. kind: "button", "link", "tab", "field", "row", ...
|
-- Register an interactive element. kind: "button", "link", "tab", "field", "row", ...
|
||||||
-- label: its text (for click "label=..."); zone: the stencil node clipping it, if any;
|
-- label: its text (for click "label=..."); zone: the stencil node clipping it, if any;
|
||||||
-- no_focus: not in the keyboard focus order (the web's tabindex=-1).
|
-- no_focus: not in the keyboard focus order (the web's tabindex=-1).
|
||||||
|
local function name(node, id)
|
||||||
|
if id then
|
||||||
|
names[gui.get_id(node)] = { id = id, scope = scope }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function M.register(id, node, kind, label, zone, no_focus)
|
function M.register(id, node, kind, label, zone, no_focus)
|
||||||
|
blocks.note(M.register, id, node, kind, label, zone, no_focus) -- (a kept block registers again)
|
||||||
|
if zone and blocks.replaying() then
|
||||||
|
zone = require("druid.helper").get_closest_stencil_node(node) -- (kept, perhaps under a new stencil)
|
||||||
|
end
|
||||||
if not no_focus then
|
if not no_focus then
|
||||||
focus.add(id, node, kind, label, zone)
|
focus.add(id, node, kind, label, zone)
|
||||||
end
|
end
|
||||||
M.name(node, id)
|
name(node, id)
|
||||||
if not M.enabled or not id then
|
if not M.enabled or not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if elements[id] then
|
local old = elements[id]
|
||||||
|
if old and not pcall(gui.get_id, old.node) then
|
||||||
|
-- (its node went: a long list's row scrolled out and back keeps its plain id)
|
||||||
|
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
if old then
|
||||||
local n = 2
|
local n = 2
|
||||||
while elements[id .. "#" .. n] do
|
while elements[id .. "#" .. n] do
|
||||||
n = n + 1
|
n = n + 1
|
||||||
@@ -94,9 +118,8 @@ end
|
|||||||
-- Give a node an element's id (register does; also a node that takes the clicks for a
|
-- Give a node an element's id (register does; also a node that takes the clicks for a
|
||||||
-- registered one). id_of: the id of a node, or nil.
|
-- registered one). id_of: the id of a node, or nil.
|
||||||
function M.name(node, id)
|
function M.name(node, id)
|
||||||
if id then
|
blocks.note(name, node, id)
|
||||||
names[gui.get_id(node)] = { id = id, scope = scope }
|
name(node, id)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.id_of(node)
|
function M.id_of(node)
|
||||||
@@ -181,10 +204,11 @@ local function visible(node)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A run of commands spread over frames: { frame_fn, ... }.
|
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
|
||||||
local function steps(g, list, done)
|
-- error reply when a step fails.
|
||||||
|
local function steps(g, list, done, id)
|
||||||
g.test_steps = g.test_steps or {}
|
g.test_steps = g.test_steps or {}
|
||||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
|
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
||||||
@@ -326,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
|
|||||||
}
|
}
|
||||||
steps(g, list, function()
|
steps(g, list, function()
|
||||||
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
||||||
end)
|
end, cmd.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
||||||
@@ -344,7 +368,7 @@ function COMMANDS.scroll_to(g, cmd)
|
|||||||
local scale = g.size[1] / sys.get_config_int("display.width", 1280)
|
local scale = g.size[1] / sys.get_config_int("display.width", 1280)
|
||||||
local off = (g.scroll_pos and g.scroll_pos[a.key] or 0) + (vy - ey) * scale
|
local off = (g.scroll_pos and g.scroll_pos[a.key] or 0) + (vy - ey) * scale
|
||||||
g.scroll_pos[a.key] = math.max(0, math.min(a.max, off))
|
g.scroll_pos[a.key] = math.max(0, math.min(a.max, off))
|
||||||
g.dirty = true
|
g.dirty, g.dirty_all = true, true
|
||||||
return { ok = true, key = a.key, offset = g.scroll_pos[a.key] }
|
return { ok = true, key = a.key, offset = g.scroll_pos[a.key] }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -368,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
|
|||||||
function() input(k, { released = true }) end,
|
function() input(k, { released = true }) end,
|
||||||
}, function()
|
}, function()
|
||||||
reply(cmd.id, { ok = true })
|
reply(cmd.id, { ok = true })
|
||||||
end)
|
end, cmd.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
function COMMANDS.type(g, cmd, input)
|
function COMMANDS.type(g, cmd, input)
|
||||||
@@ -415,6 +439,182 @@ function COMMANDS.list(g)
|
|||||||
return { ok = true, elements = out }
|
return { ok = true, elements = out }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Layout dump: every shown node under g.page that matters for layout, in render order:
|
||||||
|
-- registered elements (kind = their kind, id = test id), text nodes ("text"), drawn boxes
|
||||||
|
-- ("panel") and stencils ("clip"). Rects in layout px (x right, y down from the page's
|
||||||
|
-- top-left); a text's rect is where its glyphs go (metrics, by pivot), bw/bh its node box.
|
||||||
|
-- p: the index of the nearest dumped ancestor (0 none); layer: the page's top child it is
|
||||||
|
-- in ("layout", "dialog", ...); clip: "part"/"all" when a stencil cuts it; en: false for
|
||||||
|
-- a disabled button; cut: text ends with "…" (ui.fit_text). scripts/ui_lint.py reads it.
|
||||||
|
local PIV = {
|
||||||
|
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||||
|
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||||
|
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function r1(v)
|
||||||
|
return math.floor(v * 2 + 0.5) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
local dump_ids = 0
|
||||||
|
|
||||||
|
local function cut80(s) -- (between UTF-8 characters)
|
||||||
|
if #s <= 80 then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
local n = 80
|
||||||
|
while n > 1 and s:byte(n + 1) >= 0x80 and s:byte(n + 1) < 0xC0 do
|
||||||
|
n = n - 1
|
||||||
|
end
|
||||||
|
return s:sub(1, n)
|
||||||
|
end
|
||||||
|
local walk_dump
|
||||||
|
|
||||||
|
local function dump(g)
|
||||||
|
local page = g.page
|
||||||
|
local probe = gui.new_box_node(vmath.vector3(100, -100, 0), vmath.vector3(1, 1, 0))
|
||||||
|
gui.set_parent(probe, page, false)
|
||||||
|
local o, q = gui.get_screen_position(page), gui.get_screen_position(probe)
|
||||||
|
gui.set_enabled(probe, false)
|
||||||
|
local kx, ky = (q.x - o.x) / 100, (o.y - q.y) / 100
|
||||||
|
local size = gui.get_size(page)
|
||||||
|
-- gui.get_tree maps ids to nodes, and ids repeat (gui.new_*_node all have the empty id,
|
||||||
|
-- Druid widgets give clones their template ids): it shows one node per id. So name
|
||||||
|
-- every node uniquely for the dump (one round per repeat) and put the ids back after.
|
||||||
|
local orig = {} -- dump id -> the node's own id
|
||||||
|
local tree
|
||||||
|
repeat
|
||||||
|
tree = gui.get_tree(page)
|
||||||
|
local fresh = false
|
||||||
|
for k, n in pairs(tree) do
|
||||||
|
if not orig[k] then
|
||||||
|
dump_ids = dump_ids + 1
|
||||||
|
local t = hash("mm_dump_" .. dump_ids)
|
||||||
|
gui.set_id(n, t)
|
||||||
|
orig[t], fresh = k, true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
until not fresh
|
||||||
|
local ok, res = pcall(walk_dump, g, page, tree, orig, o, kx, ky)
|
||||||
|
for t, k in pairs(orig) do
|
||||||
|
gui.set_id(tree[t], k)
|
||||||
|
end
|
||||||
|
gui.delete_node(probe)
|
||||||
|
if not ok then
|
||||||
|
error(res)
|
||||||
|
end
|
||||||
|
res.w, res.h = r1(size.x), r1(size.y)
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The dump's walk over a tree whose ids are all unique (orig: their own ids).
|
||||||
|
walk_dump = function(g, page, tree, orig, o, kx, ky)
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
-- Children by parent, in render order.
|
||||||
|
local kids = {}
|
||||||
|
for _, n in pairs(tree) do
|
||||||
|
local p = gui.get_parent(n)
|
||||||
|
if p then
|
||||||
|
local k = gui.get_id(p)
|
||||||
|
kids[k] = kids[k] or {}
|
||||||
|
table.insert(kids[k], n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, list in pairs(kids) do
|
||||||
|
table.sort(list, function(a, b) return gui.get_index(a) < gui.get_index(b) end)
|
||||||
|
end
|
||||||
|
local layer_names = {}
|
||||||
|
for _, k in ipairs({ "layout", "toast_layer", "tip_layer", "focus_layer", "dialog", "fx", "reveal" }) do
|
||||||
|
local v = g[k]
|
||||||
|
for _, x in ipairs({ v or false, type(v) == "table" and (v.layer or v.root) or false }) do
|
||||||
|
local ok, p = pcall(gui.get_parent, x)
|
||||||
|
if ok and p and gui.get_id(p) == gui.get_id(page) and not layer_names[gui.get_id(x)] then
|
||||||
|
layer_names[gui.get_id(x)] = (k:gsub("_layer$", ""))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
local function walk(n, sx, sy, zone, layer, parent)
|
||||||
|
if not gui.is_enabled(n) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local sc = gui.get_scale(n)
|
||||||
|
sx, sy = sx * sc.x, sy * sc.y
|
||||||
|
local t = gui.get_type(n)
|
||||||
|
local sp = gui.get_screen_position(n)
|
||||||
|
local px, py = (sp.x - o.x) / kx, (o.y - sp.y) / ky
|
||||||
|
local sz = gui.get_size(n)
|
||||||
|
local a = PIV[gui.get_pivot(n)] or { 0.5, 0.5 }
|
||||||
|
local key = gui.get_id(n)
|
||||||
|
local named = names[orig[key]]
|
||||||
|
local e = named and elements[named.id]
|
||||||
|
local alpha = gui.get_visible(n) and gui.get_color(n).w or 0 -- (visible: this node only)
|
||||||
|
local item
|
||||||
|
local w, h = sz.x * sx, sz.y * sy
|
||||||
|
if t == gui.TYPE_TEXT then
|
||||||
|
local text = gui.get_text(n)
|
||||||
|
if text ~= "" and alpha > 0.01 then
|
||||||
|
local wrap = gui.get_line_break(n)
|
||||||
|
local m = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), text,
|
||||||
|
{ tracking = gui.get_tracking(n), line_break = wrap, width = wrap and sz.x or nil, leading = gui.get_leading(n) })
|
||||||
|
local tw, th = m.width * sx, m.height * sy
|
||||||
|
item = { kind = "text", x = px - a[1] * tw, y = py - a[2] * th, w = tw, h = th,
|
||||||
|
text = cut80(text), cut = text:find("…", 1, true) and true or nil,
|
||||||
|
wrap = wrap or nil, bw = r1(w), bh = r1(h) }
|
||||||
|
end
|
||||||
|
elseif e or (t == gui.TYPE_BOX and (alpha > 0.01 or gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE) and w * h >= 16) then
|
||||||
|
local look = ui.button_looks[n]
|
||||||
|
item = { kind = e and e.kind or (gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and "clip" or "panel"),
|
||||||
|
id = e and e.id or nil, x = px - a[1] * w, y = py - a[2] * h, w = w, h = h,
|
||||||
|
en = look and look.disabled and false or nil }
|
||||||
|
end
|
||||||
|
if item then
|
||||||
|
local nm = tostring(orig[key]):match("%[(.-)%]")
|
||||||
|
item.name = nm and nm:match("^[%a_]") and nm or nil
|
||||||
|
item.p, item.layer = parent, layer
|
||||||
|
if zone then
|
||||||
|
local ix = math.min(item.x + item.w, zone[3]) - math.max(item.x, zone[1])
|
||||||
|
local iy = math.min(item.y + item.h, zone[4]) - math.max(item.y, zone[2])
|
||||||
|
if ix <= 0 or iy <= 0 then
|
||||||
|
item.clip = "all"
|
||||||
|
elseif ix < item.w - 0.5 or iy < item.h - 0.5 then
|
||||||
|
item.clip = "part"
|
||||||
|
-- (what shows of it: the lint's overlap rule only counts that)
|
||||||
|
local vx, vy = math.max(item.x, zone[1]), math.max(item.y, zone[2])
|
||||||
|
item.vis = { r1(vx), r1(vy), r1(ix), r1(iy) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
item.x, item.y, item.w, item.h = r1(item.x), r1(item.y), r1(item.w), r1(item.h)
|
||||||
|
out[#out + 1] = item
|
||||||
|
item.i = #out
|
||||||
|
parent = #out
|
||||||
|
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and not gui.get_clipping_inverted(n) then
|
||||||
|
local z = { px - a[1] * w, py - a[2] * h, px + (1 - a[1]) * w, py + (1 - a[2]) * h }
|
||||||
|
if zone then
|
||||||
|
z = { math.max(z[1], zone[1]), math.max(z[2], zone[2]), math.min(z[3], zone[3]), math.min(z[4], zone[4]) }
|
||||||
|
end
|
||||||
|
zone = z
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, c in ipairs(kids[key] or {}) do
|
||||||
|
walk(c, sx, sy, zone, layer, parent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local pk = gui.get_id(page)
|
||||||
|
for i, c in ipairs(kids[pk] or {}) do
|
||||||
|
walk(c, 1, 1, nil, layer_names[gui.get_id(c)] or ("layer" .. i), 0)
|
||||||
|
end
|
||||||
|
return { view = plain(g.view), dialog = g.dialog and g.dialog:is_open() or false, nodes = out }
|
||||||
|
end
|
||||||
|
|
||||||
|
function COMMANDS.dump(g, cmd)
|
||||||
|
-- On a later frame: nodes deleted this frame (a rebuild) are only gone after it.
|
||||||
|
steps(g, { function() end, function()
|
||||||
|
local ok, d = pcall(dump, g)
|
||||||
|
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
||||||
|
end }, nil, cmd.id)
|
||||||
|
end
|
||||||
|
|
||||||
function COMMANDS.state(g)
|
function COMMANDS.state(g)
|
||||||
return { ok = true, state = summary(g) }
|
return { ok = true, state = summary(g) }
|
||||||
end
|
end
|
||||||
@@ -427,6 +627,64 @@ function COMMANDS.set(g, cmd, _, api)
|
|||||||
return { ok = true }
|
return { ok = true }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Frame times (the frames command): the engine's dt, the game screen's update time and
|
||||||
|
-- the rebuilds' time, recorded by M.frame every frame (core/main.lua) with M.clock (on the
|
||||||
|
-- web the page's performance.now: socket.gettime has whole milliseconds there).
|
||||||
|
local FRAMES = 600
|
||||||
|
local frames
|
||||||
|
|
||||||
|
local function frames_reset()
|
||||||
|
frames = { n = 0, i = 0, dt = {}, lua = {}, builds = {} }
|
||||||
|
end
|
||||||
|
frames_reset()
|
||||||
|
|
||||||
|
function M.clock()
|
||||||
|
if html5 then
|
||||||
|
return (tonumber(html5.run("performance.now()")) or 0) / 1000
|
||||||
|
end
|
||||||
|
return socket.gettime()
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.frame(dt, lua_secs, build_secs)
|
||||||
|
frames.i = frames.i % FRAMES + 1
|
||||||
|
frames.n = math.min(frames.n + 1, FRAMES)
|
||||||
|
frames.dt[frames.i] = dt
|
||||||
|
frames.lua[frames.i] = lua_secs
|
||||||
|
if build_secs and #frames.builds < FRAMES then
|
||||||
|
frames.builds[#frames.builds + 1] = build_secs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stats(list, n)
|
||||||
|
local t, sum = {}, 0
|
||||||
|
for i = 1, n do
|
||||||
|
t[i] = list[i] * 1000
|
||||||
|
sum = sum + t[i]
|
||||||
|
end
|
||||||
|
table.sort(t)
|
||||||
|
local function r(v)
|
||||||
|
return math.floor(v * 100 + 0.5) / 100
|
||||||
|
end
|
||||||
|
local function q(p)
|
||||||
|
return n > 0 and r(t[math.min(n, math.floor(p * n) + 1)]) or 0
|
||||||
|
end
|
||||||
|
return { n = n, mean = n > 0 and r(sum / n) or 0, p50 = q(0.5), p95 = q(0.95), max = q(1) }
|
||||||
|
end
|
||||||
|
|
||||||
|
function COMMANDS.frames(g, cmd)
|
||||||
|
local n = frames.n
|
||||||
|
local total = 0
|
||||||
|
for i = 1, n do
|
||||||
|
total = total + frames.dt[i]
|
||||||
|
end
|
||||||
|
local res = { ok = true, fps = total > 0 and math.floor(n / total * 10 + 0.5) / 10 or 0,
|
||||||
|
dt = stats(frames.dt, n), lua = stats(frames.lua, n), build = stats(frames.builds, #frames.builds) }
|
||||||
|
if cmd.args[1] == "reset" then
|
||||||
|
frames_reset()
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
|
||||||
function COMMANDS.mark(g, cmd)
|
function COMMANDS.mark(g, cmd)
|
||||||
print("mm: shot " .. tostring(cmd.args[1]))
|
print("mm: shot " .. tostring(cmd.args[1]))
|
||||||
return { ok = true }
|
return { ok = true }
|
||||||
@@ -441,9 +699,13 @@ function M.update(g, input, api)
|
|||||||
-- Running multi-frame steps.
|
-- Running multi-frame steps.
|
||||||
if g.test_steps and #g.test_steps > 0 then
|
if g.test_steps and #g.test_steps > 0 then
|
||||||
local s = g.test_steps[1]
|
local s = g.test_steps[1]
|
||||||
s.list[s.i]()
|
local ok, err = pcall(s.list[s.i])
|
||||||
s.i = s.i + 1
|
s.i = s.i + 1
|
||||||
if s.i > #s.list then
|
if not ok then
|
||||||
|
-- (a failing step ends its run: reply with the error, or the command never answers)
|
||||||
|
table.remove(g.test_steps, 1)
|
||||||
|
reply(s.id, { ok = false, error = tostring(err) })
|
||||||
|
elseif s.i > #s.list then
|
||||||
table.remove(g.test_steps, 1)
|
table.remove(g.test_steps, 1)
|
||||||
if s.done then
|
if s.done then
|
||||||
s.done()
|
s.done()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
|
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
|
||||||
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
||||||
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ M.palettes = {
|
|||||||
loss = { "#ff8a96" },
|
loss = { "#ff8a96" },
|
||||||
teal = { "#2dd4bf" },
|
teal = { "#2dd4bf" },
|
||||||
violet = { "#b69cff" },
|
violet = { "#b69cff" },
|
||||||
violet_hi = { "#dccfff" }, -- attachment and advisor effects
|
violet_hi = { "#dccfff" }, -- advisor effects
|
||||||
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
|
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
|
||||||
up = { "#3ee6a0" },
|
up = { "#3ee6a0" },
|
||||||
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
|
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
|
||||||
@@ -168,10 +168,10 @@ M.size = {
|
|||||||
aside_w_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
|
aside_w_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
|
||||||
aside_w_max = 580,
|
aside_w_max = 580,
|
||||||
content_max = 960, -- widest a section's content gets (tables, rows) in the main area
|
content_max = 960, -- widest a section's content gets (tables, rows) in the main area
|
||||||
market_max = 960, -- the market's (quotes, detail and ticket)
|
market_max = 1320, -- the market's (quotes, detail and ticket)
|
||||||
market_panel_max = 460, -- tallest the market's detail and ticket panels get
|
market_panel_max = 460, -- tallest the market's detail and ticket panels get
|
||||||
text_max = 720, -- widest a paragraph gets
|
text_max = 720, -- widest a paragraph gets
|
||||||
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered
|
cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
|
||||||
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
|
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
|
||||||
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
|
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
|
||||||
panel_pad = 14,
|
panel_pad = 14,
|
||||||
@@ -183,10 +183,6 @@ M.size = {
|
|||||||
button_sm_h = 32,
|
button_sm_h = 32,
|
||||||
well_row_h = 52,
|
well_row_h = 52,
|
||||||
corner = 14,
|
corner = 14,
|
||||||
refinery_max = 960, -- the refinery's content (slots, recipe rows), centered
|
|
||||||
slot_w_max = 240, -- widest a refinery slot card gets
|
|
||||||
slot_h = 92,
|
|
||||||
recipe_row_h = 112, -- (an estimate: the description wraps)
|
|
||||||
leaderboard_max = 640, -- the leaderboard table, centered
|
leaderboard_max = 640, -- the leaderboard table, centered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-32
@@ -1,6 +1,9 @@
|
|||||||
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at
|
-- Toasts for action results (the web's .toasts). Two places:
|
||||||
-- most 3, each fading out on its own after a few seconds. They live on their own layer
|
-- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
|
||||||
-- node, outside the rebuilt layout, so a rebuild doesn't drop them.
|
-- toast is listed there, newest at the top, the last LOG_MAX (5) kept (the log survives
|
||||||
|
-- rebuilds; a rebuilt pane lists it again without entrances).
|
||||||
|
-- - Without a pane (no moon list shown): floating, bottom-left, newest at the bottom, at
|
||||||
|
-- most 3, each fading out on its own after a few seconds, on their own layer node.
|
||||||
--
|
--
|
||||||
-- Motion (app.css): in, the toast slides from -60 px with a squash and overshoot and a
|
-- Motion (app.css): in, the toast slides from -60 px with a squash and overshoot and a
|
||||||
-- bright flash (fx-toast-in, 0.55 s; errors 0.45 s, then a shake), a scan line wipes
|
-- bright flash (fx-toast-in, 0.55 s; errors 0.45 s, then a shake), a scan line wipes
|
||||||
@@ -10,12 +13,15 @@
|
|||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local anim = require("modules.anim")
|
local anim = require("modules.anim")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
M.__index = M
|
M.__index = M
|
||||||
|
|
||||||
local MAX = 3
|
local MAX = 3
|
||||||
local LIFE = 3.5
|
local LIFE = 3.5
|
||||||
|
local LOG_MAX = 5 -- (the pane's list: the last 5)
|
||||||
local W, H = 320, 44 -- (app.css .toasts: 320 px wide; a toast grows taller as its text wraps)
|
local W, H = 320, 44 -- (app.css .toasts: 320 px wide; a toast grows taller as its text wraps)
|
||||||
local TEXT_X, PAD_R, PAD_Y = 46, 14, 8
|
local TEXT_X, PAD_R, PAD_Y = 46, 14, 8
|
||||||
local GAP, MARGIN = 6, 16
|
local GAP, MARGIN = 6, 16
|
||||||
@@ -29,23 +35,21 @@ local KIND = {
|
|||||||
redrill = { "cyan", "▼" },
|
redrill = { "cyan", "▼" },
|
||||||
upgrade = { "cyan", "▲" },
|
upgrade = { "cyan", "▲" },
|
||||||
["buy-tool"] = { "cyan", "+" },
|
["buy-tool"] = { "cyan", "+" },
|
||||||
["buy-attachment"] = { "cyan", "+" },
|
|
||||||
["buy-seat"] = { "cyan", "+" },
|
["buy-seat"] = { "cyan", "+" },
|
||||||
["buy-slot"] = { "cyan", "+" },
|
["buy-slot"] = { "cyan", "+" },
|
||||||
equip = { "cyan", "▲" },
|
|
||||||
unequip = { "muted", "▼" },
|
|
||||||
hire = { "violet", "●" },
|
hire = { "violet", "●" },
|
||||||
refine = { "violet", "◆" },
|
refine = { "violet", "◆" },
|
||||||
collect = { "teal", "◆" },
|
collect = { "teal", "◆" },
|
||||||
["sell-moon"] = { "ready", "₥" },
|
["sell-moon"] = { "ready", "₥" },
|
||||||
["sell-tool"] = { "ready", "₥" },
|
["sell-tool"] = { "ready", "₥" },
|
||||||
["sell-attachment"] = { "ready", "₥" },
|
["save-code"] = { "violet", "★" },
|
||||||
|
restore = { "violet", "★" },
|
||||||
error = { "danger", "!" },
|
error = { "danger", "!" },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- layer: a node over the layout; its top-left is the page's top-left.
|
-- layer: a node over the layout; its top-left is the page's top-left.
|
||||||
function M.new(layer)
|
function M.new(layer)
|
||||||
return setmetatable({ layer = layer, items = {}, h = 800 }, M)
|
return setmetatable({ layer = layer, items = {}, h = 800, log = {} }, M)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The page height, to place the stack at the bottom.
|
-- The page height, to place the stack at the bottom.
|
||||||
@@ -57,11 +61,14 @@ end
|
|||||||
-- it.root: an invisible group the stack moves (place); it.body: the toast, which the
|
-- it.root: an invisible group the stack moves (place); it.body: the toast, which the
|
||||||
-- entrance and exit move, squash and fade inside it.
|
-- entrance and exit move, squash and fade inside it.
|
||||||
function M:place(animate)
|
function M:place(animate)
|
||||||
local y = self.h - MARGIN
|
-- A column at the bottom of the page (newest last), GAP apart.
|
||||||
for i = #self.items, 1, -1 do
|
local boxes = {}
|
||||||
local it = self.items[i]
|
for i, it in ipairs(self.items) do
|
||||||
y = y - it.h
|
boxes[i] = { style = { height = it.h, shrink = 0 } }
|
||||||
local pos = vmath.vector3(0, -y, 0)
|
end
|
||||||
|
flex.solve(flex.box({ direction = "column", justify = "end", gap = GAP, height = self.h - MARGIN }, boxes), 0, 0)
|
||||||
|
for i, it in ipairs(self.items) do
|
||||||
|
local pos = vmath.vector3(0, -boxes[i].y, 0)
|
||||||
if animate then
|
if animate then
|
||||||
gui.animate(it.root, "position.y", pos.y, gui.EASING_OUTCUBIC, 0.25)
|
gui.animate(it.root, "position.y", pos.y, gui.EASING_OUTCUBIC, 0.25)
|
||||||
else
|
else
|
||||||
@@ -70,7 +77,6 @@ function M:place(animate)
|
|||||||
-- Older toasts dim (still opaque).
|
-- Older toasts dim (still opaque).
|
||||||
local dim = (#self.items - i == 0) and 1 or ((#self.items - i == 1) and 0.8 or 0.6)
|
local dim = (#self.items - i == 0) and 1 or ((#self.items - i == 1) and 0.8 or 0.6)
|
||||||
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
|
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
|
||||||
y = y - GAP
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -92,7 +98,7 @@ end
|
|||||||
-- The body's look at a moment: x offset, vertical squash (about its middle), opacity,
|
-- The body's look at a moment: x offset, vertical squash (about its middle), opacity,
|
||||||
-- flash (an additive overlay in the accent, lighter: the web's brightness filter).
|
-- flash (an additive overlay in the accent, lighter: the web's brightness filter).
|
||||||
local function pose(it, dx, sy, alpha, flash)
|
local function pose(it, dx, sy, alpha, flash)
|
||||||
anim.scale_about(it.body, vmath.vector3(MARGIN + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
|
anim.scale_about(it.body, vmath.vector3(it.mx + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
|
||||||
local c = it.fill
|
local c = it.fill
|
||||||
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
|
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
|
||||||
local f = it.flash_color
|
local f = it.flash_color
|
||||||
@@ -106,7 +112,7 @@ local function fade(self, it)
|
|||||||
it.gone = true
|
it.gone = true
|
||||||
anim.cancel(it.tween)
|
anim.cancel(it.tween)
|
||||||
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease).
|
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease).
|
||||||
local x0 = gui.get_position(it.body).x - MARGIN
|
local x0 = gui.get_position(it.body).x - it.mx
|
||||||
it.tween = anim.tween(it.body, 0.5, function(k)
|
it.tween = anim.tween(it.body, 0.5, function(k)
|
||||||
local e = anim.fn.ease(k)
|
local e = anim.fn.ease(k)
|
||||||
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0)
|
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0)
|
||||||
@@ -161,21 +167,19 @@ local function enter(it, is_error)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
|
-- A toast's nodes under parent (root at 0, 0; the body w wide, mx from the left).
|
||||||
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
|
local function make(parent, e, w, mx)
|
||||||
function M:push(message, kind, is_error, opts)
|
local k = KIND[e.is_error and "error" or e.kind] or { "cyan", "›" }
|
||||||
local k = KIND[is_error and "error" or kind] or { "cyan", "›" }
|
local accent = e.color or k[1]
|
||||||
local accent = (not is_error and opts and opts.color) or k[1]
|
local root = anim.group(parent, 0, 0)
|
||||||
|
local it = { root = root, accent = accent, mx = mx }
|
||||||
local root = anim.group(self.layer, 0, 0)
|
local h = H
|
||||||
local it = { root = root, accent = accent }
|
|
||||||
local w, h = W, H
|
|
||||||
local made = ui.collect(function()
|
local made = ui.collect(function()
|
||||||
-- The text first (wrapped at the toast's width), to size the toast.
|
-- The text first (wrapped at the toast's width), to size the toast.
|
||||||
local style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
|
local style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
|
||||||
local line, th
|
local line, th
|
||||||
local text_nodes = ui.collect(function()
|
local text_nodes = ui.collect(function()
|
||||||
line, th = ui.wrap(root, message, style, 0, 0, W - TEXT_X - PAD_R)
|
line, th = ui.wrap(root, e.message, style, 0, 0, w - TEXT_X - PAD_R)
|
||||||
end)
|
end)
|
||||||
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
|
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
|
||||||
-- but not drawn, so the runs under them inherit alpha 1.)
|
-- but not drawn, so the runs under them inherit alpha 1.)
|
||||||
@@ -186,18 +190,28 @@ function M:push(message, kind, is_error, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
h = math.max(H, math.floor(th + PAD_Y * 2))
|
h = math.max(H, math.floor(th + PAD_Y * 2))
|
||||||
local body = ui.rect(root, MARGIN, 0, w, h, "field")
|
local body = ui.rect(root, mx, 0, w, h, "field")
|
||||||
it.body = body
|
it.body = body
|
||||||
local tint = ui.rect(body, 0, 0, w, h, accent, 0.1)
|
ui.rect(body, 0, 0, w, h, accent, 0.1)
|
||||||
-- A stronger tint toward the icon (the web's gradient), in two steps.
|
-- A stronger tint toward the icon (the web's gradient), in two steps.
|
||||||
ui.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
|
ui.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
|
||||||
ui.outline(body, 0, 0, w, h, accent, 0.7)
|
ui.outline(body, 0, 0, w, h, accent, 0.7)
|
||||||
ui.rect(body, 0, 0, 3, h, accent)
|
ui.rect(body, 0, 0, 3, h, accent)
|
||||||
local icon = ui.outline(body, 11, math.floor(h / 2 - 14), 28, 28, accent)
|
-- The icon (28, 11 in) and the text (from TEXT_X), centered on the toast's height.
|
||||||
|
local icon
|
||||||
|
local tree = flex.box({ width = w, height = h, padding_left = 11, align = "center" }, {
|
||||||
|
{ style = { width = 28, height = 28, shrink = 0 }, make = function(p, x, y)
|
||||||
|
icon = ui.outline(p, x, y, 28, 28, accent)
|
||||||
ui.rect(icon, 1, 1, 26, 26, accent, 0.18)
|
ui.rect(icon, 1, 1, 26, 26, accent, 0.18)
|
||||||
ui.text(icon, k[2], { font = "sym", px = 16, color = accent }, 14, 14, gui.PIVOT_CENTER)
|
ui.text(icon, k[2], { font = "sym", px = 16, color = accent }, 14, 14, gui.PIVOT_CENTER)
|
||||||
gui.set_parent(line, body, false)
|
end },
|
||||||
gui.set_position(line, vmath.vector3(TEXT_X, -math.floor((h - th) / 2), 0))
|
{ style = { width = TEXT_X - 11 - 28, shrink = 0 } },
|
||||||
|
{ style = { height = th, grow = 1 }, make = function(p, x, y)
|
||||||
|
gui.set_parent(line, p, false)
|
||||||
|
gui.set_position(line, vmath.vector3(x, -y, 0))
|
||||||
|
end },
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), body)
|
||||||
gui.move_above(line, icon)
|
gui.move_above(line, icon)
|
||||||
it.flash = ui.rect(body, 0, 0, w, h, "white", 0)
|
it.flash = ui.rect(body, 0, 0, w, h, "white", 0)
|
||||||
gui.set_blend_mode(it.flash, theme.glow_blend())
|
gui.set_blend_mode(it.flash, theme.glow_blend())
|
||||||
@@ -214,7 +228,96 @@ function M:push(message, kind, is_error, opts)
|
|||||||
it.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
|
it.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
|
||||||
it.fill = ui.color("field", 0.97)
|
it.fill = ui.color("field", 0.97)
|
||||||
it.scale = gui.get_scale(it.body)
|
it.scale = gui.get_scale(it.body)
|
||||||
|
return it
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- the pane
|
||||||
|
|
||||||
|
-- The list's height for the last LOG_MAX one-line toasts (the pane is sized to it).
|
||||||
|
M.LIST_H = LOG_MAX * (H + GAP) - GAP
|
||||||
|
|
||||||
|
-- holder: a node at the pane's top-left (w x h), in the moon list (rebuilt with it).
|
||||||
|
function M:attach(holder, w, h)
|
||||||
|
self.log = self.log or {} -- (a hot reload keeps the old instance)
|
||||||
|
gui.set_clipping_mode(holder, gui.CLIPPING_MODE_STENCIL)
|
||||||
|
self.pane = { holder = holder, w = w, h = h, items = {} }
|
||||||
|
self:fill(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- No pane (no moon list): toasts float again.
|
||||||
|
function M:detach()
|
||||||
|
self.pane = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pane(self)
|
||||||
|
local p = self.pane
|
||||||
|
if p and pcall(gui.get_position, p.holder) then
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
self.pane = nil -- (its moon list went away)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lists the log in the pane, newest at the top, as many as fit. fresh: the entry just
|
||||||
|
-- pushed (it enters; the others slide down to make room).
|
||||||
|
function M:fill(fresh)
|
||||||
|
local p = self.pane
|
||||||
|
for _, it in ipairs(p.items) do
|
||||||
|
anim.cancel(it.tween)
|
||||||
|
pcall(gui.delete_node, it.root)
|
||||||
|
end
|
||||||
|
p.items = {}
|
||||||
|
if p.empty then
|
||||||
|
pcall(gui.delete_node, p.empty)
|
||||||
|
p.empty = nil
|
||||||
|
end
|
||||||
|
if #self.log == 0 then
|
||||||
|
p.empty = ui.text(p.holder, "Nothing yet.", "small", 0, 10)
|
||||||
|
ui.fit_text(p.empty, p.w)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local y, shift = 0, 0
|
||||||
|
for i, e in ipairs(self.log) do
|
||||||
|
if y >= p.h then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local it = make(p.holder, e, p.w, 0)
|
||||||
|
p.items[#p.items + 1] = it
|
||||||
|
local dim = i == 1 and 1 or (i == 2 and 0.85 or 0.7)
|
||||||
|
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
|
||||||
|
if e == fresh then
|
||||||
|
gui.set_position(it.root, vmath.vector3(0, -y, 0))
|
||||||
|
shift = it.h + GAP
|
||||||
|
enter(it, e.is_error)
|
||||||
|
else
|
||||||
|
pose(it, 0, 1, 1, 0)
|
||||||
|
gui.set_position(it.root, vmath.vector3(0, -(y - shift), 0))
|
||||||
|
if shift > 0 and not anim.reduced then
|
||||||
|
gui.animate(it.root, "position.y", -y, gui.EASING_OUTCUBIC, 0.25)
|
||||||
|
else
|
||||||
|
gui.set_position(it.root, vmath.vector3(0, -y, 0))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
y = y + it.h + GAP
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- push
|
||||||
|
|
||||||
|
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
|
||||||
|
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
|
||||||
|
function M:push(message, kind, is_error, opts)
|
||||||
|
local e = { message = message, kind = kind, is_error = is_error, color = (not is_error and opts and opts.color) or nil }
|
||||||
|
self.log = self.log or {}
|
||||||
|
table.insert(self.log, 1, e)
|
||||||
|
while #self.log > LOG_MAX do
|
||||||
|
table.remove(self.log)
|
||||||
|
end
|
||||||
|
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
|
||||||
|
if pane(self) then
|
||||||
|
self:fill(e)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local it = make(self.layer, e, W, MARGIN)
|
||||||
self.items[#self.items + 1] = it
|
self.items[#self.items + 1] = it
|
||||||
while #self.items > MAX do
|
while #self.items > MAX do
|
||||||
remove(self, self.items[1])
|
remove(self, self.items[1])
|
||||||
@@ -225,7 +328,6 @@ function M:push(message, kind, is_error, opts)
|
|||||||
it.timer = nil
|
it.timer = nil
|
||||||
fade(self, it)
|
fade(self, it)
|
||||||
end)
|
end)
|
||||||
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local screen = require("modules.screen")
|
local screen = require("modules.screen")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ function M.add(node, text)
|
|||||||
if not node or not text or text == "" then
|
if not node or not text or text == "" then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
blocks.note(M.add, node, text) -- (a kept block adds it again)
|
||||||
tips[#tips + 1] = { node = node, text = tostring(text), zone = zone_of(node) }
|
tips[#tips + 1] = { node = node, text = tostring(text), zone = zone_of(node) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
-- difference at the rebuild is kept as an offset that eases to 0 while the value goes on
|
-- difference at the rebuild is kept as an offset that eases to 0 while the value goes on
|
||||||
-- ticking. Static values are the same thing with a constant function.
|
-- ticking. Static values are the same thing with a constant function.
|
||||||
|
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
M.__index = M
|
M.__index = M
|
||||||
|
|
||||||
@@ -46,9 +48,14 @@ end
|
|||||||
-- source (the number itself for a static value, e.g. "start:end" for a timer): while it
|
-- source (the number itself for a static value, e.g. "start:end" for a timer): while it
|
||||||
-- stays the same a running ease goes on; when it changes, the ease starts from what is
|
-- stays the same a running ease goes on; when it changes, the ease starts from what is
|
||||||
-- shown. Returns the entry.
|
-- shown. Returns the entry.
|
||||||
|
local function seen(self, key)
|
||||||
|
self.seen[key] = true
|
||||||
|
end
|
||||||
|
|
||||||
function M:track(key, value, sig)
|
function M:track(key, value, sig)
|
||||||
sig = sig == nil and value or sig
|
sig = sig == nil and value or sig
|
||||||
self.seen[key] = true
|
seen(self, key)
|
||||||
|
blocks.note(seen, self, key) -- (a kept block keeps the key)
|
||||||
local e = self.vals[key]
|
local e = self.vals[key]
|
||||||
if not e then
|
if not e then
|
||||||
e = { shown = value, off = 0, t0 = 0, sig = sig }
|
e = { shown = value, off = 0, t0 = 0, sig = sig }
|
||||||
|
|||||||
+25
-6
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
|
|||||||
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
|
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
|
||||||
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
|
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
|
||||||
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
|
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
|
||||||
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
-- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
||||||
-- root, nodes, width.
|
-- root, nodes, width.
|
||||||
function M.button(parent, label, x, y, opts)
|
function M.button(parent, label, x, y, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
|
|||||||
local parts = { { text, M.text_width(text) } }
|
local parts = { { text, M.text_width(text) } }
|
||||||
if opts.price then
|
if opts.price then
|
||||||
local accent = opts.accent or v.price
|
local accent = opts.accent or v.price
|
||||||
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
|
||||||
gui.set_color(sep, M.color_spec(tc))
|
|
||||||
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
|
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
|
||||||
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
|
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
|
||||||
|
if opts.bare then -- (no "·" between: "+ ₥290")
|
||||||
|
parts[#parts + 1] = { sym, M.text_width(sym), 5 }
|
||||||
|
else
|
||||||
|
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
||||||
|
gui.set_color(sep, M.color_spec(tc))
|
||||||
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
||||||
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
||||||
|
end
|
||||||
parts[#parts + 1] = { num, M.text_width(num), 1 }
|
parts[#parts + 1] = { num, M.text_width(num), 1 }
|
||||||
end
|
end
|
||||||
local content = 0
|
local content = 0
|
||||||
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
while #text > 1 do
|
while #text > 1 do
|
||||||
-- (a cut entity like "&am" would show as text)
|
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
|
||||||
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||||
gui.set_text(node, text .. "…")
|
gui.set_text(node, text .. "…")
|
||||||
if M.text_width(node) <= max_w then
|
if M.text_width(node) <= max_w then
|
||||||
return
|
return
|
||||||
@@ -333,8 +337,13 @@ function M.dur(ms)
|
|||||||
return string.format("%d:%02d", m, sec)
|
return string.format("%d:%02d", m, sec)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
|
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
|
||||||
function M.cycle(secs)
|
function M.cycle(secs)
|
||||||
|
if secs < 60 and secs ~= math.floor(secs) then
|
||||||
|
local t = math.floor(secs * 10 + 0.5) / 10
|
||||||
|
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
|
||||||
|
end
|
||||||
|
secs = math.floor(secs + 0.5)
|
||||||
if secs < 60 then
|
if secs < 60 then
|
||||||
return secs .. "s"
|
return secs .. "s"
|
||||||
elseif secs % 60 == 0 then
|
elseif secs % 60 == 0 then
|
||||||
@@ -343,6 +352,16 @@ function M.cycle(secs)
|
|||||||
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
|
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
|
||||||
|
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
|
||||||
|
function M.levels_tag(t)
|
||||||
|
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
|
||||||
|
if y == 0 and c == 0 then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return " Y" .. y .. " C" .. c
|
||||||
|
end
|
||||||
|
|
||||||
function M.plural(n, word)
|
function M.plural(n, word)
|
||||||
return n .. " " .. word .. (n == 1 and "" or "s")
|
return n .. " " .. word .. (n == 1 and "" or "s")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#version 140
|
#version 140
|
||||||
|
|
||||||
// The viewport node: shows the moon scene the render script drew into `moon_tex` (HDR,
|
// The viewport node: shows the moon scene the render script drew into `moon_tex` (HDR,
|
||||||
// 2x the node's size: linear filtering at the pixel centers averages 2x2 samples) plus
|
// up to 2x the node's size: linear filtering at the pixel centers averages 2x2 samples) plus
|
||||||
// its bloom (`bloom_tex`, half size, upsampled), with three.js's output pass: ACES
|
// its bloom (`bloom_tex`, half the GUI reference size, upsampled), with three.js's output pass: ACES
|
||||||
// filmic tone mapping (exposure) and the sRGB transfer.
|
// filmic tone mapping (exposure) and the sRGB transfer.
|
||||||
// Without a moon (moon_rect.z == 0) the node keeps its plain color.
|
// Without a moon (moon_rect.z == 0) the node keeps its plain color.
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ void main()
|
|||||||
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
|
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
|
||||||
normal = normalize(abs(det) * normal - grad);
|
normal = normalize(abs(det) * normal - grad);
|
||||||
}
|
}
|
||||||
|
// cam_pos matches the view matrix (a debug render of |V(cam_pos) - V(view matrix)| was 0
|
||||||
|
// over the whole moon); moon_glow and the halo take V in view space (-mv.xyz) instead.
|
||||||
vec3 V = normalize(cam_pos.xyz - var_world);
|
vec3 V = normalize(cam_pos.xyz - var_world);
|
||||||
vec3 albedo = var_color;
|
vec3 albedo = var_color;
|
||||||
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
|
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#version 140
|
#version 140
|
||||||
|
|
||||||
// The moon surface (vertex colors are linear RGB from terrain.lua).
|
// The moon surface (vertex colors are linear RGB from mmterrain).
|
||||||
const float MOON_RADIUS = 2.0; // scene.js MOON_RADIUS
|
const float MOON_RADIUS = 2.0; // scene.js MOON_RADIUS
|
||||||
|
|
||||||
in highp vec4 position;
|
in highp vec4 position;
|
||||||
|
|||||||
+127
-66
@@ -1,5 +1,5 @@
|
|||||||
-- The moon scene (see modules/moon/moon.lua): builds meshes for the requested moon id
|
-- The moon scene (see modules/moon/moon.lua): builds meshes for the requested moon id
|
||||||
-- (terrain.lua, spread over frames, cached per id), spins the moon, orbits the camera
|
-- (the mmterrain extension; cached per id), spins the moon, orbits the camera
|
||||||
-- (drag to rotate, wheel to zoom, like three.js OrbitControls with damping) and writes
|
-- (drag to rotate, wheel to zoom, like three.js OrbitControls with damping) and writes
|
||||||
-- what the render script needs into moon.frame. Values follow server scene.js.
|
-- what the render script needs into moon.frame. Values follow server scene.js.
|
||||||
--
|
--
|
||||||
@@ -24,15 +24,16 @@ local FOV = math.rad(35)
|
|||||||
local RESUME = 1.5 -- seconds before auto-rotation resumes after a drag
|
local RESUME = 1.5 -- seconds before auto-rotation resumes after a drag
|
||||||
local ROTATE_SPEED = 0.6
|
local ROTATE_SPEED = 0.6
|
||||||
local DAMPING = 0.05
|
local DAMPING = 0.05
|
||||||
local BUDGET = html5 and 0.012 or 0.006 -- seconds of generation work per frame (the web runs
|
|
||||||
-- plain Lua, no LuaJIT: several times slower, so it gets more of each frame)
|
|
||||||
local CACHE_SIZE = 6
|
local CACHE_SIZE = 6
|
||||||
local SUPERSAMPLE = 2 -- the scene renders at 2x and is averaged down (antialiasing)
|
local SUPERSAMPLE = 2 -- the scene renders at 2x and is averaged down (antialiasing),
|
||||||
|
-- counted in GUI reference pixels: a HiDPI display (or UI zoom) already gives the viewport
|
||||||
|
-- more window pixels, so the target is 2x the reference size, at least the window size
|
||||||
local CHARGE = 1.1 -- reveal: charge-up before the burst (s)
|
local CHARGE = 1.1 -- reveal: charge-up before the burst (s)
|
||||||
local BURST = 0.9 -- reveal: the burst itself (s)
|
local BURST = 0.9 -- reveal: the burst itself (s)
|
||||||
local TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
|
local TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
|
||||||
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts
|
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts
|
||||||
local SHROUD_ROT = vmath.quat_rotation_x(0.28) * vmath.quat_rotation_z(-0.22)
|
local SHROUD_ROT = vmath.quat_rotation_x(0.28) * vmath.quat_rotation_z(-0.22)
|
||||||
|
local SHROUD_SPIN = 0.25 -- radians a second the shroud turns
|
||||||
|
|
||||||
local SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
|
local SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
|
||||||
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4))
|
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4))
|
||||||
@@ -182,42 +183,21 @@ local function mesh_key(id, settings)
|
|||||||
return id .. "|" .. terrain.serialize(rest)
|
return id .. "|" .. terrain.serialize(rest)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A job: a coroutine that generates the moon and fills a buffer, yielding when the
|
-- A job generates one moon (the mmterrain extension, mmterrain/src/terrain.c) and fills
|
||||||
-- frame's budget is spent.
|
-- its buffer: on a thread of its own on desktop; on the web (wasm, no threads) a share of
|
||||||
local WEB_RESOLUTION = 96 -- the web's cap on the grid (plain Lua: a quarter of the work of 180)
|
-- each frame, then the buffer a part a frame.
|
||||||
|
local native = mmterrain
|
||||||
|
local NATIVE_SHARE, NATIVE_MIN, NATIVE_MAX = 0.3, 0.002, 0.012 -- (seconds of work a frame there)
|
||||||
|
local NATIVE_FILL = 32768 -- strip vertices copied into the buffer a frame there
|
||||||
|
local WEB_RESOLUTION = 128 -- the web's grid (desktop: the settings')
|
||||||
|
|
||||||
local function start_job(self, id, key, params)
|
local function start_job(self, id, key, params)
|
||||||
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
||||||
params.resolution = WEB_RESOLUTION
|
params.resolution = WEB_RESOLUTION
|
||||||
end
|
end
|
||||||
local job = { id = id, key = key, params = params, started = now(), frames = 0, work = 0 }
|
local job = { id = id, key = key, params = params, t0 = now() }
|
||||||
job.co = coroutine.create(function()
|
job.gen = native.new(terrain.native_params(params))
|
||||||
local function checkpoint()
|
job.threaded = native.start(job.gen)
|
||||||
if now() > job.deadline then
|
|
||||||
coroutine.yield()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local m = terrain.generate(params, checkpoint)
|
|
||||||
job.gen_work = job.work + (now() - job.resume_at)
|
|
||||||
local strip = terrain.strip(m.n)
|
|
||||||
local buf = buffer.create(#strip, STREAMS)
|
|
||||||
local pos = buffer.get_stream(buf, hash("position"))
|
|
||||||
local nor = buffer.get_stream(buf, hash("normal"))
|
|
||||||
local col = buffer.get_stream(buf, hash("color"))
|
|
||||||
local P, N, C = m.positions, m.normals, m.colors
|
|
||||||
for k = 1, #strip do
|
|
||||||
local v = strip[k] * 3
|
|
||||||
local o = (k - 1) * 3
|
|
||||||
pos[o + 1], pos[o + 2], pos[o + 3] = P[v + 1], P[v + 2], P[v + 3]
|
|
||||||
nor[o + 1], nor[o + 2], nor[o + 3] = N[v + 1], N[v + 2], N[v + 3]
|
|
||||||
col[o + 1], col[o + 2], col[o + 3] = C[v + 1], C[v + 2], C[v + 3]
|
|
||||||
if k % 2048 == 0 then
|
|
||||||
checkpoint()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
job.buffer = buf
|
|
||||||
job.vertices = #strip
|
|
||||||
end)
|
|
||||||
self.job = job
|
self.job = job
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -267,30 +247,90 @@ local function set_mesh(self, key, res, params)
|
|||||||
self.shown_params = params
|
self.shown_params = params
|
||||||
end
|
end
|
||||||
|
|
||||||
local function step_job(self)
|
-- A finished job's buffer becomes a mesh resource in the cache (and the shown moon when
|
||||||
local job = self.job
|
-- it's the wanted one).
|
||||||
local t0 = now()
|
local function job_done(self, job)
|
||||||
job.resume_at = t0
|
|
||||||
job.deadline = t0 + BUDGET
|
|
||||||
job.frames = job.frames + 1
|
|
||||||
local ok, err = coroutine.resume(job.co)
|
|
||||||
job.work = job.work + (now() - t0)
|
|
||||||
if not ok then
|
|
||||||
print("moon: generation failed: " .. tostring(err))
|
|
||||||
self.job = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if coroutine.status(job.co) == "dead" then
|
|
||||||
self.job = nil
|
|
||||||
local res = new_buffer_resource(job.buffer)
|
local res = new_buffer_resource(job.buffer)
|
||||||
cache_put(self, job.key, res)
|
cache_put(self, job.key, res)
|
||||||
print(string.format("moon: %s (%s) built: %.0f ms of work (terrain %.0f, buffer %.0f) over %d frames (%.0f ms wall), %d vertices",
|
local ms = (now() - job.t0) * 1000
|
||||||
job.id, job.params.technique, job.work * 1000, job.gen_work * 1000, (job.work - job.gen_work) * 1000,
|
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
|
||||||
job.frames, (now() - job.started) * 1000, job.vertices))
|
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
|
||||||
if job.key == self.want_key then
|
if job.key == self.want_key then
|
||||||
set_mesh(self, job.key, res, job.params)
|
set_mesh(self, job.key, res, job.params)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Done (threaded) or stepped to the end, then the buffer is filled (on the web a part a
|
||||||
|
-- frame, the frames after the last step). Returns true when complete.
|
||||||
|
local function step_native(self, job)
|
||||||
|
if not job.buffer then
|
||||||
|
local done
|
||||||
|
if job.threaded then
|
||||||
|
done = native.done(job.gen)
|
||||||
|
else
|
||||||
|
done = native.step(job.gen, math.max(NATIVE_MIN, math.min(NATIVE_MAX, (self.frame_dt or 0) * NATIVE_SHARE)))
|
||||||
|
end
|
||||||
|
if not done then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
job.vertices = native.vertices(job.gen)
|
||||||
|
job.buffer = buffer.create(job.vertices, STREAMS)
|
||||||
|
job.filled = 0
|
||||||
|
if not job.threaded then
|
||||||
|
return false -- (the fill starts next frame)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local to = job.threaded and job.vertices or math.min(job.vertices, job.filled + NATIVE_FILL)
|
||||||
|
native.fill(job.gen, job.buffer, job.filled + 1, to)
|
||||||
|
job.filled = to
|
||||||
|
if to < job.vertices then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
native.free(job.gen)
|
||||||
|
job.gen = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A job left behind for another moon: a thread runs to the end (freeing it would wait
|
||||||
|
-- for it), and the moon goes into the cache; a stepped one is dropped.
|
||||||
|
local function orphan(self, job)
|
||||||
|
if job.threaded and type(job.gen) == "userdata" then
|
||||||
|
self.orphans[#self.orphans + 1] = job
|
||||||
|
elseif type(job.gen) == "userdata" then
|
||||||
|
native.free(job.gen)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function step_orphans(self)
|
||||||
|
for i = #self.orphans, 1, -1 do
|
||||||
|
local job = self.orphans[i]
|
||||||
|
if native.done(job.gen) then
|
||||||
|
table.remove(self.orphans, i)
|
||||||
|
step_native(self, job)
|
||||||
|
job_done(self, job)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function step_job(self)
|
||||||
|
local job = self.job
|
||||||
|
if step_native(self, job) then
|
||||||
|
self.job = nil
|
||||||
|
job_done(self, job)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The mesh for moon `id` (from the cache, or a job for it); returns its key and params.
|
||||||
|
local function want(self, id)
|
||||||
|
local params = terrain.resolve(moon.settings, id)
|
||||||
|
local key = mesh_key(id, moon.settings)
|
||||||
|
if not cache_get(self, key) and not (self.job and self.job.key == key) then
|
||||||
|
if self.job then
|
||||||
|
orphan(self, self.job)
|
||||||
|
end
|
||||||
|
start_job(self, id, key, params)
|
||||||
|
end
|
||||||
|
return key, params
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- camera
|
---------------------------------------------------------------- camera
|
||||||
@@ -320,6 +360,7 @@ end
|
|||||||
function init(self)
|
function init(self)
|
||||||
msg.post(".", "acquire_input_focus")
|
msg.post(".", "acquire_input_focus")
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
|
self.orphans = {}
|
||||||
self.spin = 0
|
self.spin = 0
|
||||||
self.spin_speed = 0
|
self.spin_speed = 0
|
||||||
self.dragging = false
|
self.dragging = false
|
||||||
@@ -361,6 +402,12 @@ function final(self)
|
|||||||
for _, e in ipairs(self.cache) do
|
for _, e in ipairs(self.cache) do
|
||||||
resource.release(e.res)
|
resource.release(e.res)
|
||||||
end
|
end
|
||||||
|
if self.job and type(self.job.gen) == "userdata" then
|
||||||
|
native.free(self.job.gen)
|
||||||
|
end
|
||||||
|
for _, job in ipairs(self.orphans) do
|
||||||
|
native.free(job.gen)
|
||||||
|
end
|
||||||
resource.release(self.shell_res)
|
resource.release(self.shell_res)
|
||||||
resource.release(self.stars_res)
|
resource.release(self.stars_res)
|
||||||
resource.release(self.motes_res)
|
resource.release(self.motes_res)
|
||||||
@@ -368,18 +415,13 @@ end
|
|||||||
|
|
||||||
function update(self, dt)
|
function update(self, dt)
|
||||||
dt = math.min(dt, 0.1)
|
dt = math.min(dt, 0.1)
|
||||||
|
self.frame_dt = self.frame_dt and (self.frame_dt * 0.8 + dt * 0.2) or dt -- (smoothed, for the native budget)
|
||||||
local f = moon.frame
|
local f = moon.frame
|
||||||
local rect = moon.rect
|
local rect = moon.rect
|
||||||
local mystery = moon.mode == "mystery"
|
local mystery = moon.mode == "mystery"
|
||||||
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
|
||||||
f.visible = false
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if mystery and moon.settings and not self.mystery_values then
|
|
||||||
self.mystery_values = render_values(terrain.resolve(moon.settings, "mystery"))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The wanted mesh: from the cache, or start (or keep) a job for it.
|
-- The wanted mesh: from the cache, or start (or keep) a job for it. A moon asked for
|
||||||
|
-- ahead (moon.prefetch: a bought one, before its reveal shows) gets one too.
|
||||||
if moon.settings and moon.id then
|
if moon.settings and moon.id then
|
||||||
if moon.id ~= self.want_id or moon.settings ~= self.want_settings then
|
if moon.id ~= self.want_id or moon.settings ~= self.want_settings then
|
||||||
self.want_id = moon.id
|
self.want_id = moon.id
|
||||||
@@ -395,12 +437,29 @@ function update(self, dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.shown_key ~= self.want_key and not (self.job and self.job.key == self.want_key) then
|
if self.shown_key ~= self.want_key and not (self.job and self.job.key == self.want_key) then
|
||||||
start_job(self, moon.id, self.want_key, self.want_params)
|
want(self, moon.id)
|
||||||
end
|
end
|
||||||
|
elseif moon.settings and moon.prefetch_id and not self.job then
|
||||||
|
want(self, moon.prefetch_id)
|
||||||
|
moon.prefetch_id = nil
|
||||||
|
end
|
||||||
|
if moon.prefetch_id == moon.id then
|
||||||
|
moon.prefetch_id = nil
|
||||||
end
|
end
|
||||||
if self.job then
|
if self.job then
|
||||||
step_job(self)
|
step_job(self)
|
||||||
end
|
end
|
||||||
|
if #self.orphans > 0 then
|
||||||
|
step_orphans(self)
|
||||||
|
end
|
||||||
|
moon.building = self.job ~= nil or #self.orphans > 0
|
||||||
|
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
||||||
|
f.visible = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mystery and moon.settings and not self.mystery_values then
|
||||||
|
self.mystery_values = render_values(terrain.resolve(moon.settings, "mystery"))
|
||||||
|
end
|
||||||
|
|
||||||
local values = self.values
|
local values = self.values
|
||||||
if mystery then
|
if mystery then
|
||||||
@@ -510,10 +569,12 @@ function update(self, dt)
|
|||||||
if shroud then
|
if shroud then
|
||||||
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
|
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
|
||||||
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale)
|
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale)
|
||||||
|
-- The shroud and its motes turn slowly, like a moon (the halo, a glow around it, stays).
|
||||||
|
local turn = spin_enabled and vmath.quat_rotation_y(self.shroud_t * SHROUD_SPIN) or vmath.quat()
|
||||||
for _, id in ipairs({ "shroud", "halo", "motes" }) do
|
for _, id in ipairs({ "shroud", "halo", "motes" }) do
|
||||||
go.set_position(pos, id)
|
go.set_position(pos, id)
|
||||||
go.set_scale(sc, id)
|
go.set_scale(sc, id)
|
||||||
go.set_rotation(SHROUD_ROT, id)
|
go.set_rotation(id == "halo" and SHROUD_ROT or SHROUD_ROT * turn, id)
|
||||||
end
|
end
|
||||||
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
|
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
|
||||||
else
|
else
|
||||||
@@ -528,13 +589,13 @@ function update(self, dt)
|
|||||||
self.spin = self.spin + self.spin_speed * dt
|
self.spin = self.spin + self.spin_speed * dt
|
||||||
go.set_rotation(TILT * vmath.quat_rotation_y(self.spin), "surface")
|
go.set_rotation(TILT * vmath.quat_rotation_y(self.spin), "surface")
|
||||||
|
|
||||||
local ss = SUPERSAMPLE
|
local ss = math.max(1, SUPERSAMPLE / math.max(1, moon.unit))
|
||||||
local aspect = rect.w / rect.h
|
local aspect = rect.w / rect.h
|
||||||
local eye = update_camera(self, aspect)
|
local eye = update_camera(self, aspect)
|
||||||
f.visible = values ~= nil
|
f.visible = values ~= nil
|
||||||
f.has_mesh = self.shown_key ~= nil and show_moon
|
f.has_mesh = self.shown_key ~= nil and show_moon
|
||||||
f.rect = rect
|
f.rect = rect
|
||||||
f.rt_w, f.rt_h = rect.w * ss, rect.h * ss
|
f.rt_w, f.rt_h = math.floor(rect.w * ss + 0.5), math.floor(rect.h * ss + 0.5)
|
||||||
f.unit = moon.unit * ss
|
f.unit = moon.unit * ss
|
||||||
f.view = vmath.matrix4_look_at(eye, vmath.vector3(0, 0, 0), vmath.vector3(0, 1, 0))
|
f.view = vmath.matrix4_look_at(eye, vmath.vector3(0, 0, 0), vmath.vector3(0, 1, 0))
|
||||||
f.proj = vmath.matrix4_perspective(self.fov, aspect, 0.1, 500)
|
f.proj = vmath.matrix4_perspective(self.fov, aspect, 0.1, 500)
|
||||||
@@ -544,7 +605,7 @@ function update(self, dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function on_input(self, action_id, action)
|
function on_input(self, action_id, action)
|
||||||
local rect = moon.frame.visible and moon.rect
|
local rect = moon.frame.visible and not moon.covered and moon.rect
|
||||||
if not rect then
|
if not rect then
|
||||||
self.dragging = false
|
self.dragging = false
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- Moon Miners render script: Defold's default render script (builtins
|
-- Moon Miners render script: Defold's default render script (builtins
|
||||||
-- default.render_script of the pinned engine) plus the moon pass. Before the GUI, the moon
|
-- default.render_script of the pinned engine) plus the moon pass. Before the GUI, the moon
|
||||||
-- scene (moon/moon.collection, see modules/moon/moon.lua) is drawn into an HDR render
|
-- scene (moon/moon.collection, see modules/moon/moon.lua) is drawn into an HDR render
|
||||||
-- target at 2x the viewport size: backdrop, stars, the moon, the atmosphere shell; then
|
-- target at 2x the viewport's GUI reference size (at least its window size; moon.script): backdrop, stars, the moon, the atmosphere shell; then
|
||||||
-- its bloom (three.js UnrealBloomPass) into smaller targets. The GUI then draws it in its viewport node (material moon/materials/moon_gui, sampler
|
-- its bloom (three.js UnrealBloomPass) into smaller targets. The GUI then draws it in its viewport node (material moon/materials/moon_gui, sampler
|
||||||
-- `moon_tex`), tone mapped, so HUD panels, captions and popups layer over it as usual.
|
-- `moon_tex`), tone mapped, so HUD panels, captions and popups layer over it as usual.
|
||||||
|
|
||||||
@@ -57,7 +57,9 @@ local BLOOM_KERNELS = { 3, 5, 7, 9, 11 }
|
|||||||
local CLEAR_BLACK = { [graphics.BUFFER_TYPE_COLOR0_BIT] = vmath.vector4(0, 0, 0, 0) }
|
local CLEAR_BLACK = { [graphics.BUFFER_TYPE_COLOR0_BIT] = vmath.vector4(0, 0, 0, 0) }
|
||||||
|
|
||||||
-- three.js UnrealBloomPass on the moon scene (moon_post material): high pass at half the
|
-- three.js UnrealBloomPass on the moon scene (moon_post material): high pass at half the
|
||||||
-- viewport size, 5 mips of separable blur, composite into "bloom_h1".
|
-- viewport's GUI reference size (the web's CSS pixels: a HiDPI display or UI zoom does the
|
||||||
|
-- same work as 1x, and the blur's texels cover the same UI pixels, so the glow looks the
|
||||||
|
-- same), 5 mips of separable blur, composite into "bloom_h1".
|
||||||
local function draw_bloom(self, f, scene_rt)
|
local function draw_bloom(self, f, scene_rt)
|
||||||
local p = self.predicates.moon_post
|
local p = self.predicates.moon_post
|
||||||
local c = self.post_constants
|
local c = self.post_constants
|
||||||
@@ -69,7 +71,8 @@ local function draw_bloom(self, f, scene_rt)
|
|||||||
render.clear(CLEAR_BLACK)
|
render.clear(CLEAR_BLACK)
|
||||||
render.draw(p, options)
|
render.draw(p, options)
|
||||||
end
|
end
|
||||||
local w, h = math.floor(f.rect.w / 2 + 0.5), math.floor(f.rect.h / 2 + 0.5)
|
local u = math.max(1, moon.unit) -- (window pixels per GUI reference pixel)
|
||||||
|
local w, h = math.max(1, math.floor(f.rect.w / u / 2 + 0.5)), math.max(1, math.floor(f.rect.h / u / 2 + 0.5))
|
||||||
local bright = hdr_target(self, "bloom_bright", w, h)
|
local bright = hdr_target(self, "bloom_bright", w, h)
|
||||||
c.post = vmath.vector4(0, 0, 0, 0)
|
c.post = vmath.vector4(0, 0, 0, 0)
|
||||||
c.post2 = vmath.vector4(0, 0, b.z, 0)
|
c.post2 = vmath.vector4(0, 0, b.z, 0)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
|
||||||
|
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
|
||||||
|
-- builds it under the header instead of the moon list, the section and the aside; a bar
|
||||||
|
-- on top lists the screens and leads back to the game (so does any navigation: a header
|
||||||
|
-- tab, Back).
|
||||||
|
--
|
||||||
|
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
|
||||||
|
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
|
||||||
|
-- Test ids: "admin:<screen>", "admin:back".
|
||||||
|
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local button = require("components.button")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local SCREENS = {
|
||||||
|
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function find(id)
|
||||||
|
for _, s in ipairs(SCREENS) do
|
||||||
|
if s.id == id then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.allowed(g)
|
||||||
|
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open(g, id)
|
||||||
|
local s = find(id or SCREENS[1].id)
|
||||||
|
if not s then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if g.admin and g.admin ~= s.id then
|
||||||
|
M.close(g)
|
||||||
|
end
|
||||||
|
if g.admin ~= s.id then
|
||||||
|
g.admin = s.id
|
||||||
|
if s.mod.open then
|
||||||
|
s.mod.open(g)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
g.dirty, g.dirty_all = true, true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.close(g)
|
||||||
|
local s = g.admin and find(g.admin)
|
||||||
|
if s and s.mod.close then
|
||||||
|
s.mod.close(g)
|
||||||
|
end
|
||||||
|
g.admin = nil
|
||||||
|
g.dirty, g.dirty_all = true, true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The block's inputs besides the rect.
|
||||||
|
function M.sig(g)
|
||||||
|
local s = find(g.admin)
|
||||||
|
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The bar and the screen in (x, y, w, h).
|
||||||
|
function M.build(g, parent, x, y, w, h)
|
||||||
|
local s = find(g.admin)
|
||||||
|
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
|
||||||
|
for _, sc in ipairs(SCREENS) do
|
||||||
|
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
|
||||||
|
function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
kids[#kids + 1] = fg.fill()
|
||||||
|
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
|
||||||
|
end)
|
||||||
|
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
|
||||||
|
flex.solve(bar, x, y)
|
||||||
|
fg.build(bar, parent)
|
||||||
|
local top = y + bar.h + S.gap
|
||||||
|
if s then
|
||||||
|
s.mod.build(g, parent, x, top, w, y + h - top)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.update(g, dt)
|
||||||
|
local s = g.admin and find(g.admin)
|
||||||
|
if s and s.mod.update then
|
||||||
|
s.mod.update(g, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
+262
-205
@@ -15,12 +15,16 @@ local scroll = require("components.scroll")
|
|||||||
local store = require("screens.game.store")
|
local store = require("screens.game.store")
|
||||||
local card = require("components.card")
|
local card = require("components.card")
|
||||||
local button = require("components.button")
|
local button = require("components.button")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
local vec = cards.vec
|
local vec = cards.vec
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
|
|
||||||
local BONUS = { speed = "Drill speed", yield = "Yield", sell = "Sell prices", refine = "Refinery" } -- (older servers)
|
local BONUS = { speed = "Drill speed", yield = "Yield", sell = "Sell prices", refine = "Refinery" } -- (older servers)
|
||||||
|
|
||||||
local H3 = { font = "sans_semi", px = 12, color = "text_2", tracking = 0.16, upper = true }
|
local H3 = { font = "sans_semi", px = 12, color = "text_2", tracking = 0.16, upper = true }
|
||||||
@@ -58,33 +62,30 @@ local function pick(self, H, name)
|
|||||||
self.dirty = true
|
self.dirty = true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Bonus chips ("Drill speed +10%"), wrapping at w; returns the number of lines.
|
-- Bonus chips ("Drill speed +10%"), wrapping (lines 24 tall, 6 apart). A tree piece.
|
||||||
local function chips(parent, bonuses, x, y, w)
|
local function chips(parent, bonuses)
|
||||||
local cx, lines = x, 1
|
local kids = {}
|
||||||
for _, b in ipairs(bonuses or {}) do
|
for _, b in ipairs(bonuses or {}) do
|
||||||
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
|
||||||
local label = (b.label or BONUS[b.kind] or b.kind) .. " " .. (b.text or cards.pct(b.mult))
|
local label = (b.label or BONUS[b.kind] or b.kind) .. " " .. (b.text or cards.pct(b.mult))
|
||||||
local tw = cards.tag(probe, label, 0, 0)
|
kids[#kids + 1] = fg.inline(parent, 24, function(p, x, y, probing)
|
||||||
gui.delete_node(probe)
|
local cw, tag = cards.tag(p, label, x, y)
|
||||||
if cx > x and cx + tw > x + w then
|
if b.capped and not probing then
|
||||||
cx, lines = x, lines + 1
|
tooltip.add(tag, "At the cap")
|
||||||
end
|
end
|
||||||
if parent then
|
return cw
|
||||||
local cw, box = cards.tag(parent, label, cx, y + (lines - 1) * 24)
|
end)
|
||||||
if b.capped then
|
|
||||||
tooltip.add(box, "At the cap")
|
|
||||||
end
|
end
|
||||||
cx = cx + cw + 6
|
return row({ wrap = true, column_gap = 6 }, kids)
|
||||||
end
|
|
||||||
end
|
|
||||||
return lines
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function stat_box(parent, x, y, w, h, label, fill)
|
-- A stat panel (ui.panel): a cap label, then content (a tree piece) under it.
|
||||||
local _, nodes = ui.panel(parent, x, y, w, h)
|
local function stat_box(parent, w, label, content)
|
||||||
local r = ui.part(nodes, "panel", "root")
|
return col({ width = w, padding = { 10, 14, 8, 14 }, shrink = 0 }, {
|
||||||
ui.text(r, label, "cap", 14, 18)
|
fg.label(parent, label, "cap", 16), space(0, 4), content,
|
||||||
fill(r)
|
}, { make = function(p, x, y, bw, bh)
|
||||||
|
local _, nodes = ui.panel(p, x, y, bw, bh)
|
||||||
|
return ui.part(nodes, "panel", "root")
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Why Hire is disabled (the server's whyText) and its color: red when it's credits.
|
-- Why Hire is disabled (the server's whyText) and its color: red when it's credits.
|
||||||
@@ -110,60 +111,82 @@ end
|
|||||||
|
|
||||||
-- The seats: one tile per seat up to maxSeats, in rows when narrow (tiles >= 210 wide):
|
-- The seats: one tile per seat up to maxSeats, in rows when narrow (tiles >= 210 wide):
|
||||||
-- the owned ones from seatList (who sits there, or empty), then the next one to buy and
|
-- the owned ones from seatList (who sits there, or empty), then the next one to buy and
|
||||||
-- the locked ones. Returns the y under it.
|
-- the locked ones. A grid; each tile is built at its size, its content a tree inside.
|
||||||
local function board(self, H, ad, parent, x, y, inner)
|
local function board(self, H, ad, parent, inner)
|
||||||
local list = ad.seatList or {}
|
local list = ad.seatList or {}
|
||||||
local n = math.max(ad.maxSeats or 0, #list)
|
local n = math.max(ad.maxSeats or 0, #list)
|
||||||
local gap = 10
|
local gap = 10
|
||||||
local cols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
|
local ncols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
|
||||||
local tw = math.floor((inner - (cols - 1) * gap) / cols)
|
local tw = math.floor((inner - (ncols - 1) * gap) / ncols)
|
||||||
local th = 86
|
local th = 86
|
||||||
local sel = selected(self, ad)
|
local sel = selected(self, ad)
|
||||||
|
local cols, tiles = {}, {}
|
||||||
|
for i = 1, ncols do
|
||||||
|
cols[i] = tw
|
||||||
|
end
|
||||||
|
local SEAT = { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }
|
||||||
for i = 1, n do
|
for i = 1, n do
|
||||||
local tx = x + (i - 1) % cols * (tw + gap)
|
|
||||||
local ty = y + math.floor((i - 1) / cols) * (th + gap)
|
|
||||||
local seat = list[i]
|
local seat = list[i]
|
||||||
local a = seat and seat.advisor ~= "" and find(ad, seat.advisor)
|
local a = seat and seat.advisor ~= "" and find(ad, seat.advisor)
|
||||||
local owned = seat ~= nil
|
local owned = seat ~= nil
|
||||||
local num = seat and seat.n or i
|
local num = seat and seat.n or i
|
||||||
local tile = ui.rect(parent, tx, ty, tw, th, a and "panel" or "none")
|
tiles[#tiles + 1] = made({ height = th }, function(p, x, y, w, h)
|
||||||
|
local tile = ui.rect(p, x, y, w, h, a and "panel" or "none")
|
||||||
local on = a and sel and sel.name == a.name
|
local on = a and sel and sel.name == a.name
|
||||||
local edge = ui.outline(tile, 0, 0, tw, th, on and "cyan" or (a and "ready" or (owned and "line_hi" or "line")), (a and not on) and 0.45 or nil)
|
local edge = ui.outline(tile, 0, 0, w, h, on and "cyan" or (a and "ready" or (owned and "line_hi" or "line")), (a and not on) and 0.45 or nil)
|
||||||
local seat_lbl = ui.text(tile, "Seat " .. num, { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 10, 12)
|
local tree
|
||||||
if a then
|
if a then
|
||||||
gui.set_position(seat_lbl, vec(tw - 8 - ui.text_width(seat_lbl), -12))
|
local img = h - 2
|
||||||
local img = th - 2
|
local fit = { fit = true, shrink = 1 }
|
||||||
local media = ui.rect(tile, 1, 1, img, img, "bg")
|
tree = row({ width = w, height = h, padding = 1 }, {
|
||||||
cards.image(media, a.image, 0, 0, img)
|
made({ width = img, height = img, shrink = 0 }, function(q, ix, iy)
|
||||||
local nx = img + 12
|
cards.image(ui.rect(q, ix, iy, img, img, "bg"), a.image, 0, 0, img)
|
||||||
local nm = ui.text(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, nx, 32)
|
end),
|
||||||
ui.fit_text(nm, tw - nx - 8)
|
space(11),
|
||||||
local tt = ui.text(tile, a.title, { font = "sans", px = 12, color = "muted" }, nx, 50)
|
col({ grow = 1, shrink = 1, padding = { 23, 7, 0, 0 } }, {
|
||||||
ui.fit_text(tt, tw - nx - 8)
|
fg.label(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, fit), space(0, 2),
|
||||||
local ef = ui.text(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, nx, 68)
|
fg.label(tile, a.title, { font = "sans", px = 12, color = "muted" }, 16, fit), space(0, 2),
|
||||||
ui.fit_text(ef, tw - nx - 8)
|
fg.label(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, 16, fit),
|
||||||
button.bind(self, tile, nil, { id = "seat:" .. num, label = a.name, on_click = function()
|
}),
|
||||||
open(self, H, a.name)
|
fg.label(tile, "Seat " .. num, SEAT, 12, { position = "absolute", top = 6, right = 8 }),
|
||||||
end })
|
})
|
||||||
elseif owned then
|
else
|
||||||
ui.text(tile, "Empty", { font = "sans", px = 14, color = "muted" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
|
local center = {}
|
||||||
|
if owned then
|
||||||
|
center = { fg.label(tile, "Empty", { font = "sans", px = 14, color = "muted" }, 16), space(0, 0) }
|
||||||
elseif i == ad.nextSeat and ad.nextSeatPrice > 0 then
|
elseif i == ad.nextSeat and ad.nextSeatPrice > 0 then
|
||||||
-- The next seat to buy: the tile is the button.
|
-- The next seat to buy: the tile is the button.
|
||||||
ui.text(tile, "+ Add seat", { font = "sans_semi", px = 14, color = ad.canBuySeat and "cyan" or "muted", tracking = 0.12, upper = true }, tw / 2, th / 2 - 8, gui.PIVOT_CENTER)
|
local color = ad.canBuySeat and "cyan" or "muted"
|
||||||
local probe = ui.rect(tile, 0, 0, 1, 1, "none")
|
center = {
|
||||||
local cw = ui.credits(probe, ad.nextSeatPrice, 0, 0, { px = 14 })
|
space(0, 0),
|
||||||
gui.delete_node(probe)
|
fg.label(tile, "+ Add seat", { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, 16),
|
||||||
ui.credits(tile, ad.nextSeatPrice, (tw - cw) / 2, th / 2 + 14, { px = 14, color = ad.canBuySeat and "cyan" or "muted" })
|
space(0, 6),
|
||||||
|
fg.inline(tile, 16, function(q, cx, cy)
|
||||||
|
return ui.credits(q, ad.nextSeatPrice, cx, cy, { px = 14, color = color })
|
||||||
|
end),
|
||||||
|
}
|
||||||
gui.set_color(edge, ui.color("cyan", ad.canBuySeat and 0.6 or 0.25))
|
gui.set_color(edge, ui.color("cyan", ad.canBuySeat and 0.6 or 0.25))
|
||||||
if ad.canBuySeat then
|
if ad.canBuySeat then
|
||||||
gui.set_color(tile, ui.color("cyan", 0.04))
|
gui.set_color(tile, ui.color("cyan", 0.04))
|
||||||
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
|
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
ui.text(tile, "Locked", { font = "sans", px = 13, color = "faint" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
|
center = { fg.label(tile, "Locked", { font = "sans", px = 13, color = "faint" }, 16), space(0, 0) }
|
||||||
end
|
end
|
||||||
|
-- (text centered 4 below the middle; Add seat: its two lines around it)
|
||||||
|
tree = col({ width = w, height = h, align = "center", justify = "center", padding_top = (#center > 2) and 6 or 8 }, center)
|
||||||
|
table.insert(tree.children, 1, fg.label(tile, "Seat " .. num, SEAT, 12, { position = "absolute", top = 6, left = 10 }))
|
||||||
end
|
end
|
||||||
return y + math.ceil(n / cols) * (th + gap) - gap
|
fg.build(flex.solve(tree, 0, 0), tile)
|
||||||
|
if a then
|
||||||
|
button.bind(self, tile, nil, { id = "seat:" .. num, label = a.name, on_click = function()
|
||||||
|
open(self, H, a.name)
|
||||||
|
end })
|
||||||
|
end
|
||||||
|
return tile
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return box({ columns = cols, gap = gap }, tiles)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The hired advisors not seated, in the web's order (the server's bench: hire order; older
|
-- The hired advisors not seated, in the web's order (the server's bench: hire order; older
|
||||||
@@ -187,36 +210,49 @@ local function benched(ad)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Hired, not seated (no bonus): a row each, the name opens the panel, Seat puts them in
|
-- Hired, not seated (no bonus): a row each, the name opens the panel, Seat puts them in
|
||||||
-- the lowest free seat. Returns the y under it.
|
-- the lowest free seat. A tree piece (nil when none).
|
||||||
local function bench(self, H, ad, parent, x, y, inner)
|
local function bench(self, H, ad, parent, inner)
|
||||||
local list = benched(ad)
|
local list = benched(ad)
|
||||||
if #list == 0 then
|
if #list == 0 then
|
||||||
return y
|
return nil
|
||||||
end
|
end
|
||||||
local t = ui.text(parent, "Hired, not seated", "cap", x, y + 6)
|
|
||||||
ui.text(parent, " · no bonus", { font = "sans_semi", px = 11, color = "faint", tracking = 0.2, upper = true }, x + ui.text_width(t), y + 6)
|
|
||||||
y = y + 18
|
|
||||||
local sel = selected(self, ad)
|
local sel = selected(self, ad)
|
||||||
local rh = 50
|
local rh = 50
|
||||||
|
local rows = {}
|
||||||
for _, a in ipairs(list) do
|
for _, a in ipairs(list) do
|
||||||
local on = sel and sel.name == a.name
|
local on = sel and sel.name == a.name
|
||||||
local row = ui.rect(parent, x, y, inner, rh, on and "cyan" or "panel", on and 0.06 or nil)
|
local bw = fg.button_w(parent, "Seat", { primary = true, small = true, disabled = not a.canSeat })
|
||||||
ui.outline(row, 0, 0, inner, rh, on and "cyan" or "line", on and 0.7 or nil)
|
rows[#rows + 1] = row({ height = rh, padding = { 0, 10, 0, 12 }, align = "center" }, {
|
||||||
local b, nodes, bw = ui.button(row, "Seat", 0, 0, { primary = true, small = true, disabled = not a.canSeat })
|
-- (the row's text links to the panel; Seat takes its own clicks)
|
||||||
gui.set_position(b, vec(inner - 10 - bw, -(rh - S.button_sm_h) / 2))
|
made({ position = "absolute", left = 0, top = 0, bottom = 0, right = bw + 20 }, function(p, x, y, w, h)
|
||||||
local hit = ui.rect(row, 0, 0, inner - bw - 20, rh, "none")
|
H.link(self, ui.rect(p, x, y, w, h, "none"), { section = "advisors", moon = self.view.moon, adv = a.name })
|
||||||
local nm = ui.text(row, a.name, { font = "sans_semi", px = 15, color = "text" }, 12, 18)
|
end),
|
||||||
ui.fit_text(nm, inner - bw - 40)
|
col({ grow = 1, shrink = 1, padding_right = 18, padding_top = 10, align_self = "start" }, {
|
||||||
ui.text(row, effect_of(a), { font = "mono", px = 12, color = "muted" }, 12, 35)
|
fg.label(parent, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
|
||||||
H.link(self, hit, { section = "advisors", moon = self.view.moon, adv = a.name })
|
fg.label(parent, effect_of(a), { font = "mono", px = 12, color = "muted" }, 16),
|
||||||
|
}),
|
||||||
|
fg.button(parent, "Seat", { primary = true, small = true, disabled = not a.canSeat }, function(b, nodes)
|
||||||
if a.canSeat then
|
if a.canSeat then
|
||||||
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
|
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
|
||||||
elseif (a.seatWhyText or "") ~= "" then
|
elseif (a.seatWhyText or "") ~= "" then
|
||||||
tooltip.add(b, a.seatWhyText)
|
tooltip.add(b, a.seatWhyText)
|
||||||
end
|
end
|
||||||
y = y + rh + 6
|
end),
|
||||||
|
}, { make = function(p, x, y, w, h)
|
||||||
|
local r = ui.rect(p, x, y, w, h, on and "cyan" or "panel", on and 0.06 or nil)
|
||||||
|
ui.outline(r, 0, 0, w, h, on and "cyan" or "line", on and 0.7 or nil)
|
||||||
|
return r
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
return y + 4
|
return col({}, {
|
||||||
|
row({ height = 12 }, {
|
||||||
|
fg.label(parent, "Hired, not seated", "cap", 12),
|
||||||
|
fg.label(parent, " · no bonus", { font = "sans_semi", px = 11, color = "faint", tracking = 0.2, upper = true }, 12),
|
||||||
|
}),
|
||||||
|
space(0, 6),
|
||||||
|
col({ gap = 6 }, rows),
|
||||||
|
space(0, 10),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local function advisor_card(self, H, ad, c, a)
|
local function advisor_card(self, H, ad, c, a)
|
||||||
@@ -227,24 +263,32 @@ local function advisor_card(self, H, ad, c, a)
|
|||||||
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
|
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
|
||||||
c:gap(4)
|
c:gap(4)
|
||||||
local why, why_color = hire_block(a)
|
local why, why_color = hire_block(a)
|
||||||
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
|
-- (an advisor for hire keeps the reason's place, shown or not: credits coming and going
|
||||||
|
-- don't resize the card)
|
||||||
|
c:footer(S.button_sm_h + (a.hired and 0 or 18), function(f, bw)
|
||||||
|
local kids
|
||||||
if a.hired then
|
if a.hired then
|
||||||
local seat = (a.seat or 0) > 0
|
local seat = (a.seat or 0) > 0
|
||||||
local b = ui.button(f, (seat and ("Seat " .. a.seat) or "Not seated") .. " · Manage", 0, 0, { small = true, w = bw,
|
kids = { fg.button(f, (seat and ("Seat " .. a.seat) or "Not seated") .. " · Manage", { small = true,
|
||||||
edge = seat and { "ready", 0.5 } or nil })
|
edge = seat and { "ready", 0.5 } or nil }, function(b)
|
||||||
H.link(self, b, { section = "advisors", moon = self.view.moon, adv = a.name }, "manage:" .. a.name)
|
H.link(self, b, { section = "advisors", moon = self.view.moon, adv = a.name }, "manage:" .. a.name)
|
||||||
return
|
end, { width = bw }) }
|
||||||
end
|
else
|
||||||
local can = a.canHire
|
local can = a.canHire
|
||||||
local b, nodes = ui.button(f, "Hire", 0, 0, { primary = true, small = true, w = bw, price = a.price, disabled = not can })
|
kids = {
|
||||||
if not can then
|
fg.button(f, "Hire", { primary = true, small = true, price = a.price, disabled = not can }, function(b, nodes)
|
||||||
tooltip.add(b, a.whyText)
|
|
||||||
end
|
|
||||||
if can then
|
if can then
|
||||||
H.action(self, b, nodes, "hire", { advisor = a.name })
|
H.action(self, b, nodes, "hire", { advisor = a.name })
|
||||||
elseif why then
|
else
|
||||||
store.need_text(f, why, bw, S.button_sm_h + 10, why_color)
|
tooltip.add(b, a.whyText)
|
||||||
end
|
end
|
||||||
|
end, { width = bw }),
|
||||||
|
fg.reserve({ height = 20 }, (not can and why) and made({ width = bw, height = 20 }, function(p, x, y, w)
|
||||||
|
store.need_text(p, why, x + w, y + 10, why_color)
|
||||||
|
end) or nil),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
|
||||||
end)
|
end)
|
||||||
if a.hired then
|
if a.hired then
|
||||||
c.edge_color, c.edge_hover = { "ready", 0.45 }, { "ready", 0.7 }
|
c.edge_color, c.edge_hover = { "ready", 0.45 }, { "ready", 0.7 }
|
||||||
@@ -266,39 +310,21 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
local ad = st.advisors
|
local ad = st.advisors
|
||||||
local area, x, inner, y = cards.page(self, root, px, py, w, h, "advisors")
|
local area, x, inner, y = cards.page(self, root, px, py, w, h, "advisors")
|
||||||
local c = area.content
|
local c = area.content
|
||||||
y = H.heading(c, x, y, "Advisors")
|
|
||||||
local right = 0
|
local right = 0
|
||||||
if ad.nextSeatPrice > 0 then
|
if ad.nextSeatPrice > 0 then
|
||||||
local _, bw = buy_seat(self, ad, c, x + inner, py + 10, { right = true, small = true })
|
right = fg.button_w(c, "+ Seat", { small = true, price = ad.nextSeatPrice }) + 16
|
||||||
right = bw + 16
|
|
||||||
end
|
end
|
||||||
y = y + cards.para(c, "Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.",
|
|
||||||
{ font = "sans", px = 15, color = "muted" }, x, y - 8, math.min(inner - right, S.text_max)) + 12
|
|
||||||
|
|
||||||
local sw = math.floor((inner - 12) / 2)
|
local sw = math.floor((inner - 12) / 2)
|
||||||
local probe = ui.rect(c, 0, 0, 1, 1, "none")
|
local seats_line = row({ height = 24, align = "center" }, {
|
||||||
local lines = chips(probe, ad.bonuses, 0, 0, sw - 28)
|
fg.label(c, tostring(ad.hired), { font = "mono_semi", px = 20, color = "text" }, 24),
|
||||||
gui.delete_node(probe)
|
fg.label(c, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 24),
|
||||||
local bh = 62 + (lines - 1) * 24
|
})
|
||||||
stat_box(c, x, y, sw, bh, "Seats filled", function(r)
|
local bonuses = #(ad.bonuses or {}) == 0
|
||||||
local t = ui.text(r, ad.hired, { font = "mono_semi", px = 20, color = "text" }, 14, 42)
|
and row({ height = 24, align = "center" }, { fg.label(c, "None yet", { font = "sans", px = 15, color = "muted" }, 24) })
|
||||||
ui.text(r, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 14 + ui.text_width(t), 42)
|
or chips(c, ad.bonuses)
|
||||||
end)
|
local function cap(label, after)
|
||||||
stat_box(c, x + sw + 12, y, sw, bh, "Active bonuses", function(r)
|
return col({}, { fg.label(c, label, "cap", 12), space(0, after) })
|
||||||
if #(ad.bonuses or {}) == 0 then
|
|
||||||
ui.text(r, "None yet", { font = "sans", px = 15, color = "muted" }, 14, 42)
|
|
||||||
else
|
|
||||||
chips(r, ad.bonuses, 14, 42, sw - 28)
|
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
y = y + bh + 20
|
|
||||||
|
|
||||||
ui.text(c, "Seats", "cap", x, y + 6)
|
|
||||||
y = board(self, H, ad, c, x, y + 18, inner) + 22
|
|
||||||
y = bench(self, H, ad, c, x, y, inner)
|
|
||||||
|
|
||||||
ui.text(c, "Advisors for hire", "cap", x, y + 6)
|
|
||||||
y = y + 20
|
|
||||||
-- (hired advisors no longer in the config have no card, like the web)
|
-- (hired advisors no longer in the config have no card, like the web)
|
||||||
local listed = {}
|
local listed = {}
|
||||||
for _, a in ipairs(ad.advisors) do
|
for _, a in ipairs(ad.advisors) do
|
||||||
@@ -306,45 +332,80 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
listed[#listed + 1] = a
|
listed[#listed + 1] = a
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
y = card.grid(self, c, x, y, inner, listed, function(cd, a)
|
y = fg.flow(c, x, y, inner, {
|
||||||
|
col({}, {
|
||||||
|
-- (the line under the heading starts 8 into its 44)
|
||||||
|
col({ height = 36 }, { fg.label(c, "Advisors", "h1", 40, { shrink = 0 }) }),
|
||||||
|
fg.text_para(c, "Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.",
|
||||||
|
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner - right, S.text_max) }),
|
||||||
|
space(0, 20), -- (the 8 it starts early, then 12)
|
||||||
|
ad.nextSeatPrice > 0 and made({ position = "absolute", top = 12, right = 0 }, function(p, bx, by)
|
||||||
|
buy_seat(self, ad, p, bx, by, { right = true, small = true })
|
||||||
|
end) or nil,
|
||||||
|
row({ gap = 12 }, { stat_box(c, sw, "Seats filled", seats_line), stat_box(c, sw, "Active bonuses", bonuses) }),
|
||||||
|
space(0, 20),
|
||||||
|
cap("Seats", 6),
|
||||||
|
board(self, H, ad, c, inner),
|
||||||
|
space(0, 22),
|
||||||
|
bench(self, H, ad, c, inner),
|
||||||
|
cap("Advisors for hire", 8),
|
||||||
|
}),
|
||||||
|
function(cy)
|
||||||
|
return card.grid(self, c, x, cy, inner, listed, function(cd, a)
|
||||||
advisor_card(self, H, ad, cd, a)
|
advisor_card(self, H, ad, cd, a)
|
||||||
end)
|
end, { key = function(a) return "adv:" .. tostring(a.name) end, sig = function()
|
||||||
|
-- (kept while what it shows stays the same: modules/blocks)
|
||||||
|
return { ad.selected or false, self.advisor_pick or false, self.inflight, self.view }
|
||||||
|
end })
|
||||||
|
end,
|
||||||
|
})
|
||||||
area:finish(y + py)
|
area:finish(y + py)
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- aside
|
---------------------------------------------------------------- aside
|
||||||
|
|
||||||
-- A line of the panel: text on the left (wrapping), a small button on the right.
|
-- A line of the panel: text on the left (a tree piece), a small button on the right,
|
||||||
-- fill(parent, x, y, w) draws the text and returns its height. Returns the y under it.
|
-- centered on it; 8 under it.
|
||||||
local function line(c, pad, y, inner, label, opts, fill)
|
local function line(c, label, opts, piece)
|
||||||
local b, nodes, bw = ui.button(c, label, 0, 0, { small = true, primary = opts.primary, variant = opts.variant })
|
return col({}, {
|
||||||
local th = fill(c, pad, y, inner - bw - 12)
|
row({ align = "center", gap = 12, min_height = S.button_sm_h }, {
|
||||||
local hgt = math.max(th, S.button_sm_h)
|
col({ grow = 1, shrink = 1 }, { piece }),
|
||||||
gui.set_position(b, vec(pad + inner - bw, -(y + (hgt - S.button_sm_h) / 2)))
|
fg.button(c, label, { small = true, primary = opts.primary, variant = opts.variant }, opts.bind),
|
||||||
opts.bind(b, nodes)
|
}),
|
||||||
return y + hgt + 8
|
space(0, 8),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local function name_effect(a)
|
local function name_effect(c, a)
|
||||||
return function(c, x, y, w)
|
return col({ height = 34 }, {
|
||||||
local nm = ui.text(c, a.name, { font = "sans_semi", px = 14, color = "text" }, x, y + 8)
|
fg.label(c, a.name, { font = "sans_semi", px = 14, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
|
||||||
ui.fit_text(nm, w)
|
fg.label(c, effect_of(a), { font = "mono", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
|
||||||
local ef = ui.text(c, effect_of(a), { font = "mono", px = 12, color = "muted" }, x, y + 25)
|
})
|
||||||
ui.fit_text(ef, w)
|
|
||||||
return 34
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function small_para(str)
|
local function small_para(c, str)
|
||||||
return function(c, x, y, w)
|
return fg.text_para(c, str, { font = "sans", px = 13, color = "muted" })
|
||||||
return cards.para(c, str, { font = "sans", px = 13, color = "muted" }, x, y, w)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function heading(c, pad, y, inner, str)
|
-- A rule, then the heading (H3) 20 under it; 34 in all.
|
||||||
ui.rect(c, pad, y, inner, 1, "line")
|
local function heading(c, str)
|
||||||
ui.text(c, str, H3, pad, y + 20)
|
return col({ padding_top = 12 }, {
|
||||||
return y + 34
|
made({ position = "absolute", top = 0, left = 0, right = 0, height = 1 }, function(p, x, y, w)
|
||||||
|
ui.rect(p, x, y, w, 1, "line")
|
||||||
|
end),
|
||||||
|
fg.label(c, str, H3, 16), space(0, 6),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The square picture, centered.
|
||||||
|
local function media(a, size, edge, alpha)
|
||||||
|
return row({ justify = "center" }, {
|
||||||
|
made({ width = size, height = size, shrink = 0 }, function(p, x, y)
|
||||||
|
local m = ui.rect(p, x, y, size, size, "bg")
|
||||||
|
ui.outline(m, 0, 0, size, size, edge, alpha)
|
||||||
|
cards.image(m, a.image, 0, 0, size)
|
||||||
|
end),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A hired advisor's panel (the web's advisor-panel).
|
-- A hired advisor's panel (the web's advisor-panel).
|
||||||
@@ -353,26 +414,27 @@ local function panel(self, H, ad, a, root, w, h)
|
|||||||
local inner = w - pad * 2
|
local inner = w - pad * 2
|
||||||
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor:" .. a.name)
|
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor:" .. a.name)
|
||||||
local c = area.content
|
local c = area.content
|
||||||
local y = pad
|
|
||||||
local close, _, cbw = ui.button(c, "×", 0, 0, { small = true, w = 32 })
|
|
||||||
gui.set_position(close, vec(pad + inner - cbw, -y))
|
|
||||||
H.link(self, close, { section = "advisors", moon = self.view.moon }, "advisor:close")
|
|
||||||
ui.text(c, a.title, "cap", pad, y + 6)
|
|
||||||
ui.text(c, a.name, "h2", pad, y + 26)
|
|
||||||
y = y + 44
|
|
||||||
local seat = a.seat or 0
|
local seat = a.seat or 0
|
||||||
local size = math.min(inner, 140)
|
local kids = {
|
||||||
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
|
fg.button(c, "×", { small = true, w = 32 }, function(b)
|
||||||
ui.outline(media, 0, 0, size, size, seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1)
|
H.link(self, b, { section = "advisors", moon = self.view.moon }, "advisor:close")
|
||||||
cards.image(media, a.image, 0, 0, size)
|
end, { width = 32, position = "absolute", top = 0, right = 0 }),
|
||||||
y = y + size + 14
|
fg.label(c, a.title, "cap", 12), space(0, 4),
|
||||||
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
|
fg.label(c, a.name, "h2", 20), space(0, 8),
|
||||||
y = y + 24
|
media(a, math.min(inner, 140), seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1),
|
||||||
y = y + cards.para(c, a.description, { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 10
|
space(0, 14),
|
||||||
y = H.fact(c, "Seat", pad, y, inner, seat > 0 and tostring(seat) or "Not seated")
|
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
|
||||||
y = H.fact(c, "Bonus", pad, y, inner, seat > 0 and "Active" or "Off until seated", { color = seat > 0 and "ready" or "muted" })
|
fg.text_para(c, a.description, { font = "sans", px = 13, color = "muted" }), space(0, 10),
|
||||||
y = y + 6
|
made({ height = 20 }, function(p, x, y, iw) H.fact(p, "Seat", x, y, iw, seat > 0 and tostring(seat) or "Not seated") end),
|
||||||
y = y + cards.para(c, "Changes count from your next drill, sale or batch.", { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 14
|
made({ height = 20 }, function(p, x, y, iw)
|
||||||
|
H.fact(p, "Bonus", x, y, iw, seat > 0 and "Active" or "Off until seated", { color = seat > 0 and "ready" or "muted" })
|
||||||
|
end),
|
||||||
|
space(0, 6),
|
||||||
|
small_para(c, "Changes count from your next drill, sale or batch."), space(0, 14),
|
||||||
|
}
|
||||||
|
local function add(b)
|
||||||
|
kids[#kids + 1] = b
|
||||||
|
end
|
||||||
|
|
||||||
-- Every other seat: move there (empty) or swap with who sits there.
|
-- Every other seat: move there (empty) or swap with who sits there.
|
||||||
local moves = {}
|
local moves = {}
|
||||||
@@ -382,55 +444,53 @@ local function panel(self, H, ad, a, root, w, h)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #moves > 0 then
|
if #moves > 0 then
|
||||||
y = heading(c, pad, y, inner, seat > 0 and "Move to" or "Seat in")
|
add(heading(c, seat > 0 and "Move to" or "Seat in"))
|
||||||
for _, s in ipairs(moves) do
|
for _, s in ipairs(moves) do
|
||||||
local other = s.advisor ~= "" and find(ad, s.advisor)
|
local other = s.advisor ~= "" and find(ad, s.advisor)
|
||||||
local function fill(cc, x, yy, ww)
|
local piece = row({ height = 34, align = "center" }, {
|
||||||
local n = ui.text(cc, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, x, yy + 17)
|
fg.label(c, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, 34, { min_width = 22, padding_right = 10 }),
|
||||||
local nx = x + math.max(22, ui.text_width(n) + 10)
|
other and name_effect(c, other) or fg.label(c, "Empty", { font = "sans", px = 14, color = "muted" }, 34),
|
||||||
|
})
|
||||||
if other then
|
if other then
|
||||||
return name_effect(other)(cc, nx, yy, ww - (nx - x))
|
add(line(c, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
|
||||||
end
|
|
||||||
ui.text(cc, "Empty", { font = "sans", px = 14, color = "muted" }, nx, yy + 17)
|
|
||||||
return 34
|
|
||||||
end
|
|
||||||
if other then
|
|
||||||
y = line(c, pad, y, inner, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
|
|
||||||
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = other.name })
|
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = other.name })
|
||||||
end }, fill)
|
end }, piece))
|
||||||
else
|
else
|
||||||
y = line(c, pad, y, inner, seat > 0 and "Move" or "Seat", { primary = true, bind = function(b, nodes)
|
add(line(c, seat > 0 and "Move" or "Seat", { primary = true, bind = function(b, nodes)
|
||||||
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = s.n })
|
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = s.n })
|
||||||
end }, fill)
|
end }, piece))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
y = y + 6
|
add(space(0, 6))
|
||||||
end
|
end
|
||||||
|
|
||||||
if seat > 0 then
|
if seat > 0 then
|
||||||
local swaps = benched(ad)
|
local swaps = benched(ad)
|
||||||
if #swaps > 0 then
|
if #swaps > 0 then
|
||||||
y = heading(c, pad, y, inner, "Give the seat to")
|
add(heading(c, "Give the seat to"))
|
||||||
for _, x in ipairs(swaps) do
|
for _, x in ipairs(swaps) do
|
||||||
y = line(c, pad, y, inner, "Swap in", { bind = function(b, nodes)
|
add(line(c, "Swap in", { bind = function(b, nodes)
|
||||||
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = x.name })
|
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = x.name })
|
||||||
end }, name_effect(x))
|
end }, name_effect(c, x)))
|
||||||
end
|
end
|
||||||
y = y + 6
|
add(space(0, 6))
|
||||||
end
|
end
|
||||||
y = heading(c, pad, y, inner, "Unseat")
|
add(heading(c, "Unseat"))
|
||||||
y = line(c, pad, y, inner, "Unseat", { bind = function(b, nodes)
|
add(line(c, "Unseat", { bind = function(b, nodes)
|
||||||
H.action(self, b, nodes, "unseat-advisor", { advisor = a.name })
|
H.action(self, b, nodes, "unseat-advisor", { advisor = a.name })
|
||||||
end }, small_para("Frees seat " .. seat .. ". " .. a.name .. " stays hired, without a bonus, until seated again.")) + 6
|
end }, small_para(c, "Frees seat " .. seat .. ". " .. a.name .. " stays hired, without a bonus, until seated again.")))
|
||||||
|
add(space(0, 6))
|
||||||
end
|
end
|
||||||
|
|
||||||
y = heading(c, pad, y, inner, "Dismiss")
|
add(heading(c, "Dismiss"))
|
||||||
y = line(c, pad, y, inner, "Dismiss", { variant = "danger", bind = function(b)
|
add(line(c, "Dismiss", { variant = "danger", bind = function(b)
|
||||||
if a.dismiss then
|
if a.dismiss then
|
||||||
cards.confirm_server_button(self, b, a.dismiss, "dismiss-advisor", { advisor = a.name })
|
cards.confirm_server_button(self, b, a.dismiss, "dismiss-advisor", { advisor = a.name })
|
||||||
end
|
end
|
||||||
end }, small_para("Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded."))
|
end }, small_para(c, "Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded.")))
|
||||||
area:finish(y + pad)
|
local tree = col({ width = inner }, kids)
|
||||||
|
fg.build(flex.solve(tree, pad, pad), c)
|
||||||
|
area:finish(tree.y + tree.h + pad)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -440,36 +500,33 @@ local function preview(self, H, a, root, w, h)
|
|||||||
local inner = w - pad * 2
|
local inner = w - pad * 2
|
||||||
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor")
|
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor")
|
||||||
local c = area.content
|
local c = area.content
|
||||||
local y = pad
|
local why, why_color = hire_block(a)
|
||||||
local size = math.min(inner, 300)
|
local tree = col({ width = inner }, {
|
||||||
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
|
media(a, math.min(inner, 300), "line", 1),
|
||||||
ui.outline(media, 0, 0, size, size, "line", 1)
|
fg.button(c, "×", { small = true, w = 32 }, function(b)
|
||||||
cards.image(media, a.image, 0, 0, size)
|
button.bind(self, b, nil, { id = "advisor:close", label = "Close", on_click = function()
|
||||||
local close, _, cbw = ui.button(c, "×", 0, 0, { small = true, w = 32 })
|
|
||||||
gui.set_position(close, vec(pad + inner - cbw, -y))
|
|
||||||
button.bind(self, close, nil, { id = "advisor:close", label = "Close", on_click = function()
|
|
||||||
self.advisor_pick = nil
|
self.advisor_pick = nil
|
||||||
self.dirty = true
|
self.dirty = true
|
||||||
end })
|
end })
|
||||||
y = y + size + 18
|
end, { width = 32, position = "absolute", top = 0, right = 0 }),
|
||||||
ui.text(c, a.title, "cap", pad, y + 6)
|
space(0, 18),
|
||||||
ui.text(c, a.name, "h2", pad, y + 26)
|
fg.label(c, a.title, "cap", 12), space(0, 4),
|
||||||
y = y + 42
|
fg.label(c, a.name, "h2", 20), space(0, 6),
|
||||||
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
|
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
|
||||||
y = y + 24
|
fg.text_para(c, a.description, { font = "sans", px = 14, color = "text_2" }), space(0, 14),
|
||||||
y = y + cards.para(c, a.description, { font = "sans", px = 14, color = "text_2" }, pad, y, inner) + 14
|
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end), space(0, 9),
|
||||||
ui.rect(c, pad, y, inner, 1, "line")
|
fg.button(c, "Hire", { primary = true, price = a.price, disabled = not a.canHire }, function(b, nodes)
|
||||||
y = y + 10
|
|
||||||
local why, why_color = hire_block(a)
|
|
||||||
local b, nodes = ui.button(c, "Hire", pad, y, { primary = true, w = inner, price = a.price, disabled = not a.canHire })
|
|
||||||
y = y + S.button_h + 6
|
|
||||||
if a.canHire then
|
if a.canHire then
|
||||||
H.action(self, b, nodes, "hire", { advisor = a.name })
|
H.action(self, b, nodes, "hire", { advisor = a.name })
|
||||||
elseif why then
|
|
||||||
store.need_text(c, why, pad + inner, y + 8, why_color)
|
|
||||||
y = y + 18
|
|
||||||
end
|
end
|
||||||
area:finish(y + pad)
|
end, { width = inner }),
|
||||||
|
space(0, 6),
|
||||||
|
(not a.canHire and why) and made({ height = 18 }, function(p, x, y, iw)
|
||||||
|
store.need_text(p, why, x + iw, y + 8, why_color)
|
||||||
|
end) or nil,
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, pad, pad), c)
|
||||||
|
area:finish(tree.y + tree.h + pad)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+370
-324
@@ -1,25 +1,29 @@
|
|||||||
-- The aside (region, right column): the moon viewport (the selected moon in every
|
-- The aside (region, right column): the moon viewport (the selected moon in every
|
||||||
-- section, the shroud on New moon, a grid when there is none) and under it the selected
|
-- section, the shroud on New moon, a grid when there is none) and under it the selected
|
||||||
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
|
-- well's facts (supply, what the drills leave, price, its drills and their times), the
|
||||||
-- there (a section module's aside / aside_below, see core/main.lua), always under the
|
-- moon's when no well is picked, or what the section puts there (a section module's
|
||||||
-- viewport.
|
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
|
||||||
|
-- section it only shows, but for destructive actions (Sell moon, at its foot): every
|
||||||
|
-- other action is in the main panel (moons.lua's well cards).
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local moon_view = require("modules.moon.moon")
|
local moon_view = require("modules.moon.moon")
|
||||||
local reveal = require("screens.game.reveal")
|
local reveal = require("screens.game.reveal")
|
||||||
local moons = require("screens.game.moons")
|
local moons = require("screens.game.moons")
|
||||||
|
local moon_sale = require("screens.game.moon_sale")
|
||||||
|
local predict = require("modules.predict")
|
||||||
local panel = require("components.panel")
|
local panel = require("components.panel")
|
||||||
local scroll = require("components.scroll")
|
local scroll = require("components.scroll")
|
||||||
local bar = require("components.bar")
|
local bar = require("components.bar")
|
||||||
local tooltip = require("modules.tooltip")
|
local tooltip = require("modules.tooltip")
|
||||||
local row_c = require("components.row")
|
local row_c = require("components.row")
|
||||||
local button = require("components.button")
|
|
||||||
local chip = require("components.chip")
|
local chip = require("components.chip")
|
||||||
local anim = require("modules.anim")
|
|
||||||
local images = require("modules.images")
|
local images = require("modules.images")
|
||||||
local cards = require("screens.game.cards")
|
local flex = require("modules.flex")
|
||||||
local ready = require("screens.game.core.ready")
|
local fg = require("modules.flex_gui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local anim = require("modules.anim")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -51,7 +55,10 @@ local function viewport(g, parent, x, y, size, moon)
|
|||||||
g.viewport_node = vp
|
g.viewport_node = vp
|
||||||
g.viewport_rect = g.fx:rect_of(vp)
|
g.viewport_rect = g.fx:rect_of(vp)
|
||||||
g.view_was_mystery = false
|
g.view_was_mystery = false
|
||||||
if g.view.section == "new-moon" and not g.reveal:active() then
|
if g.reveal:active() then
|
||||||
|
-- The reveal shows the moon in its own window (reveal.lua): leave the 3D moon there.
|
||||||
|
g.viewport_node = nil
|
||||||
|
elseif g.view.section == "new-moon" then
|
||||||
-- An unknown moon: the shroud and its "?" (reveal.lua).
|
-- An unknown moon: the shroud and its "?" (reveal.lua).
|
||||||
moon_view.mystery(vp)
|
moon_view.mystery(vp)
|
||||||
reveal.mystery_mark(vp, size)
|
reveal.mystery_mark(vp, size)
|
||||||
@@ -88,241 +95,41 @@ local function reach_short(st, n)
|
|||||||
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
-- moon"; "" for one.
|
|
||||||
local function reach_label(st, n)
|
|
||||||
local list = st.catalog and st.catalog.reach or {}
|
|
||||||
local pick = list[#list]
|
|
||||||
for _, r in ipairs(list) do
|
|
||||||
if r.reach == n then
|
|
||||||
pick = r
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return pick and pick.label or ""
|
|
||||||
end
|
|
||||||
|
|
||||||
-- An attachment's effect ("+20% speed", catalog), for its tag's tooltip.
|
-- A stat line (H.fact: cap label, value at the right; 20 tall).
|
||||||
local function effect_of(st, name)
|
local function fact(H, label, value, opts)
|
||||||
for _, a in ipairs(st.catalog and st.catalog.attachments or {}) do
|
return made({ height = 20 }, function(p, x, y, w)
|
||||||
if a.name == name then
|
H.fact(p, label, x, y, w, value, opts)
|
||||||
return a.effectText
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Deploy cards: one per idle tool group (well.idle). A tool that would get less than its
|
|
||||||
-- yield here has an amber edge; one that would get nothing (the well is spoken for) is
|
|
||||||
-- also dimmed to .6 (.85 under the mouse) by a veil in the panel's color (app.css
|
|
||||||
-- .deploy-card.is-short / .is-spoken).
|
|
||||||
local function deploy_cards(g, root, pad, cy, inner, well, H)
|
|
||||||
for _, grp in ipairs(well.idle) do
|
|
||||||
local hgt = grp.short and 52 or 40
|
|
||||||
local spoken = grp.short and grp.here <= 0
|
|
||||||
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
|
|
||||||
local b, bn = ui.button(root, "", pad, cy, { w = inner, small = true, edge = edge })
|
|
||||||
gui.set_size(b, vec(inner, hgt))
|
|
||||||
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
|
|
||||||
-- The tool's picture (grey when spoken for), its attachments as pips under it.
|
|
||||||
local tool = find(g.state.tools, grp.toolId)
|
|
||||||
local img = ui.rect(b, 6, 4, 32, 32, "none")
|
|
||||||
if tool and tool.image then
|
|
||||||
images.set(img, tool.image)
|
|
||||||
if spoken then
|
|
||||||
-- Greyed (no grayscale filter here): a neutral grey over the picture.
|
|
||||||
ui.rect(b, 6, 4, 32, 32, "off", 0.6)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local n_att = #(grp.attachments or {})
|
|
||||||
if n_att > 0 then
|
|
||||||
cards.pips(b, n_att, n_att, 22 - n_att * 13 / 2, hgt - 8)
|
|
||||||
end
|
|
||||||
local x0 = 46
|
|
||||||
local name = ui.text(b, grp.name, { font = "sans_semi", px = 14, color = "text" }, x0, 13)
|
|
||||||
ui.fit_text(name, inner - 150 - x0 + 12)
|
|
||||||
local tx = x0 + ui.text_width(name) + 6
|
|
||||||
local tier = ui.text(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, tx, 13)
|
|
||||||
if grp.count > 1 then
|
|
||||||
ui.text(b, "×" .. grp.count, { font = "mono", px = 11, color = "muted" }, tx + ui.text_width(tier) + 6, 13)
|
|
||||||
end
|
|
||||||
local gets = ui.text(b, "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs), { font = "mono", px = 12, color = "muted" }, x0, 29)
|
|
||||||
tooltip.add(gets, "What a cycle gets here after the drills already on it"
|
|
||||||
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
|
|
||||||
if (grp.reach or 1) > 1 then
|
|
||||||
-- Its reach ("· 3 wells"), the long words in its tooltip ("Mines 3 wells").
|
|
||||||
local rt = ui.text(b, "· " .. reach_short(g.state, grp.reach), { font = "sans", px = 12, color = "muted" }, x0 + ui.text_width(gets) + 5, 29)
|
|
||||||
local label = reach_label(g.state, grp.reach)
|
|
||||||
if label ~= "" then
|
|
||||||
tooltip.add(rt, label)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local title = "Deploy " .. grp.name .. " T" .. grp.tier
|
|
||||||
for _, a in ipairs(grp.attachments or {}) do
|
|
||||||
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
|
|
||||||
end
|
|
||||||
tooltip.add(b, title)
|
|
||||||
ui.text(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, inner - 12, hgt / 2, gui.PIVOT_E)
|
|
||||||
if grp.short then
|
|
||||||
local note = grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
|
|
||||||
or "Well spoken for: nothing left after current drills"
|
|
||||||
ui.fit_text(ui.text(b, note, { font = "sans", px = 12, color = "ready" }, x0, 43), inner - x0 - 8)
|
|
||||||
end
|
|
||||||
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
|
|
||||||
if spoken then
|
|
||||||
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
|
|
||||||
g.druid:new_hover(b, nil, function(_, on)
|
|
||||||
gui.cancel_animations(veil, "color.w")
|
|
||||||
local a = on and 0.15 or 0.4
|
|
||||||
if anim.reduced or button.instant then
|
|
||||||
anim.set(veil, "color.w", a)
|
|
||||||
else
|
|
||||||
gui.animate(veil, "color.w", a, gui.EASING_OUTQUAD, 0.15)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
cy = cy + hgt + 6
|
|
||||||
|
local CARD_NAME = { font = "sans_semi", px = 14, color = "text" }
|
||||||
|
local M11 = { font = "mono", px = 11, color = "muted" }
|
||||||
|
local M12 = { font = "mono", px = 12, color = "muted" }
|
||||||
|
local S12 = { font = "sans", px = 12, color = "muted" }
|
||||||
|
|
||||||
|
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
|
||||||
|
-- pieces for the foot of the well or moon facts.
|
||||||
|
local function sale_kids(g, c, moon, inner, H)
|
||||||
|
if not (g.state.unlocked.store and moon.sale) then
|
||||||
|
return {}
|
||||||
end
|
end
|
||||||
return cy
|
return {
|
||||||
|
space(0, 14),
|
||||||
|
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
|
||||||
|
space(0, 12),
|
||||||
|
moon_sale.box(g, H, c, moon, inner),
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The selected well under the viewport; returns the height used (it scrolls).
|
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
|
||||||
local function well_panel(g, root, pad, cy, inner, H)
|
-- thumbnail, name and track levels; what it gets; time left or Ready.
|
||||||
local st = g.state
|
local function drill_line(g, c, st, d, H, enters)
|
||||||
local moon = st.moon
|
local name, tag = moons.tool_name(st, d.toolId)
|
||||||
local w = st.well
|
|
||||||
local row = find(moon.wells, w.id) or { supply = 0, drills = {}, ready = 0 }
|
|
||||||
local dry = row.supply <= 0
|
|
||||||
-- (tapped out: the heading greys and its dot dims, app.css .side.depleted)
|
|
||||||
ui.rect(root, pad, cy + 5, 8, 8, w.resource, dry and 0.4 or 1)
|
|
||||||
local h2 = ui.text(root, w.resource .. " well", "h2", pad + 16, cy + 9)
|
|
||||||
if dry then
|
|
||||||
gui.set_color(h2, ui.color("muted"))
|
|
||||||
end
|
|
||||||
cy = cy + 26
|
|
||||||
if dry then
|
|
||||||
ui.rect(root, pad, cy + 4, inner, 6, "line")
|
|
||||||
cy = cy + 18
|
|
||||||
ui.text(root, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, pad, cy + 6)
|
|
||||||
cy = cy + 22
|
|
||||||
else
|
|
||||||
moons.supply_bar(root, pad, cy, inner, row, moons.supply_max(st))
|
|
||||||
cy = cy + 14
|
|
||||||
-- The well note in the server's words (the web's): the lead (over-drilled, fully
|
|
||||||
-- committed) in the warning color, then what the drills take of it, amber once
|
|
||||||
-- they want it all; one paragraph (engine markup runs).
|
|
||||||
if (row.noteText or "") ~= "" then
|
|
||||||
local runs = {}
|
|
||||||
if (row.statusText or "") ~= "" then
|
|
||||||
runs[#runs + 1] = { row.statusText .. ". ", "ready" }
|
|
||||||
end
|
|
||||||
runs[#runs + 1] = { row.noteText, (row.full or row.over) and "ready" or "muted" }
|
|
||||||
cy = cy + H.para(root, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, pad, cy, inner) + 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
cy = H.fact(root, "Supply left", pad, cy, inner, ui.money(row.supply))
|
|
||||||
if (row.demand or 0) > 0 then
|
|
||||||
cy = H.fact(root, "After current drills", pad, cy, inner, ui.money(row.remain), { color = row.full and "ready" or "text" })
|
|
||||||
end
|
|
||||||
cy = H.fact(root, "Sells for", pad, cy, inner, w.rate, { credits = true, suffix = " each" })
|
|
||||||
-- Advisor bonuses (the web's bonus chips; gold at the cap).
|
|
||||||
if #(st.bonuses or {}) > 0 then
|
|
||||||
local cx = pad
|
|
||||||
cy = cy + 6
|
|
||||||
for _, bn in ipairs(st.bonuses) do
|
|
||||||
local label = bn.label .. " " .. bn.text
|
|
||||||
local style = { color = bn.capped and "ready" or "violet", alpha = bn.capped and 1 or 0.4, text = bn.capped and "ready" or "violet_hi", font = "mono", px = 11 }
|
|
||||||
local probe = ui.rect(root, 0, 0, 1, 1, "none")
|
|
||||||
local _, cw = chip.new(probe, label, 0, 0, style)
|
|
||||||
gui.delete_node(probe)
|
|
||||||
if cx > pad and cx + cw > pad + inner then
|
|
||||||
cx, cy = pad, cy + 24
|
|
||||||
end
|
|
||||||
local c = chip.new(root, label, cx, cy + 10, style)
|
|
||||||
if bn.capped then
|
|
||||||
tooltip.add(c, "At the cap")
|
|
||||||
end
|
|
||||||
cx = cx + cw + 6
|
|
||||||
end
|
|
||||||
cy = cy + 24
|
|
||||||
end
|
|
||||||
cy = cy + 6
|
|
||||||
|
|
||||||
-- Multi-well drills reaching this well (claimed together, from the moon).
|
|
||||||
local wide = moon.wide
|
|
||||||
local wide_here = {}
|
|
||||||
if wide and #(row.wide or {}) > 0 then
|
|
||||||
for _, d in ipairs(wide.drills or {}) do
|
|
||||||
if contains(row.wide, d.id) then
|
|
||||||
wide_here[#wide_here + 1] = d
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The web's order: deploy, drills, then the claims and Redrill.
|
|
||||||
if not dry then
|
|
||||||
ui.text(root, "Deploy a tool", "h3", pad, cy + 6)
|
|
||||||
cy = cy + 18
|
|
||||||
if #w.idle > 0 then
|
|
||||||
cy = deploy_cards(g, root, pad, cy, inner, w, H)
|
|
||||||
local better = w.better
|
|
||||||
if better then
|
|
||||||
-- "Better: <resource> well on <moon>, X free": the well is a link.
|
|
||||||
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
|
||||||
local b, bn, bw = ui.button(root, "Deploy there", 0, 0, { small = true, primary = true })
|
|
||||||
gui.set_position(b, vec(pad + inner - bw, -cy))
|
|
||||||
local ty = cy + 16
|
|
||||||
local lead = ui.text(root, "Better:", "small", pad, ty)
|
|
||||||
local lx = pad + ui.text_width(lead) + 4
|
|
||||||
ui.rect(root, lx, ty - 4, 8, 8, better.resource)
|
|
||||||
local _, lw = H.text_link(g, root, better.resource .. " well", "small", lx + 12, ty,
|
|
||||||
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well")
|
|
||||||
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
|
|
||||||
local t = ui.label(root, rest, "small", lx + 12 + lw, ty)
|
|
||||||
if not ui.needs_rich(rest) then
|
|
||||||
ui.fit_text(t, pad + inner - bw - 8 - (lx + 12 + lw))
|
|
||||||
end
|
|
||||||
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
|
|
||||||
local name, tier = moons.tool_name(st, better.toolId)
|
|
||||||
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
|
|
||||||
cy = cy + 40
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local t = ui.text(root, "No idle tools.", "small", pad, cy + 8)
|
|
||||||
if st.unlocked.store then
|
|
||||||
local lx = pad + ui.text_width(t) + 5
|
|
||||||
local _, lw = H.text_link(g, root, "Buy one in the store", { font = "sans", px = 13 }, lx, cy + 8, { section = "store", moon = g.view.moon })
|
|
||||||
ui.text(root, ".", "small", lx + lw, cy + 8)
|
|
||||||
end
|
|
||||||
cy = cy + 22
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Drills on this well, its own and multi-well ones, oldest first (the web's Queue).
|
|
||||||
do
|
|
||||||
local drills = {}
|
|
||||||
for _, d in ipairs(row.drills) do
|
|
||||||
drills[#drills + 1] = d
|
|
||||||
end
|
|
||||||
for _, d in ipairs(wide_here) do
|
|
||||||
drills[#drills + 1] = d
|
|
||||||
end
|
|
||||||
table.sort(drills, function(a, b) return (a.start or 0) < (b.start or 0) end)
|
|
||||||
local head = ui.text(root, "Drills", "h3", pad, cy + 6)
|
|
||||||
if #drills > 1 then
|
|
||||||
local share = ui.text(root, "· " .. #drills .. " share this well, oldest claims first", "small", pad + ui.text_width(head) + 8, cy + 6)
|
|
||||||
ui.fit_text(share, inner - ui.text_width(head) - 8)
|
|
||||||
end
|
|
||||||
cy = cy + 18
|
|
||||||
if #row.drills == 0 and #(row.wide or {}) == 0 then
|
|
||||||
ui.text(root, "Nothing is mining here yet.", "small", pad, cy + 8)
|
|
||||||
cy = cy + 22
|
|
||||||
end
|
|
||||||
moons.track_drills(g)
|
|
||||||
for _, d in ipairs(drills) do
|
|
||||||
local name, tier = moons.tool_name(st, d.toolId)
|
|
||||||
-- A row of its own (it slides in when the drill was just deployed).
|
|
||||||
local dr = ui.rect(root, pad, cy, inner, 34, "none")
|
|
||||||
-- Thumbnail, name, tier and the attachments' tags.
|
|
||||||
local tool = find(st.tools, d.toolId)
|
local tool = find(st.tools, d.toolId)
|
||||||
|
return made({ height = 34 }, function(p, x, y, inner)
|
||||||
|
local dr = ui.rect(p, x, y, inner, 34, "none")
|
||||||
local thumb = ui.rect(dr, 0, 3, 28, 28, "none")
|
local thumb = ui.rect(dr, 0, 3, 28, 28, "none")
|
||||||
if tool and tool.image then
|
if tool and tool.image then
|
||||||
images.set(thumb, tool.image)
|
images.set(thumb, tool.image)
|
||||||
@@ -333,101 +140,212 @@ local function well_panel(g, root, pad, cy, inner, H)
|
|||||||
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
|
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
|
||||||
end
|
end
|
||||||
local x0 = 36
|
local x0 = 36
|
||||||
local nt = ui.text(dr, name, { font = "sans_semi", px = 14, color = "text" }, x0, 8)
|
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
|
||||||
local tt = ui.text(dr, "T" .. tier, { font = "mono", px = 11, color = "muted" }, x0 + ui.text_width(nt) + 6, 8)
|
local head = { fg.node_label(nt, 16) }
|
||||||
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
|
if tag ~= "" then
|
||||||
for _, a in ipairs(tool and tool.attachments or {}) do
|
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
|
||||||
if tx + 60 > inner - 50 then
|
head[#head + 1] = space(6)
|
||||||
break
|
head[#head + 1] = fg.node_label(tt, 16)
|
||||||
end
|
|
||||||
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
|
|
||||||
local aw = cards.tag(probe, a, 0, 0)
|
|
||||||
gui.delete_node(probe)
|
|
||||||
if tx + aw > inner - 50 then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
local tw = cards.tag(dr, a, tx, 8)
|
|
||||||
local fx_text = effect_of(st, a)
|
|
||||||
if fx_text then
|
|
||||||
tooltip.add(ui.rect(dr, tx, 0, tw, 16, "none"), fx_text)
|
|
||||||
end
|
|
||||||
tx = tx + tw + 4
|
|
||||||
end
|
end
|
||||||
-- The amount it gets in ready when it comes up short (engine markup runs).
|
-- The amount it gets in ready when it comes up short (engine markup runs).
|
||||||
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
|
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
|
||||||
local gets
|
local line2
|
||||||
if d.state == "none" then
|
if d.state == "none" then
|
||||||
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
|
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
|
||||||
gets = "+" .. ui.money(d.amount) .. "/cycle" .. reach
|
local tag_style = { color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true }
|
||||||
local lead = ui.text(dr, gets, { font = "sans", px = 12, color = "muted" }, x0, 24)
|
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
|
||||||
local tag, tw = chip.new(dr, "Nothing left", x0 + ui.text_width(lead) + 6, 24,
|
local _, tw = chip.new(probe, "Nothing left", 0, 0, tag_style)
|
||||||
{ color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true })
|
gui.delete_node(probe)
|
||||||
|
line2 = {
|
||||||
|
fg.label(dr, "+" .. ui.money(d.amount) .. "/cycle" .. reach, S12, 16), space(6),
|
||||||
|
made({ width = tw, height = 16, shrink = 0 }, function(q, ax, ay)
|
||||||
|
local tag = chip.new(q, "Nothing left", ax, ay + 8, tag_style)
|
||||||
tooltip.add(tag, "Nothing left by its turn: stops and goes idle when the well runs dry")
|
tooltip.add(tag, "Nothing left by its turn: stops and goes idle when the well runs dry")
|
||||||
gets = nil
|
end),
|
||||||
|
}
|
||||||
else
|
else
|
||||||
local short = d.state ~= "ok" and "ready" or "muted"
|
local short = d.state ~= "ok" and "ready" or "muted"
|
||||||
gets = ui.runs({
|
line2 = { fg.label(dr, ui.runs({
|
||||||
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
|
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
|
||||||
{ ui.money(d.here or d.take), short },
|
{ ui.money(d.here or d.take), short },
|
||||||
{ ((d.reach or 1) > 1 and (" here" .. (d.gets and d.gets ~= d.here and (" · " .. ui.money(d.gets) .. " in all") or "")) or ""), "muted" },
|
{ ((d.reach or 1) > 1 and (" here" .. (d.gets and d.gets ~= d.here and (" · " .. ui.money(d.gets) .. " in all") or "")) or ""), "muted" },
|
||||||
{ d.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
|
{ d.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
|
||||||
})
|
}), { font = "sans", px = 12, color = "white", markup = true }, 16) }
|
||||||
end
|
|
||||||
if gets then
|
|
||||||
ui.text(dr, gets, { font = "sans", px = 12, color = "white", markup = true }, x0, 24)
|
|
||||||
end
|
end
|
||||||
|
local right = made({ position = "absolute", right = 0, top = 6, width = 0, height = 16 }, function(q, rx, ry)
|
||||||
if d.ready then
|
if d.ready then
|
||||||
bar.ready_label(dr, "Ready", inner, 14, gui.PIVOT_E)
|
bar.ready_label(q, "Ready", rx, ry + 8, gui.PIVOT_E)
|
||||||
else
|
else
|
||||||
local t = ui.text(dr, ui.dur(d["end"] - H.now(g)), { font = "mono", px = 12, color = "muted" }, inner, 14, gui.PIVOT_E)
|
local t = ui.text(q, ui.dur(d["end"] - H.now(g)), M12, rx, ry + 8, gui.PIVOT_E)
|
||||||
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
||||||
end
|
end
|
||||||
row_c.enter(g, "drill:" .. d.toolId, dr, inner, 34)
|
end)
|
||||||
cy = cy + 36
|
local tree = box({ width = inner, height = 34, padding_left = x0, direction = "column" }, {
|
||||||
|
row({ height = 16, align = "center" }, head),
|
||||||
|
row({ height = 16, align = "center" }, line2),
|
||||||
|
right,
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), dr)
|
||||||
|
enters[#enters + 1] = function() row_c.enter(g, "drill:" .. d.toolId, dr, inner, 34) end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
cy = cy + 8
|
|
||||||
|
|
||||||
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
|
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
|
||||||
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
|
local function well_panel(g, root, pad, top, inner, H)
|
||||||
local amount = ready.amount(g, row, row.supply)
|
local st = g.state
|
||||||
if row.ready > 0 then
|
local moon = st.moon
|
||||||
local b, bn, bw = ui.button(root, "Claim " .. ui.money(amount), pad, cy, { primary = true })
|
local w = st.well
|
||||||
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
|
local c = root
|
||||||
if not dry and st.unlocked.redeploy then
|
local wrow = find(moon.wells, w.id) or { supply = 0, drills = {}, ready = 0 }
|
||||||
local b2, bn2 = ui.button(root, "Claim and redeploy", pad + bw + 8, cy)
|
local dry = wrow.supply <= 0
|
||||||
H.action(g, b2, bn2, "claim", { ["claim-well"] = w.id, redeploy = "1" })
|
local kids = {}
|
||||||
end
|
local function add(b)
|
||||||
cy = cy + S.button_h + 10
|
if b then
|
||||||
elseif row.nextEnd then
|
kids[#kids + 1] = b
|
||||||
H.claim_wait(g, root, pad, cy, row.nextEnd, { key = "wait:well", params = { ["claim-well"] = w.id }, moon = moon.id,
|
|
||||||
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil,
|
|
||||||
also = not dry and st.unlocked.redeploy
|
|
||||||
and { label = "Claim and redeploy", params = { ["claim-well"] = w.id, redeploy = "1" } } or nil })
|
|
||||||
cy = cy + S.button_h + 10
|
|
||||||
end
|
|
||||||
if #wide_here > 0 then
|
|
||||||
local wamount = ready.amount(g, wide, wide.cap)
|
|
||||||
if wide.ready > 0 then
|
|
||||||
local b, bn, bw = ui.button(root, "Claim " .. ui.money(wamount), pad, cy, { primary = true })
|
|
||||||
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
|
|
||||||
if st.unlocked.redeploy then
|
|
||||||
local b2, bn2 = ui.button(root, "Claim and redeploy", pad + bw + 8, cy)
|
|
||||||
H.action(g, b2, bn2, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
|
|
||||||
end
|
|
||||||
cy = cy + S.button_h + 10
|
|
||||||
elseif wide.nextEnd then
|
|
||||||
H.claim_wait(g, root, pad, cy, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
|
|
||||||
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
|
|
||||||
cy = cy + S.button_h + 10
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if (row.redrill or 0) > 0 and not dry and st.unlocked.redrill then
|
-- (tapped out: the heading greys and its dot dims, app.css .side.depleted)
|
||||||
local b, bn = ui.button(root, "Redrill" .. (row.redrill > 1 and (" " .. row.redrill) or ""), pad, cy, { variant = "redrill" })
|
local h2 = fg.label(c, w.resource .. " well", "h2", 18)
|
||||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
|
if dry then
|
||||||
cy = cy + S.button_h + 10
|
gui.set_color(h2.node, ui.color("muted"))
|
||||||
end
|
end
|
||||||
return cy
|
add(row({ height = 18, align = "center" }, {
|
||||||
|
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, w.resource, dry and 0.4 or 1) end),
|
||||||
|
space(8), h2,
|
||||||
|
}))
|
||||||
|
add(space(0, 8))
|
||||||
|
if dry then
|
||||||
|
add(space(0, 4))
|
||||||
|
add(made({ height = 6 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 6, "line") end))
|
||||||
|
add(space(0, 8))
|
||||||
|
add(fg.label(c, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, 12))
|
||||||
|
add(space(0, 10))
|
||||||
|
else
|
||||||
|
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st, moon)) end))
|
||||||
|
add(space(0, 8))
|
||||||
|
-- The well note in the server's words (the web's): the lead (over-drilled, fully
|
||||||
|
-- committed) in the warning color, then what the drills take of it, amber once
|
||||||
|
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
|
||||||
|
-- when there is no note, so a deploy doesn't make the facts jump.
|
||||||
|
local note = nil
|
||||||
|
if (wrow.noteText or "") ~= "" then
|
||||||
|
local runs = {}
|
||||||
|
if (wrow.statusText or "") ~= "" then
|
||||||
|
runs[#runs + 1] = { wrow.statusText .. ". ", "ready" }
|
||||||
|
end
|
||||||
|
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" }
|
||||||
|
note = fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) })
|
||||||
|
end
|
||||||
|
add(col({ min_height = 32, shrink = 0 }, { note }))
|
||||||
|
add(space(0, 4))
|
||||||
|
end
|
||||||
|
add(fact(H, "Supply left", ui.money(wrow.supply)))
|
||||||
|
local demand = (wrow.demand or 0) > 0
|
||||||
|
add(fact(H, "After current drills", ui.money(demand and wrow.remain or wrow.supply), { color = (demand and wrow.full) and "ready" or "text" }))
|
||||||
|
add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" }))
|
||||||
|
-- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping.
|
||||||
|
if #(st.bonuses or {}) > 0 then
|
||||||
|
local chips = {}
|
||||||
|
for _, bn in ipairs(st.bonuses) do
|
||||||
|
local label = bn.label .. " " .. bn.text
|
||||||
|
local style = { color = bn.capped and "ready" or "violet", alpha = bn.capped and 1 or 0.4, text = bn.capped and "ready" or "violet_hi", font = "mono", px = 11 }
|
||||||
|
local probe = ui.rect(c, 0, 0, 1, 1, "none")
|
||||||
|
local _, cw = chip.new(probe, label, 0, 0, style)
|
||||||
|
gui.delete_node(probe)
|
||||||
|
chips[#chips + 1] = made({ width = cw, height = 18, shrink = 0 }, function(p, x, y)
|
||||||
|
local ch = chip.new(p, label, x, y + 9, style)
|
||||||
|
if bn.capped then
|
||||||
|
tooltip.add(ch, "At the cap")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
add(space(0, 7))
|
||||||
|
add(row({ wrap = true, column_gap = 6, row_gap = 6 }, chips))
|
||||||
|
add(space(0, 5))
|
||||||
|
end
|
||||||
|
add(space(0, 6))
|
||||||
|
|
||||||
|
-- Multi-well drills reaching this well (claimed together, from the moon).
|
||||||
|
local wide = moon.wide
|
||||||
|
local wide_here = {}
|
||||||
|
if wide and #(wrow.wide or {}) > 0 then
|
||||||
|
-- The server's here on these is the whole moon (what it gets in all): this well's
|
||||||
|
-- share comes from the projection (the take from this well).
|
||||||
|
local p = predict.project(st, moon)
|
||||||
|
local wi
|
||||||
|
for i, x in ipairs(moon.wells or {}) do
|
||||||
|
if x.id == w.id then
|
||||||
|
wi = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, d in ipairs(wide.drills or {}) do
|
||||||
|
if contains(wrow.wide, d.id) then
|
||||||
|
local dd = {}
|
||||||
|
for k, v in pairs(d) do
|
||||||
|
dd[k] = v
|
||||||
|
end
|
||||||
|
local take = p.take[d.id]
|
||||||
|
if take and wi then
|
||||||
|
dd.here = take[wi] or 0
|
||||||
|
end
|
||||||
|
wide_here[#wide_here + 1] = dd
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drills on this well, its own and multi-well ones, oldest first (the web's Queue).
|
||||||
|
local drills = {}
|
||||||
|
for _, d in ipairs(wrow.drills) do
|
||||||
|
drills[#drills + 1] = d
|
||||||
|
end
|
||||||
|
for _, d in ipairs(wide_here) do
|
||||||
|
drills[#drills + 1] = d
|
||||||
|
end
|
||||||
|
table.sort(drills, function(a, b) return (a.start or 0) < (b.start or 0) end)
|
||||||
|
add(row({ height = 12, align = "center" }, {
|
||||||
|
fg.label(c, "Drills", "h3", 12),
|
||||||
|
#drills > 1 and space(8) or nil,
|
||||||
|
#drills > 1 and fg.label(c, "· " .. #drills .. " share this well, oldest claims first", "small", 12, { fit = true, shrink = 1 }) or nil,
|
||||||
|
}))
|
||||||
|
add(space(0, 6))
|
||||||
|
if #wrow.drills == 0 and #(wrow.wide or {}) == 0 then
|
||||||
|
add(fg.label(c, "Nothing is mining here yet.", "small", 16))
|
||||||
|
add(space(0, 6))
|
||||||
|
end
|
||||||
|
moons.track_drills(g)
|
||||||
|
local enters = {}
|
||||||
|
local lines = {}
|
||||||
|
for _, d in ipairs(drills) do
|
||||||
|
lines[#lines + 1] = drill_line(g, c, st, d, H, enters)
|
||||||
|
end
|
||||||
|
add(col({ gap = 2 }, lines))
|
||||||
|
add(space(0, #lines > 0 and 10 or 8))
|
||||||
|
|
||||||
|
-- Idle drills that last mined it (Redrill on its card), and a better well for the
|
||||||
|
-- idle drills (the server's pick), as text.
|
||||||
|
if (wrow.redrill or 0) > 0 then
|
||||||
|
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
|
||||||
|
add(space(0, 6))
|
||||||
|
end
|
||||||
|
local better = w.better
|
||||||
|
if better and not dry then
|
||||||
|
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
||||||
|
local name, tag = moons.tool_name(st, better.toolId)
|
||||||
|
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
|
||||||
|
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
|
||||||
|
add(space(0, 6))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (no buttons here but Sell moon: every action on the well is on its card)
|
||||||
|
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||||
|
add(k)
|
||||||
|
end
|
||||||
|
local tree = col({ width = inner }, kids)
|
||||||
|
fg.build(flex.solve(tree, pad, top), c)
|
||||||
|
for _, f in ipairs(enters) do
|
||||||
|
f()
|
||||||
|
end
|
||||||
|
return tree.y + tree.h
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The moon the viewport shows: the selected moon in every section (but New moon).
|
-- The moon the viewport shows: the selected moon in every section (but New moon).
|
||||||
@@ -457,10 +375,132 @@ function M.row_viewport(w)
|
|||||||
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
|
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The selected moon's facts under the viewport when no well is picked (read only).
|
||||||
|
local function moon_status(g, c, x, y, inner, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local supply, dry, mining, done = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(moon.wells or {}) do
|
||||||
|
supply = supply + w.supply
|
||||||
|
dry = dry + (w.supply <= 0 and 1 or 0)
|
||||||
|
end
|
||||||
|
local now = H.now(g)
|
||||||
|
for _, s in ipairs(st.stakes or {}) do
|
||||||
|
if s.moonId == moon.id then
|
||||||
|
if s.ready or (s["end"] or math.huge) <= now then
|
||||||
|
done = done + 1
|
||||||
|
else
|
||||||
|
mining = mining + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local idle = 0
|
||||||
|
for _, t in ipairs(st.tools or {}) do
|
||||||
|
idle = idle + (t.stakeId and 0 or 1)
|
||||||
|
end
|
||||||
|
local kids = {
|
||||||
|
fg.label(c, moon.name, "h2", 18), space(0, 10),
|
||||||
|
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
|
||||||
|
space(0, 10),
|
||||||
|
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
|
||||||
|
fact(H, "Supply left", ui.money(supply)),
|
||||||
|
fact(H, "Drills mining", tostring(mining)),
|
||||||
|
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
|
||||||
|
fact(H, "Idle drills", tostring(idle)),
|
||||||
|
}
|
||||||
|
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||||
|
kids[#kids + 1] = k
|
||||||
|
end
|
||||||
|
local tree = col({ width = inner }, kids)
|
||||||
|
fg.build(flex.solve(tree, x, y), c)
|
||||||
|
return tree.y + tree.h
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A heading (h2) and a line under the viewport.
|
||||||
|
local function caption(c, x, y, inner, title, text)
|
||||||
|
fg.build(flex.solve(col({ width = inner }, {
|
||||||
|
fg.label(c, title, "h2", 18), space(0, 10),
|
||||||
|
fg.text_para(c, text, "small", { max_width = math.min(inner, S.text_max) }),
|
||||||
|
}), x, y), c)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The facts under the viewport on the Moons section, built into root (at where.root's
|
||||||
|
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
|
||||||
|
-- block of their own (core/main.lua), kept while M.facts_sig stays.
|
||||||
|
function M.facts(g, root, where, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
|
||||||
|
if st.well then
|
||||||
|
-- The well panel scrolls under the viewport (many drills).
|
||||||
|
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||||
|
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||||
|
elseif moon.finished then
|
||||||
|
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||||
|
else
|
||||||
|
-- (scrolls like the well panel: Sell moon at its foot)
|
||||||
|
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||||
|
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What M.facts shows (its block's signature, with where): from the state and our clock.
|
||||||
|
function M.facts_sig(g, where, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local now = H.now(g)
|
||||||
|
local sale = st.unlocked.store and moon.sale or false
|
||||||
|
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
|
||||||
|
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
|
||||||
|
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
|
||||||
|
if moon.finished then
|
||||||
|
return blocks.sig(common)
|
||||||
|
end
|
||||||
|
local supplies = {}
|
||||||
|
for i, x in ipairs(moon.wells or {}) do
|
||||||
|
supplies[i] = x.supply
|
||||||
|
end
|
||||||
|
if st.well then
|
||||||
|
local w = st.well
|
||||||
|
local wrow = find(moon.wells, w.id) or false
|
||||||
|
local tools = {}
|
||||||
|
for i, d in ipairs(wrow and wrow.drills or {}) do
|
||||||
|
local t = find(st.tools, d.toolId)
|
||||||
|
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||||
|
end
|
||||||
|
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
|
||||||
|
for _, d in ipairs(wide and wide.drills or {}) do
|
||||||
|
local t = find(st.tools, d.toolId)
|
||||||
|
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||||
|
end
|
||||||
|
local better = w.better or false
|
||||||
|
local bt = better and find(st.tools, better.toolId)
|
||||||
|
local bm = better and find(st.moons, better.moonId)
|
||||||
|
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
|
||||||
|
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
|
||||||
|
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
|
||||||
|
end
|
||||||
|
local mining, done, idle = 0, 0, 0
|
||||||
|
for _, s in ipairs(st.stakes or {}) do
|
||||||
|
if s.moonId == moon.id then
|
||||||
|
if s.ready or (s["end"] or math.huge) <= now then
|
||||||
|
done = done + 1
|
||||||
|
else
|
||||||
|
mining = mining + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, t in ipairs(st.tools or {}) do
|
||||||
|
idle = idle + (t.stakeId and 0 or 1)
|
||||||
|
end
|
||||||
|
return blocks.sig(common, supplies, mining, done, idle)
|
||||||
|
end
|
||||||
|
|
||||||
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
||||||
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
||||||
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
||||||
-- Returns the panel (components/panel: its entrance).
|
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
|
||||||
|
-- moon, where its facts go (M.facts).
|
||||||
function M.build(g, parent, x, y, w, h, H, mod, row)
|
function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
local p = panel.new(parent, x, y, w, h)
|
local p = panel.new(parent, x, y, w, h)
|
||||||
@@ -470,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
|||||||
local section = g.view.section
|
local section = g.view.section
|
||||||
local moon = section == "moons" and st.moon or nil
|
local moon = section == "moons" and st.moon or nil
|
||||||
local cy
|
local cy
|
||||||
if row then
|
-- A section panel that wants the whole aside (the drill panel): no moon over it.
|
||||||
|
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
|
||||||
|
if full then
|
||||||
|
moon_view.hide()
|
||||||
|
g.viewport_node, g.viewport_rect = nil, nil
|
||||||
|
cy = 8
|
||||||
|
elseif row then
|
||||||
local vp = M.row_viewport(w)
|
local vp = M.row_viewport(w)
|
||||||
viewport(g, root, pad, pad, vp, shown_moon(g))
|
viewport(g, root, pad, pad, vp, shown_moon(g))
|
||||||
-- The side panel gets the rest of the row as its own box.
|
-- The side panel gets the rest of the row as its own box.
|
||||||
@@ -480,9 +526,15 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
|||||||
inner = w - pad * 2
|
inner = w - pad * 2
|
||||||
cy = pad + 10
|
cy = pad + 10
|
||||||
else
|
else
|
||||||
|
-- The square viewport, centered, leaving 300 under it for the panel.
|
||||||
local vp = math.min(inner, h - pad * 2 - 300)
|
local vp = math.min(inner, h - pad * 2 - 300)
|
||||||
viewport(g, root, pad + (inner - vp) / 2, pad, vp, shown_moon(g))
|
local top = col({ width = w, padding = { pad, pad, 0, pad }, align = "center" }, {
|
||||||
cy = pad + vp + 18
|
made({ width = vp, aspect_ratio = 1 }, function(q, vx, vy, size)
|
||||||
|
viewport(g, q, vx, vy, size, shown_moon(g))
|
||||||
|
end),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(top, 0, 0), root)
|
||||||
|
cy = top.h + 18
|
||||||
end
|
end
|
||||||
local limit = h - pad
|
local limit = h - pad
|
||||||
if g.reveal:hiding() then
|
if g.reveal:hiding() then
|
||||||
@@ -497,20 +549,14 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
|||||||
end
|
end
|
||||||
gui.delete_node(box)
|
gui.delete_node(box)
|
||||||
end
|
end
|
||||||
if moon and st.well then
|
if moon then
|
||||||
-- The well panel scrolls under the viewport (many deploy cards or drills).
|
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
|
||||||
local top = cy - 8
|
-- viewport doesn't show keeps the panel and its moon.
|
||||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
|
||||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
|
||||||
elseif moon then
|
|
||||||
ui.label(root, moon.name, "h2", pad, cy + 9)
|
|
||||||
H.para(root, moon.finished and "Mined out. Nothing left to dig." or "Pick a well in the table to deploy tools and claim resources.",
|
|
||||||
"small", pad, cy + 28, inner)
|
|
||||||
elseif mod and mod.aside_below and st[section] then
|
elseif mod and mod.aside_below and st[section] then
|
||||||
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
||||||
elseif section == "new-moon" then
|
elseif section == "new-moon" then
|
||||||
ui.text(root, "Unknown moon", "h2", pad, cy + 9)
|
caption(root, pad, cy, inner, "Unknown moon", "Scan resolution too low to make out the surface. Claim it to see what it holds.")
|
||||||
H.para(root, "Scan resolution too low to make out the surface. Claim it to see what it holds.", "small", pad, cy + 28, inner)
|
|
||||||
end
|
end
|
||||||
return p
|
return p
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ local STEPS = {
|
|||||||
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
||||||
function(a) return a:press("buy-moon") end,
|
function(a) return a:press("buy-moon") end,
|
||||||
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
||||||
-- Buy another, straight from the card (its button registers as buy-moon).
|
-- Buy another, straight from the card (its button registers as reveal-buy).
|
||||||
function(a) return a:press("buy-moon") end,
|
function(a) return a:press("reveal-buy") end,
|
||||||
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
||||||
function(a) return a:press("reveal-close") end,
|
function(a) return a:press("reveal-close") end,
|
||||||
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
||||||
|
|||||||
@@ -105,17 +105,13 @@ local STEPS = {
|
|||||||
return j and j.ready and a:press("collect", function(p) return p.job == j.id end)
|
return j and j.ready and a:press("collect", function(p) return p.job == j.id end)
|
||||||
end,
|
end,
|
||||||
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") end,
|
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") end,
|
||||||
function(a) return go(a, "leaderboard") end,
|
function(a) return go(a, "market") end,
|
||||||
function(a) return a.g.view.section == "leaderboard" and shot("10-leaderboard") end,
|
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
||||||
function(a) return go(a, "help") end,
|
function(a) return go(a, "help") end,
|
||||||
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
||||||
-- Start over: arm the button (its first click), then run new-game.
|
-- Start over: open its confirm, then run new-game (as its OK).
|
||||||
function(a)
|
function(a) return a:press("confirm:new-game") end,
|
||||||
a.g.help_armed = a.g.h.now(a.g) + 30000
|
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
|
||||||
a.g.dirty = true
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
function() return shot("12-help-confirm") end,
|
|
||||||
function(a) return a:press("new-game") end,
|
function(a) return a:press("new-game") end,
|
||||||
function(a) return a.g.state.view.section ~= "help" or true end,
|
function(a) return a.g.state.view.section ~= "help" or true end,
|
||||||
function() return shot("13-new-game") end,
|
function() return shot("13-new-game") end,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
-- padding 6px 16px).
|
-- padding 6px 16px).
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -22,75 +24,64 @@ function M.time_left(ms)
|
|||||||
return string.format("%dm left", mins)
|
return string.format("%dm left", mins)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One item (dot, text, time left) at (0, 0) of a new node; returns it and its width.
|
-- One item (dot, text, time left), a tree piece at most max_w wide: on one line, or when
|
||||||
|
-- longer than the window is wide, the text wraps and the time left goes under it.
|
||||||
local function item(self, H, parent, b, max_w)
|
local function item(self, H, parent, b, max_w)
|
||||||
local root = ui.rect(parent, 0, 0, 1, ROW_H, "none")
|
|
||||||
local color = b.level == "warning" and "danger_text" or (b.level == "event" and "ready" or "text")
|
local color = b.level == "warning" and "danger_text" or (b.level == "event" and "ready" or "text")
|
||||||
ui.rect(root, 0, ROW_H / 2 - 3, 6, 6, DOT[b.level] or "cyan")
|
|
||||||
local style = { font = "sans_semi", px = 14, color = color, tracking = 0.06 }
|
local style = { font = "sans_semi", px = 14, color = color, tracking = 0.06 }
|
||||||
local node, tw = ui.label(root, b.text, style, 14, ROW_H / 2)
|
local dot = fg.made({ width = 6, height = 6, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 6, 6, DOT[b.level] or "cyan") end)
|
||||||
local w, h = 14 + tw, ROW_H
|
local left
|
||||||
if w > max_w then
|
|
||||||
-- Longer than the window is wide: the text wraps (the time left goes under it).
|
|
||||||
gui.delete_node(node)
|
|
||||||
local _, th = ui.wrap(root, b.text, style, 14, 0, max_w - 14)
|
|
||||||
w, h = max_w, math.max(ROW_H, th)
|
|
||||||
end
|
|
||||||
if b.kind == "event" and (b.left or "") ~= "" then
|
if b.kind == "event" and (b.left or "") ~= "" then
|
||||||
local text = "— " .. b.left
|
|
||||||
local ends = b["end"]
|
local ends = b["end"]
|
||||||
|
local text = (ends and ends > 0) and ("— " .. M.time_left(ends - H.now(self))) or ("— " .. b.left)
|
||||||
|
left = fg.label(parent, text, { font = "sans", px = 14, color = "text_2", tracking = 0.06 }, ROW_H)
|
||||||
if ends and ends > 0 then
|
if ends and ends > 0 then
|
||||||
text = "— " .. M.time_left(ends - H.now(self))
|
left.done = function(n)
|
||||||
end
|
H.tick_text(self, n, ends, function(ms)
|
||||||
local lx, ly = w + 5, ROW_H / 2
|
|
||||||
if h > ROW_H then
|
|
||||||
lx, ly, h = 14, h + ROW_H / 2, h + ROW_H
|
|
||||||
end
|
|
||||||
local left = ui.text(root, text, { font = "sans", px = 14, color = "text_2", tracking = 0.06 }, lx, ly)
|
|
||||||
if ends and ends > 0 then
|
|
||||||
H.tick_text(self, left, ends, function(ms)
|
|
||||||
return "— " .. M.time_left(ms)
|
return "— " .. M.time_left(ms)
|
||||||
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
|
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
|
||||||
end
|
end
|
||||||
w = math.max(w, lx + ui.text_width(left))
|
|
||||||
end
|
end
|
||||||
return root, math.min(w, max_w), h
|
end
|
||||||
|
local label = fg.label(parent, b.text, style, ROW_H)
|
||||||
|
if 14 + label.measure() <= max_w then
|
||||||
|
return fg.row({ height = ROW_H, align = "center", shrink = 0 }, { dot, fg.space(8), label, left and fg.space(5) or nil, left })
|
||||||
|
end
|
||||||
|
gui.delete_node(label.node)
|
||||||
|
return fg.col({ width = max_w, shrink = 0 }, {
|
||||||
|
fg.row({ align = "start" }, {
|
||||||
|
fg.row({ height = ROW_H, align = "center" }, { dot }), fg.space(8),
|
||||||
|
fg.measured(parent, function(p, x, y, w)
|
||||||
|
local _, th = ui.wrap(p, b.text, style, x, y, w)
|
||||||
|
return math.max(ROW_H, th)
|
||||||
|
end, { grow = 1 }),
|
||||||
|
}),
|
||||||
|
left and fg.row({ padding_left = 14 }, { left }) or nil,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The banner across the top of parent, W wide. Returns its height (0 when empty).
|
-- The banner across the top of parent, W wide: the items centered in rows that wrap
|
||||||
|
-- (gap 4 24, padding 6 16), a line under it. Returns its height (0 when empty).
|
||||||
function M.build(self, H, parent, W)
|
function M.build(self, H, parent, W)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
if not st.banner or #st.banner == 0 then
|
if not st.banner or #st.banner == 0 then
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
local root = ui.rect(parent, 0, 0, W, 1, "cyan", 0.08)
|
|
||||||
-- Rows of items: fill a row, then center it.
|
|
||||||
local max_w = W - PAD_X * 2
|
local max_w = W - PAD_X * 2
|
||||||
local rows, row = {}, { w = 0, h = 0 }
|
local items = {}
|
||||||
for _, b in ipairs(st.banner) do
|
for _, b in ipairs(st.banner) do
|
||||||
local node, w, h = item(self, H, root, b, max_w)
|
items[#items + 1] = item(self, H, parent, b, max_w)
|
||||||
if #row > 0 and row.w + GAP_X + w > max_w then
|
|
||||||
rows[#rows + 1] = row
|
|
||||||
row = { w = 0, h = 0 }
|
|
||||||
end
|
end
|
||||||
row.w = row.w + (#row > 0 and GAP_X or 0) + w
|
local tree = fg.col({ width = W, padding = { PAD_Y, PAD_X } }, {
|
||||||
row.h = math.max(row.h or 0, h)
|
fg.row({ wrap = true, justify = "center", align = "start", column_gap = GAP_X, row_gap = GAP_Y }, items),
|
||||||
row[#row + 1] = { node = node, w = w }
|
fg.made({ position = "absolute", left = 0, right = 0, bottom = 0, height = 1 }, function(p, x, y, w)
|
||||||
end
|
ui.rect(p, x, y, w, 1, "line_hi")
|
||||||
rows[#rows + 1] = row
|
end),
|
||||||
local y = PAD_Y
|
}, { make = function(p, x, y, w, h)
|
||||||
for i, r in ipairs(rows) do
|
return ui.rect(p, x, y, w, h, "cyan", 0.08)
|
||||||
local x = math.floor((W - r.w) / 2)
|
end })
|
||||||
for _, it in ipairs(r) do
|
fg.build(flex.solve(tree, 0, 0), parent)
|
||||||
gui.set_position(it.node, vmath.vector3(x, -y, 0))
|
return tree.h
|
||||||
x = x + it.w + GAP_X
|
|
||||||
end
|
|
||||||
y = y + r.h + (i < #rows and GAP_Y or 0)
|
|
||||||
end
|
|
||||||
local h = y + PAD_Y
|
|
||||||
gui.set_size(root, vmath.vector3(W, h, 0))
|
|
||||||
ui.rect(root, 0, h - 1, W, 1, "line_hi")
|
|
||||||
return h
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+180
-23
@@ -12,6 +12,8 @@ local scroll = require("components.scroll")
|
|||||||
local button = require("components.button")
|
local button = require("components.button")
|
||||||
local chip = require("components.chip")
|
local chip = require("components.chip")
|
||||||
local stat = require("components.stat")
|
local stat = require("components.stat")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -30,11 +32,12 @@ function M.compact(n)
|
|||||||
local function one(f)
|
local function one(f)
|
||||||
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
||||||
end
|
end
|
||||||
if a >= 1e6 then
|
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
|
||||||
|
if a >= 1e6 - 500 then
|
||||||
return one(n / 1e6) .. "M"
|
return one(n / 1e6) .. "M"
|
||||||
elseif a >= 1e4 then
|
elseif a >= 1e4 then
|
||||||
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
||||||
elseif a >= 1e3 then
|
elseif a >= 1e3 - 0.5 then
|
||||||
return one(n / 1e3) .. "k"
|
return one(n / 1e3) .. "k"
|
||||||
end
|
end
|
||||||
return ui.money(math.floor(n + 0.5))
|
return ui.money(math.floor(n + 0.5))
|
||||||
@@ -47,14 +50,13 @@ function M.pct(m)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local EFFECT_WORDS = {
|
local EFFECT_WORDS = {
|
||||||
attachment = { { "speed", "speed" }, { "yield", "yield" } },
|
|
||||||
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
|
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
|
-- "+5% drill speed, +5% yield" (kind: "advisor").
|
||||||
function M.effect(effect, kind)
|
function M.effect(effect, kind)
|
||||||
local parts = {}
|
local parts = {}
|
||||||
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
|
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
|
||||||
local m = effect and effect[e[1]] or 1
|
local m = effect and effect[e[1]] or 1
|
||||||
if m > 1 then
|
if m > 1 then
|
||||||
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
|
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
|
||||||
@@ -186,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
|
|||||||
return w
|
return w
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A violet tag (attachment names); returns its width.
|
-- A violet tag (advisor labels); returns its width and the box.
|
||||||
function M.tag(parent, text, x, y, color)
|
function M.tag(parent, text, x, y, color)
|
||||||
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
|
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
|
||||||
return w, box
|
return w, box
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
|
|
||||||
function M.pips(parent, used, slots, x, y)
|
|
||||||
for i = 1, slots do
|
|
||||||
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
|
|
||||||
gui.set_pivot(p, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
|
|
||||||
gui.set_rotation(p, vmath.vector3(0, 0, 45))
|
|
||||||
if i > used then
|
|
||||||
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
|
|
||||||
gui.set_pivot(o, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
|
|
||||||
gui.set_rotation(o, vmath.vector3(0, 0, 45))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return slots * 13
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
|
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
|
||||||
-- height used.
|
-- height used.
|
||||||
function M.parts(st, parent, parts, x, y, w)
|
function M.parts(st, parent, parts, x, y, w)
|
||||||
@@ -277,6 +262,178 @@ function M.confirm_server_button(self, b, c, act, params, id)
|
|||||||
end
|
end
|
||||||
end })
|
end })
|
||||||
self.fx:button(id, b)
|
self.fx:button(id, b)
|
||||||
|
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
|
||||||
|
-- its confirm is open), and presses `id` to open the confirm.
|
||||||
|
if self.autoplay then
|
||||||
|
local function confirmed()
|
||||||
|
local d = self.dialog
|
||||||
|
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
|
||||||
|
d:act(act, params)
|
||||||
|
else
|
||||||
|
self.fx:origin(b, id)
|
||||||
|
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function ask()
|
||||||
|
M.confirm_server(self, c, act, params, opener(self, b, id))
|
||||||
|
end
|
||||||
|
local function register()
|
||||||
|
if self.autoplay then
|
||||||
|
self.autoplay:register(act, params, confirmed)
|
||||||
|
self.autoplay:register(id, params, ask)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
register()
|
||||||
|
blocks.note(register) -- (a kept block registers again)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- inline confirm
|
||||||
|
|
||||||
|
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
|
||||||
|
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
|
||||||
|
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
|
||||||
|
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
|
||||||
|
-- left of it, so a small move left confirms, and the server's question takes the room of
|
||||||
|
-- what was left of it. Nothing else moves. Confirm takes the keyboard focus (soft, like
|
||||||
|
-- the dialog's OK: Enter confirms). It cancels on Esc, a click anywhere else, ASK_IDLE
|
||||||
|
-- seconds without a key or button press, navigating, and when what it asks about changes (its words or
|
||||||
|
-- params: core/main and session call M.cancel_ask). State: self.ask = { id, act, params,
|
||||||
|
-- key, t, nodes }.
|
||||||
|
M.ASK_IDLE = 6
|
||||||
|
|
||||||
|
local function ask_key(c, params)
|
||||||
|
local parts = { c and c.text or "", c and c.ok or "" }
|
||||||
|
local ks = {}
|
||||||
|
for k in pairs(params or {}) do
|
||||||
|
ks[#ks + 1] = k
|
||||||
|
end
|
||||||
|
table.sort(ks)
|
||||||
|
for _, k in ipairs(ks) do
|
||||||
|
parts[#parts + 1] = k .. "=" .. tostring(params[k])
|
||||||
|
end
|
||||||
|
return table.concat(parts, "|")
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.cancel_ask(self)
|
||||||
|
if self.ask then
|
||||||
|
self.ask = nil
|
||||||
|
self.dirty = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The ask in progress for the button `id`, when its words and params are still the same
|
||||||
|
-- (else it is dropped: the state changed under it).
|
||||||
|
function M.asking(self, id, c, params)
|
||||||
|
local a = self.ask
|
||||||
|
if not a or a.id ~= id then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if a.key ~= ask_key(c, params) then
|
||||||
|
self.ask = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
a.nodes = {}
|
||||||
|
return a
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every frame (core/main): the idle timeout.
|
||||||
|
function M.ask_update(self)
|
||||||
|
if self.ask and socket.gettime() - self.ask.t > M.ASK_IDLE then
|
||||||
|
M.cancel_ask(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Input (core/main, before the page gets it): a key or button press keeps the ask alive
|
||||||
|
-- (moves don't: a rebuild replays the last one); Esc and a press outside its buttons
|
||||||
|
-- cancel it. Returns true when the input is used up (Esc).
|
||||||
|
function M.ask_input(self, action_id, action)
|
||||||
|
local a = self.ask
|
||||||
|
if not a then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if action.pressed then
|
||||||
|
a.t = socket.gettime()
|
||||||
|
end
|
||||||
|
if action_id == hash("key_esc") and action.pressed then
|
||||||
|
M.cancel_ask(self)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if action_id == hash("touch") and action.pressed and action.x then
|
||||||
|
for _, n in ipairs(a.nodes or {}) do
|
||||||
|
if pcall(gui.get_id, n) and gui.pick_node(n, action.x, action.y) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
M.cancel_ask(self)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The button that asks (bound like confirm_server_button, test id `id`).
|
||||||
|
function M.ask_button(self, b, c, act, params, id)
|
||||||
|
id = id or ("confirm:" .. act)
|
||||||
|
button.bind(self, b, nil, { id = id, on_click = function()
|
||||||
|
if self.busy then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.ask = { id = id, act = act, params = params, key = ask_key(c, params), t = socket.gettime() }
|
||||||
|
-- Confirm takes the focus (soft after a mouse click: Enter confirms, no ring).
|
||||||
|
self.focus = self.focus or { index = 0 }
|
||||||
|
self.focus.key = "page:" .. id .. ":ok"
|
||||||
|
self.focus.soft = not self.focus.visible
|
||||||
|
require("modules.sfx").play("open")
|
||||||
|
self.dirty = true
|
||||||
|
end })
|
||||||
|
self.fx:button(id, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The asking state's buttons, tree pieces for a row (made under parent): Confirm then
|
||||||
|
-- Cancel (cancel_w: the asking button's width: Cancel is at least as wide, so set at the
|
||||||
|
-- row's right end it covers the button's spot).
|
||||||
|
function M.ask_buttons(self, parent, c, cancel_w, opts)
|
||||||
|
local a = self.ask
|
||||||
|
opts = opts or {}
|
||||||
|
local label, price = (c.ok or "Confirm"), nil
|
||||||
|
local head, num = label:match("^(.-)%s*₥%s*([%d,%.]+)$")
|
||||||
|
if head then
|
||||||
|
label = head:gsub("%s+for$", "")
|
||||||
|
price = tonumber((num:gsub(",", "")))
|
||||||
|
end
|
||||||
|
local small = opts.small ~= false
|
||||||
|
local bopts = { small = small, variant = "danger", price = price, fill = { "danger", 0.32 }, edge = { "danger", 1 } }
|
||||||
|
local ok = fg.button(parent, label, bopts, function(b, nodes)
|
||||||
|
a.nodes[#a.nodes + 1] = b
|
||||||
|
button.bind(self, b, nodes, { id = a.id .. ":ok", on_click = function()
|
||||||
|
if self.ask ~= a then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.ask = nil
|
||||||
|
-- (the effects start where the asking button was, like the dialog's opener)
|
||||||
|
self.fx:origin(b, a.id)
|
||||||
|
require("modules.sfx").play("confirm")
|
||||||
|
self.h.act(self, a.act, a.params or {}) -- (core/session, through the H table: no require cycle)
|
||||||
|
self.dirty = true
|
||||||
|
end })
|
||||||
|
self.fx:button(a.id, b)
|
||||||
|
end)
|
||||||
|
local cancel = fg.button(parent, "Cancel", { small = small }, function(b, nodes)
|
||||||
|
a.nodes[#a.nodes + 1] = b
|
||||||
|
button.bind(self, b, nodes, { id = a.id .. ":cancel", on_click = function()
|
||||||
|
require("modules.sfx").play("close")
|
||||||
|
M.cancel_ask(self)
|
||||||
|
end })
|
||||||
|
end, { width = math.max(cancel_w, fg.button_w(parent, "Cancel", { small = small })) })
|
||||||
|
return ok, cancel
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The server's question, compact: at most `lines` lines (default 2) in what room is left.
|
||||||
|
function M.ask_text(c, parent, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local t = fg.para(parent, c.text or "", { font = "sans", px = 12, color = "text_2" }, 15,
|
||||||
|
{ lines = opts.lines or 2, grow = 1, shrink = 1, basis = 0 })
|
||||||
|
t.done = function(n) tooltip.add(n, c.text) end -- (the whole question, when it's cut)
|
||||||
|
return t
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
|
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-- thinned to about one point per few pixels so a chart stays a few hundred nodes.
|
-- thinned to about one point per few pixels so a chart stays a few hundred nodes.
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -167,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
|
|||||||
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
||||||
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
||||||
gui.set_enabled(cross, false)
|
gui.set_enabled(cross, false)
|
||||||
local rect
|
|
||||||
local shown = -1
|
local shown = -1
|
||||||
local function move(action)
|
local function move(action)
|
||||||
rect = rect or screen_rect(box, cw, ch)
|
-- Measured on every move: scrolling or the entrance slide moves the box.
|
||||||
|
local rect = screen_rect(box, cw, ch)
|
||||||
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
||||||
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
||||||
if not inside then
|
if not inside then
|
||||||
@@ -199,7 +200,7 @@ function M.chart(parent, x, y, w, h, data, opts)
|
|||||||
gui.set_position(tip, vec(tx, -math.max(0, math.min(ch - 22, cy - 30))))
|
gui.set_position(tip, vec(tx, -math.max(0, math.min(ch - 22, cy - 30))))
|
||||||
end
|
end
|
||||||
if opts.pointers then
|
if opts.pointers then
|
||||||
opts.pointers[#opts.pointers + 1] = move
|
blocks.push(opts.pointers, "pointers", move) -- (opts.pointers: the screen, g)
|
||||||
end
|
end
|
||||||
return box
|
return box
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ local button = require("components.button")
|
|||||||
local stat = require("components.stat")
|
local stat = require("components.stat")
|
||||||
local anim = require("modules.anim")
|
local anim = require("modules.anim")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -110,10 +111,13 @@ end
|
|||||||
-- registered for name in modules/predict) the result shows at once and the button can be
|
-- registered for name in modules/predict) the result shows at once and the button can be
|
||||||
-- clicked again right away (actions queue in order); without one the button shows a
|
-- clicked again right away (actions queue in order); without one the button shows a
|
||||||
-- spinner and ignores clicks until the server answers.
|
-- spinner and ignores clicks until the server answers.
|
||||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. Returns the
|
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
||||||
-- click function (a text field's Enter runs it too).
|
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
||||||
function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
|
||||||
local key = button_key(name, params)
|
-- busy key, so each such button has its own (else they all share the action's name).
|
||||||
|
-- Returns the click function (a text field's Enter runs it too).
|
||||||
|
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||||
|
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
|
||||||
if g.inflight[key] then
|
if g.inflight[key] then
|
||||||
button.set_busy(b)
|
button.set_busy(b)
|
||||||
end
|
end
|
||||||
@@ -129,7 +133,6 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
|||||||
end
|
end
|
||||||
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a
|
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a
|
||||||
-- click anywhere on it counts from the first frame.
|
-- click anywhere on it counts from the first frame.
|
||||||
local hit
|
|
||||||
local r = name == "claim" and ready_at(g)[key]
|
local r = name == "claim" and ready_at(g)[key]
|
||||||
if r and not anim.reduced and socket.gettime() - r.t < POP then
|
if r and not anim.reduced and socket.gettime() - r.t < POP then
|
||||||
local p, size = gui.get_position(b), gui.get_size(b)
|
local p, size = gui.get_position(b), gui.get_size(b)
|
||||||
@@ -140,9 +143,15 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
|||||||
if name == "claim" then
|
if name == "claim" then
|
||||||
ready_look(g, b, key)
|
ready_look(g, b, key)
|
||||||
end
|
end
|
||||||
|
if g.autoplay then
|
||||||
|
local p = type(params) == "function" and params() or params
|
||||||
|
g.autoplay:register(name, p, fire)
|
||||||
|
blocks.note(function() -- (a kept block registers again; params made now)
|
||||||
if g.autoplay then
|
if g.autoplay then
|
||||||
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
return fire
|
return fire
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -246,6 +255,26 @@ function M.claim_wait(g, parent, x, y, ends, opts)
|
|||||||
return b, bw
|
return b, bw
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The width claim_wait's button will have now (a probe; registers nothing), for layout.
|
||||||
|
function M.claim_wait_w(g, parent, ends, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local key = opts.params and button_key("claim", opts.params)
|
||||||
|
local r = key and ready_at(g)[key]
|
||||||
|
local label
|
||||||
|
if opts.params and (ends - session.now(g) <= 0 or (r and r.ends == ends)) then
|
||||||
|
label = opts.done or "Claim"
|
||||||
|
else
|
||||||
|
label = (opts.wait or "Claim") .. " " .. ui.dur(ends - session.now(g))
|
||||||
|
end
|
||||||
|
local b, nodes, bw = ui.button(parent, label, 0, 0, { primary = true, small = opts.small, w = opts.w })
|
||||||
|
if opts.badge then
|
||||||
|
bw = M.badge(b, nodes, opts.badge)
|
||||||
|
end
|
||||||
|
ui.button_looks[b] = nil
|
||||||
|
gui.delete_node(b)
|
||||||
|
return bw
|
||||||
|
end
|
||||||
|
|
||||||
-- A count badge inside a button after its label (the web's Claim all .badge: gold edge
|
-- A count badge inside a button after its label (the web's Claim all .badge: gold edge
|
||||||
-- and number), made by ui.button(b, nodes); widens the button. Returns its new width.
|
-- and number), made by ui.button(b, nodes); widens the button. Returns its new width.
|
||||||
function M.badge(b, nodes, n)
|
function M.badge(b, nodes, n)
|
||||||
@@ -296,7 +325,7 @@ end
|
|||||||
-- The H table section modules get.
|
-- The H table section modules get.
|
||||||
function M.table()
|
function M.table()
|
||||||
return {
|
return {
|
||||||
action = M.action, link = M.link, text_link = M.text_link, claim_wait = M.claim_wait, badge = M.badge,
|
action = M.action, link = M.link, text_link = M.text_link, claim_wait = M.claim_wait, claim_wait_w = M.claim_wait_w, badge = M.badge,
|
||||||
navigate = session.navigate, act = session.act, now = session.now,
|
navigate = session.navigate, act = session.act, now = session.now,
|
||||||
para = M.para, heading = M.heading, fact = M.fact,
|
para = M.para, heading = M.heading, fact = M.fact,
|
||||||
tick_text = ticks.tick_text, tick_bar = ticks.tick_bar, tick_num = ticks.tick_num,
|
tick_text = ticks.tick_text, tick_bar = ticks.tick_bar, tick_num = ticks.tick_num,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
-- the focus ring) scroll the scroll area under the mouse
|
-- the focus ring) scroll the scroll area under the mouse
|
||||||
-- (else the one holding the focused element, else the biggest one), like a browser.
|
-- (else the one holding the focused element, else the biggest one), like a browser.
|
||||||
-- F11 toggles fullscreen; Ctrl + / Ctrl - / Ctrl 0 change the UI scale (modules/screen),
|
-- F11 toggles fullscreen; Ctrl + / Ctrl - / Ctrl 0 change the UI scale (modules/screen),
|
||||||
-- like the browser's zoom.
|
-- like the browser's zoom. F3 (debug builds) shows the engine's profiler (frame times).
|
||||||
|
-- G opens the quick grant dialog for admins (state.admin, screens/game/grant.lua).
|
||||||
--
|
--
|
||||||
-- Text fields made in a build register in g.inputs ({ input = Druid input, submit = fn,
|
-- Text fields made in a build register in g.inputs ({ input = Druid input, submit = fn,
|
||||||
-- step = { min, max } }: modules/field, the refinery's quantity); core/main resets the list
|
-- step = { min, max } }: modules/field, the refinery's quantity); core/main resets the list
|
||||||
@@ -30,7 +31,9 @@ local SHIFT = { [hash("key_lshift")] = true, [hash("key_rshift")] = true }
|
|||||||
local ALT = { [hash("key_lalt")] = true, [hash("key_ralt")] = true }
|
local ALT = { [hash("key_lalt")] = true, [hash("key_ralt")] = true }
|
||||||
local CTRL = { [hash("key_lctrl")] = true, [hash("key_rctrl")] = true }
|
local CTRL = { [hash("key_lctrl")] = true, [hash("key_rctrl")] = true }
|
||||||
local F11 = hash("key_f11")
|
local F11 = hash("key_f11")
|
||||||
|
local F3 = hash("key_f3")
|
||||||
local ESC = hash("key_esc")
|
local ESC = hash("key_esc")
|
||||||
|
local KEY_G = hash("key_g")
|
||||||
local ZOOM = { [hash("key_plus")] = 1, [hash("key_equals")] = 1, [hash("key_numpad_add")] = 1,
|
local ZOOM = { [hash("key_plus")] = 1, [hash("key_equals")] = 1, [hash("key_numpad_add")] = 1,
|
||||||
[hash("key_minus")] = -1, [hash("key_numpad_subtract")] = -1, [hash("key_0")] = 0, [hash("key_numpad_0")] = 0 }
|
[hash("key_minus")] = -1, [hash("key_numpad_subtract")] = -1, [hash("key_0")] = 0, [hash("key_numpad_0")] = 0 }
|
||||||
local UP, DOWN = hash("key_up"), hash("key_down")
|
local UP, DOWN = hash("key_up"), hash("key_down")
|
||||||
@@ -172,6 +175,11 @@ function M.before(g, action_id, action, input)
|
|||||||
screen.toggle_fullscreen()
|
screen.toggle_fullscreen()
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if action_id == F3 and action.pressed and profiler and sys.get_engine_info().is_debug then
|
||||||
|
g.profiler_ui = not g.profiler_ui
|
||||||
|
profiler.enable_ui(g.profiler_ui)
|
||||||
|
return true
|
||||||
|
end
|
||||||
if g.mods.ctrl and action_id and ZOOM[action_id] and action.pressed then
|
if g.mods.ctrl and action_id and ZOOM[action_id] and action.pressed then
|
||||||
screen.step_ui_scale(ZOOM[action_id])
|
screen.step_ui_scale(ZOOM[action_id])
|
||||||
return true
|
return true
|
||||||
@@ -237,6 +245,9 @@ function M.before(g, action_id, action, input)
|
|||||||
elseif action_id == MOUSE_FORWARD or (action_id == RIGHT and g.mods.alt) then
|
elseif action_id == MOUSE_FORWARD or (action_id == RIGHT and g.mods.alt) then
|
||||||
session.go(g, 1)
|
session.go(g, 1)
|
||||||
return true
|
return true
|
||||||
|
elseif action_id == KEY_G and not g.mods.ctrl and not g.mods.alt and g.state and g.state.admin then
|
||||||
|
require("screens.game.grant").open(g) -- (admins: the quick grant dialog)
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
-- for the module contract the regions and sections follow.
|
-- for the module contract the regions and sections follow.
|
||||||
--
|
--
|
||||||
-- Rebuild model: every new state (a response, a prediction) or a window/UI-scale change
|
-- Rebuild model: every new state (a response, a prediction) or a window/UI-scale change
|
||||||
-- sets g.dirty and the next update rebuilds the whole layout from the state: the Druid
|
-- sets g.dirty and the next update rebuilds the layout from the state. The regions and the
|
||||||
-- instance and every layout node are dropped and made again. What must survive a rebuild
|
-- costly parts inside them are blocks (modules/blocks): one whose inputs (its signature)
|
||||||
-- lives on g (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects
|
-- didn't change is kept with its nodes and Druid components, the rest is built again; a
|
||||||
-- g.fx and toasts g.toasts on their own layers). Regions are laid out here: banner and
|
-- full rebuild (g.dirty_all: the theme, reduced motion, a size change, ...) drops the
|
||||||
-- header across the top, then the moon list, the main panel (the current section) and
|
-- Druid instance and every layout node first. What must survive a rebuild lives on g
|
||||||
-- the aside.
|
-- (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects g.fx and
|
||||||
|
-- toasts g.toasts on their own layers). Regions are laid out here: banner and header
|
||||||
|
-- across the top, then the moon list, the main panel (the current section), the right
|
||||||
|
-- side panel (sidepanel.lua: the leaderboard; folded away when there's no room) and the
|
||||||
|
-- aside.
|
||||||
|
|
||||||
local druid = require("druid.druid")
|
local druid = require("druid.druid")
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
@@ -34,8 +38,14 @@ local banner = require("screens.game.banner")
|
|||||||
local header = require("screens.game.header")
|
local header = require("screens.game.header")
|
||||||
local moonlist = require("screens.game.moonlist")
|
local moonlist = require("screens.game.moonlist")
|
||||||
local aside = require("screens.game.aside")
|
local aside = require("screens.game.aside")
|
||||||
|
local sidepanel = require("screens.game.sidepanel")
|
||||||
|
local cards = require("screens.game.cards")
|
||||||
local reveal = require("screens.game.reveal")
|
local reveal = require("screens.game.reveal")
|
||||||
local moon_view = require("modules.moon.moon")
|
local moon_view = require("modules.moon.moon")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local sfx = require("modules.sfx")
|
||||||
|
local ready = require("screens.game.core.ready")
|
||||||
|
local admin = require("screens.game.admin")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
||||||
@@ -52,7 +62,6 @@ M.SECTIONS = {
|
|||||||
moons = require("screens.game.moons"),
|
moons = require("screens.game.moons"),
|
||||||
market = require("screens.game.market"),
|
market = require("screens.game.market"),
|
||||||
refinery = require("screens.game.refinery"),
|
refinery = require("screens.game.refinery"),
|
||||||
leaderboard = require("screens.game.leaderboard"),
|
|
||||||
help = require("screens.game.help"),
|
help = require("screens.game.help"),
|
||||||
equipment = require("screens.game.equipment"),
|
equipment = require("screens.game.equipment"),
|
||||||
store = require("screens.game.store"),
|
store = require("screens.game.store"),
|
||||||
@@ -79,14 +88,16 @@ end
|
|||||||
|
|
||||||
---------------------------------------------------------------- build
|
---------------------------------------------------------------- build
|
||||||
|
|
||||||
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when
|
-- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
|
||||||
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
|
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
|
||||||
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
|
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
|
||||||
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
|
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
|
||||||
-- the first state and after the reveal closes (g.enter_all).
|
-- the first state and after the reveal closes (g.enter_all).
|
||||||
function enter(g)
|
function enter(g)
|
||||||
local v = g.view
|
local v = g.view
|
||||||
local main_key = v.section .. "|" .. (v.moon or "")
|
-- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
|
||||||
|
-- Claim all finishing one, mustn't replay the section's entrance)
|
||||||
|
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
|
||||||
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
|
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
|
||||||
local all = g.enter_all or not g.entered
|
local all = g.enter_all or not g.entered
|
||||||
if g.reveal:hiding() then
|
if g.reveal:hiding() then
|
||||||
@@ -130,36 +141,13 @@ local function boot_screen(g, W, H)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_main(g, parent, x, y, w, h)
|
|
||||||
local st = g.state
|
|
||||||
local p = panel.new(parent, x, y, w, h)
|
|
||||||
g.main_panel = p
|
|
||||||
local root = p.body
|
|
||||||
local px, py = S.main_pad_x, S.main_pad_y
|
|
||||||
local section = g.view.section
|
|
||||||
local mod = M.SECTIONS[section]
|
|
||||||
if g.reveal:hiding() then
|
|
||||||
return -- what the new moon holds stays hidden until its card is closed
|
|
||||||
elseif section == "new-moon" then
|
|
||||||
local inner = math.min(w - px * 2, S.content_max)
|
|
||||||
reveal.build_teaser(g, root, px + math.floor((w - px * 2 - inner) / 2), py, inner)
|
|
||||||
elseif mod and (NO_BLOCK[section] or st[section]) then
|
|
||||||
mod.build(g, root, px, py, w, h, g.h) -- modules center their own content
|
|
||||||
elseif not mod then
|
|
||||||
coming_soon(g, root, px, py, w)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Whether the aside shows, like the web's (page.html: hidden unless the view selects
|
-- Whether the aside shows, like the web's (page.html: hidden unless the view selects
|
||||||
-- something for the viewport, views.go Scene.Select): a moon (s=moons with a moon), the
|
-- something for the viewport, views.go Scene.Select): a moon (s=moons with a moon), the
|
||||||
-- new-moon teaser, the drill panel (Equipment with a drill) or an advisor's panel. Other
|
-- new-moon teaser, the drill panel (Equipment with a drill) or an advisor's panel. Other
|
||||||
-- views (Market, Store, Refinery, Leaderboard, Help, Equipment, Advisors, the first-moon
|
-- views (Market, Store, Refinery, Leaderboard, Help, Equipment, Advisors, the first-moon
|
||||||
-- pitch) give the main panel the whole width. The reveal keeps it (it plays in the viewport).
|
-- pitch) give the main panel the whole width. (The reveal plays in its own window, reveal.lua.)
|
||||||
function M.aside_shown(g)
|
function M.aside_shown(g)
|
||||||
local st, section = g.state, g.view.section
|
local st, section = g.state, g.view.section
|
||||||
if g.reveal:active() then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
-- An advisor not hired yet, picked on its card (the client's own preview).
|
-- An advisor not hired yet, picked on its card (the client's own preview).
|
||||||
if section == "advisors" and g.advisor_pick and st.advisors then
|
if section == "advisors" and g.advisor_pick and st.advisors then
|
||||||
for _, a in ipairs(st.advisors.advisors or {}) do
|
for _, a in ipairs(st.advisors.advisors or {}) do
|
||||||
@@ -193,12 +181,147 @@ function M.aside_shown(g)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- What each section shows from the state (besides the view and the screen-wide inputs):
|
||||||
|
-- its block is kept while these stay the same (modules/blocks). The aside shows the
|
||||||
|
-- section's own aside or the moon's well panel.
|
||||||
|
local ALWAYS = { "rules", "unlocked", "catalogVersion", "bonuses", "liveEvents", "showNav" }
|
||||||
|
-- The state keys each section shows (its block's signature): a section that reads a new
|
||||||
|
-- state key must add it here, or it won't update. ASIDE_KEYS and the header's and moon
|
||||||
|
-- list's sigs work the same way.
|
||||||
|
local SECTION_KEYS = {
|
||||||
|
moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" },
|
||||||
|
market = { "market", "leaderboard" },
|
||||||
|
refinery = { "refinery", "jobs", "refined", "credits", "resources" },
|
||||||
|
help = { "help" },
|
||||||
|
equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" },
|
||||||
|
store = { "store", "credits", "moonPrice", "resources", "refined" },
|
||||||
|
advisors = { "advisors", "credits" },
|
||||||
|
["new-moon"] = { "newMoon", "moonPrice", "moonGrade", "credits" },
|
||||||
|
}
|
||||||
|
local ASIDE_KEYS = { "moon", "moons", "past", "tools", "well", "stakes", "equipment", "advisors", "newMoon", "moonPrice", "credits", "refined" }
|
||||||
|
|
||||||
|
local function pick_keys(st, ...)
|
||||||
|
local out = {}
|
||||||
|
for _, list in ipairs({ ... }) do
|
||||||
|
for _, k in ipairs(list) do
|
||||||
|
if out[k] == nil then
|
||||||
|
out[k] = st[k] == nil and false or st[k]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if st.catalogVersion == nil then
|
||||||
|
out.catalog = st.catalog or false
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drills and batches whose end passed on our clock (core/ready counts them before the
|
||||||
|
-- refetch): the counts they show change with it. kinds: the state lists to look at
|
||||||
|
-- (default both).
|
||||||
|
local function ready_key(g, kinds)
|
||||||
|
local st, now = g.state, session.now(g)
|
||||||
|
local out = {}
|
||||||
|
for _, k in ipairs(kinds or { "stakes", "jobs" }) do
|
||||||
|
local list = st[k] or {}
|
||||||
|
for _, x in ipairs(list) do
|
||||||
|
if not x.ready and x["end"] and x["end"] <= now then
|
||||||
|
out[#out + 1] = tostring(x.id or x.toolId) .. "@" .. x["end"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out, ",")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What every region depends on besides the state: busy buttons, Claims that turned ready
|
||||||
|
-- on our clock, the toggles.
|
||||||
|
local function local_sig(g, kinds)
|
||||||
|
return blocks.sig(g.inflight, g.ready_at, ready_key(g, kinds), sfx.muted(), anim.reduced, theme.name, g.ask and g.ask.id or false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The ready lists each section shows: a drill turning ready doesn't rebuild the Refinery
|
||||||
|
-- (a 60-90 ms build, twice per drill: on our clock, then at the refetch), nor a batch the
|
||||||
|
-- Moons. Sections not listed show neither.
|
||||||
|
local MAIN_READY = { moons = { "stakes" }, equipment = { "stakes" }, refinery = { "jobs" } }
|
||||||
|
|
||||||
|
-- The reveal hides the main panel, the aside's content and the new moon's row.
|
||||||
|
local function reveal_sig(g)
|
||||||
|
local r = g.reveal
|
||||||
|
return blocks.sig(r:active(), r:hiding(), r.r and r.r.data and r.r.data.moonId or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The section's block in the main panel.
|
||||||
|
local function build_main(g, parent, x, y, w, h)
|
||||||
|
local st = g.state
|
||||||
|
local section = g.view.section
|
||||||
|
local common = local_sig(g, MAIN_READY[section] or {})
|
||||||
|
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
|
||||||
|
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
|
||||||
|
blocks.keep(g, "main", sig, parent, function(holder)
|
||||||
|
for _, m in pairs(M.SECTIONS) do
|
||||||
|
if m.teardown then
|
||||||
|
m.teardown(g)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
g.well_nodes = {}
|
||||||
|
g.frames = {} -- nested frames (cards) the main panel's entrance staggers
|
||||||
|
local p = panel.new(holder, x, y, w, h)
|
||||||
|
g.main_panel = p
|
||||||
|
local root = p.body
|
||||||
|
local px, py = S.main_pad_x, S.main_pad_y
|
||||||
|
local mod = M.SECTIONS[section]
|
||||||
|
if g.reveal:hiding() then
|
||||||
|
return -- what the new moon holds stays hidden until its card is closed
|
||||||
|
elseif section == "new-moon" then
|
||||||
|
local inner = math.min(w - px * 2, S.content_max)
|
||||||
|
reveal.build_teaser(g, root, px + math.floor((w - px * 2 - inner) / 2), py, inner)
|
||||||
|
elseif mod and (NO_BLOCK[section] or st[section]) then
|
||||||
|
mod.build(g, root, px, py, w, h, g.h) -- modules center their own content
|
||||||
|
elseif not mod then
|
||||||
|
coming_soon(g, root, px, py, w)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The aside's block (narrow: the row under the panels).
|
||||||
|
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||||
|
local st = g.state
|
||||||
|
local section = g.view.section
|
||||||
|
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||||
|
-- their own, below)
|
||||||
|
local moon_only = section == "moons" and st.moon ~= nil
|
||||||
|
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||||
|
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||||
|
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||||
|
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||||
|
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||||
|
end)
|
||||||
|
if facts then
|
||||||
|
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||||
|
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||||
|
aside.facts(g, holder, facts, g.h)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||||
|
-- regions (and the blocks inside them) whose inputs didn't change are kept (modules/
|
||||||
|
-- blocks). Full: the first state, a window or UI-scale change, the narrow layout (one
|
||||||
|
-- page scroll), the theme or another screen-wide toggle (g.dirty_all).
|
||||||
function M.build(g)
|
function M.build(g)
|
||||||
g.builds = (g.builds or 0) + 1 -- (test counter)
|
g.builds = (g.builds or 0) + 1 -- (test counter)
|
||||||
|
local W, H = screen.fit(g.root)
|
||||||
|
local size = { W, H, select(3, screen.layout()) }
|
||||||
|
local narrow = W < NARROW_W
|
||||||
|
local full = not g.layout or not g.state or not g.layout_state or g.dirty_all or narrow
|
||||||
|
or not g.size or g.size[1] ~= size[1] or g.size[2] ~= size[2] or g.size[3] ~= size[3]
|
||||||
|
g.dirty_all = false
|
||||||
|
g.full_builds = (g.full_builds or 0) + (full and 1 or 0) -- (test counter)
|
||||||
-- The page's ground and its top-right glow (game.gui) in the theme's colors.
|
-- The page's ground and its top-right glow (game.gui) in the theme's colors.
|
||||||
gui.set_color(gui.get_node("bg"), theme.color.bg)
|
gui.set_color(gui.get_node("bg"), theme.color.bg)
|
||||||
gui.set_color(gui.get_node("glow"), theme.color.bg_glow)
|
gui.set_color(gui.get_node("glow"), theme.color.bg_glow)
|
||||||
button.begin_build() -- (a press the rebuild interrupts carries over: components/button)
|
button.begin_build() -- (a press the rebuild interrupts carries over: components/button)
|
||||||
|
if full then
|
||||||
each_module(function(m)
|
each_module(function(m)
|
||||||
if m.teardown then
|
if m.teardown then
|
||||||
m.teardown(g)
|
m.teardown(g)
|
||||||
@@ -209,49 +332,87 @@ function M.build(g)
|
|||||||
g.druid:final()
|
g.druid:final()
|
||||||
g.tearing_down = false
|
g.tearing_down = false
|
||||||
g.druid = druid.new(g)
|
g.druid = druid.new(g)
|
||||||
|
blocks.hook(g.druid)
|
||||||
gui.delete_node(g.layout)
|
gui.delete_node(g.layout)
|
||||||
|
g.layout = nil
|
||||||
end
|
end
|
||||||
local W, H = screen.fit(g.root)
|
blocks.clear(g)
|
||||||
g.size = { W, H, select(3, screen.layout()) }
|
end
|
||||||
|
blocks.begin(g, full)
|
||||||
|
g.size = size
|
||||||
gui.set_size(g.page, vec(W, H))
|
gui.set_size(g.page, vec(W, H))
|
||||||
gui.set_position(g.page, vec(-W / 2, H / 2))
|
gui.set_position(g.page, vec(-W / 2, H / 2))
|
||||||
g.tickers = {}
|
g.tickers = {}
|
||||||
g.scroll_areas = {}
|
g.scroll_areas = {}
|
||||||
g.tween:begin()
|
g.tween:begin()
|
||||||
g.tab_nodes, g.moon_nodes, g.well_nodes = {}, {}, {}
|
|
||||||
g.fx:begin_build()
|
g.fx:begin_build()
|
||||||
test.begin_build()
|
test.begin_build()
|
||||||
tooltip.begin_build()
|
tooltip.begin_build()
|
||||||
g.inputs = {} -- text fields (core/keys: Enter submits, keys skip while editing)
|
g.inputs = {} -- text fields (core/keys: Enter submits, keys skip while editing)
|
||||||
|
g.pointers = {} -- mouse-move handlers (the market chart's crosshair)
|
||||||
if g.autoplay then
|
if g.autoplay then
|
||||||
g.autoplay:reset()
|
g.autoplay:reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if not g.layout then
|
||||||
g.layout = ui.rect(g.page, 0, 0, W, H, "none")
|
g.layout = ui.rect(g.page, 0, 0, W, H, "none")
|
||||||
gui.move_below(g.layout, g.toast_layer)
|
gui.move_below(g.layout, g.toast_layer)
|
||||||
|
end
|
||||||
g.toasts:set_height(H)
|
g.toasts:set_height(H)
|
||||||
g.dialog:layout(W, H)
|
g.dialog:layout(W, H)
|
||||||
if not g.state then
|
if not g.state then
|
||||||
|
g.tab_nodes, g.moon_nodes, g.well_nodes = {}, {}, {}
|
||||||
boot_screen(g, W, H)
|
boot_screen(g, W, H)
|
||||||
|
g.layout_state = false
|
||||||
g.dirty = false
|
g.dirty = false
|
||||||
|
blocks.finish(g)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
g.pointers = {} -- mouse-move handlers (the market chart's crosshair)
|
g.layout_state = true
|
||||||
-- Announcements and live events above the header.
|
|
||||||
local banner_h = banner.build(g, g.h, g.layout, W)
|
|
||||||
local header_root = ui.rect(g.layout, 0, banner_h, W, 1, "none")
|
|
||||||
g.header_node = header_root
|
|
||||||
local header_h = header.build(g, header_root, 0, 0, W, nil, g.h) + banner_h
|
|
||||||
|
|
||||||
local st = g.state
|
local st = g.state
|
||||||
|
local common = local_sig(g)
|
||||||
|
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||||
|
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||||
|
-- rebuild them)
|
||||||
|
local still = local_sig(g, {})
|
||||||
|
-- Announcements and live events above the header.
|
||||||
|
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||||
|
return banner.build(g, g.h, holder, W)
|
||||||
|
end)
|
||||||
|
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||||
|
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||||
|
g.tab_nodes = {}
|
||||||
|
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||||
|
g.header_node = header_root
|
||||||
|
return header.build(g, header_root, 0, 0, W, nil, g.h) + banner_h
|
||||||
|
end)
|
||||||
|
|
||||||
local pad, gap = S.body_pad, S.gap
|
local pad, gap = S.body_pad, S.gap
|
||||||
local top = header_h + pad
|
local top = header_h + pad
|
||||||
local body_h = H - top - pad
|
local body_h = H - top - pad
|
||||||
|
if g.admin then
|
||||||
|
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
|
||||||
|
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
|
||||||
|
g.toasts:detach()
|
||||||
|
g.moon_nodes, g.well_nodes = {}, {}
|
||||||
|
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||||
|
g.page_key, g.page_main, g.side_w = nil, nil, 0
|
||||||
|
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
|
||||||
|
admin.build(g, holder, pad, top, W - pad * 2, body_h)
|
||||||
|
end)
|
||||||
|
blocks.finish(g)
|
||||||
|
g.tween:finish()
|
||||||
|
ticks.run(g)
|
||||||
|
g.dirty = false
|
||||||
|
g.fx:after_build(g)
|
||||||
|
g.reveal:after_build()
|
||||||
|
g.rehover = true
|
||||||
|
return
|
||||||
|
end
|
||||||
local has_aside = M.aside_shown(g)
|
local has_aside = M.aside_shown(g)
|
||||||
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
||||||
-- under the moon list and the main panel, the viewport and the side panel side by
|
-- under the moon list and the main panel, the viewport and the side panel side by
|
||||||
-- side, and the page scrolls to it (a page scroll area under the header).
|
-- side, and the page scrolls to it (a page scroll area under the header).
|
||||||
local narrow = W < NARROW_W
|
|
||||||
local aside_w = has_aside and math.max(S.aside_w_min, math.min(S.aside_w_max, math.floor(W * 0.33))) or 0
|
local aside_w = has_aside and math.max(S.aside_w_min, math.min(S.aside_w_max, math.floor(W * 0.33))) or 0
|
||||||
-- The moon list shows once there is a moon (or the store is open), like the web.
|
-- The moon list shows once there is a moon (or the store is open), like the web.
|
||||||
local list_w = math.max(S.moonlist_w, math.min(S.moonlist_w_max, math.floor(W * 0.15)))
|
local list_w = math.max(S.moonlist_w, math.min(S.moonlist_w_max, math.floor(W * 0.15)))
|
||||||
@@ -263,6 +424,13 @@ function M.build(g)
|
|||||||
end
|
end
|
||||||
local main_x = pad + (list_w > 0 and (list_w + gap) or 0)
|
local main_x = pad + (list_w > 0 and (list_w + gap) or 0)
|
||||||
local main_w = W - pad - main_x - ((has_aside and not narrow) and (aside_w + gap) or 0)
|
local main_w = W - pad - main_x - ((has_aside and not narrow) and (aside_w + gap) or 0)
|
||||||
|
-- The right side panel takes its width from the main panel's, or folds away.
|
||||||
|
-- (the side panel is the Market's: Top miners beside it)
|
||||||
|
local side_w = sidepanel.width(g, W, main_w, narrow or has_aside or g.view.section ~= "market")
|
||||||
|
g.side_w = side_w
|
||||||
|
if side_w > 0 then
|
||||||
|
main_w = main_w - side_w - gap
|
||||||
|
end
|
||||||
local parent, y0, page = g.layout, top, nil
|
local parent, y0, page = g.layout, top, nil
|
||||||
local row_h
|
local row_h
|
||||||
if has_aside and narrow then
|
if has_aside and narrow then
|
||||||
@@ -279,46 +447,73 @@ function M.build(g)
|
|||||||
parent, y0 = page.content, pad
|
parent, y0 = page.content, pad
|
||||||
end
|
end
|
||||||
if list_w > 0 then
|
if list_w > 0 then
|
||||||
moonlist.build(g, g.h, parent, pad, y0, list_w, body_h)
|
-- (the rows' ready counts: per moon, from the stakes on our clock)
|
||||||
|
local v = g.view
|
||||||
|
local hides = {}
|
||||||
|
for _, m in ipairs(st.moons) do
|
||||||
|
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||||
|
end
|
||||||
|
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||||
|
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||||
|
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||||
|
g.moon_nodes = {}
|
||||||
|
moonlist.teardown(g)
|
||||||
|
moonlist.build(g, g.h, holder, pad, y0, list_w, body_h)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
g.moon_nodes = {}
|
||||||
|
g.toasts:detach() -- (no moon list: toasts float)
|
||||||
end
|
end
|
||||||
g.frames = {} -- nested frames (cards) the main panel's entrance staggers
|
|
||||||
build_main(g, parent, main_x, y0, main_w, body_h)
|
build_main(g, parent, main_x, y0, main_w, body_h)
|
||||||
|
if side_w > 0 then
|
||||||
|
sidepanel.build(g, parent, main_x + main_w + gap, y0, side_w, body_h, common)
|
||||||
|
if sidepanel.missing(g) then
|
||||||
|
if not g.side_asked then
|
||||||
|
g.side_asked = true
|
||||||
|
session.fetch(g, g.nav_view or g.view)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
g.side_asked = false
|
||||||
|
end
|
||||||
|
end
|
||||||
if page then
|
if page then
|
||||||
g.aside_panel = aside.build(g, parent, pad, y0 + body_h + gap, aside_w, row_h, g.h, M.SECTIONS[g.view.section], true)
|
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||||
if g.viewport_node then
|
if g.viewport_node and not g.reveal:active() then
|
||||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||||
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
||||||
end
|
end
|
||||||
-- Picking a well, a drill or an advisor (or a reveal) brings the aside into view.
|
-- Picking a drill or an advisor brings the aside into view (not a well: its card,
|
||||||
|
-- where its actions are, stays in view; the aside only shows its facts).
|
||||||
local v = g.view
|
local v = g.view
|
||||||
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick), tostring(g.reveal:active()) }, "|")
|
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
|
||||||
local want = g.reveal:active() or (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
|
local want = (v.drill or "") ~= "" or v.section == "advisors"
|
||||||
if s and want and key ~= g.page_key then
|
if s and want and key ~= g.page_key then
|
||||||
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
|
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
|
||||||
end
|
end
|
||||||
g.page_key = key
|
g.page_key = key
|
||||||
if s then
|
if s then
|
||||||
-- The viewport moves with the page: the 3D moon follows it (and a reveal's dim).
|
-- The viewport moves with the page: the 3D moon follows it.
|
||||||
s.on_scroll:subscribe(function()
|
s.on_scroll:subscribe(function()
|
||||||
if g.viewport_node and not g.tearing_down then
|
if g.viewport_node and not g.tearing_down and not g.reveal:active() then
|
||||||
moon_view.move(g.viewport_node)
|
moon_view.move(g.viewport_node)
|
||||||
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
||||||
if g.reveal:active() then
|
|
||||||
g.reveal:place()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
elseif has_aside then
|
elseif has_aside then
|
||||||
g.aside_panel = aside.build(g, parent, W - pad - aside_w, top, aside_w, body_h, g.h, M.SECTIONS[g.view.section])
|
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||||
else
|
else
|
||||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||||
|
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||||
moon_view.hide()
|
moon_view.hide()
|
||||||
end
|
end
|
||||||
|
end
|
||||||
if not page then
|
if not page then
|
||||||
g.page_key, g.page_main = nil, nil
|
g.page_key, g.page_main = nil, nil
|
||||||
end
|
end
|
||||||
|
blocks.finish(g)
|
||||||
|
ready.watch(g) -- (rebuild when the next drill or batch end passes)
|
||||||
enter(g)
|
enter(g)
|
||||||
g.tween:finish()
|
g.tween:finish()
|
||||||
ticks.run(g)
|
ticks.run(g)
|
||||||
@@ -370,6 +565,7 @@ function M.init(g)
|
|||||||
end
|
end
|
||||||
msg.post(".", "acquire_input_focus")
|
msg.post(".", "acquire_input_focus")
|
||||||
g.druid = druid.new(g)
|
g.druid = druid.new(g)
|
||||||
|
blocks.hook(g.druid)
|
||||||
g.root = gui.get_node("root")
|
g.root = gui.get_node("root")
|
||||||
g.page = gui.get_node("page")
|
g.page = gui.get_node("page")
|
||||||
theme.set(theme.name) -- the saved theme's clear color (the render script starts dark)
|
theme.set(theme.name) -- the saved theme's clear color (the render script starts dark)
|
||||||
@@ -403,6 +599,7 @@ function M.final(g)
|
|||||||
end)
|
end)
|
||||||
g.dialog:remove()
|
g.dialog:remove()
|
||||||
g.druid:final()
|
g.druid:final()
|
||||||
|
blocks.clear(g)
|
||||||
end
|
end
|
||||||
|
|
||||||
local TOUCH = hash("touch")
|
local TOUCH = hash("touch")
|
||||||
@@ -421,6 +618,10 @@ function input(g, action_id, action)
|
|||||||
if action_id == TOUCH and action.released then
|
if action_id == TOUCH and action.released then
|
||||||
button.release() -- (carried only while the mouse is down; after this Druid clicks)
|
button.release() -- (carried only while the mouse is down; after this Druid clicks)
|
||||||
end
|
end
|
||||||
|
-- A sell asking in place (cards.ask_button): Esc or a press elsewhere cancels it.
|
||||||
|
if not g.dialog:is_open() and cards.ask_input(g, action_id, action) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
-- Tab focus, Enter/Space on the focused element, back/forward (core/keys).
|
-- Tab focus, Enter/Space on the focused element, back/forward (core/keys).
|
||||||
if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
|
if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
|
||||||
return true
|
return true
|
||||||
@@ -460,14 +661,20 @@ local TEST_API = {
|
|||||||
g.scroll_pos = g.scroll_pos or {}
|
g.scroll_pos = g.scroll_pos or {}
|
||||||
g.scroll_pos[k:sub(8)] = tonumber(v) or 0
|
g.scroll_pos[k:sub(8)] = tonumber(v) or 0
|
||||||
end
|
end
|
||||||
g.dirty = true
|
g.dirty, g.dirty_all = true, true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
function M.update(g, dt)
|
function M.update(g, dt)
|
||||||
|
local t0 = test.enabled and test.clock()
|
||||||
|
local built
|
||||||
local W, H, scale = screen.layout()
|
local W, H, scale = screen.layout()
|
||||||
if g.dirty or W ~= g.size[1] or H ~= g.size[2] or scale ~= g.size[3] then
|
local resized = W ~= g.size[1] or H ~= g.size[2] or scale ~= g.size[3]
|
||||||
|
-- (while the reveal card flies into the moon list, a new state waits for it to land:
|
||||||
|
-- the page is hidden under it anyway, and the flight keeps its frames)
|
||||||
|
if resized or (g.dirty and not g.reveal:closing()) then
|
||||||
M.build(g)
|
M.build(g)
|
||||||
|
built = t0 and test.clock() - t0
|
||||||
end
|
end
|
||||||
if g.boot_tick and not g.state then
|
if g.boot_tick and not g.state then
|
||||||
local left = math.max(0, math.ceil(g.boot_tick.at - socket.gettime()))
|
local left = math.max(0, math.ceil(g.boot_tick.at - socket.gettime()))
|
||||||
@@ -478,6 +685,9 @@ function M.update(g, dt)
|
|||||||
g.fx:update(dt)
|
g.fx:update(dt)
|
||||||
g.reveal:update(dt)
|
g.reveal:update(dt)
|
||||||
g.dialog:update(dt)
|
g.dialog:update(dt)
|
||||||
|
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
|
||||||
|
moon_view.covered = g.dialog:is_open()
|
||||||
|
cards.ask_update(g)
|
||||||
check_input(g)
|
check_input(g)
|
||||||
tooltip.update(g.dialog:is_open())
|
tooltip.update(g.dialog:is_open())
|
||||||
focus.update(g, g.focus_layer)
|
focus.update(g, g.focus_layer)
|
||||||
@@ -486,6 +696,9 @@ function M.update(g, dt)
|
|||||||
m.update(g, dt)
|
m.update(g, dt)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
if g.admin then
|
||||||
|
admin.update(g, dt)
|
||||||
|
end
|
||||||
g.druid:update(dt)
|
g.druid:update(dt)
|
||||||
if g.rehover then
|
if g.rehover then
|
||||||
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
||||||
@@ -505,6 +718,9 @@ function M.update(g, dt)
|
|||||||
g.autoplay:update(dt)
|
g.autoplay:update(dt)
|
||||||
end
|
end
|
||||||
test.update(g, function(id, a) return input(g, id, a) end, TEST_API)
|
test.update(g, function(id, a) return input(g, id, a) end, TEST_API)
|
||||||
|
if t0 then
|
||||||
|
test.frame(dt, test.clock() - t0, built)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.on_message(g, message_id, message, sender)
|
function M.on_message(g, message_id, message, sender)
|
||||||
@@ -528,7 +744,7 @@ function M.on_reload(g)
|
|||||||
theme.set(theme.name) -- (the page's clear color)
|
theme.set(theme.name) -- (the page's clear color)
|
||||||
ensure(g)
|
ensure(g)
|
||||||
g.h = hooks.table()
|
g.h = hooks.table()
|
||||||
g.dirty = true
|
g.dirty, g.dirty_all = true, true
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -33,6 +33,25 @@ function M.drills(g, moon_id)
|
|||||||
return (st.moon and st.moon.id == moon_id and st.moon.ready or 0) + n
|
return (st.moon and st.moon.id == moon_id and st.moon.ready or 0) + n
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A batch shown as finished: ready in the state or its end passed on our clock.
|
||||||
|
function M.batch_done(g, j)
|
||||||
|
return j.ready or crossed(j, session.now(g))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Seconds since batch j finished (0 when it just did and the watch below hasn't seen it
|
||||||
|
-- yet), or nil when it isn't finished or already was when first seen: the effects that
|
||||||
|
-- follow a batch's end through rebuilds go by it.
|
||||||
|
function M.batch_age(g, j)
|
||||||
|
if not g.batches_done or not M.batch_done(g, j) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local t = g.batches_done[j.id]
|
||||||
|
if t == nil then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return t > 0 and socket.gettime() - t or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Finished refinery batches (the Refinery tab's badge).
|
-- Finished refinery batches (the Refinery tab's badge).
|
||||||
function M.batches(g)
|
function M.batches(g)
|
||||||
local now = session.now(g)
|
local now = session.now(g)
|
||||||
@@ -92,7 +111,37 @@ function M.watch(g)
|
|||||||
g.ready_due = M.next_due(g)
|
g.ready_due = M.next_due(g)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Batches that finished since the last look (ready in the state, or their end passed on
|
||||||
|
-- our clock), whichever section is open: each one's moment goes in g.batches_done[id]
|
||||||
|
-- (seconds; 0 for those already finished when first seen, which get no effect), for
|
||||||
|
-- effects that carry on through rebuilds (refinery.lua's slot, header.lua's badge), and
|
||||||
|
-- fx:batch_ready plays once for them (sound, the slot or the Refinery tab).
|
||||||
|
local function watch_batches(g)
|
||||||
|
local st = g.state
|
||||||
|
if not st or not st.jobs then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now, t = session.now(g), socket.gettime()
|
||||||
|
local seen, out, fresh = g.batches_done, {}, nil
|
||||||
|
for _, j in ipairs(st.jobs) do
|
||||||
|
if j.id and (j.ready or crossed(j, now)) then
|
||||||
|
out[j.id] = seen and (seen[j.id] or t) or 0
|
||||||
|
if seen and not seen[j.id] then
|
||||||
|
fresh = fresh or {}
|
||||||
|
fresh[#fresh + 1] = j
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
g.batches_done = out
|
||||||
|
if fresh then
|
||||||
|
g.batch_ready_t = t
|
||||||
|
g.dirty = true
|
||||||
|
g.fx:batch_ready(g, fresh)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function M.update(g)
|
function M.update(g)
|
||||||
|
watch_batches(g)
|
||||||
if g.ready_due and g.state and session.now(g) >= g.ready_due then
|
if g.ready_due and g.state and session.now(g) >= g.ready_due then
|
||||||
g.ready_due = nil
|
g.ready_due = nil
|
||||||
g.dirty = true
|
g.dirty = true
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ local api = require("modules.api")
|
|||||||
local predict = require("modules.predict")
|
local predict = require("modules.predict")
|
||||||
local sfx = require("modules.sfx")
|
local sfx = require("modules.sfx")
|
||||||
local refinery = require("screens.game.refinery")
|
local refinery = require("screens.game.refinery")
|
||||||
|
local sidepanel = require("screens.game.sidepanel")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -61,8 +63,20 @@ end
|
|||||||
|
|
||||||
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like
|
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like
|
||||||
-- state.view). page only for the Past list past its first page (the web's data-page).
|
-- state.view). page only for the Past list past its first page (the web's data-page).
|
||||||
function M.view_params(v, extra)
|
-- g: the blocks the right side panel shows are asked for too (sidepanel.includes), on
|
||||||
local p = { s = v.section ~= "" and v.section or nil }
|
-- every request while it shows, so they never drop out of the state.
|
||||||
|
function M.view_params(v, extra, g)
|
||||||
|
-- (the leaderboard is a side panel, or a pane in the market when that is folded away:
|
||||||
|
-- an old link to it opens the market, which asks for its block)
|
||||||
|
local section = v.section == "leaderboard" and "market" or v.section
|
||||||
|
local p = { s = section ~= "" and section or nil }
|
||||||
|
-- (new-moon always: "+ New moon" in the moon list shows each grade's price)
|
||||||
|
p.include = section == "market" and "leaderboard,new-moon" or "new-moon"
|
||||||
|
for _, x in ipairs(g and sidepanel.includes(g) or {}) do
|
||||||
|
if not (","..p.include..","):find(","..x..",", 1, true) then
|
||||||
|
p.include = p.include .. "," .. x
|
||||||
|
end
|
||||||
|
end
|
||||||
if v.moon and v.moon ~= "" then p.moon = v.moon end
|
if v.moon and v.moon ~= "" then p.moon = v.moon end
|
||||||
if v.well and v.well ~= "" then p.well = v.well end
|
if v.well and v.well ~= "" then p.well = v.well end
|
||||||
if v.past then p.list = "past" end
|
if v.past then p.list = "past" end
|
||||||
@@ -71,8 +85,12 @@ function M.view_params(v, extra)
|
|||||||
if v.drill and v.drill ~= "" then p.drill = v.drill end
|
if v.drill and v.drill ~= "" then p.drill = v.drill end
|
||||||
if v.adv and v.adv ~= "" then p.adv = v.adv end
|
if v.adv and v.adv ~= "" then p.adv = v.adv end
|
||||||
for k, x in pairs(extra or {}) do
|
for k, x in pairs(extra or {}) do
|
||||||
|
if k == "include" and not (","..p.include..","):find(","..x..",", 1, true) then
|
||||||
|
p.include = p.include .. "," .. x
|
||||||
|
elseif k ~= "include" then
|
||||||
p[k] = x
|
p[k] = x
|
||||||
end
|
end
|
||||||
|
end
|
||||||
return p
|
return p
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -111,6 +129,9 @@ local function suspend(g)
|
|||||||
g.refetch = nil
|
g.refetch = nil
|
||||||
end
|
end
|
||||||
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
||||||
|
if g.history then
|
||||||
|
g.history.want = nil -- (its request was in the outbox)
|
||||||
|
end
|
||||||
g.boot = { text = SUSPENDED }
|
g.boot = { text = SUSPENDED }
|
||||||
if g.dialog and g.dialog:is_open() then
|
if g.dialog and g.dialog:is_open() then
|
||||||
g.dialog:close("cancel")
|
g.dialog:close("cancel")
|
||||||
@@ -118,24 +139,49 @@ local function suspend(g)
|
|||||||
g.dirty = true
|
g.dirty = true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Refetch when the next drill or refinery batch is due (the server decides what is ready).
|
-- Refetch when the next drill or refinery batch is due (the server decides what is ready),
|
||||||
|
-- or a banner item ends (the server's state no longer has it).
|
||||||
local function arm_refetch(g)
|
local function arm_refetch(g)
|
||||||
if g.refetch then
|
if g.refetch then
|
||||||
timer.cancel(g.refetch)
|
timer.cancel(g.refetch)
|
||||||
g.refetch = nil
|
g.refetch = nil
|
||||||
end
|
end
|
||||||
local due = g.state.nextEnd
|
-- (a time already past is skipped: the state we have is after it)
|
||||||
local batch = refinery.next_end(g.state)
|
local now = M.now(g)
|
||||||
if batch and (not due or batch < due) then
|
local due
|
||||||
due = batch
|
local function consider(t)
|
||||||
|
if t and t > 0 and t - now >= -2000 and (not due or t < due) then
|
||||||
|
due = t
|
||||||
|
end
|
||||||
|
end
|
||||||
|
consider(g.state.nextEnd)
|
||||||
|
consider(refinery.next_end(g.state))
|
||||||
|
for _, b in ipairs(g.state.banner or {}) do
|
||||||
|
consider(b["end"])
|
||||||
end
|
end
|
||||||
if not due then
|
if not due then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local left = due - M.now(g)
|
-- Ends within a second after it wait for the last of them: one refetch and rebuild,
|
||||||
if left < -2000 then
|
-- not one each (four batches started together end a few ms apart).
|
||||||
return -- already past: the state we have is after it
|
local first = due
|
||||||
|
local function join(t)
|
||||||
|
if t and t > due and t <= first + 1000 then
|
||||||
|
due = t
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
join(g.state.nextEnd)
|
||||||
|
for _, j in ipairs(g.state.jobs or {}) do
|
||||||
|
if not j.ready then
|
||||||
|
join(j["end"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, s in ipairs(g.state.stakes or {}) do
|
||||||
|
if not s.ready then
|
||||||
|
join(s["end"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local left = due - now
|
||||||
g.refetch = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
|
g.refetch = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
|
||||||
g.refetch = nil
|
g.refetch = nil
|
||||||
-- (a navigation still on its way: its view, or this answer would take it back)
|
-- (a navigation still on its way: its view, or this answer would take it back)
|
||||||
@@ -145,7 +191,8 @@ end
|
|||||||
|
|
||||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||||
local function show(g)
|
local function show(g)
|
||||||
g.state = predict.apply(g.base, g.pending)
|
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||||
|
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||||
g.view = g.state.view
|
g.view = g.state.view
|
||||||
arm_refetch(g)
|
arm_refetch(g)
|
||||||
g.dirty = true
|
g.dirty = true
|
||||||
@@ -173,7 +220,9 @@ end
|
|||||||
|
|
||||||
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
||||||
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
||||||
-- without pushing). g.history = { list = { view, ... }, i = current }.
|
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
|
||||||
|
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
|
||||||
|
-- to requests sent before it (an action, a refetch) push nothing.
|
||||||
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
||||||
|
|
||||||
local function view_key(v)
|
local function view_key(v)
|
||||||
@@ -200,7 +249,14 @@ local function remember(g, from_history)
|
|||||||
if from_history then
|
if from_history then
|
||||||
if h.list[from_history] then
|
if h.list[from_history] then
|
||||||
h.list[from_history] = v
|
h.list[from_history] = v
|
||||||
|
h.i = from_history
|
||||||
end
|
end
|
||||||
|
if h.want == from_history then
|
||||||
|
h.want = nil
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if h.want then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
||||||
@@ -248,6 +304,10 @@ pump = function(g)
|
|||||||
elseif g.base then
|
elseif g.base then
|
||||||
show(g) -- rolled back
|
show(g) -- rolled back
|
||||||
end
|
end
|
||||||
|
-- (a back/forward that failed: the history stays where it was)
|
||||||
|
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
|
||||||
|
history(g).want = nil
|
||||||
|
end
|
||||||
if status == 403 then
|
if status == 403 then
|
||||||
suspend(g)
|
suspend(g)
|
||||||
g.inflight, g.busy, g.nav_view = {}, false, nil
|
g.inflight, g.busy, g.nav_view = {}, false, nil
|
||||||
@@ -265,17 +325,19 @@ pump = function(g)
|
|||||||
-- the click; its answer only corrects the numbers (or rolls them back, with the
|
-- the click; its answer only corrects the numbers (or rolls them back, with the
|
||||||
-- error's).
|
-- error's).
|
||||||
-- A new game is a fresh start (the web reloads the page): no toast, no wallet
|
-- A new game is a fresh start (the web reloads the page): no toast, no wallet
|
||||||
-- differences, the page enters again.
|
-- differences, the page enters again. So is a restore from a save code (another
|
||||||
local fresh = applied and r.kind == "new-game" and data.ok
|
-- player: api.lua stored its id from the response), with its toast.
|
||||||
|
local fresh = applied and (r.kind == "new-game" or r.kind == "restore") and data.ok
|
||||||
if fresh then
|
if fresh then
|
||||||
g.fx.counters = {}
|
g.fx.counters = {}
|
||||||
|
g.batches_done = nil -- (another player's batches: no ready effects for them)
|
||||||
g.fx:on_response(g, "get", data, nil, g.state, {})
|
g.fx:on_response(g, "get", data, nil, g.state, {})
|
||||||
g.enter_all = true
|
g.enter_all = true
|
||||||
elseif g.state and g.state ~= prev then
|
elseif g.state and g.state ~= prev then
|
||||||
local d = type(data) == "table" and data or { toast = { error = true } }
|
local d = type(data) == "table" and data or { toast = { error = true } }
|
||||||
g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
|
g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
|
||||||
end
|
end
|
||||||
if revealing or fresh then
|
if revealing or (fresh and r.kind == "new-game") then
|
||||||
-- (toast deferred, or none)
|
-- (toast deferred, or none)
|
||||||
elseif type(data) == "table" and type(data.toast) == "table" then
|
elseif type(data) == "table" and type(data.toast) == "table" then
|
||||||
local color = g.fx:take_claim_color()
|
local color = g.fx:take_claim_color()
|
||||||
@@ -287,6 +349,11 @@ pump = function(g)
|
|||||||
sfx.play("error") -- (a rolled-back prediction had fx's error effects)
|
sfx.play("error") -- (a rolled-back prediction had fx's error effects)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- A one-time announcement besides the toast: where the save code is (the server's
|
||||||
|
-- words name no menu).
|
||||||
|
if applied and type(data.notice) == "table" and data.notice.kind == "save-code" then
|
||||||
|
g.toasts:push("Your progress is kept on this device only. Make a save code in the settings (the gear, top right) to play it on another device.", "save-code", false)
|
||||||
|
end
|
||||||
-- Toast a failing server once, not every retry (M.fetch tries again).
|
-- Toast a failing server once, not every retry (M.fetch tries again).
|
||||||
g.offline = status == 0 or status >= 500
|
g.offline = status == 0 or status >= 500
|
||||||
for k, v in pairs(g.inflight) do
|
for k, v in pairs(g.inflight) do
|
||||||
@@ -314,7 +381,7 @@ end
|
|||||||
-- 5xx) it tries again: at start as g.boot says (and a 429 too), in play every 3 s,
|
-- 5xx) it tries again: at start as g.boot says (and a 429 too), in play every 3 s,
|
||||||
-- doubling up to 30 s, until it answers; the shown state stays meanwhile.
|
-- doubling up to 30 s, until it answers; the shown state stays meanwhile.
|
||||||
function M.fetch(g, view, nav)
|
function M.fetch(g, view, nav)
|
||||||
request(g, "get", M.view_params(view or {}), function(err, data, status)
|
request(g, "get", M.view_params(view or {}, nil, g), function(err, data, status)
|
||||||
if g.retry then
|
if g.retry then
|
||||||
timer.cancel(g.retry)
|
timer.cancel(g.retry)
|
||||||
g.retry = nil
|
g.retry = nil
|
||||||
@@ -354,6 +421,10 @@ function M.navigate(g, v)
|
|||||||
sfx.play("nav")
|
sfx.play("nav")
|
||||||
end
|
end
|
||||||
g.nav_view = v
|
g.nav_view = v
|
||||||
|
g.ask = nil -- (a sell asking in place: cards.ask_button)
|
||||||
|
if g.admin then
|
||||||
|
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
|
||||||
|
end
|
||||||
M.fetch(g, v, v)
|
M.fetch(g, v, v)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -361,12 +432,13 @@ end
|
|||||||
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
||||||
function M.go(g, dir)
|
function M.go(g, dir)
|
||||||
local h = history(g)
|
local h = history(g)
|
||||||
local v = h.list[h.i + dir]
|
local i = (h.want or h.i) + dir
|
||||||
|
local v = h.list[i]
|
||||||
if not v then
|
if not v then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
h.i = h.i + dir
|
h.want = i
|
||||||
local to = { from_history = h.i }
|
local to = { from_history = i }
|
||||||
for k, x in pairs(v) do
|
for k, x in pairs(v) do
|
||||||
to[k] = x
|
to[k] = x
|
||||||
end
|
end
|
||||||
@@ -380,7 +452,7 @@ end
|
|||||||
function M.act(g, name, params, on_done, fn)
|
function M.act(g, name, params, on_done, fn)
|
||||||
g.seq = g.seq + 1
|
g.seq = g.seq + 1
|
||||||
local seq = g.seq
|
local seq = g.seq
|
||||||
local p = M.view_params(g.nav_view or g.view, params)
|
local p = M.view_params(g.nav_view or g.view, params, g)
|
||||||
p.clientSeq = seq
|
p.clientSeq = seq
|
||||||
fn = fn or predict.registry[name]
|
fn = fn or predict.registry[name]
|
||||||
local predicted = false
|
local predicted = false
|
||||||
@@ -390,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
|||||||
if predict.run(st, e) then
|
if predict.run(st, e) then
|
||||||
g.pending[#g.pending + 1] = e
|
g.pending[#g.pending + 1] = e
|
||||||
local prev = g.state
|
local prev = g.state
|
||||||
g.state = st
|
g.state = blocks.freeze(st)
|
||||||
g.dirty = true
|
g.dirty = true
|
||||||
predicted = true
|
predicted = true
|
||||||
-- The action's effects play now, from the predicted change (fx.lua).
|
-- The action's effects play now, from the predicted change (fx.lua).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local session = require("screens.game.core.session")
|
local session = require("screens.game.core.session")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ function M.tick_text(g, node, ends, fmt, done, key, on_done)
|
|||||||
if key then
|
if key then
|
||||||
g.tween:track(key, ends - now(g), ends)
|
g.tween:track(key, ends - now(g), ends)
|
||||||
end
|
end
|
||||||
g.tickers[#g.tickers + 1] = { kind = "time", node = node, ends = ends, fmt = fmt, done = done, key = key, on_done = on_done }
|
blocks.push(g, "tickers", { kind = "time", node = node, ends = ends, fmt = fmt, done = done, key = key, on_done = on_done })
|
||||||
end
|
end
|
||||||
|
|
||||||
local function bar_fill(t, start, ends)
|
local function bar_fill(t, start, ends)
|
||||||
@@ -33,7 +34,7 @@ function M.tick_bar(g, node, start, ends, w, key, on_full)
|
|||||||
if key then
|
if key then
|
||||||
g.tween:track(key, bar_fill(now(g), start, ends), start .. ":" .. ends)
|
g.tween:track(key, bar_fill(now(g), start, ends), start .. ":" .. ends)
|
||||||
end
|
end
|
||||||
g.tickers[#g.tickers + 1] = { kind = "bar", node = node, start = start, ends = ends, w = w, key = key, on_done = on_full }
|
blocks.push(g, "tickers", { kind = "bar", node = node, start = start, ends = ends, w = w, key = key, on_done = on_full })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A number text node showing fmt(value), easing from the shown value when it changes.
|
-- A number text node showing fmt(value), easing from the shown value when it changes.
|
||||||
@@ -41,7 +42,7 @@ end
|
|||||||
function M.tick_num(g, node, key, value, fmt)
|
function M.tick_num(g, node, key, value, fmt)
|
||||||
g.tween:track(key, value)
|
g.tween:track(key, value)
|
||||||
local k = { kind = "num", node = node, key = key, value = value, fmt = fmt or ui.money }
|
local k = { kind = "num", node = node, key = key, value = value, fmt = fmt or ui.money }
|
||||||
g.tickers[#g.tickers + 1] = k
|
blocks.push(g, "tickers", k)
|
||||||
local v = g.tween:value(key, value)
|
local v = g.tween:value(key, value)
|
||||||
if v ~= value and value == math.floor(value) then
|
if v ~= value and value == math.floor(value) then
|
||||||
v = math.floor(v + 0.5) -- counts tick as whole numbers
|
v = math.floor(v + 0.5) -- counts tick as whole numbers
|
||||||
@@ -55,7 +56,7 @@ function M.run(g)
|
|||||||
local t = now(g)
|
local t = now(g)
|
||||||
local tw = g.tween
|
local tw = g.tween
|
||||||
for _, k in ipairs(g.tickers) do
|
for _, k in ipairs(g.tickers) do
|
||||||
if k.dead then
|
if k.dead or not pcall(gui.get_id, k.node) then
|
||||||
-- node gone (deleted outside a rebuild)
|
-- node gone (deleted outside a rebuild)
|
||||||
elseif k.kind == "time" then
|
elseif k.kind == "time" then
|
||||||
local left = k.ends - t
|
local left = k.ends - t
|
||||||
|
|||||||
+673
-329
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
|||||||
|
-- Quick grant (admins only, state.admin): give yourself credits, a resource or a refined
|
||||||
|
-- item (server action admin-grant). Opened by the header's grant button or the G key
|
||||||
|
-- (core/keys). An item picker and an amount picker (buttons, the pick ringed), then Grant.
|
||||||
|
-- Test ids: "dialog:grant:<item>", "dialog:amount:<n>", "dialog:ok".
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local AMOUNTS = { { 100, "100" }, { 1000, "1K" }, { 10000, "10K" }, { 100000, "100K" }, { 1000000, "1M" } }
|
||||||
|
local W = 520
|
||||||
|
|
||||||
|
-- The choices: credits, then the catalog's resources and refined items.
|
||||||
|
local function items(st)
|
||||||
|
local out = { { "credits", "Credits" } }
|
||||||
|
local cat = st.catalog or {}
|
||||||
|
for _, list in ipairs({ cat.resources or {}, cat.refined or {} }) do
|
||||||
|
for _, r in ipairs(list) do
|
||||||
|
table.insert(out, { r.name, r.name })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A wrapping row of small buttons (choices: { value, label }) at (x, y), inner width w.
|
||||||
|
local function picker(parent, choices, x, y, w)
|
||||||
|
local kids = {}
|
||||||
|
for i, c in ipairs(choices) do
|
||||||
|
kids[i] = { style = { width = fg.button_w(parent, c[2], { small = true }), height = S.button_sm_h, shrink = 0 } }
|
||||||
|
end
|
||||||
|
local tree = flex.box({ wrap = true, gap = 8, width = w }, kids)
|
||||||
|
flex.solve(tree, x, y)
|
||||||
|
return tree
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open(g)
|
||||||
|
local st = g.state
|
||||||
|
if not (st and st.admin) or g.dialog:is_open() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local choices, amounts = items(st), {}
|
||||||
|
for i, a in ipairs(AMOUNTS) do
|
||||||
|
amounts[i] = { a[1], a[2] }
|
||||||
|
end
|
||||||
|
-- (laid out first: the dialog's height comes from it)
|
||||||
|
local x, y, iw = 24, 64, W - 48
|
||||||
|
local item_tree = picker(g.dialog.layer, choices, x, y + 22, iw)
|
||||||
|
local ay = y + 22 + item_tree.h + 20
|
||||||
|
local amount_tree = picker(g.dialog.layer, amounts, x, ay + 22, iw)
|
||||||
|
local h = ay + 22 + amount_tree.h + 24 + S.button_h + 24
|
||||||
|
local params = { grant = "credits", amount = "1000" }
|
||||||
|
g.dialog:open({
|
||||||
|
title = "Grant",
|
||||||
|
w = W,
|
||||||
|
h = h,
|
||||||
|
ok = { label = "Grant", act = "admin-grant", params = params },
|
||||||
|
build = function(d, box)
|
||||||
|
local rings = {}
|
||||||
|
local function pick(kind, tree, list, key, value_of)
|
||||||
|
for i, c in ipairs(list) do
|
||||||
|
local b = tree.children[i]
|
||||||
|
local function ring()
|
||||||
|
if rings[kind] then
|
||||||
|
gui.delete_node(rings[kind])
|
||||||
|
end
|
||||||
|
rings[kind] = ui.outline(box, b.x - 3, b.y - 3, b.w + 6, b.h + 6, "cyan")
|
||||||
|
end
|
||||||
|
d:button(box, c[2], b.x, b.y, { small = true, w = b.w, id = "dialog:" .. kind .. ":" .. tostring(c[1]),
|
||||||
|
on_click = function()
|
||||||
|
params[key] = value_of(c[1])
|
||||||
|
ring()
|
||||||
|
end })
|
||||||
|
if value_of(c[1]) == params[key] then
|
||||||
|
ring()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ui.text(box, "Item", "cap", x, y + 6)
|
||||||
|
pick("grant", item_tree, choices, "grant", tostring)
|
||||||
|
ui.text(box, "Amount", "cap", x, ay + 6)
|
||||||
|
pick("amount", amount_tree, amounts, "amount", function(n) return string.format("%d", n) end)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
+192
-144
@@ -18,7 +18,12 @@ local S = theme.size
|
|||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
-- Header sections in the web's order; each shows once state.unlocked has it.
|
-- Header sections in the web's order; each shows once state.unlocked has it.
|
||||||
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
|
-- (the leaderboard is the right side panel, sidepanel.lua; How to play is in the settings menu)
|
||||||
|
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors" }
|
||||||
|
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
|
|
||||||
local function vec(x, y)
|
local function vec(x, y)
|
||||||
return vmath.vector3(x, y, 0)
|
return vmath.vector3(x, y, 0)
|
||||||
@@ -33,8 +38,47 @@ local function contains(list, value)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The wallet at the right end; returns the x where it starts.
|
-- The Refinery tab's badge pops in (fx-ready-pop, 0.6 s) when a batch has just finished
|
||||||
local function wallet(g, parent, W)
|
-- (core/ready), by the clock, so the refetch's rebuild carries on. Reduced motion: none.
|
||||||
|
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.25 }, { 1, 1 } }
|
||||||
|
local function pop_badge(g, node, w)
|
||||||
|
local age
|
||||||
|
for _, j in ipairs(g.state.jobs or {}) do
|
||||||
|
local a = ready.batch_age(g, j)
|
||||||
|
if a and (not age or a < age) then
|
||||||
|
age = a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if anim.reduced or not age or age >= 0.6 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local p, base = gui.get_position(node), gui.get_scale(node)
|
||||||
|
anim.tween(node, 0.6, function(k, n)
|
||||||
|
anim.scale_about(n, p, base, w / 2, -7.5, anim.keys(POP_SCALE, k, anim.fn.ready_pop)) -- (about its center: 15 tall)
|
||||||
|
end, -age)
|
||||||
|
end
|
||||||
|
|
||||||
|
local GEAR = 32
|
||||||
|
local CLAIM_W = 200 -- Claim all's place in the header (its widest label and badge)
|
||||||
|
local CHIP_GAP = 18
|
||||||
|
|
||||||
|
-- The resource chips' widths (probes: nodes made and dropped), for the layout.
|
||||||
|
local function chip_widths(g, parent)
|
||||||
|
local out = {}
|
||||||
|
for i, r in ipairs(g.state.resources) do
|
||||||
|
local probe = ui.text(parent, ui.money(r.amount), "num", 0, 0)
|
||||||
|
local tw = ui.text_width(probe)
|
||||||
|
gui.set_text(probe, g.fx:text(r.name, r.amount))
|
||||||
|
out[i] = 14 + math.max(tw, ui.text_width(probe))
|
||||||
|
gui.delete_node(probe)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The wallet at the right end: the resource chips (unless chips is false: then they go on
|
||||||
|
-- a line under the balance, see M.build), the credit balance, the settings gear. Returns
|
||||||
|
-- the x where it starts.
|
||||||
|
local function wallet(g, parent, W, chips_w, chips_below)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they
|
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they
|
||||||
-- count up and down as effects land) and are sized for the wider of what they show now
|
-- count up and down as effects land) and are sized for the wider of what they show now
|
||||||
@@ -45,194 +89,198 @@ local function wallet(g, parent, W)
|
|||||||
gui.set_text(probe, ui.money(st.credits))
|
gui.set_text(probe, ui.money(st.credits))
|
||||||
num_w = math.max(num_w, ui.text_width(probe))
|
num_w = math.max(num_w, ui.text_width(probe))
|
||||||
gui.delete_node(probe)
|
gui.delete_node(probe)
|
||||||
|
-- Built right to left (the order of focus and input), laid out as a row (modules/flex)
|
||||||
|
-- that ends at the header's padding, everything on the balance's middle.
|
||||||
|
local row = {}
|
||||||
|
local function push(node, w, h, gap_after)
|
||||||
|
if gap_after then
|
||||||
|
table.insert(row, 1, { style = { width = gap_after } })
|
||||||
|
end
|
||||||
|
table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } })
|
||||||
|
end
|
||||||
|
-- Settings (screens/game/settings.lua): a menu under the gear.
|
||||||
|
local gear = ui.outline(parent, 0, 0, GEAR, GEAR, "line_hi")
|
||||||
|
push(gear, GEAR, GEAR)
|
||||||
|
local gicon = ui.rect(gear, 6, 6, 20, 20, "nav")
|
||||||
|
gui.play_flipbook(gicon, "gear")
|
||||||
|
test.register("settings", gear, "button", "settings")
|
||||||
|
tooltip.add(gear, "Settings")
|
||||||
|
g.druid:new_button(gear, function()
|
||||||
|
local top = -gui.get_position(g.header_node).y + 16 + 36 / 2 + GEAR / 2 + 6
|
||||||
|
require("screens.game.settings").open(g, S.header_pad_x, top)
|
||||||
|
end):set_animations_disabled()
|
||||||
|
|
||||||
local bal_w = math.floor(num_w + 16 + 32 + 4)
|
local bal_w = math.floor(num_w + 16 + 32 + 4)
|
||||||
local bal_x = W - S.header_pad_x - bal_w
|
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36)
|
||||||
local _, bal = ui.panel(parent, bal_x, 16, bal_w, 36)
|
push(bal_panel, bal_w, 36, 12)
|
||||||
local bal_root = ui.part(bal, "panel", "root")
|
local bal_root = ui.part(bal, "panel", "root")
|
||||||
ui.text(bal_root, theme.currency, "sym", 16, 18)
|
ui.text(bal_root, theme.currency, "sym", 16, 18)
|
||||||
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
|
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
|
||||||
|
|
||||||
local cx = bal_x - 22
|
-- Chips: in the row before the balance, or a row of their own under it (chips_below).
|
||||||
|
local chip_row = chips_below and {} or row
|
||||||
|
local gap = chips_below and nil or 22
|
||||||
for i = #st.resources, 1, -1 do
|
for i = #st.resources, 1, -1 do
|
||||||
local r = st.resources[i]
|
local r = st.resources[i]
|
||||||
local root, nodes = ui.clone("chip", parent, 0, 0)
|
local root, nodes = ui.clone("chip", parent, 0, 0)
|
||||||
local t = ui.part(nodes, "chip", "text")
|
local t = ui.part(nodes, "chip", "text")
|
||||||
ui.style_text(t, "num", ui.money(r.amount))
|
|
||||||
local tw = ui.text_width(t)
|
|
||||||
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
|
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
|
||||||
local w = 14 + math.max(tw, ui.text_width(t))
|
local w = chips_w[i]
|
||||||
cx = cx - w
|
|
||||||
gui.set_position(root, vec(cx, -24))
|
|
||||||
gui.set_size(root, vec(w, 20))
|
gui.set_size(root, vec(w, 20))
|
||||||
|
if gap then
|
||||||
|
table.insert(chip_row, 1, { style = { width = gap } })
|
||||||
|
end
|
||||||
|
table.insert(chip_row, 1, { node = root, size = false, style = { width = w, height = 20 } })
|
||||||
|
gap = CHIP_GAP
|
||||||
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
|
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
|
||||||
g.fx:bind(r.name, t, root)
|
g.fx:bind(r.name, t, root)
|
||||||
tooltip.add(root, r.name)
|
tooltip.add(root, r.name)
|
||||||
cx = cx - 18
|
|
||||||
end
|
end
|
||||||
-- Sound on/off (saved).
|
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
|
||||||
local mute = ui.outline(parent, cx - 28 + 14, 20, 28, 28, "line_hi")
|
flex.apply(tree)
|
||||||
local icon = ui.rect(mute, 4, 4, 20, 20, sfx.muted() and "muted" or "cyan")
|
if chips_below then
|
||||||
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
|
flex.apply(flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = chip_row }, 0, 16 + 36 + 8, W, 20))
|
||||||
test.register("mute", mute, "button", "mute")
|
|
||||||
tooltip.add(mute, sfx.muted() and "Sound off" or "Sound on")
|
|
||||||
g.druid:new_button(mute, function()
|
|
||||||
sfx.toggle_mute()
|
|
||||||
g.dirty = true
|
|
||||||
end):set_animations_disabled()
|
|
||||||
-- Light / dark theme (saved); the icon shows the theme it switches to, like the web.
|
|
||||||
local tx = cx - 14 - 28 - 8
|
|
||||||
local tgl = ui.outline(parent, tx, 20, 28, 28, "line_hi")
|
|
||||||
local ticon = ui.rect(tgl, 5, 5, 18, 18, "nav")
|
|
||||||
gui.play_flipbook(ticon, theme.light and "moon" or "sun")
|
|
||||||
test.register("theme", tgl, "button", "theme")
|
|
||||||
tooltip.add(tgl, "Light / dark theme")
|
|
||||||
g.druid:new_button(tgl, function()
|
|
||||||
theme.toggle()
|
|
||||||
g.dirty = true
|
|
||||||
end):set_animations_disabled()
|
|
||||||
-- Reduced motion (saved): speed lines, lit while motion is on (the web follows the
|
|
||||||
-- system's setting; here it's the player's).
|
|
||||||
local mx = tx - 28 - 8
|
|
||||||
local mot = ui.outline(parent, mx, 20, 28, 28, "line_hi")
|
|
||||||
local lit = anim.reduced and "muted" or "nav"
|
|
||||||
for i, l in ipairs({ { 8, 9, 14 }, { 5, 13, 18 }, { 10, 17, 11 } }) do
|
|
||||||
ui.rect(mot, l[1], l[2] + (i - 2) * 1, l[3], 2, lit)
|
|
||||||
end
|
end
|
||||||
if anim.reduced then
|
return row[1].x - 10
|
||||||
local slash = ui.rect(mot, 14, 14, 20, 2, "muted")
|
|
||||||
gui.set_pivot(slash, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(slash, vec(14, -14))
|
|
||||||
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
|
|
||||||
end
|
|
||||||
test.register("motion", mot, "button", "motion")
|
|
||||||
tooltip.add(mot, anim.reduced and "Reduced motion: on" or "Reduced motion: off")
|
|
||||||
g.druid:new_button(mot, function()
|
|
||||||
anim.set_reduced_motion(not anim.reduced)
|
|
||||||
sfx.play("toggle")
|
|
||||||
g.dirty = true
|
|
||||||
end):set_animations_disabled()
|
|
||||||
-- Display settings (UI scale, fullscreen: screens/game/settings.lua); a screen icon.
|
|
||||||
local dx = mx - 28 - 8
|
|
||||||
local disp = ui.outline(parent, dx, 20, 28, 28, "line_hi")
|
|
||||||
ui.outline(disp, 6, 7, 16, 11, "nav")
|
|
||||||
ui.rect(disp, 11, 20, 6, 1, "nav")
|
|
||||||
test.register("display", disp, "button", "display")
|
|
||||||
tooltip.add(disp, "Display: UI scale, fullscreen")
|
|
||||||
g.druid:new_button(disp, function()
|
|
||||||
require("screens.game.settings").open(g)
|
|
||||||
end):set_animations_disabled()
|
|
||||||
return dx - 10
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Builds the header across parent (W wide); returns its height.
|
-- Builds the header across parent (W wide); returns its height. One tree: the brand,
|
||||||
|
-- the tabs and Claim all after it (on a second row when they don't fit before the
|
||||||
|
-- wallet), the edge line under it. Each tab is a template clone in a slot (its reveal
|
||||||
|
-- moves and scales it).
|
||||||
function M.build(g, parent, x, y, W, h, H)
|
function M.build(g, parent, x, y, W, h, H)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
x = S.header_pad_x
|
local pad = S.header_pad_x
|
||||||
local brand = ui.text(parent, "Moon Miners", "brand", x, 24)
|
local brand = fg.label(parent, "Moon Miners", "brand", 48)
|
||||||
local cap = ui.text(parent, "Extraction command", "brand_cap", x, 44)
|
local cap = fg.label(parent, "Extraction command", "brand_cap", 16, { position = "absolute", top = 36, left = 0 })
|
||||||
local brand_w = math.max(ui.text_width(brand), ui.text_width(cap))
|
local brand_w = math.max(brand.measure(), cap.measure())
|
||||||
local wallet_x = wallet(g, parent, W)
|
|
||||||
|
|
||||||
-- Sections (tabs), after the brand, as they unlock (none until Equipment, like the
|
-- Sections (tabs), as they unlock (none until Equipment, like the web).
|
||||||
-- web); on a second row when they don't fit.
|
local section = g.view.section
|
||||||
local tabs = {}
|
|
||||||
local nav_w = 0
|
|
||||||
local ink = tabs_c.ink(g, parent, "nav") -- under the tabs (made first)
|
local ink = tabs_c.ink(g, parent, "nav") -- under the tabs (made first)
|
||||||
|
local active_tab = false
|
||||||
|
local names, tabs = {}, {}
|
||||||
|
local nav_w = 0
|
||||||
if st.unlocked.equipment then
|
if st.unlocked.equipment then
|
||||||
for _, name in ipairs(M.SECTIONS) do
|
for _, name in ipairs(M.SECTIONS) do
|
||||||
if st.unlocked[name] then
|
if st.unlocked[name] then
|
||||||
local root, nodes = ui.clone("tab", parent, 0, 0)
|
local label = fg.label(parent, name, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, 44)
|
||||||
local t = ui.part(nodes, "tab", "text")
|
local w = math.floor(label.measure() + 24)
|
||||||
ui.style_text(t, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, name)
|
gui.delete_node(label.node)
|
||||||
local w = math.floor(ui.text_width(t) + 24)
|
|
||||||
-- (counts move on as drills and batches finish on our clock: core/ready)
|
-- (counts move on as drills and batches finish on our clock: core/ready)
|
||||||
local count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
|
local count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
|
||||||
local badge = count > 0 and count or nil
|
local badge = count > 0 and count or nil
|
||||||
if badge then
|
-- The count is a small badge on the tab's top-right corner: it takes no width,
|
||||||
w = w + 26
|
-- so the tabs don't move when it comes or goes (clicker rule).
|
||||||
end
|
names[#names + 1] = name
|
||||||
tabs[#tabs + 1] = { name = name, root = root, nodes = nodes, w = w, badge = badge }
|
|
||||||
nav_w = nav_w + w + 2
|
nav_w = nav_w + w + 2
|
||||||
|
tabs[#tabs + 1] = { style = { width = w, height = 44, shrink = 0 }, make = function(p, tx, ty)
|
||||||
|
local slot = ui.rect(p, tx, ty, w, 44, "none")
|
||||||
|
local root, nodes = ui.clone("tab", slot, 0, 0)
|
||||||
|
local t = ui.part(nodes, "tab", "text")
|
||||||
|
local u = ui.part(nodes, "tab", "underline")
|
||||||
|
ui.style_text(t, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, name)
|
||||||
|
gui.set_size(root, vec(w, 44))
|
||||||
|
gui.set_enabled(u, false) -- the ink draws the active tab's underline
|
||||||
|
local active = name == section
|
||||||
|
local shine = contains(st.shine, name)
|
||||||
|
gui.set_position(t, vec(w / 2, -22))
|
||||||
|
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
|
||||||
|
if active then
|
||||||
|
ink:to(tx, ty, w, 44)
|
||||||
|
active_tab = true
|
||||||
|
end
|
||||||
|
if shine then
|
||||||
|
-- Revealed, not opened yet: a lit tab held in a targeting reticle (fx.lua).
|
||||||
|
gui.set_color(root, ui.color("cyan", 0.1))
|
||||||
|
g.fx:reticle(root, w, 44, name)
|
||||||
|
end
|
||||||
|
if badge then
|
||||||
|
local cb, cw = chip.new(root, badge, w - 1, 9, { right = true, color = "ready", text = "bg", fill = 1,
|
||||||
|
font = "mono_semi", px = 10, h = 15, min_w = 15, pad = 3 })
|
||||||
|
if name == "refinery" then
|
||||||
|
pop_badge(g, cb, cw)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
tabs_c.tab(g, root, t, {
|
||||||
|
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. name, label = name,
|
||||||
|
on_click = function()
|
||||||
|
-- Like the web's tabs: only the moon carries over (not the well).
|
||||||
|
H.navigate(g, { section = name, moon = g.view.moon })
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
g.tab_nodes[name] = root
|
||||||
|
tabs_c.reveal(g, "nav", name, root, t, w, 44)
|
||||||
|
end }
|
||||||
end
|
end
|
||||||
local n_ready = ready.drills(g)
|
|
||||||
local claim_all = st.unlocked["claim-all"] and (n_ready > 0 or st.nextEnd)
|
|
||||||
if claim_all then
|
|
||||||
nav_w = nav_w + 170
|
|
||||||
end
|
end
|
||||||
local nav_x = x + brand_w + 28
|
|
||||||
local nav_y = 10
|
|
||||||
local header_h = S.header_h
|
|
||||||
if nav_x + nav_w > wallet_x then
|
|
||||||
nav_x = S.header_pad_x - 12
|
|
||||||
nav_y = header_h - 8
|
|
||||||
header_h = header_h + 40
|
|
||||||
end
|
|
||||||
local section = g.view.section
|
|
||||||
local active_tab = false
|
|
||||||
local names = {}
|
|
||||||
for i, tab in ipairs(tabs) do
|
|
||||||
names[i] = tab.name
|
|
||||||
end
|
end
|
||||||
tabs_c.revealed(g, "nav", names)
|
tabs_c.revealed(g, "nav", names)
|
||||||
-- "+ New moon" (moonlist.lua) enters too when revealed after load; tracked here, where
|
-- "+ New moon" (moonlist.lua) enters too when revealed after load; tracked here, where
|
||||||
-- every state is seen (the moon list may not be shown yet).
|
-- every state is seen (the moon list may not be shown yet).
|
||||||
tabs_c.revealed(g, "newmoon", st.unlocked["new-moon"] and { "new-moon" } or {})
|
tabs_c.revealed(g, "newmoon", st.unlocked["new-moon"] and { "new-moon" } or {})
|
||||||
for _, tab in ipairs(tabs) do
|
|
||||||
local t = ui.part(tab.nodes, "tab", "text")
|
|
||||||
local u = ui.part(tab.nodes, "tab", "underline")
|
|
||||||
gui.set_position(tab.root, vec(nav_x, -nav_y))
|
|
||||||
gui.set_size(tab.root, vec(tab.w, 44))
|
|
||||||
gui.set_enabled(u, false) -- the ink draws the active tab's underline
|
|
||||||
local active = tab.name == section
|
|
||||||
local shine = contains(st.shine, tab.name)
|
|
||||||
gui.set_position(t, vec(tab.badge and (tab.w - 26) / 2 or tab.w / 2, -22))
|
|
||||||
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
|
|
||||||
if active then
|
|
||||||
ink:to(nav_x, nav_y, tab.w, 44)
|
|
||||||
active_tab = true
|
|
||||||
end
|
|
||||||
if shine then
|
|
||||||
-- Revealed, not opened yet: a lit tab held in a targeting reticle (fx.lua).
|
|
||||||
gui.set_color(tab.root, ui.color("cyan", 0.1))
|
|
||||||
g.fx:reticle(tab.root, tab.w, 44, tab.name)
|
|
||||||
end
|
|
||||||
if tab.badge then
|
|
||||||
chip.new(tab.root, tab.badge, tab.w - 34, 22, { color = "ready", font = "mono", px = 12, min_w = 22, pad = 0 })
|
|
||||||
end
|
|
||||||
tabs_c.tab(g, tab.root, t, {
|
|
||||||
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. tab.name, label = tab.name,
|
|
||||||
on_click = function()
|
|
||||||
-- Like the web's tabs: only the moon carries over (not the well).
|
|
||||||
H.navigate(g, { section = tab.name, moon = g.view.moon })
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
if tab.name == "help" then
|
|
||||||
tooltip.add(tab.root, "How to play")
|
|
||||||
end
|
|
||||||
g.tab_nodes[tab.name] = tab.root
|
|
||||||
tabs_c.reveal(g, "nav", tab.name, tab.root, t, tab.w, 44)
|
|
||||||
nav_x = nav_x + tab.w + 2
|
|
||||||
end
|
|
||||||
if not active_tab then
|
|
||||||
ink:none()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Claim all: every finished drill; waits with a countdown while none is ready.
|
-- Claim all: every finished drill; waits with a countdown while none is ready. Its
|
||||||
if claim_all then
|
-- place is kept (CLAIM_W) once it's unlocked, shown or not, whatever its label, so the
|
||||||
|
-- tabs never move for it.
|
||||||
|
local n_ready = ready.drills(g)
|
||||||
|
local reserve = st.unlocked["claim-all"]
|
||||||
|
local claim_all = reserve and (n_ready > 0 or st.nextEnd)
|
||||||
|
if reserve then
|
||||||
|
nav_w = nav_w + 10 + CLAIM_W
|
||||||
|
end
|
||||||
|
local claim = reserve and { style = { width = CLAIM_W, height = 44, shrink = 0 }, make = claim_all and function(p, cx, cy)
|
||||||
if st.ready > 0 then
|
if st.ready > 0 then
|
||||||
local b, nodes = ui.button(parent, "Claim all", nav_x + 8, nav_y + 6, { primary = true, small = true })
|
local b, nodes = ui.button(p, "Claim all", cx, cy + 6, { primary = true, small = true })
|
||||||
H.badge(b, nodes, n_ready)
|
H.badge(b, nodes, n_ready)
|
||||||
H.action(g, b, nodes, "claim", {})
|
H.action(g, b, nodes, "claim", {})
|
||||||
else
|
else
|
||||||
H.claim_wait(g, parent, nav_x + 8, nav_y + 6, st.nextEnd, { small = true, key = "wait:all", params = {},
|
H.claim_wait(g, p, cx, cy + 6, st.nextEnd, { small = true, key = "wait:all", params = {},
|
||||||
wait = "Claim all", done = "Claim all", badge = n_ready > 0 and n_ready or nil })
|
wait = "Claim all", done = "Claim all", badge = n_ready > 0 and n_ready or nil })
|
||||||
end
|
end
|
||||||
|
end or nil } or nil
|
||||||
|
local nav = row({ align = "start" }, { row({ gap = 2 }, tabs), claim and space(10) or nil, claim })
|
||||||
|
|
||||||
|
-- One row: brand, tabs and Claim all's place, the wallet. When the wallet doesn't fit
|
||||||
|
-- with its chips in the row, the chips go on a line under the balance (the header grows
|
||||||
|
-- by it); then, if still short, the brand goes. Last resort (a very high UI scale):
|
||||||
|
-- the tabs on a row of their own. None of it depends on Claim all being shown.
|
||||||
|
local chips_w = chip_widths(g, parent)
|
||||||
|
local chips_total = 22
|
||||||
|
for _, cw in ipairs(chips_w) do
|
||||||
|
chips_total = chips_total + cw + CHIP_GAP
|
||||||
|
end
|
||||||
|
local bal_probe = ui.text(parent, ui.money(math.max(st.credits, 0)), "balance", 0, 0)
|
||||||
|
local wallet_min = math.floor(ui.text_width(bal_probe) + 52) + 12 + GEAR + pad + 10
|
||||||
|
gui.delete_node(bal_probe)
|
||||||
|
local left = pad + brand_w + 28 + nav_w
|
||||||
|
local chips_below = left + chips_total + wallet_min > W
|
||||||
|
local fits = left + wallet_min <= W
|
||||||
|
local show_brand = fits or pad + nav_w + wallet_min > W -- (two rows: the brand stays)
|
||||||
|
fits = fits or not show_brand
|
||||||
|
if not show_brand then
|
||||||
|
gui.delete_node(brand.node)
|
||||||
|
gui.delete_node(cap.node)
|
||||||
|
end
|
||||||
|
wallet(g, parent, W, chips_w, chips_below)
|
||||||
|
local base_h = chips_below and S.header_h + 16 or S.header_h
|
||||||
|
local header_h = fits and base_h or base_h + 40
|
||||||
|
local tree = col({ width = W, height = header_h }, {
|
||||||
|
row({ height = fits and header_h or base_h - 8, padding_left = pad, align = "start" }, {
|
||||||
|
show_brand and box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }) or nil,
|
||||||
|
fits and show_brand and space(28) or nil,
|
||||||
|
fits and col({ padding_top = 10 }, { nav }) or nil,
|
||||||
|
}),
|
||||||
|
not fits and row({ padding_left = pad - 12 }, { nav }) or nil,
|
||||||
|
made({ position = "absolute", left = 0, right = 0, bottom = 0, height = 1 }, function(p, lx, ly, lw)
|
||||||
|
ui.rect(p, lx, ly, lw, 1, "edge")
|
||||||
|
end),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), parent)
|
||||||
|
if not active_tab then
|
||||||
|
ink:none()
|
||||||
end
|
end
|
||||||
ready.watch(g)
|
ready.watch(g)
|
||||||
|
|
||||||
ui.rect(parent, 0, header_h - 1, W, 1, "edge")
|
|
||||||
return header_h
|
return header_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local cards = require("screens.game.cards")
|
local cards = require("screens.game.cards")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
local M = {}
|
local M = {}
|
||||||
@@ -13,23 +15,32 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
local avail = w - px * 2
|
local avail = w - px * 2
|
||||||
local inner = math.min(avail, S.text_max)
|
local inner = math.min(avail, S.text_max)
|
||||||
local x = px + math.floor((avail - inner) / 2)
|
local x = px + math.floor((avail - inner) / 2)
|
||||||
local cy = H.heading(root, x, py, "How to play") + 8
|
|
||||||
local style = { font = "sans", px = 16, color = "text_2" }
|
local style = { font = "sans", px = 16, color = "text_2" }
|
||||||
|
-- Steps: the number right-aligned at 22, the text from 32.
|
||||||
|
local steps = {}
|
||||||
for i, step in ipairs(hp.steps) do
|
for i, step in ipairs(hp.steps) do
|
||||||
ui.text(root, i .. ".", { font = "mono", px = 14, color = "cyan" }, x + 22, cy + 11, gui.PIVOT_E)
|
steps[#steps + 1] = fg.row({ align = "start", width = inner }, {
|
||||||
cy = cy + H.para(root, step, style, x + 32, cy, inner - 32) + 12
|
-- (out of the flow: the row is as tall as its text)
|
||||||
|
fg.label(root, i .. ".", { font = "mono", px = 14, color = "cyan" }, 22, { position = "absolute", top = 0, right = inner - 22 }),
|
||||||
|
fg.space(32),
|
||||||
|
fg.text_para(root, step, style, { width = inner - 32 }),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
cy = cy + 6
|
local note = hp.note and hp.note ~= ""
|
||||||
if hp.note and hp.note ~= "" then
|
local tree = fg.col({ width = inner, align = "start" }, {
|
||||||
cy = cy + H.para(root, hp.note, { font = "sans", px = 14, color = "muted" }, x, cy, inner) + 18
|
fg.label(root, "How to play", "h1", 40), fg.space(0, 12),
|
||||||
end
|
fg.col({ gap = 12, align = "stretch" }, steps), fg.space(0, 18),
|
||||||
|
note and fg.text_para(root, hp.note, { font = "sans", px = 14, color = "muted" }) or nil,
|
||||||
|
note and fg.space(0, 18) or nil,
|
||||||
-- Start over: asks with the server's question (help.newGame, the web's confirm).
|
-- Start over: asks with the server's question (help.newGame, the web's confirm).
|
||||||
local b = ui.button(root, "Start a new game", x, cy)
|
fg.button(root, "Start a new game", {}, function(b)
|
||||||
cards.confirm_server_button(self, b, hp.newGame or {
|
cards.confirm_server_button(self, b, hp.newGame or {
|
||||||
title = "Start over", ok = "Start over", danger = true,
|
title = "Start over", ok = "Start over", danger = true,
|
||||||
text = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it.",
|
text = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it.",
|
||||||
}, "new-game", {})
|
}, "new-game", {})
|
||||||
|
end),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, x, py), root)
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -1,70 +1,109 @@
|
|||||||
-- The Leaderboard section (state.leaderboard): the top miners by credits, and the
|
-- The leaderboard (state.leaderboard): the top miners by credits in compact rows, and the
|
||||||
-- player's own place under them when they're further down (`you`). Centered, capped; the
|
-- player's own row under them when they're further down (`you`). The rows scroll when the
|
||||||
-- rows scroll when the panel is too short for them (components/list).
|
-- room is too short (components/list). Shown in the right side panel (sidepanel.lua; this
|
||||||
|
-- module is one of its panels), or, when that panel is folded away (narrow windows), as a
|
||||||
|
-- pane under the Market (market.lua, M.pane).
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local list = require("components.list")
|
local list = require("components.list")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local ROW_H = 38
|
local ROW_H = 30
|
||||||
|
local PAD = 12
|
||||||
|
local HEAD_H = 52 -- title and "Your place" line
|
||||||
|
|
||||||
|
-- The side panel's contract (sidepanel.lua).
|
||||||
|
M.key = "leaderboard"
|
||||||
|
M.include = "leaderboard" -- the state block it shows (asked for while the panel shows)
|
||||||
|
|
||||||
|
function M.unlocked(g)
|
||||||
|
local u = g.state and g.state.unlocked
|
||||||
|
return u ~= nil and u.leaderboard == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.sig(g)
|
||||||
|
return g.state.leaderboard or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A row (ROW_H tall, w wide) at (x, y): rank, the name, credits at the right; the
|
||||||
|
-- player's own row lit.
|
||||||
local function row(root, r, x, y, w)
|
local function row(root, r, x, y, w)
|
||||||
local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
|
local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
|
||||||
if r.me then
|
if r.me then
|
||||||
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
|
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
|
||||||
end
|
end
|
||||||
ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
|
ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
|
||||||
ui.text(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, 52, ROW_H / 2, gui.PIVOT_E)
|
fg.build(flex.solve(fg.row({ width = w, height = ROW_H, padding_right = PAD, align = "center" }, {
|
||||||
local name = ui.text(bg, r.name, { font = "sans", px = 15, color = "text" }, 76, ROW_H / 2)
|
fg.row({ width = 30, justify = "end", shrink = 0 }, {
|
||||||
ui.fit_text(name, w - 76 - 180)
|
fg.label(bg, r.rank, { font = "mono", px = 12, color = r.rank <= 3 and "ready" or "muted" }, ROW_H),
|
||||||
if r.me then
|
}),
|
||||||
ui.text(bg, "You", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 76 + ui.text_width(name) + 10, ROW_H / 2)
|
fg.space(10),
|
||||||
end
|
fg.label(bg, r.name, { font = "sans", px = 13, color = r.me and "cyan" or "text" }, ROW_H, { fit = true, shrink = 1, max_width = w - 52 - 90 }),
|
||||||
ui.credits(bg, r.credits, w - 14, ROW_H / 2, { pivot = gui.PIVOT_E, px = 14 })
|
fg.fill(),
|
||||||
|
fg.space(8),
|
||||||
|
fg.inline(bg, ROW_H, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 12 })) end),
|
||||||
|
}), 0, 0), bg)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.build(self, root, px, py, w, h, H)
|
-- The height the leaderboard takes with every row shown.
|
||||||
local lb = self.state.leaderboard
|
local function natural(lb)
|
||||||
local avail = w - px * 2
|
return HEAD_H + math.max(#lb.top, 1) * ROW_H + (lb.you and ROW_H * 2 or 0) + PAD
|
||||||
local inner = math.min(avail, S.leaderboard_max)
|
end
|
||||||
local x = px + math.floor((avail - inner) / 2)
|
|
||||||
local cy = H.heading(root, x, py, "Leaderboard")
|
|
||||||
cy = cy + H.para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".",
|
|
||||||
{ font = "sans", px = 15, color = "text_2" }, x, cy - 6, inner) + 16
|
|
||||||
|
|
||||||
ui.text(root, "Rank", "cap", x + 52, cy + 6, gui.PIVOT_E)
|
-- The leaderboard's content in p (a panel's root) w x h: title, place, the rows (they
|
||||||
ui.text(root, "Miner", "cap", x + 76, cy + 6)
|
-- scroll when h is short). Without the state block yet (it comes with the next state:
|
||||||
ui.text(root, "Credits", "cap", x + inner - 14, cy + 6, gui.PIVOT_E)
|
-- sidepanel.lua asks for it), the title and a placeholder.
|
||||||
cy = cy + 16
|
local function body(g, p, w, h)
|
||||||
ui.rect(root, x, cy, inner, 1, "edge")
|
local lb = g.state.leaderboard
|
||||||
local bottom = h - py - (lb.you and ROW_H * 2 or 0) - (lb.me and 34 or 0)
|
ui.text(p, "Top miners", "cap", PAD, 18)
|
||||||
local list_h = math.max(ROW_H, math.min(#lb.top * ROW_H, bottom - cy))
|
if not lb then
|
||||||
|
ui.text(p, "Loading…", "small", PAD, 38)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local place = lb.me and ("Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players)) or (tostring(lb.players) .. " miners")
|
||||||
|
ui.text(p, place, "small", PAD, 38)
|
||||||
|
local foot = lb.you and ROW_H * 2 or 0
|
||||||
|
local ly = HEAD_H
|
||||||
|
local lh = math.max(ROW_H, math.min(h, natural(lb)) - HEAD_H - foot - PAD)
|
||||||
|
ui.rect(p, 0, ly - 1, w, 1, "edge")
|
||||||
if #lb.top > 0 then
|
if #lb.top > 0 then
|
||||||
local l
|
local l
|
||||||
l = list.new(self, root, x, cy, inner, list_h, {
|
l = list.new(g, p, 0, ly, w, lh, {
|
||||||
key = "leaderboard", row_h = ROW_H,
|
key = "leaderboard", row_h = ROW_H,
|
||||||
create = function(r)
|
create = function(r)
|
||||||
local holder = ui.rect(l.content, 0, 0, inner, ROW_H, "none")
|
local holder = ui.rect(l.content, 0, 0, w, ROW_H, "none")
|
||||||
row(holder, r, 0, 0, inner)
|
row(holder, r, 0, 0, w)
|
||||||
return holder
|
return holder
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
l:set_data(lb.top)
|
l:set_data(lb.top)
|
||||||
end
|
end
|
||||||
cy = cy + list_h
|
|
||||||
if lb.you then
|
if lb.you then
|
||||||
-- (the web's "⋯": no font has it; three middle dots look the same)
|
-- (the web's "⋯": no font has it; three middle dots look the same)
|
||||||
ui.text(root, "···", { font = "sans", px = 15, color = "muted" }, x + 52, cy + ROW_H / 2, gui.PIVOT_E)
|
local fy = ly + lh
|
||||||
cy = cy + ROW_H
|
ui.text(p, "···", { font = "sans", px = 13, color = "muted" }, 30, fy + ROW_H / 2, gui.PIVOT_E)
|
||||||
row(root, lb.you, x, cy, inner)
|
row(p, lb.you, 0, fy + ROW_H, w)
|
||||||
cy = cy + ROW_H
|
|
||||||
end
|
|
||||||
if lb.me then
|
|
||||||
ui.text(root, "Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players), "small", x, cy + 22)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- As a side panel (sidepanel.lua): its content in root (the panel's), w x h.
|
||||||
|
function M.build(g, root, x, y, w, h)
|
||||||
|
local p = ui.rect(root, x, y, w, h, "none")
|
||||||
|
body(g, p, w, h)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- As a pane of its own at (x, y), w wide, in its own frame (the Market, when the side
|
||||||
|
-- panel is folded away); h: the most it may take (rows scroll), or nil for every row.
|
||||||
|
-- Returns its height.
|
||||||
|
function M.pane(g, root, x, y, w, h)
|
||||||
|
local n = natural(g.state.leaderboard)
|
||||||
|
h = h and math.min(h, n) or n
|
||||||
|
local _, nodes = ui.panel(root, x, y, w, h)
|
||||||
|
body(g, ui.part(nodes, "panel", "root"), w, h)
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+392
-258
@@ -15,6 +15,10 @@ local chart = require("screens.game.chart")
|
|||||||
local ticker = require("screens.game.ticker")
|
local ticker = require("screens.game.ticker")
|
||||||
local sale = require("screens.game.sale")
|
local sale = require("screens.game.sale")
|
||||||
local scroll = require("components.scroll")
|
local scroll = require("components.scroll")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local leaderboard = require("screens.game.leaderboard")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
-- Windows this narrow (layout units) stack the detail and the sell ticket, and the
|
-- Windows this narrow (layout units) stack the detail and the sell ticket, and the
|
||||||
-- section scrolls (app.css @media max-width 1100px: .mk-grid one column).
|
-- section scrolls (app.css @media max-width 1100px: .mk-grid one column).
|
||||||
@@ -22,6 +26,8 @@ local STACK_W = 1100
|
|||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
|
|
||||||
local function vec(x, y)
|
local function vec(x, y)
|
||||||
return vmath.vector3(x, y, 0)
|
return vmath.vector3(x, y, 0)
|
||||||
end
|
end
|
||||||
@@ -114,7 +120,8 @@ local function tint(node, color)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The server's words for what makes a price (each "" when it doesn't apply).
|
-- The server's words for what makes a price: the live event on the shared quote, the
|
||||||
|
-- advisors on the player's payout (each "" when it doesn't apply).
|
||||||
local function boost_text(q)
|
local function boost_text(q)
|
||||||
return q.boostText or mult(q.boost or 1)
|
return q.boostText or mult(q.boost or 1)
|
||||||
end
|
end
|
||||||
@@ -129,109 +136,144 @@ local function quote_view(self, item)
|
|||||||
return { section = "market", moon = self.view.moon, pick = item }
|
return { section = "market", moon = self.view.moon, pick = item }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A quote card: name (or symbol when narrow), what you hold, price, change and sparkline.
|
-- A quote's 24h change as an inline box h tall.
|
||||||
local function card(self, H, root, x, y, w, h, q, on)
|
local function change_box(c, q, h, opts)
|
||||||
local st = self.state
|
return fg.inline(c, h, function(p, x, y) return change(p, q, x, y, opts) end)
|
||||||
local c = ui.rect(root, x, y, w, h, "cyan", on and 0.12 or 0.03)
|
end
|
||||||
-- (the edge token keeps its own alpha: an alpha here would replace it)
|
|
||||||
local edge = ui.outline(c, 0, 0, w, h, on and "cyan" or "edge")
|
-- A ui.credits amount as an inline box h tall.
|
||||||
local empty = q.balance <= 0
|
local function credits_box(c, amount, opts, h)
|
||||||
local col = color_of(st, q.item)
|
return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
|
||||||
ui.rect(c, 10, 13, 8, 8, col)
|
end
|
||||||
|
|
||||||
|
-- A quote card's sizes for its width: the amount held (the card's big number) and the
|
||||||
|
-- card's height (every card of a grid is as tall). Wide cards (w >= 150) show the name and
|
||||||
|
-- what the holding is worth over it, price and change under it; narrow ones the symbol and
|
||||||
|
-- the change over it, the price under it (the worth in the name's tooltip).
|
||||||
|
local function card_size(w)
|
||||||
local wide = w >= 150
|
local wide = w >= 150
|
||||||
-- The name (the symbol when narrow), the symbol and "Refined" under it when wide.
|
local big = w >= 220 and 40 or w >= 160 and 34 or 30
|
||||||
local name = ui.text(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
|
return wide, big, 10 + 16 + 4 + big + 4 + 14 + 6 + 16 + 8
|
||||||
or { font = "mono_semi", px = 12, color = empty and "muted" or "text", tracking = 0.06 }, 24, 17)
|
|
||||||
local hold = ui.text(c, ui.money(q.balance), { font = "mono", px = 11, color = empty and "faint" or "text_2" }, w - 10, 17, gui.PIVOT_E)
|
|
||||||
ui.fit_text(name, w - 34 - ui.text_width(hold) - 6) -- (sized for the final number)
|
|
||||||
H.tick_num(self, hold, "mk:bal:" .. q.item, q.balance)
|
|
||||||
-- What it's worth (≈ ₥X) under the holding; "Refined" under the name when there's room.
|
|
||||||
local vw = ui.credits(c, q.value or 0, w - 10, 31, { px = 10, color = empty and "faint" or "muted", pivot = gui.PIVOT_E })
|
|
||||||
ui.text(c, "≈", { font = "sym", px = 10, color = empty and "faint" or "muted" }, w - 10 - vw - 3, 31, gui.PIVOT_E)
|
|
||||||
if wide and w - 34 - vw - 20 > 50 then
|
|
||||||
local sub = ui.text(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 24, 31)
|
|
||||||
ui.fit_text(sub, w - 34 - vw - 20)
|
|
||||||
elseif q.refined then
|
|
||||||
tooltip.add(name, q.item .. " · Refined")
|
|
||||||
else
|
|
||||||
tooltip.add(name, q.item)
|
|
||||||
end
|
end
|
||||||
-- Price and change on one line when the card is wide, else on two.
|
|
||||||
local py = 50
|
-- A quote card (w x h): name (or symbol when narrow) and what the holding is worth, the
|
||||||
local pw = ui.credits(c, q.price, 10, py, { px = 13, color = "text" })
|
-- amount held in large type (how much you own reads at a glance), price, change and
|
||||||
local cy = wide and py or py + 16
|
-- sparkline. A tree piece; the card's node takes the hover and the click.
|
||||||
change(c, q, wide and (w - 10) or 10, cy, { pivot = wide and gui.PIVOT_E or nil, px = 11 })
|
local function card(self, H, c, w, h, q, on)
|
||||||
if q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001) then
|
local st = self.state
|
||||||
if wide then
|
local empty = q.balance <= 0
|
||||||
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 10 + pw + 6, py)
|
local wide, big = card_size(w)
|
||||||
else
|
local muted = empty and "faint" or "muted"
|
||||||
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, w - 10, cy, gui.PIVOT_E)
|
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
|
||||||
|
or { font = "mono_semi", px = 13, color = empty and "muted" or "text" }, 16, { fit = true, shrink = 1 })
|
||||||
|
name.done = function(n)
|
||||||
|
tooltip.add(n, (q.refined and (q.item .. " · Refined") or q.item)
|
||||||
|
.. (wide and "" or (" · worth ≈ " .. theme.currency .. ui.money(q.value or 0))))
|
||||||
end
|
end
|
||||||
|
-- What it's worth (≈ ₥X), at the name's right on a wide card.
|
||||||
|
local value = wide and credits_box(c, q.value or 0, { px = 11, color = muted }, 16) or nil
|
||||||
|
-- The amount held: the card's big number (shrinks, then cuts, when it is very long).
|
||||||
|
local hold = fg.label(c, ui.money(q.balance), { font = "mono_semi", px = big, color = empty and "faint" or "text" }, big,
|
||||||
|
{ fit = true, shrink = 1, shrink_text = 0.6 })
|
||||||
|
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
|
||||||
|
local owned = w >= 170 and fg.label(c, "owned", { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 14,
|
||||||
|
{ shrink = 0 }) or nil
|
||||||
|
local boosted = q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001)
|
||||||
|
local boost = boosted and fg.label(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 14) or nil
|
||||||
|
local price = credits_box(c, q.price, { px = 13, color = "text_2" }, 14)
|
||||||
|
-- Price (and the change, on a wide card) under the amount.
|
||||||
|
local lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(),
|
||||||
|
wide and change_box(c, q, 14, { px = 11 }) or nil }) }
|
||||||
|
local spark_color = (q.change >= 0) and "up" or "danger"
|
||||||
|
local kids = {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, color_of(st, q.item)) end),
|
||||||
|
space(6), name, space(6), fg.fill(),
|
||||||
|
wide and fg.label(c, "≈", { font = "sym", px = 11, color = muted }, 16) or nil, wide and space(3) or nil, value,
|
||||||
|
(not wide) and change_box(c, q, 16, { px = 10 }) or nil,
|
||||||
|
}),
|
||||||
|
space(0, 4),
|
||||||
|
row({ height = big, align = "end" }, { hold, owned and space(6) or nil,
|
||||||
|
owned and col({ padding_bottom = math.floor(big * 0.12), shrink = 0 }, { owned }) or nil }),
|
||||||
|
space(0, 4),
|
||||||
|
-- (kept while the series is the same: it moves on with the 5-minute tick)
|
||||||
|
made({ position = "absolute", left = 10, top = h - 24, width = w - 20, height = 16 }, function(p, x, y)
|
||||||
|
blocks.keep(self, "mk:spark:" .. q.item, blocks.sig(w, h, q.spark, spark_color), p, function(r)
|
||||||
|
chart.spark(r, x, y, w - 20, 16, q.spark, spark_color)
|
||||||
|
end)
|
||||||
|
end),
|
||||||
|
}
|
||||||
|
for _, l in ipairs(lines) do
|
||||||
|
kids[#kids + 1] = l
|
||||||
end
|
end
|
||||||
chart.spark(c, 10, h - 20, w - 20, 13, q.spark, (q.change >= 0) and "up" or "danger")
|
return col({ width = w, height = h, padding = { 10, 10, 0, 10 }, shrink = 0 }, kids, { make = function(p, x, y)
|
||||||
|
local cn = ui.rect(p, x, y, w, h, "cyan", on and 0.12 or 0.03)
|
||||||
|
-- (the edge token keeps its own alpha: an alpha here would replace it)
|
||||||
|
local edge = ui.outline(cn, 0, 0, w, h, on and "cyan" or "edge")
|
||||||
-- Hover (app.css .mk-card:hover): a brighter edge and fill; the selected card keeps its look.
|
-- Hover (app.css .mk-card:hover): a brighter edge and fill; the selected card keeps its look.
|
||||||
local b = H.link(self, c, quote_view(self, q.item))
|
local b = H.link(self, cn, quote_view(self, q.item))
|
||||||
if not on then
|
if not on then
|
||||||
b.hover.on_mouse_hover:subscribe(function(_, over)
|
b.hover.on_mouse_hover:subscribe(function(_, over)
|
||||||
tint(c, ui.color(over and "panel_hi" or "cyan", not over and 0.03 or nil))
|
tint(cn, ui.color(over and "panel_hi" or "cyan", not over and 0.03 or nil))
|
||||||
tint(edge, ui.color(over and "line_hi" or "edge"))
|
tint(edge, ui.color(over and "line_hi" or "edge"))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
self.quote_nodes[q.item] = c
|
self.quote_nodes[q.item] = cn
|
||||||
self.quote_edges[q.item] = edge
|
self.quote_edges[q.item] = edge
|
||||||
|
return cn
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A group of cards ("Resources", "Refined items"), up to 5 per row. Returns the new y.
|
-- A group of cards ("Resources", "Refined items"), up to 5 per row (3 when that would
|
||||||
local function cards(self, H, root, x, y, inner, quotes, title)
|
-- make them too narrow): a grid.
|
||||||
ui.text(root, title, "cap", x, y + 6)
|
local function cards(self, H, c, inner, quotes, title)
|
||||||
y = y + 18
|
local gap = 10
|
||||||
local per = math.min(5, #quotes)
|
local per = math.min(#quotes, (inner - gap * 4) / 5 >= 112 and 5 or 3)
|
||||||
local gap = 8
|
|
||||||
local w = math.floor((inner - gap * (per - 1)) / per)
|
local w = math.floor((inner - gap * (per - 1)) / per)
|
||||||
local h = w >= 150 and 82 or 90
|
local _, _, h = card_size(w)
|
||||||
for i, q in ipairs(quotes) do
|
local cols, kids = {}, {}
|
||||||
local col = (i - 1) % per
|
for i = 1, per do
|
||||||
local row = math.floor((i - 1) / per)
|
cols[i] = w
|
||||||
card(self, H, root, x + col * (w + gap), y + row * (h + gap), w, h, q, q.item == self.state.market.selected)
|
|
||||||
end
|
end
|
||||||
return y + math.ceil(#quotes / per) * (h + gap) + 6
|
for _, q in ipairs(quotes) do
|
||||||
|
kids[#kids + 1] = card(self, H, c, w, h, q, q.item == self.state.market.selected)
|
||||||
|
end
|
||||||
|
return col({}, {
|
||||||
|
fg.label(c, title, "cap", 12), space(0, 6),
|
||||||
|
box({ columns = cols, gap = gap }, kids),
|
||||||
|
space(0, 14),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A stat in the heading row: caption over a value; fill(parent, x, y) draws the value
|
-- A stat in the heading row: a caption over a value (a tree piece).
|
||||||
-- and returns its width. Placed right to left; returns the new right edge.
|
local function stat(c, label, value)
|
||||||
local function stat(root, right, y, label, fill)
|
return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
|
||||||
local box = ui.rect(root, 0, y, 1, 1, "none")
|
|
||||||
local w = fill(box, 0, 26)
|
|
||||||
local cap = ui.text(box, label, "cap", 0, 6)
|
|
||||||
w = math.max(w, ui.text_width(cap))
|
|
||||||
gui.set_position(box, vec(right - w, -y))
|
|
||||||
return right - w - 28
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.build(self, root, px, py, w, h, H, opts)
|
-- The market itself in w (its content centered in it); full_w: the ticker's width (the
|
||||||
opts = opts or {}
|
-- whole panel). Returns the bottom of what it built and the y under the ticker.
|
||||||
|
local function build_market(self, root, px, py, w, h, H, stacked, full_w)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local mk = st.market
|
local mk = st.market
|
||||||
local stacked = self.size and self.size[1] <= STACK_W
|
local c = root
|
||||||
local area
|
|
||||||
if stacked then
|
|
||||||
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
|
|
||||||
root = area.content
|
|
||||||
end
|
|
||||||
local inner = math.min(w - px * 2, theme.size.market_max)
|
local inner = math.min(w - px * 2, theme.size.market_max)
|
||||||
local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
|
local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
|
||||||
self.quote_nodes, self.quote_edges = {}, {}
|
self.quote_nodes, self.quote_edges = {}, {}
|
||||||
|
|
||||||
-- Ticker tape across the panel.
|
-- Ticker tape across the panel (kept while the quotes are the same).
|
||||||
local cy = 14
|
local tape, tick_bottom = {}, 14
|
||||||
cy = cy + ticker.build(self, root, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
|
for i, q in ipairs(mk.quotes) do
|
||||||
|
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
|
||||||
|
end
|
||||||
|
local function ticker_part(cy)
|
||||||
|
tick_bottom = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, full_w, tape), root, function(r)
|
||||||
|
return ticker.build(self, r, 14, cy, full_w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
|
||||||
H.navigate(self, quote_view(self, item))
|
H.navigate(self, quote_view(self, item))
|
||||||
|
end)
|
||||||
end) + 8
|
end) + 8
|
||||||
|
return tick_bottom
|
||||||
|
end
|
||||||
|
|
||||||
-- Heading, with the holdings stats on its right.
|
|
||||||
local top = cy
|
|
||||||
cy = H.heading(root, ox, cy, "Market") - 8
|
|
||||||
local right = ox + inner
|
|
||||||
-- Top mover and biggest drop: links to their quote; in the heading row when there's
|
-- Top mover and biggest drop: links to their quote; in the heading row when there's
|
||||||
-- room, else on a line of their own under it.
|
-- room, else on a line of their own under it.
|
||||||
local movers = {}
|
local movers = {}
|
||||||
@@ -242,52 +284,62 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local function mover(b, x, y, q)
|
-- A mover: its dot, name and change; links to its quote, the name brightens under the
|
||||||
ui.rect(b, x, y - 4, 8, 8, color_of(st, q.item))
|
-- mouse (.mk-stat-link:hover).
|
||||||
local n = ui.text(b, q.item, { font = "sans", px = 15, color = "text" }, x + 14, y)
|
local function mover(q)
|
||||||
return 14 + ui.text_width(n) + 6 + change(b, q, x + 14 + ui.text_width(n) + 6, y), n
|
local name = fg.label(c, q.item, { font = "sans", px = 15, color = "text" }, 20)
|
||||||
end
|
return row({ height = 20, align = "center", shrink = 0 }, {
|
||||||
-- A mover links to its quote; its name brightens under the mouse (.mk-stat-link:hover).
|
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, color_of(st, q.item)) end),
|
||||||
local function link(b, x, y, w, q, name)
|
space(6), name, space(6), change_box(c, q, 20),
|
||||||
local hit = ui.rect(b, x, y - 10, w, 20, "none")
|
}, { make = function(p, x, y, mw, mh)
|
||||||
|
local hit = ui.rect(p, x, y, mw, mh, "none")
|
||||||
local btn = H.link(self, hit, quote_view(self, q.item))
|
local btn = H.link(self, hit, quote_view(self, q.item))
|
||||||
btn.hover.on_mouse_hover:subscribe(function(_, over)
|
btn.hover.on_mouse_hover:subscribe(function(_, over)
|
||||||
tint(name, ui.color(over and "cyan_hi" or "text"))
|
tint(name.node, ui.color(over and "cyan_hi" or "text"))
|
||||||
end)
|
end)
|
||||||
|
return hit
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
if inner >= 700 then
|
local held = row({ height = 20, align = "center" }, {
|
||||||
for _, mv in ipairs(movers) do
|
fg.label(c, tostring(mk.held or 0), { font = "mono_semi", px = 18, color = "text" }, 20),
|
||||||
right = stat(root, right, top + 6, mv.label, function(b, x, y)
|
fg.label(c, " / " .. #mk.quotes, { font = "mono", px = 13, color = "muted" }, 20),
|
||||||
local mw, n = mover(b, x, y, mv.q)
|
})
|
||||||
link(b, x, y, mw, mv.q, n)
|
local total = fg.inline(c, 20, function(p, x, y, probing)
|
||||||
return mw
|
local cw, num = ui.credits(p, mk.total, x, y, { px = 18, color = "text", font = "mono_semi" })
|
||||||
end)
|
if not probing then
|
||||||
end
|
|
||||||
elseif #movers > 0 then
|
|
||||||
local mx = ox
|
|
||||||
for i = #movers, 1, -1 do
|
|
||||||
local mv = movers[i]
|
|
||||||
local cap = ui.text(root, mv.label:gsub(" 24h", ""), "cap", mx, cy + 18)
|
|
||||||
mx = mx + ui.text_width(cap) + 8
|
|
||||||
local mw, n = mover(root, mx, cy + 18, mv.q)
|
|
||||||
link(root, mx, cy + 18, mw, mv.q, n)
|
|
||||||
mx = mx + mw + 20
|
|
||||||
end
|
|
||||||
cy = cy + 26
|
|
||||||
end
|
|
||||||
-- (placed right to left: the web's order is Holdings value, Assets held, Top mover,
|
|
||||||
-- Biggest drop)
|
|
||||||
right = stat(root, right, top + 6, "Assets held", function(b, x, y)
|
|
||||||
local t = ui.text(b, mk.held or 0, { font = "mono_semi", px = 18, color = "text" }, x, y)
|
|
||||||
local s = ui.text(b, " / " .. #mk.quotes, { font = "mono", px = 13, color = "muted" }, x + ui.text_width(t), y)
|
|
||||||
return ui.text_width(t) + ui.text_width(s)
|
|
||||||
end)
|
|
||||||
right = stat(root, right, top + 6, "Holdings value", function(b, x, y)
|
|
||||||
local cw, num = ui.credits(b, mk.total, x, y, { px = 18, color = "text", font = "mono_semi" })
|
|
||||||
H.tick_num(self, num, "mk:total", mk.total)
|
H.tick_num(self, num, "mk:total", mk.total)
|
||||||
|
end
|
||||||
return cw
|
return cw
|
||||||
end)
|
end)
|
||||||
cy = cy + 6
|
-- (the web's order: Holdings value, Assets held, Top mover, Biggest drop)
|
||||||
|
local stats = { stat(c, "Holdings value", total), stat(c, "Assets held", held) }
|
||||||
|
local in_row = inner >= 700
|
||||||
|
if in_row then
|
||||||
|
for i = #movers, 1, -1 do
|
||||||
|
stats[#stats + 1] = stat(c, movers[i].label, mover(movers[i].q))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local below
|
||||||
|
if not in_row and #movers > 0 then
|
||||||
|
local kids = {}
|
||||||
|
for i = #movers, 1, -1 do
|
||||||
|
local mv = movers[i]
|
||||||
|
kids[#kids + 1] = fg.label(c, (mv.label:gsub(" 24h", "")), "cap", 20)
|
||||||
|
kids[#kids + 1] = space(8)
|
||||||
|
kids[#kids + 1] = mover(mv.q)
|
||||||
|
kids[#kids + 1] = space(20)
|
||||||
|
end
|
||||||
|
below = row({ height = 18, align = "start" }, kids)
|
||||||
|
end
|
||||||
|
local head = col({}, {
|
||||||
|
row({ height = 36, align = "start" }, {
|
||||||
|
fg.label(c, "Market", "h1", 40, { shrink = 0 }),
|
||||||
|
fg.fill(),
|
||||||
|
row({ column_gap = 28, padding_top = 6 }, stats),
|
||||||
|
}),
|
||||||
|
below and space(0, 8) or nil, below,
|
||||||
|
space(0, 6),
|
||||||
|
})
|
||||||
|
|
||||||
-- Quote cards.
|
-- Quote cards.
|
||||||
local raw, refined = {}, {}
|
local raw, refined = {}, {}
|
||||||
@@ -298,21 +350,21 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
raw[#raw + 1] = q
|
raw[#raw + 1] = q
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
cy = cards(self, H, root, ox, cy, inner, raw, "Resources")
|
|
||||||
if st.unlocked.refinery and #refined > 0 then
|
|
||||||
cy = cards(self, H, root, ox, cy + 4, inner, refined, "Refined items")
|
|
||||||
end
|
|
||||||
local q
|
local q
|
||||||
for _, x in ipairs(mk.quotes) do
|
for _, x in ipairs(mk.quotes) do
|
||||||
if x.item == mk.selected then
|
if x.item == mk.selected then
|
||||||
q = x
|
q = x
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local show_refined = st.unlocked.refinery and #refined > 0
|
||||||
|
local cy = fg.flow(c, ox, 14, inner, {
|
||||||
|
ticker_part,
|
||||||
|
head,
|
||||||
|
cards(self, H, c, inner, raw, "Resources"),
|
||||||
|
show_refined and col({}, { space(0, 4), cards(self, H, c, inner, refined, "Refined items") }) or nil,
|
||||||
|
})
|
||||||
if not q then
|
if not q then
|
||||||
if area then
|
return cy, tick_bottom
|
||||||
area:finish(cy + 18)
|
|
||||||
end
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
cy = cy + 6
|
cy = cy + 6
|
||||||
|
|
||||||
@@ -322,98 +374,111 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
local bh = math.max(250, math.min(h - cy - 18, theme.size.market_panel_max))
|
local bh = math.max(250, math.min(h - cy - 18, theme.size.market_panel_max))
|
||||||
if stacked then
|
if stacked then
|
||||||
tw, dw, bh = inner, inner, 320
|
tw, dw, bh = inner, inner, 320
|
||||||
area:finish(cy + bh * 2 + 12 + 18)
|
|
||||||
end
|
end
|
||||||
local tight = bh < 300 -- short window (or a banner): a more compact ticket
|
local tight = bh < 300 -- short window (or a banner): a more compact ticket
|
||||||
local col = color_of(st, q.item)
|
local col_ = color_of(st, q.item)
|
||||||
local up = q.change >= 0
|
local up = q.change >= 0
|
||||||
|
|
||||||
local _, dn = ui.panel(root, ox, cy, dw, bh)
|
---------------------------------------------------------------- detail
|
||||||
local d = ui.part(dn, "panel", "root")
|
-- Name row: dot, name, Refined, "SYM / ₥" (when it fits); the live event's chip on
|
||||||
ui.rect(d, 14, 14, 12, 12, col)
|
-- the name's row, or under the price when that's full.
|
||||||
local name = ui.text(d, q.item, "h2", 34, 20)
|
local nm = fg.label(c, q.item, "h2", 16)
|
||||||
local nx = 34 + ui.text_width(name) + 10
|
local nx = 34 + nm.measure() + 10
|
||||||
|
local refined_chip
|
||||||
if q.refined then
|
if q.refined then
|
||||||
local _, rw = chip.new(d, "Refined", nx, 20, { color = "violet", alpha = 0.5, text = "violet_hi", upper = true })
|
local style = { color = "violet", alpha = 0.5, text = "violet_hi", upper = true }
|
||||||
nx = nx + rw + 8
|
refined_chip = fg.inline(c, 16, function(p, x, y)
|
||||||
|
local _, rw = chip.new(p, "Refined", x, y, style)
|
||||||
|
return rw
|
||||||
|
end)
|
||||||
|
nx = nx + refined_chip.style.width + 8
|
||||||
end
|
end
|
||||||
local sym = ui.text(d, (q.symbol or "") .. " / ", "cap", nx, 21)
|
local sym = fg.label(c, (q.symbol or "") .. " / ", "cap", 16)
|
||||||
local cur = ui.text(d, theme.currency, { font = "sym", px = 10, color = "muted" }, nx + ui.text_width(sym), 21)
|
local cur = fg.label(c, theme.currency, { font = "sym", px = 10, color = "muted" }, 16)
|
||||||
if nx + ui.text_width(sym) + ui.text_width(cur) > dw - 14 then
|
local sym_fits = nx + sym.measure() + cur.measure() <= dw - 14
|
||||||
gui.delete_node(sym) -- (no room: the symbol is on the card and the ticket)
|
if not sym_fits then
|
||||||
gui.delete_node(cur)
|
gui.delete_node(sym.node) -- (no room: the symbol is on the card and the ticket)
|
||||||
|
gui.delete_node(cur.node)
|
||||||
end
|
end
|
||||||
-- The live event's chip: on the name's row, or under the price when that's full.
|
|
||||||
local ev_low = false
|
|
||||||
local boosted = q.boosted
|
local boosted = q.boosted
|
||||||
if boosted == nil then
|
if boosted == nil then
|
||||||
boosted = (q.boost or 1) > 1.001
|
boosted = (q.boost or 1) > 1.001
|
||||||
end
|
end
|
||||||
|
local ev_low = boosted and dw < 340
|
||||||
|
local event
|
||||||
if boosted then
|
if boosted then
|
||||||
local ev = ui.text(d, "Event " .. boost_text(q), { font = "mono", px = 11, color = "ready", tracking = 0.1, upper = true }, 0, 20, gui.PIVOT_E)
|
local ev = fg.label(c, "Event " .. boost_text(q), { font = "mono", px = 11, color = "ready", tracking = 0.1, upper = true }, 22)
|
||||||
local ew = ui.text_width(ev) + 16
|
local ew = ev.measure() + 16
|
||||||
ev_low = dw < 340
|
event = box({ position = "absolute", width = ew, height = 22, padding_left = 8,
|
||||||
local ex, ey = ev_low and 14 or (dw - 14 - ew), ev_low and 66 or 9
|
left = ev_low and 14 or nil, right = (not ev_low) and 14 or nil, top = ev_low and 66 or 9 }, { ev }, { make = function(p, x, y)
|
||||||
gui.set_position(ev, vec(ex + ew - 8, -(ey + 11)))
|
local o = ui.outline(p, x, y, ew, 22, "ready")
|
||||||
tooltip.add(ui.outline(d, ex, ey, ew, 22, "ready"), "A live event boosts this sell price")
|
tooltip.add(o, "A live event boosts this sell price")
|
||||||
|
return o
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
local pw = ui.credits(d, q.price, 14, 52, { px = 26, color = "text", font = "mono_semi" })
|
-- What makes the quote (shared by every player): market rate × live event. The
|
||||||
local cw = change(d, q, 14 + pw + 14, 54, { px = 13 })
|
-- personal lines (advisors, recent selling) are on the ticket.
|
||||||
ui.text(d, "24h", { font = "mono", px = 12, color = "muted" }, 14 + pw + 14 + cw + 5, 54)
|
local notes = {}
|
||||||
local y = ev_low and 100 or 74
|
if boosted then
|
||||||
-- What makes the price: market rate × live event × advisors × recent selling.
|
local sx = "Market " .. theme.currency .. ui.money(q.rate) .. " × live event " .. boost_text(q)
|
||||||
local pressed, advised = q.pressed, q.advised
|
notes[#notes + 1] = fg.measured(c, function(p, x, y, ww)
|
||||||
if pressed == nil then -- (older servers)
|
local _, lh = ui.wrap(p, sx, { font = "sans", px = 12, color = "muted" }, x, y, ww)
|
||||||
pressed, advised = (q.factor or 1) < 0.995, (q.advisor or 1) > 1.001
|
return math.max(18, lh + 4)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
local pct = q.pressureText or string.format("-%d%%", math.floor((1 - (q.factor or 1)) * 100 + 0.5))
|
-- The chart takes the room between the notes and the open/high/low row.
|
||||||
if pressed then
|
local chart_box = { style = { grow = 1 } }
|
||||||
local st12 = { font = "sans", px = 12, color = "white", markup = true }
|
|
||||||
local line = ui.text(d, ui.runs({ { pct .. " from recent selling", "danger" }, { " · wears off over a few minutes", "muted" } }), st12, 14, y)
|
|
||||||
if ui.text_width(line) > dw - 28 then -- (narrow: on two lines)
|
|
||||||
gui.delete_node(line)
|
|
||||||
ui.text(d, pct .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14, y)
|
|
||||||
y = y + 16
|
|
||||||
ui.text(d, "wears off over a few minutes", { font = "sans", px = 12, color = "muted" }, 14, y)
|
|
||||||
end
|
|
||||||
y = y + 18
|
|
||||||
end
|
|
||||||
if boosted or advised or pressed then
|
|
||||||
local s = "Market " .. theme.currency .. ui.money(q.rate)
|
|
||||||
if boosted then s = s .. " × live event " .. boost_text(q) end
|
|
||||||
if advised then s = s .. " × advisors " .. advisor_text(q) end
|
|
||||||
if pressed then s = s .. " × recent selling " .. pct end
|
|
||||||
local _, lh = ui.wrap(d, s, { font = "sans", px = 12, color = "muted" }, 14, y - 8, dw - 28)
|
|
||||||
y = y + math.max(18, lh + 4)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Open, high, low, held: a row at the bottom; the chart takes the room between.
|
|
||||||
local ohl_y = bh - 40
|
|
||||||
local colw = math.floor((dw - 28) / 4)
|
|
||||||
for i, f in ipairs({ { colw >= 76 and "Open 24h" or "Open", q.open, "text" }, { "High", q.high, "up" }, { "Low", q.low, "danger" }, { colw >= 76 and "You hold" or "Held", nil, "text" } }) do
|
|
||||||
local fx = 14 + (i - 1) * colw
|
|
||||||
ui.text(d, f[1], "cap", fx, ohl_y + 6)
|
|
||||||
if f[2] then
|
|
||||||
ui.credits(d, f[2], fx, ohl_y + 24, { color = f[3] })
|
|
||||||
else
|
|
||||||
H.tick_num(self, ui.text(d, ui.money(q.balance), "num", fx, ohl_y + 24), "mk:hold", q.balance)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
ui.rect(d, 14, ohl_y - 8, dw - 28, 1, "line")
|
|
||||||
if mk.chart and mk.chart.item == q.item then
|
if mk.chart and mk.chart.item == q.item then
|
||||||
chart.chart(d, 14, y + 4, dw - 28, ohl_y - 16 - (y + 4), mk.chart, {
|
local events = price_events(st, q.item)
|
||||||
color = up and "up" or "danger", events = price_events(st, q.item), pointers = self.pointers,
|
chart_box.make = function(p, x, y, cw, ch)
|
||||||
})
|
blocks.keep(self, "mk:chart", blocks.sig(y, dw, ch, up, mk.chart, events), p, function(r)
|
||||||
|
chart.chart(r, x, y, cw, ch, mk.chart, { color = up and "up" or "danger", events = events, pointers = self })
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
-- Open, high, low, held: a row at the bottom.
|
||||||
|
local colw = math.floor((dw - 28) / 4)
|
||||||
|
local ohl = {}
|
||||||
|
for _, f in ipairs({ { colw >= 76 and "Open 24h" or "Open", q.open, "text" }, { "High", q.high, "up" }, { "Low", q.low, "danger" }, { colw >= 76 and "You hold" or "Held", nil, "text" } }) do
|
||||||
|
local v
|
||||||
|
if f[2] then
|
||||||
|
v = credits_box(c, f[2], { color = f[3] }, 16)
|
||||||
|
else
|
||||||
|
v = fg.label(c, ui.money(q.balance), "num", 16)
|
||||||
|
v.done = function(n) H.tick_num(self, n, "mk:hold", q.balance) end
|
||||||
|
end
|
||||||
|
ohl[#ohl + 1] = col({ align = "start" }, { fg.label(c, f[1], "cap", 12), space(0, 4), v })
|
||||||
|
end
|
||||||
|
local detail = col({ width = dw, height = bh, padding = { 12, 14, 8, 14 }, shrink = 0 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
made({ width = 12, height = 12, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 12, 12, col_) end),
|
||||||
|
space(8), nm, space(10),
|
||||||
|
refined_chip, refined_chip and space(8) or nil,
|
||||||
|
sym_fits and row({ padding_top = 2 }, { sym, cur }) or nil,
|
||||||
|
}),
|
||||||
|
event,
|
||||||
|
space(0, 16),
|
||||||
|
row({ height = 20, align = "start" }, {
|
||||||
|
credits_box(c, q.price, { px = 26, color = "text", font = "mono_semi" }, 16), space(14),
|
||||||
|
change_box(c, q, 20, { px = 13 }), space(5),
|
||||||
|
fg.label(c, "24h", { font = "mono", px = 12, color = "muted" }, 20),
|
||||||
|
}),
|
||||||
|
space(0, ev_low and 28 or 2),
|
||||||
|
col({}, notes),
|
||||||
|
space(0, 12),
|
||||||
|
chart_box,
|
||||||
|
space(0, 8),
|
||||||
|
made({ height = 1 }, function(p, x, y, lw) ui.rect(p, x, y, lw, 1, "line") end),
|
||||||
|
space(0, 7),
|
||||||
|
box({ columns = { colw, colw, colw, colw } }, ohl),
|
||||||
|
}, { make = function(p, x, y, bw, bhh)
|
||||||
|
local _, dn = ui.panel(p, x, y, bw, bhh)
|
||||||
|
return ui.part(dn, "panel", "root")
|
||||||
|
end })
|
||||||
|
|
||||||
-- Sell ticket.
|
---------------------------------------------------------------- sell ticket
|
||||||
local tr, tn = ui.panel(root, stacked and ox or (ox + dw + 12), stacked and (cy + bh + 12) or cy, tw, bh)
|
|
||||||
local t = ui.part(tn, "panel", "root")
|
|
||||||
local tin = tw - 28
|
local tin = tw - 28
|
||||||
ui.text(t, tw >= 260 and "Sell order · Market" or "Sell order", "cap", 14, 18)
|
local t, tr -- (the ticket's panel root; tr: its node, which the sale moves)
|
||||||
local at = ui.credits(t, q.price, tw - 14, 18, { pivot = gui.PIVOT_E, color = "muted", px = 11 })
|
|
||||||
ui.text(t, "@", { font = "mono", px = 11, color = "muted" }, tw - 14 - at - 4, 18, gui.PIVOT_E)
|
|
||||||
ui.text(t, "Amount", "cap", 14, 42)
|
|
||||||
local empty = q.balance <= 0
|
local empty = q.balance <= 0
|
||||||
local f = field.get(self, "sell")
|
local f = field.get(self, "sell")
|
||||||
if f.item ~= q.item then
|
if f.item ~= q.item then
|
||||||
@@ -421,9 +486,11 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Payout: what the amount typed would earn, or Sell all with none (display only).
|
-- Payout: what the amount typed would earn, or Sell all with none (display only).
|
||||||
-- After a sale: what it earned, powering down (sale.lua).
|
-- After a sale: what it earned, powering down (sale.lua). Made again by the field and
|
||||||
|
-- the sale, as a tree of its own under the ticket.
|
||||||
local pay, pay_tick, sale_n
|
local pay, pay_tick, sale_n
|
||||||
local function payout()
|
local payout
|
||||||
|
payout = function()
|
||||||
if pay_tick then
|
if pay_tick then
|
||||||
pay_tick.dead = true
|
pay_tick.dead = true
|
||||||
pay_tick = nil
|
pay_tick = nil
|
||||||
@@ -435,9 +502,12 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
sale_n = nil
|
sale_n = nil
|
||||||
local py0 = tight and 140 or 146
|
local py0 = tight and 140 or 146
|
||||||
local sold = sale.active(self, q.item)
|
local sold = sale.active(self, q.item)
|
||||||
|
local kids
|
||||||
if sold then
|
if sold then
|
||||||
ui.text(pay, "You receive", "cap", 14, py0)
|
kids = {
|
||||||
local hero = anim.group(pay, 14, py0 + 22)
|
fg.label(pay, "You receive", "cap", 16), space(0, 6),
|
||||||
|
made({ height = 16 }, function(p, x, y)
|
||||||
|
local hero = anim.group(p, x, y + 8)
|
||||||
local glow = ui.rect(hero, -16, -24, 10, 48, "ready", 0)
|
local glow = ui.rect(hero, -16, -24, 10, 48, "ready", 0)
|
||||||
gui.play_flipbook(glow, "glow")
|
gui.play_flipbook(glow, "glow")
|
||||||
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
|
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
|
||||||
@@ -445,39 +515,91 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
local room = tin / math.max(1, cw)
|
local room = tin / math.max(1, cw)
|
||||||
sale_n = { ticket = tr, panel = t, hero = hero, num = num, glow = glow, w = tw, h = bh, cx = cw / 2,
|
sale_n = { ticket = tr, panel = t, hero = hero, num = num, glow = glow, w = tw, h = bh, cx = cw / 2,
|
||||||
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
|
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
|
||||||
return
|
end),
|
||||||
end
|
}
|
||||||
|
else
|
||||||
|
local advised = q.advised or (q.advised == nil and (q.advisor or 1) > 1.001)
|
||||||
local typed = f.text ~= "" and (tonumber(f.text) or 0) or nil
|
local typed = f.text ~= "" and (tonumber(f.text) or 0) or nil
|
||||||
local amount = typed or q.balance
|
local amount = typed or q.balance
|
||||||
local factor = pressure_factor(st, q, amount)
|
local factor = pressure_factor(st, q, amount)
|
||||||
local unit = (q.unpressed or q.price) * factor
|
local unit = (q.unpressed or q.price) * factor
|
||||||
ui.text(pay, (typed or empty) and "You receive" or "Sell all pays", "cap", 14, py0)
|
local tot = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
|
||||||
if advised then
|
kids = {
|
||||||
ui.text(pay, "Advisors " .. advisor_text(q), { font = "mono", px = 11, color = "ready" }, tw - 14, py0, gui.PIVOT_E)
|
row({ height = 16, align = "center" }, {
|
||||||
|
fg.label(pay, (typed or empty) and "You receive" or "Sell all pays", "cap", 16), fg.fill(),
|
||||||
|
-- Personal lines (the quote is the same for everyone): advisors here,
|
||||||
|
-- recent selling under the unit price.
|
||||||
|
advised and fg.label(pay, advisor_text(q) .. " for you", { font = "mono", px = 11, color = "ready" }, 16) or nil,
|
||||||
|
}),
|
||||||
|
space(0, 6),
|
||||||
|
fg.inline(pay, 16, function(p, x, y, probing)
|
||||||
|
local cw, tn = ui.credits(p, tot, x, y, { px = 22, color = "ready", font = "mono_semi" })
|
||||||
|
if not typed and not probing then
|
||||||
|
pay_tick = H.tick_num(self, tn, "mk:value", tot)
|
||||||
end
|
end
|
||||||
local total = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
|
return cw
|
||||||
local _, tn = ui.credits(pay, total, 14, py0 + 22, { px = 22, color = "ready", font = "mono_semi" })
|
end),
|
||||||
if not typed then
|
}
|
||||||
pay_tick = H.tick_num(self, tn, "mk:value", total)
|
if not empty then
|
||||||
end
|
local unit_box = credits_box(pay, unit, { px = 11, color = "muted", suffix = " / unit" }, 16)
|
||||||
if empty then
|
local uw = unit_box.style.width
|
||||||
return -- (the note under it says why)
|
-- Recent selling (market.js): the averaged factor for the amount typed, else
|
||||||
end
|
-- the whole balance; shown from 1%.
|
||||||
local uw = ui.credits(pay, unit, 14, py0 + 45, { px = 11, color = "muted", suffix = " / unit" })
|
local pc = math.floor((1 - factor) * 100 + 0.5)
|
||||||
-- Recent selling (market.js): the averaged factor for the amount typed, else the
|
local press = pc >= 1 and string.format("−%d%%", pc) or nil
|
||||||
-- whole balance; shown from 1%.
|
local danger = { font = "sans", px = 12, color = "danger" }
|
||||||
local pct = math.floor((1 - factor) * 100 + 0.5)
|
kids[#kids + 1] = space(0, 7)
|
||||||
local press = pct >= 1 and string.format("−%d%%", pct) or nil
|
local beside = press and 14 + uw + 200 < tw
|
||||||
if press then
|
kids[#kids + 1] = row({ height = 16, align = "center" }, {
|
||||||
if 14 + uw + 160 < tw then
|
unit_box, beside and space(6) or nil,
|
||||||
ui.text(pay, "· " .. press .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14 + uw + 6, py0 + 45)
|
beside and fg.label(pay, "· " .. press .. " from your recent selling", danger, 16) or nil,
|
||||||
elseif py0 + 63 < bh - 14 - theme.size.button_h - 8 then
|
})
|
||||||
ui.text(pay, press .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14, py0 + 63)
|
if press and not beside and py0 + 63 < bh - 14 - theme.size.button_h - 8 then
|
||||||
|
kids[#kids + 1] = space(0, 2)
|
||||||
|
kids[#kids + 1] = fg.label(pay, press .. " from your recent selling", danger, 16)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
fg.build(flex.solve(col({ width = tin }, kids), 14, py0 - 8), pay)
|
||||||
|
end
|
||||||
|
payout = blocks.bound(payout) -- (made again by the field and the sale: the market's block)
|
||||||
local sell_now -- (the Sell button's click: Enter in the field sells, like the web's form)
|
local sell_now -- (the Sell button's click: Enter in the field sells, like the web's form)
|
||||||
local input = field.new(self, t, "sell", 14, 52, tin, 36, {
|
local input
|
||||||
|
local qw = math.floor((tin - 12) / 3)
|
||||||
|
local quick = {}
|
||||||
|
for _, fr in ipairs({ { "25%", 0.25 }, { "50%", 0.5 }, { "Max", 1 } }) do
|
||||||
|
quick[#quick + 1] = fg.button(c, fr[1], { small = true, disabled = empty }, function(b)
|
||||||
|
if input then
|
||||||
|
button.bind(self, b, nil, { id = "amount:" .. fr[1], on_click = function()
|
||||||
|
input:set_text(amount_text(q.balance, fr[2]))
|
||||||
|
end })
|
||||||
|
end
|
||||||
|
end, { width = qw })
|
||||||
|
end
|
||||||
|
local half = math.floor((tin - 8) / 2)
|
||||||
|
-- Clear the amount once it sold, but not while more sells are queued (prediction).
|
||||||
|
local function clear(err)
|
||||||
|
if err then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, e in ipairs(self.pending) do
|
||||||
|
if e.name == "sell" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
f.text, f.focus = "", false
|
||||||
|
end
|
||||||
|
local ticket = col({ width = tw, height = bh, padding = { 10, 14, 14, 14 }, shrink = 0 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fg.label(c, tw >= 260 and "Sell order · Market" or "Sell order", "cap", 16), fg.fill(),
|
||||||
|
fg.label(c, "@", { font = "mono", px = 11, color = "muted" }, 16), space(4),
|
||||||
|
credits_box(c, q.price, { color = "muted", px = 11 }, 16),
|
||||||
|
}),
|
||||||
|
space(0, 8),
|
||||||
|
fg.label(c, "Amount", "cap", 16),
|
||||||
|
space(0, 2),
|
||||||
|
made({ height = 36 }, function(p, x, y, fw, fh)
|
||||||
|
input = field.new(self, p, "sell", x, y, fw, fh, {
|
||||||
hint = "0", font = "mono", hint_font = "mono", allowed = "[%d%.]",
|
hint = "0", font = "mono", hint_font = "mono", allowed = "[%d%.]",
|
||||||
max_len = 12, keyboard = gui.KEYBOARD_TYPE_NUMBER_PAD, disabled = empty, unit = q.symbol,
|
max_len = 12, keyboard = gui.KEYBOARD_TYPE_NUMBER_PAD, disabled = empty, unit = q.symbol,
|
||||||
min = 0, max = q.balance, -- (Up / Down step it like the web's type=number)
|
min = 0, max = q.balance, -- (Up / Down step it like the web's type=number)
|
||||||
@@ -492,45 +614,39 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
self.sell_input = input
|
self.sell_input = input
|
||||||
|
end),
|
||||||
|
space(0, 8),
|
||||||
-- Quick amounts fill the field.
|
-- Quick amounts fill the field.
|
||||||
local qw = math.floor((tin - 12) / 3)
|
row({ gap = 6 }, quick),
|
||||||
for i, fr in ipairs({ { "25%", 0.25 }, { "50%", 0.5 }, { "Max", 1 } }) do
|
made({ height = 0 }, function() payout() end),
|
||||||
local b = ui.button(t, fr[1], 14 + (i - 1) * (qw + 6), 96, { small = true, w = qw, disabled = empty })
|
fg.fill(),
|
||||||
if input then
|
empty and fg.label(c, "You don't hold any " .. q.item .. ".", "small", 16, { fit = true, shrink = 1 }) or nil,
|
||||||
button.bind(self, b, nil, { id = "amount:" .. fr[1], on_click = function()
|
empty and space(0, 6) or nil,
|
||||||
input:set_text(amount_text(q.balance, fr[2]))
|
row({ gap = 8 }, {
|
||||||
end })
|
fg.button(c, "Sell " .. (q.symbol or ""), { primary = true, disabled = empty }, function(b1, n1)
|
||||||
end
|
|
||||||
end
|
|
||||||
payout()
|
|
||||||
|
|
||||||
local by = bh - 14 - theme.size.button_h
|
|
||||||
if empty then
|
|
||||||
local note = ui.text(t, "You don't hold any " .. q.item .. ".", "small", 14, by - 14)
|
|
||||||
ui.fit_text(note, tin)
|
|
||||||
end
|
|
||||||
local half = math.floor((tin - 8) / 2)
|
|
||||||
local b1, n1 = ui.button(t, "Sell " .. (q.symbol or ""), 14, by, { primary = true, w = half, disabled = empty })
|
|
||||||
local b2, n2 = ui.button(t, "Sell all", 14 + half + 8, by, { w = half, disabled = empty })
|
|
||||||
if not empty then
|
if not empty then
|
||||||
-- Clear the amount once it sold, but not while more sells are queued (prediction).
|
|
||||||
local function clear(err)
|
|
||||||
if err then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
for _, e in ipairs(self.pending) do
|
|
||||||
if e.name == "sell" then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
f.text, f.focus = "", false
|
|
||||||
end
|
|
||||||
sell_now = H.action(self, b1, n1, "sell", function()
|
sell_now = H.action(self, b1, n1, "sell", function()
|
||||||
return { resource = q.item, amount = f.text, pick = q.item }
|
return { resource = q.item, amount = f.text, pick = q.item }
|
||||||
end, clear)
|
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
|
||||||
|
end
|
||||||
|
end, { width = half }),
|
||||||
|
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
||||||
|
if not empty then
|
||||||
H.action(self, b2, n2, "sell", { resource = q.item, all = "1", pick = q.item }, clear)
|
H.action(self, b2, n2, "sell", { resource = q.item, all = "1", pick = q.item }, clear)
|
||||||
end
|
end
|
||||||
|
end, { width = half }),
|
||||||
|
}),
|
||||||
|
}, { make = function(p, x, y, bw, bhh)
|
||||||
|
-- (a slot: the sale moves the ticket's panel inside it)
|
||||||
|
local slot = ui.rect(p, x, y, bw, bhh, "none")
|
||||||
|
local tn
|
||||||
|
tr, tn = ui.panel(slot, 0, 0, bw, bhh)
|
||||||
|
t = ui.part(tn, "panel", "root")
|
||||||
|
return t
|
||||||
|
end })
|
||||||
|
|
||||||
|
local panels = stacked and col({ gap = 12 }, { detail, ticket }) or row({ gap = 12, align = "start" }, { detail, ticket })
|
||||||
|
fg.build(flex.solve(panels, ox, cy, inner), c)
|
||||||
|
|
||||||
-- A sale (fx.lua calls this after the build that shows it, with what it earned): the
|
-- A sale (fx.lua calls this after the build that shows it, with what it earned): the
|
||||||
-- ticket powers down; the coins leave from the payout.
|
-- ticket powers down; the coins leave from the payout.
|
||||||
@@ -546,6 +662,24 @@ function M.build(self, root, px, py, w, h, H, opts)
|
|||||||
if sale_n then
|
if sale_n then
|
||||||
sale.bind(self, sale_n) -- (a rebuild while it plays)
|
sale.bind(self, sale_n) -- (a rebuild while it plays)
|
||||||
end
|
end
|
||||||
|
return panels.y + panels.h, tick_bottom
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The leaderboard is a side panel (sidepanel.lua); when that is folded away (narrow
|
||||||
|
-- windows) it is a pane under the market here (state.leaderboard, asked for with the
|
||||||
|
-- market: session.view_params) and the section scrolls. The section scrolls too when the
|
||||||
|
-- window is too short for the cards and the panels.
|
||||||
|
function M.build(self, root, px, py, w, h, H)
|
||||||
|
local lb = (self.side_w or 0) == 0 and self.state.leaderboard or nil
|
||||||
|
local stacked = self.size and self.size[1] <= STACK_W
|
||||||
|
local area = scroll.area(self, root, 0, 2, w, h - 4, "market")
|
||||||
|
local bottom = build_market(self, area.content, px, py, w, h, H, stacked, w)
|
||||||
|
if lb then
|
||||||
|
local avail = w - px * 2
|
||||||
|
local inner = math.min(avail, theme.size.market_max)
|
||||||
|
bottom = bottom + 18 + leaderboard.pane(self, area.content, px + math.floor((avail - inner) / 2), bottom + 18, inner)
|
||||||
|
end
|
||||||
|
area:finish(bottom + 14) -- (the area is 4 shorter than the panel: what fits doesn't scroll)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.update(self)
|
function M.update(self)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel under the wells table):
|
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
|
||||||
|
-- Moons section's main panel, moons.lua):
|
||||||
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
|
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
|
||||||
-- the server's confirm dialog; why it can't be sold yet is its tooltip), and on demand the
|
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
|
||||||
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
|
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
|
||||||
-- market prices, × the factor, capped.
|
-- market prices, × the factor, capped.
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ local theme = require("modules.theme")
|
|||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local tooltip = require("modules.tooltip")
|
local tooltip = require("modules.tooltip")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -16,58 +19,91 @@ local function vec(x, y)
|
|||||||
return vmath.vector3(x, y, 0)
|
return vmath.vector3(x, y, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The panel at (x, y), w wide. Returns the new y.
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
function M.build(self, H, root, x, y, w, moon)
|
|
||||||
|
-- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
|
||||||
|
function M.box(self, H, c, moon, w)
|
||||||
local sale = moon.sale
|
local sale = moon.sale
|
||||||
if not sale then
|
|
||||||
return y
|
|
||||||
end
|
|
||||||
self.sale_open = self.sale_open or {}
|
self.sale_open = self.sale_open or {}
|
||||||
local open = self.sale_open[moon.id]
|
local open = self.sale_open[moon.id]
|
||||||
local box = ui.rect(root, x, y, w, 10, "cyan", 0.03)
|
local inline = function(fn) return fg.inline(c, 16, fn) end
|
||||||
local edge = ui.outline(box, 0, 0, w, 10, "edge")
|
|
||||||
self.fx:box(box) -- (the sale's sweep covers the panel)
|
|
||||||
|
|
||||||
-- Summary: toggle, offer, delta; Sell moon on the right.
|
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
|
||||||
local hit = ui.rect(box, 0, 0, w - 140, 48, "none")
|
local profit = sale.delta > 0
|
||||||
local cx = 14
|
-- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
|
||||||
local chev = ui.text(box, "›", { font = "sym", px = 16, color = "muted" }, cx, 23, gui.PIVOT_CENTER)
|
local narrow = w < 460
|
||||||
|
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
|
||||||
|
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
|
||||||
|
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
|
||||||
|
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
|
||||||
|
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
|
||||||
|
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
|
||||||
|
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
|
||||||
|
local sell_opts = { small = true, disabled = not sale.canSell,
|
||||||
|
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
|
||||||
|
edge = sale.canSell and { "danger", 0.8 } or nil }
|
||||||
|
local ask_row
|
||||||
|
if asking then
|
||||||
|
local sell_w = fg.button_w(c, "Sell moon", sell_opts)
|
||||||
|
local ok, cancel = cards.ask_buttons(self, c, sale.confirm, sell_w)
|
||||||
|
ask_row = row({ position = "absolute", left = 30, right = 10, top = 0, height = 48, align = "center", gap = 6 }, {
|
||||||
|
cards.ask_text(sale.confirm, c), space(4), ok, cancel,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
local summary = box({ height = 48 }, asking and { ask_row } or {
|
||||||
|
made({ position = "absolute", left = 0, top = 0, width = w - 140, height = 48 }, function(p, x, y, hw, hh)
|
||||||
|
local hit = ui.rect(p, x, y, hw, hh, "none")
|
||||||
|
self.druid:new_button(hit, function()
|
||||||
|
self.sale_open[moon.id] = not open
|
||||||
|
self.dirty = true
|
||||||
|
end):set_animations_disabled()
|
||||||
|
test.register("moon-sale:" .. moon.id, hit, "button", open and "Hide the offer" or "Show the offer")
|
||||||
|
end),
|
||||||
|
made({ position = "absolute", left = 14, top = 23, width = 0, height = 0 }, function(p, x, y)
|
||||||
|
local chev = ui.text(p, "›", { font = "sym", px = 16, color = "muted" }, x, y, gui.PIVOT_CENTER)
|
||||||
if open then
|
if open then
|
||||||
gui.set_euler(chev, vmath.vector3(0, 0, -90))
|
gui.set_euler(chev, vmath.vector3(0, 0, -90))
|
||||||
end
|
end
|
||||||
cx = cx + 16
|
end),
|
||||||
local t = ui.text(box, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, cx, 24)
|
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
|
||||||
cx = cx + ui.text_width(t) + 8
|
-- (the label and the offer keep their size: the delta gives way, cut to fit)
|
||||||
local o = ui.text(box, "· offer", { font = "sans", px = 14, color = "muted" }, cx, 24)
|
(function()
|
||||||
cx = cx + ui.text_width(o) + 8
|
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
|
||||||
cx = cx + ui.credits(box, sale.total, cx, 24, { px = 15, color = "text", font = "mono_semi" }) + 10
|
l.style.shrink = 0
|
||||||
local profit = sale.delta > 0
|
return l
|
||||||
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
|
end)(), space(10),
|
||||||
local dn = ui.label(box, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, cx, 24)
|
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
|
||||||
if not ui.needs_rich(dtxt) then
|
(function()
|
||||||
ui.fit_text(dn, w - 140 - cx)
|
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
|
||||||
end
|
b.style.shrink = 0
|
||||||
|
return b
|
||||||
local b, bn, bw = ui.button(box, "Sell moon", 0, 0, { small = true, disabled = not sale.canSell,
|
end)(), space(10),
|
||||||
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
|
(function()
|
||||||
edge = sale.canSell and { "danger", 0.8 } or nil })
|
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
|
||||||
gui.set_position(b, vec(w - 10 - bw, -8))
|
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
|
||||||
|
return l
|
||||||
|
end)(),
|
||||||
|
}),
|
||||||
|
fg.button(c, "Sell moon", sell_opts, function(b)
|
||||||
if not sale.canSell and (sale.whyText or "") ~= "" then
|
if not sale.canSell and (sale.whyText or "") ~= "" then
|
||||||
tooltip.add(b, sale.whyText)
|
tooltip.add(b, sale.whyText)
|
||||||
end
|
end
|
||||||
if sale.canSell then
|
if sale.canSell then
|
||||||
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
|
cards.ask_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
|
||||||
|
if self.autoplay then
|
||||||
|
local function reg()
|
||||||
if self.autoplay then
|
if self.autoplay then
|
||||||
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
|
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
|
||||||
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
|
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.druid:new_button(hit, function()
|
reg()
|
||||||
self.sale_open[moon.id] = not open
|
blocks.note(reg) -- (a kept block registers again)
|
||||||
self.dirty = true
|
end
|
||||||
end):set_animations_disabled()
|
end
|
||||||
test.register("moon-sale:" .. moon.id, hit, "button", open and "Hide the offer" or "Show the offer")
|
end, { position = "absolute", top = 8, right = 10 }),
|
||||||
|
})
|
||||||
|
|
||||||
-- Fetch the state again when the hold ends (the server enables the sale).
|
-- Fetch the state again when the hold ends (the server enables the sale).
|
||||||
if self.sale_timer then
|
if self.sale_timer then
|
||||||
@@ -81,48 +117,79 @@ function M.build(self, H, root, x, y, w, moon)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local cy = 48
|
-- Details: the note and a line per well (a grid: well, left, price, value).
|
||||||
|
local details
|
||||||
-- Details: the note and a line per well.
|
|
||||||
if open then
|
if open then
|
||||||
local note = "What's left in its wells at today's prices, ×" .. sale.factor
|
local note = "What's left in its wells at today's prices, ×" .. sale.factor
|
||||||
.. (sale.capped and (", capped at " .. theme.currency .. ui.money(sale.total)) or "")
|
.. (sale.capped and (", capped at " .. theme.currency .. ui.money(sale.total)) or "")
|
||||||
.. ". A new moon would cost " .. theme.currency .. ui.money(sale.replace) .. " after the sale."
|
.. ". A new moon would cost " .. theme.currency .. ui.money(sale.replace) .. " after the sale."
|
||||||
.. ((sale.ready or 0) > 0 and (" " .. ui.plural(sale.ready, "finished drill") .. " claimed first.") or "")
|
.. ((sale.ready or 0) > 0 and (" " .. ui.plural(sale.ready, "finished drill") .. " claimed first.") or "")
|
||||||
local _, nh = ui.wrap(box, note, { font = "sans", px = 13, color = "muted" }, 14, cy + 4, math.min(w - 28, theme.size.text_max))
|
local hold = sale.holdEnd and sale.holdEnd > H.now(self)
|
||||||
cy = cy + nh + 12
|
local sellable
|
||||||
if sale.holdEnd and sale.holdEnd > H.now(self) then
|
if hold then
|
||||||
-- (the web's "Sellable in <countdown>.", ticking)
|
-- (the web's "Sellable in <countdown>.", ticking)
|
||||||
local n = ui.text(box, "Sellable in " .. ui.dur(sale.holdEnd - H.now(self)) .. ".", { font = "sans", px = 13, color = "muted" }, 14, cy + 2)
|
sellable = fg.label(c, "Sellable in " .. ui.dur(sale.holdEnd - H.now(self)) .. ".", { font = "sans", px = 13, color = "muted" }, 16)
|
||||||
|
sellable.done = function(n)
|
||||||
H.tick_text(self, n, sale.holdEnd, function(left)
|
H.tick_text(self, n, sale.holdEnd, function(left)
|
||||||
return "Sellable in " .. ui.dur(left) .. "."
|
return "Sellable in " .. ui.dur(left) .. "."
|
||||||
end, "Sellable in 0:00.")
|
end, "Sellable in 0:00.")
|
||||||
cy = cy + 20
|
|
||||||
end
|
end
|
||||||
local inner = w - 28
|
|
||||||
local cols = { 0, inner - 200, inner - 100, inner }
|
|
||||||
for i, label in ipairs({ "Well", "Left", "Price", "Value" }) do
|
|
||||||
ui.text(box, label, "cap", 14 + cols[i], cy + 6, i == 1 and gui.PIVOT_W or gui.PIVOT_E)
|
|
||||||
end
|
end
|
||||||
cy = cy + 16
|
local cols = { "1fr", 100, 100, 100 }
|
||||||
ui.rect(box, 14, cy, inner, 1, "edge")
|
local function line(top)
|
||||||
|
return made({ position = "absolute", left = 0, right = 0, top = top, height = 1 }, function(p, x, y, lw)
|
||||||
|
ui.rect(p, x, y, lw, 1, top == 0 and "edge" or "line")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local function right(b)
|
||||||
|
b.style.justify_self = "end"
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
local function credits(v)
|
||||||
|
return right(inline(function(p, x, y) return (ui.credits(p, v, x, y)) end))
|
||||||
|
end
|
||||||
|
local lines = { line(0) }
|
||||||
for _, l in ipairs(sale.lines or {}) do
|
for _, l in ipairs(sale.lines or {}) do
|
||||||
ui.rect(box, 16, cy + 11, 8, 8, l.resource)
|
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
|
||||||
ui.text(box, l.resource, "body", 32, cy + 15)
|
row({ padding_left = 2, align = "center" }, {
|
||||||
ui.text(box, ui.money(l.supply), "num", 14 + cols[2], cy + 15, gui.PIVOT_E)
|
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, l.resource) end),
|
||||||
ui.credits(box, l.rate, 14 + cols[3], cy + 15, { pivot = gui.PIVOT_E })
|
space(8), fg.label(c, l.resource, "body", 16),
|
||||||
ui.credits(box, l.value, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
|
}),
|
||||||
cy = cy + 30
|
right(fg.label(c, ui.money(l.supply), "num", 16)), credits(l.rate), credits(l.value),
|
||||||
ui.rect(box, 14, cy, inner, 1, "line")
|
line(30),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
ui.text(box, "Market value", { font = "sans_semi", px = 14, color = "text" }, 32, cy + 15)
|
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
|
||||||
ui.credits(box, sale.market, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
|
row({ padding_left = 18 }, { fg.label(c, "Market value", { font = "sans_semi", px = 14, color = "text" }, 16) }),
|
||||||
cy = cy + 34
|
space(0), space(0), credits(sale.market),
|
||||||
|
})
|
||||||
|
details = col({ padding = { 0, 14, 0, 14 } }, {
|
||||||
|
space(0, 4),
|
||||||
|
fg.measured(c, function(p, x, y, nw)
|
||||||
|
local _, nh = ui.wrap(p, note, { font = "sans", px = 13, color = "muted" }, x, y, nw)
|
||||||
|
return nh
|
||||||
|
end, { max_width = theme.size.text_max }),
|
||||||
|
space(0, hold and 2 or 8), sellable, hold and space(0, 10) or nil,
|
||||||
|
box({ columns = cols, height = 16, align = "start" }, {
|
||||||
|
fg.label(c, "Well", "cap", 12), right(fg.label(c, "Left", "cap", 12)),
|
||||||
|
right(fg.label(c, "Price", "cap", 12)), right(fg.label(c, "Value", "cap", 12)),
|
||||||
|
}),
|
||||||
|
col({}, lines),
|
||||||
|
space(0, 4),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
cy = cy + 4
|
|
||||||
gui.set_size(box, vec(w, cy))
|
local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
|
||||||
gui.set_size(edge, vec(w, cy))
|
fg.measured(c, function(p, x, y, bw)
|
||||||
return y + cy
|
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
|
||||||
|
end),
|
||||||
|
}) or nil
|
||||||
|
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
|
||||||
|
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
|
||||||
|
ui.outline(b, 0, 0, bw, bh, "edge")
|
||||||
|
self.fx:box(b) -- (the sale's sweep covers the panel)
|
||||||
|
return b
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
|
||||||
|
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
|
||||||
|
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
|
||||||
|
-- object, as terrain.lua's DEFAULTS shape it).
|
||||||
|
--
|
||||||
|
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
|
||||||
|
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
|
||||||
|
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
|
||||||
|
-- the saved settings), the save status and Save; under it the settings in a scroll area,
|
||||||
|
-- grouped like the web page: a slider and a number field per number, a hex field and a
|
||||||
|
-- swatch per color, a toggle per flag, a button row for the technique.
|
||||||
|
--
|
||||||
|
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
|
||||||
|
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
|
||||||
|
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
|
||||||
|
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
|
||||||
|
-- once (moon.script's cache).
|
||||||
|
--
|
||||||
|
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
|
||||||
|
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
|
||||||
|
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
|
||||||
|
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
|
||||||
|
-- technique) or the size does: a slider's drag never rebuilds it.
|
||||||
|
--
|
||||||
|
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
|
||||||
|
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
|
||||||
|
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local field = require("modules.field")
|
||||||
|
local api = require("modules.api")
|
||||||
|
local test = require("modules.test")
|
||||||
|
local terrain = require("modules.moon.terrain")
|
||||||
|
local moon_view = require("modules.moon.moon")
|
||||||
|
local panel = require("components.panel")
|
||||||
|
local scroll = require("components.scroll")
|
||||||
|
local button = require("components.button")
|
||||||
|
local slider = require("components.slider")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
|
||||||
|
-- [key, "bool", label], [key, "select", label] (the technique).
|
||||||
|
local SCHEMA = {
|
||||||
|
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
|
||||||
|
{ title = "Shape noise", path = "shape", fields = {
|
||||||
|
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
|
||||||
|
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||||
|
} },
|
||||||
|
{ title = "Ridges", path = "ridge", fields = {
|
||||||
|
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
|
||||||
|
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
|
||||||
|
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||||
|
} },
|
||||||
|
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
|
||||||
|
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
|
||||||
|
} },
|
||||||
|
{ title = "Craters", path = "craters", fields = {
|
||||||
|
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
|
||||||
|
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
|
||||||
|
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
|
||||||
|
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
|
||||||
|
} },
|
||||||
|
{ title = "Shading", path = "shading", fields = {
|
||||||
|
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
|
||||||
|
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
|
||||||
|
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
|
||||||
|
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
|
||||||
|
} },
|
||||||
|
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
|
||||||
|
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
|
||||||
|
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
|
||||||
|
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
|
||||||
|
} },
|
||||||
|
{ title = "Render", path = "render", fields = {
|
||||||
|
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
|
||||||
|
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
|
||||||
|
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
|
||||||
|
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
|
||||||
|
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
|
||||||
|
} },
|
||||||
|
}
|
||||||
|
|
||||||
|
local SEED = "moon-lab"
|
||||||
|
local ROW_H = 30
|
||||||
|
local LABEL_W = 150
|
||||||
|
local NUM_W = 76
|
||||||
|
local PAD = S.panel_pad
|
||||||
|
local CTRL_MIN, CTRL_MAX = 440, 560
|
||||||
|
local STATS_H = 22
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clone(t)
|
||||||
|
if type(t) ~= "table" then
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
local c = {}
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
c[k] = clone(v)
|
||||||
|
end
|
||||||
|
return c
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "minValue" -> "Min value" (the web's label()).
|
||||||
|
local function label_of(key)
|
||||||
|
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
|
||||||
|
return s:sub(1, 1):upper() .. s:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
|
||||||
|
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
|
||||||
|
local function fmt(v, step)
|
||||||
|
if step >= 1 then
|
||||||
|
return string.format("%d", math.floor(v + 0.5))
|
||||||
|
end
|
||||||
|
local dec = #(tostring(step):match("%.(%d+)") or "00")
|
||||||
|
return string.format("%." .. math.min(4, dec) .. "f", v)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function group_of(settings, path)
|
||||||
|
return path and settings[path] or settings
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- state
|
||||||
|
|
||||||
|
local function lab(g)
|
||||||
|
g.lab = g.lab or { seed = SEED, rev = 0 }
|
||||||
|
return g.lab
|
||||||
|
end
|
||||||
|
|
||||||
|
local function status(L)
|
||||||
|
if L.saving then
|
||||||
|
return "Saving…"
|
||||||
|
elseif L.error then
|
||||||
|
return L.error
|
||||||
|
elseif not L.loaded then
|
||||||
|
return "Loading…"
|
||||||
|
end
|
||||||
|
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_status(L)
|
||||||
|
if L.w and L.w.status then
|
||||||
|
ui.style_text(L.w.status, "small", status(L))
|
||||||
|
ui.fit_text(L.w.status, L.w.status_w)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A value changed: the moon gets it on the next free frame (update).
|
||||||
|
local function changed(L)
|
||||||
|
L.pending = true
|
||||||
|
L.error = nil
|
||||||
|
show_status(L)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
|
||||||
|
local function rebuild(g)
|
||||||
|
local L = lab(g)
|
||||||
|
L.rev = L.rev + 1
|
||||||
|
L.pending = true
|
||||||
|
L.error = nil
|
||||||
|
g.dirty = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open(g)
|
||||||
|
local L = lab(g)
|
||||||
|
L.saved = terrain.merge_settings(moon_view.settings)
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
L.loaded, L.error, L.saving = false, nil, false
|
||||||
|
L.pending = true
|
||||||
|
api.get_moon_settings(function(err, data)
|
||||||
|
if g.lab ~= L then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if err then
|
||||||
|
L.loaded = true
|
||||||
|
L.error = "Couldn't load the saved settings (" .. err .. ")"
|
||||||
|
show_status(L)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saved = terrain.merge_settings(data)
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
L.loaded = true
|
||||||
|
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
|
||||||
|
rebuild(g)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
|
||||||
|
-- for the lab).
|
||||||
|
function M.close(g)
|
||||||
|
local L = g.lab
|
||||||
|
if L and L.saved then
|
||||||
|
moon_view.settings = clone(L.saved)
|
||||||
|
end
|
||||||
|
g.lab = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function save(g)
|
||||||
|
local L = lab(g)
|
||||||
|
if L.saving then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saving, L.error = true, nil
|
||||||
|
show_status(L)
|
||||||
|
local sent = clone(L.draft)
|
||||||
|
api.save_moon_settings(sent, function(err)
|
||||||
|
-- The game's moons take the saved settings even if the lab was left meanwhile.
|
||||||
|
if err then
|
||||||
|
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||||
|
else
|
||||||
|
moon_view.settings = clone(sent)
|
||||||
|
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||||
|
end
|
||||||
|
if g.lab ~= L then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saving = false
|
||||||
|
if err then
|
||||||
|
L.error = "Save failed: " .. err
|
||||||
|
else
|
||||||
|
L.saved = sent
|
||||||
|
end
|
||||||
|
show_status(L)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- controls
|
||||||
|
|
||||||
|
local function small_button(parent, label, id, on_click, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local bopts = { small = true, variant = opts.variant, on = opts.on }
|
||||||
|
return fg.button(parent, label, bopts, function(b, nodes)
|
||||||
|
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
|
||||||
|
end, opts.style)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One setting's row: label, then its control.
|
||||||
|
local function setting_row(g, L, parent, zone, path, f)
|
||||||
|
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
|
||||||
|
local kind = type(a) == "string" and a or "number"
|
||||||
|
local text = (kind ~= "number" and b or name) or label_of(key)
|
||||||
|
local gid = (path or "root") .. "." .. key
|
||||||
|
local target = group_of(L.draft, path)
|
||||||
|
local kids = {}
|
||||||
|
if kind ~= "select" then
|
||||||
|
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
|
||||||
|
end
|
||||||
|
if kind == "number" then
|
||||||
|
local o = { min = a, max = b, step = step }
|
||||||
|
local fkey = "lab:" .. gid
|
||||||
|
local f_state = field.get(g, fkey)
|
||||||
|
f_state.text = fmt(target[key], step)
|
||||||
|
local ctl = {}
|
||||||
|
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
|
||||||
|
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
|
||||||
|
id = "slider:" .. gid, label = text, zone = zone,
|
||||||
|
on_change = function(v)
|
||||||
|
target[key] = v
|
||||||
|
if ctl.input then
|
||||||
|
ctl.syncing = true
|
||||||
|
ctl.input:set_text(fmt(v, step))
|
||||||
|
f_state.text = fmt(v, step)
|
||||||
|
ctl.syncing = false
|
||||||
|
end
|
||||||
|
changed(L)
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
|
||||||
|
min = o.min, max = o.max,
|
||||||
|
on_change = function(t)
|
||||||
|
local v = tonumber(t)
|
||||||
|
if ctl.syncing or not v then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
v = math.max(o.min, math.min(o.max, v))
|
||||||
|
target[key] = v
|
||||||
|
ctl.slider:set(v)
|
||||||
|
changed(L)
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
elseif kind == "color" then
|
||||||
|
local fkey = "lab:" .. gid
|
||||||
|
field.get(g, fkey).text = target[key]
|
||||||
|
local sw
|
||||||
|
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
sw = ui.rect(p, x, y, w, h, "line_hi")
|
||||||
|
-- (the setting's own color, not a theme color: the swatch shows the data)
|
||||||
|
local c = terrain.hex_to_rgb(target[key])
|
||||||
|
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||||
|
end)
|
||||||
|
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
|
||||||
|
on_change = function(t)
|
||||||
|
if t:match("^#%x%x%x%x%x%x$") then
|
||||||
|
target[key] = t:lower()
|
||||||
|
local c = terrain.hex_to_rgb(t)
|
||||||
|
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||||
|
changed(L)
|
||||||
|
end
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
elseif kind == "bool" then
|
||||||
|
local on = target[key] == true
|
||||||
|
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
|
||||||
|
target[key] = not target[key]
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
|
||||||
|
else -- the technique
|
||||||
|
local row = {}
|
||||||
|
for _, t in ipairs(terrain.TECHNIQUES) do
|
||||||
|
local cur = target[key] == t
|
||||||
|
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
|
||||||
|
target[key] = t
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g, variant = cur and "primary" or nil })
|
||||||
|
end
|
||||||
|
return fg.col({ gap = 6 }, {
|
||||||
|
fg.label(parent, text, "small", 20, { fit = true }),
|
||||||
|
fg.box({ wrap = true, gap = 6 }, row),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return fg.row({ height = ROW_H, align = "center" }, kids)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
|
||||||
|
local function settings_list(g, L, parent, x, y, w, h)
|
||||||
|
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
|
||||||
|
local p = area.content
|
||||||
|
local groups = {}
|
||||||
|
for _, sec in ipairs(SCHEMA) do
|
||||||
|
local rows = { fg.label(p, sec.title, "h3", 22) }
|
||||||
|
if sec.note then
|
||||||
|
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
|
||||||
|
end
|
||||||
|
for _, f in ipairs(sec.fields) do
|
||||||
|
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
|
||||||
|
end
|
||||||
|
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
|
||||||
|
end
|
||||||
|
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
|
||||||
|
flex.solve(tree, 0, 0)
|
||||||
|
fg.build(tree, p)
|
||||||
|
area:finish(tree.h)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
|
||||||
|
-- Returns its height.
|
||||||
|
local function toolbar(g, L, parent, x, y, w)
|
||||||
|
local st = g.state or {}
|
||||||
|
field.get(g, "lab-seed").text = L.seed
|
||||||
|
local function seed_to(s)
|
||||||
|
L.seed = s
|
||||||
|
field.get(g, "lab-seed").text = s
|
||||||
|
rebuild(g)
|
||||||
|
end
|
||||||
|
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
|
||||||
|
for i, m in ipairs(st.moons or {}) do
|
||||||
|
if i > 3 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local id = tostring(m.id)
|
||||||
|
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
|
||||||
|
{ g = g, style = { max_width = 160 } })
|
||||||
|
end
|
||||||
|
local status_w = 170
|
||||||
|
local tree = fg.col({ width = w, gap = 10 }, {
|
||||||
|
fg.label(parent, "Seed (moon id)", "cap", 14),
|
||||||
|
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
|
||||||
|
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
|
||||||
|
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
|
||||||
|
on_change = function(t)
|
||||||
|
L.seed = t ~= "" and t or SEED
|
||||||
|
L.pending = true
|
||||||
|
end })
|
||||||
|
end),
|
||||||
|
small_button(parent, "New seed", "lab:seed-new", function()
|
||||||
|
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
|
||||||
|
end, { g = g }),
|
||||||
|
}),
|
||||||
|
fg.box({ wrap = true, gap = 6 }, presets),
|
||||||
|
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
|
||||||
|
small_button(parent, "Defaults", "lab:defaults", function()
|
||||||
|
L.draft = clone(terrain.DEFAULTS)
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g }),
|
||||||
|
small_button(parent, "Reset", "lab:reset", function()
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g }),
|
||||||
|
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
|
||||||
|
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
|
||||||
|
L.w.status_w = math.min(status_w, bw - 8)
|
||||||
|
show_status(L)
|
||||||
|
end),
|
||||||
|
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
|
||||||
|
end, { width = 110 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
flex.solve(tree, x, y)
|
||||||
|
fg.build(tree, parent)
|
||||||
|
return tree.h
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- build
|
||||||
|
|
||||||
|
-- The block's inputs (core/main keeps it while they stay the same).
|
||||||
|
function M.sig(g)
|
||||||
|
local L = lab(g)
|
||||||
|
return { L.rev, L.loaded }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.build(g, parent, x, y, w, h)
|
||||||
|
local L = lab(g)
|
||||||
|
L.w = {}
|
||||||
|
local gap = S.gap
|
||||||
|
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
|
||||||
|
local mw = w - cw - gap
|
||||||
|
-- The moon.
|
||||||
|
local mp = panel.new(parent, x, y, mw, h)
|
||||||
|
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
|
||||||
|
local vx = math.floor((mw - size) / 2)
|
||||||
|
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
|
||||||
|
L.vp = vp
|
||||||
|
moon_view.show(L.seed, vp)
|
||||||
|
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
|
||||||
|
L.w.stats_w = size
|
||||||
|
L.shown_build = nil
|
||||||
|
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
|
||||||
|
-- The settings.
|
||||||
|
local cp = panel.new(parent, x + mw + gap, y, cw, h)
|
||||||
|
local inner = cw - PAD * 2
|
||||||
|
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
|
||||||
|
local ly = PAD + th + 12
|
||||||
|
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
|
||||||
|
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
|
||||||
|
-- show the last build.
|
||||||
|
function M.update(g, dt)
|
||||||
|
local L = g.lab
|
||||||
|
if not L or not L.vp then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if L.pending and not moon_view.building then
|
||||||
|
L.pending = false
|
||||||
|
moon_view.settings = clone(L.draft)
|
||||||
|
moon_view.show(L.seed, L.vp)
|
||||||
|
moon_view.building = true -- (until moon.script looks: one handed over at a time)
|
||||||
|
end
|
||||||
|
local b = moon_view.last_build
|
||||||
|
if b and b ~= L.shown_build and L.w and L.w.stats then
|
||||||
|
L.shown_build = b
|
||||||
|
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
|
||||||
|
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
|
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
|
||||||
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
|
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
|
||||||
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
|
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
|
||||||
-- ready badge; a virtualized list, components/list) and "+ New moon" pinned to the bottom.
|
-- ready badge; a virtualized list, components/list), "+ New moon" at the top (buys one) and
|
||||||
|
-- the announcements pane (modules/toasts, the last 5) at the foot.
|
||||||
-- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
|
-- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
|
||||||
-- under it, the total on its tab.
|
-- under it, the total on its tab.
|
||||||
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells
|
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells
|
||||||
@@ -18,8 +19,13 @@ local field = require("modules.field")
|
|||||||
local moon_row = require("widgets.moon_row")
|
local moon_row = require("widgets.moon_row")
|
||||||
local ready = require("screens.game.core.ready")
|
local ready = require("screens.game.core.ready")
|
||||||
local tabs = require("components.tabs")
|
local tabs = require("components.tabs")
|
||||||
|
local button = require("components.button")
|
||||||
local list_c = require("components.list")
|
local list_c = require("components.list")
|
||||||
local row_c = require("components.row")
|
local row_c = require("components.row")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local toasts = require("modules.toasts")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -257,6 +263,45 @@ function M.teardown(self)
|
|||||||
self.ml_ghosts = nil
|
self.ml_ghosts = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The grade "+ New moon" buys: the toggle's pick if the server still lists it, else
|
||||||
|
-- state.moonGrade.
|
||||||
|
function M.grade(g)
|
||||||
|
local st = g.state
|
||||||
|
for _, e in ipairs(st.newMoon and st.newMoon.grades or {}) do
|
||||||
|
if e.grade == g.nm_grade then return g.nm_grade end
|
||||||
|
end
|
||||||
|
return st.moonGrade
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
|
||||||
|
-- badge, selection), both lists: the moon list's block signature has this instead of the
|
||||||
|
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
|
||||||
|
-- keeps the list.
|
||||||
|
function M.rows_sig(self)
|
||||||
|
local st, v = self.state, self.view
|
||||||
|
local out, n = {}, 0
|
||||||
|
for _, list in ipairs({ st.moons, st.past }) do
|
||||||
|
for _, m in ipairs(list) do
|
||||||
|
local dry = not m.finished and #(m.wells or {}) > 0
|
||||||
|
if dry then
|
||||||
|
for _, w in ipairs(m.wells) do
|
||||||
|
if w.supply > 0 then
|
||||||
|
dry = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
n = n + 1
|
||||||
|
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
|
||||||
|
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
|
||||||
|
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
|
||||||
|
end
|
||||||
|
n = n + 1
|
||||||
|
out[n] = "|"
|
||||||
|
end
|
||||||
|
return blocks.sig(out)
|
||||||
|
end
|
||||||
|
|
||||||
function M.build(self, H, parent, x, y, w, h)
|
function M.build(self, H, parent, x, y, w, h)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local v = self.view
|
local v = self.view
|
||||||
@@ -264,25 +309,66 @@ function M.build(self, H, parent, x, y, w, h)
|
|||||||
local root = ui.panel(parent, x, y, w, h)
|
local root = ui.panel(parent, x, y, w, h)
|
||||||
local pad = S.panel_pad
|
local pad = S.panel_pad
|
||||||
local inner = w - pad * 2
|
local inner = w - pad * 2
|
||||||
local cy = pad
|
local kids = {} -- the panel, top to bottom (one tree; full width, rows padded)
|
||||||
|
|
||||||
-- Active / Past tabs (once the store is open, like the web).
|
-- "+ New moon" (dashed in the web build), at the top: buys a moon straight away (the
|
||||||
|
-- new-moon section's Claim and reveal); disabled with the reason when short.
|
||||||
|
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
|
||||||
|
if st.unlocked["new-moon"] then
|
||||||
|
-- The grade it buys: the one picked with the toggle at its right (g.nm_grade, kept
|
||||||
|
-- across builds), else the server's default (state.moonGrade).
|
||||||
|
local grades = st.newMoon and st.newMoon.grades or {}
|
||||||
|
local grade = M.grade(self)
|
||||||
|
local entry
|
||||||
|
for _, e in ipairs(grades) do
|
||||||
|
if e.grade == grade then entry = e end
|
||||||
|
end
|
||||||
|
local price = entry and entry.price or st.moonPrice or 0
|
||||||
|
local ok = entry and entry.canAfford or (not entry and (st.credits or 0) >= price)
|
||||||
|
local why = entry and (entry.whyText or "") ~= "" and entry.whyText
|
||||||
|
or ("Need " .. theme.currency .. ui.money(math.max(0, price - (st.credits or 0))) .. " more")
|
||||||
|
local label = grade and ("+ New " .. grade .. " moon") or "+ New moon"
|
||||||
|
local tw = #grades > 1 and 34 or 0
|
||||||
|
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad }, gap = tw > 0 and 6 or 0 }, {
|
||||||
|
fg.button(root, label, { price = price, variant = "ghost", on = v.section == "new-moon", disabled = not ok }, function(b, nodes)
|
||||||
|
if ok then
|
||||||
|
H.action(self, b, nodes, "buy-moon", { grade = grade, include = "new-moon" }, nil, nil, "newmoon:buy")
|
||||||
|
else
|
||||||
|
tooltip.add(b, why)
|
||||||
|
end
|
||||||
|
local extra = { ui.part(nodes, "button", "edge") }
|
||||||
|
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
|
||||||
|
if i > 1 then
|
||||||
|
extra[#extra + 1] = part[1] -- (the price)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner - tw - (tw > 0 and 6 or 0), S.button_h, extra)
|
||||||
|
end, { grow = 1, shrink = 1 }),
|
||||||
|
-- The grade toggle: steps through the grades (cheapest first), stays in place.
|
||||||
|
tw > 0 and fg.button(root, grade or "?", { variant = "ghost" }, function(b)
|
||||||
|
button.bind(self, b, nil, { id = "newmoon:grade", label = "Grade " .. (grade or ""), on_click = function()
|
||||||
|
local at = 0
|
||||||
|
for i, e in ipairs(grades) do
|
||||||
|
if e.grade == grade then at = i end
|
||||||
|
end
|
||||||
|
self.nm_grade = grades[at % #grades + 1].grade
|
||||||
|
self.dirty = true
|
||||||
|
end })
|
||||||
|
tooltip.add(b, "Grade to buy: " .. (grade or "") .. " (click to change)")
|
||||||
|
end, { width = tw }) or nil,
|
||||||
|
})
|
||||||
|
kids[#kids + 1] = fg.space(0, 12)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Active / Past tabs (once the store is open, like the web): a tab is its label and
|
||||||
|
-- count in a hit box 4 wider on the left; a line under them.
|
||||||
if (#st.moons + #st.past) > 0 and st.unlocked.store then
|
if (#st.moons + #st.past) > 0 and st.unlocked.store then
|
||||||
local tx = pad
|
|
||||||
local ink = tabs.ink(self, root, "moonlist") -- the sliding underline (under the tabs)
|
local ink = tabs.ink(self, root, "moonlist") -- the sliding underline (under the tabs)
|
||||||
local past_total = st.pastPage and st.pastPage.total or #st.past
|
local past_total = st.pastPage and st.pastPage.total or #st.past
|
||||||
|
local tab_boxes = {}
|
||||||
for _, tab in ipairs({ { "Active", #st.moons, false }, { "Past", past_total, true } }) do
|
for _, tab in ipairs({ { "Active", #st.moons, false }, { "Past", past_total, true } }) do
|
||||||
local on = v.past == tab[3]
|
local on = v.past == tab[3]
|
||||||
local hit = ui.rect(root, tx - 4, cy, 10, 30, "none")
|
|
||||||
local color = on and "cyan" or (tab[2] > 0 and "nav" or "faint")
|
local color = on and "cyan" or (tab[2] > 0 and "nav" or "faint")
|
||||||
local t = ui.text(root, tab[1], { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, tx, cy + 14)
|
|
||||||
local tw = ui.text_width(t)
|
|
||||||
local n = ui.text(root, tab[2], { font = "mono", px = 12, color = "muted" }, tx + tw + 6, cy + 14)
|
|
||||||
local full = tw + 6 + ui.text_width(n) + 8
|
|
||||||
gui.set_size(hit, vec(full, 30))
|
|
||||||
if on then
|
|
||||||
ink:to(tx - 4, cy, full, 30)
|
|
||||||
end
|
|
||||||
local to
|
local to
|
||||||
if tab[3] == false then
|
if tab[3] == false then
|
||||||
local first = st.moons[1]
|
local first = st.moons[1]
|
||||||
@@ -291,28 +377,39 @@ function M.build(self, H, parent, x, y, w, h)
|
|||||||
-- (the newest past moons: page 1, the server picks the moon)
|
-- (the newest past moons: page 1, the server picks the moon)
|
||||||
to = { section = "moons", moon = not v.past and st.past[1].id or "", past = true }
|
to = { section = "moons", moon = not v.past and st.past[1].id or "", past = true }
|
||||||
end
|
end
|
||||||
|
local label = fg.label(root, tab[1], { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, 28)
|
||||||
|
if tab[3] then
|
||||||
|
label.done = function(n) past_flash(self, root, n) end
|
||||||
|
end
|
||||||
|
tab_boxes[#tab_boxes + 1] = fg.row({ height = 30, padding = { 0, 4, 0, 4 }, align = "start" }, {
|
||||||
|
label, fg.space(6), fg.label(root, tab[2], { font = "mono", px = 12, color = "muted" }, 28),
|
||||||
|
}, { make = function(p, hx, hy, hw, hh)
|
||||||
|
-- (the labels stay on the panel: past_flash puts its glow there)
|
||||||
|
local hit = ui.rect(p, hx, hy, hw, hh, "none")
|
||||||
|
if on then
|
||||||
|
ink:to(hx, hy, hw, hh)
|
||||||
|
end
|
||||||
if not to then
|
if not to then
|
||||||
tooltip.add(hit, "Moons you mine out go here")
|
tooltip.add(hit, "Moons you mine out go here")
|
||||||
else
|
else
|
||||||
tabs.tab(self, hit, t, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
|
tabs.tab(self, hit, label.node, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
|
||||||
H.navigate(self, to)
|
H.navigate(self, to)
|
||||||
end })
|
end })
|
||||||
end
|
end
|
||||||
if tab[3] then
|
return nil
|
||||||
past_flash(self, root, t)
|
end })
|
||||||
end
|
end
|
||||||
tx = tx + full + 22
|
kids[#kids + 1] = fg.col({ height = 44 }, {
|
||||||
end
|
fg.row({ gap = 22, padding_left = pad - 4 }, tab_boxes),
|
||||||
ui.rect(root, pad, cy + 30, inner, 1, "line")
|
fg.made({ position = "absolute", top = 30, left = pad, right = pad, height = 1 }, function(p, lx, ly, lw)
|
||||||
cy = cy + 44
|
ui.rect(p, lx, ly, lw, 1, "line")
|
||||||
|
end),
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local list = v.past and st.past or st.moons
|
local list = v.past and st.past or st.moons
|
||||||
local bottom = h - pad - (st.unlocked["new-moon"] and (S.button_h + 10) or 0)
|
|
||||||
local paging = v.past and st.pastPage and (st.pastPage.pages or 1) > 1 and st.pastPage
|
local paging = v.past and st.pastPage and (st.pastPage.pages or 1) > 1 and st.pastPage
|
||||||
if paging then
|
local list_h -- (the rows' box: what's left between the search and the bottom)
|
||||||
bottom = bottom - S.button_sm_h - 12
|
|
||||||
end
|
|
||||||
local rows, note = nil, nil
|
local rows, note = nil, nil
|
||||||
local slot = S.moon_row_h + 6
|
local slot = S.moon_row_h + 6
|
||||||
local ghosts -- the mined-out copies shown in this list, by where they go
|
local ghosts -- the mined-out copies shown in this list, by where they go
|
||||||
@@ -345,7 +442,7 @@ function M.build(self, H, parent, x, y, w, h)
|
|||||||
end
|
end
|
||||||
self.ml_ghosts = #ghosts.list > 0 and ghosts or nil
|
self.ml_ghosts = #ghosts.list > 0 and ghosts or nil
|
||||||
if not rows then
|
if not rows then
|
||||||
rows = list_c.new(self, root, pad, y0, inner, bottom - y0, {
|
rows = list_c.new(self, root, pad, y0, inner, list_h, {
|
||||||
key = v.past and ("moons:past:" .. (v.page or 1)) or "moons", row_h = slot,
|
key = v.past and ("moons:past:" .. (v.page or 1)) or "moons", row_h = slot,
|
||||||
focus_id = function(moon) return "moon:" .. moon.id end,
|
focus_id = function(moon) return "moon:" .. moon.id end,
|
||||||
create = function(moon, index)
|
create = function(moon, index)
|
||||||
@@ -395,51 +492,65 @@ function M.build(self, H, parent, x, y, w, h)
|
|||||||
|
|
||||||
-- The search stays across Active / Past (like the web's input, which the swap keeps).
|
-- The search stays across Active / Past (like the web's input, which the swap keeps).
|
||||||
local find = field.get(self, "find")
|
local find = field.get(self, "find")
|
||||||
|
local rows_y
|
||||||
if #list > 0 and st.unlocked.store then
|
if #list > 0 and st.unlocked.store then
|
||||||
ui.text(root, "Find a moon", "cap", pad, cy + 6)
|
kids[#kids + 1] = fg.col({ padding = { 0, pad, 0, pad } }, {
|
||||||
cy = cy + 16
|
fg.label(root, "Find a moon", "cap", 12), fg.space(0, 4),
|
||||||
local rows_y = cy + 34 + 12
|
fg.made({ height = 34 }, function(p, fx, fy, fw, fh)
|
||||||
field.new(self, root, "find", pad, cy, inner, 34, {
|
field.new(self, p, "find", fx, fy, fw, fh, {
|
||||||
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
|
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
|
||||||
on_change = function(text)
|
on_change = blocks.bound(function(text) -- (what it makes is the moon list's: modules/blocks)
|
||||||
build_rows(text, rows_y)
|
build_rows(text, rows_y)
|
||||||
end,
|
end),
|
||||||
|
})
|
||||||
|
end),
|
||||||
|
fg.space(0, 12),
|
||||||
})
|
})
|
||||||
cy = rows_y
|
|
||||||
end
|
end
|
||||||
build_rows(find.text, cy)
|
-- The rows (a virtualized list) take the room down to the pager and the announcements.
|
||||||
|
kids[#kids + 1] = fg.made({ grow = 1, shrink = 1 }, function(_, _, ly, _, lh)
|
||||||
|
rows_y, list_h = ly, lh
|
||||||
|
build_rows(find.text, ly)
|
||||||
|
end)
|
||||||
|
|
||||||
-- Past pages: ‹ Newer, "2 / 3", Older › (the web's past-paging).
|
-- Past pages: ‹ Newer, "2 / 3", Older › (the web's past-paging).
|
||||||
if paging then
|
if paging then
|
||||||
local py = bottom + 8
|
local function pager(label, page)
|
||||||
local function pager(label, page, x, right)
|
return fg.button(root, label, { small = true, disabled = not page }, function(b)
|
||||||
local b, nodes, bw = ui.button(root, label, 0, 0, { small = true, disabled = not page })
|
|
||||||
gui.set_position(b, vec(right and (x - bw) or x, -py))
|
|
||||||
if page then
|
if page then
|
||||||
H.link(self, b, { section = "moons", past = true, page = page })
|
H.link(self, b, { section = "moons", past = true, page = page })
|
||||||
end
|
end
|
||||||
return bw
|
end)
|
||||||
end
|
end
|
||||||
pager("‹ Newer", paging.page > 1 and paging.page - 1 or nil, pad)
|
local count = fg.label(root, paging.page .. " / " .. paging.pages, { font = "mono", px = 12, color = "muted" }, S.button_sm_h,
|
||||||
pager("Older ›", paging.page < paging.pages and paging.page + 1 or nil, pad + inner, true)
|
{ position = "absolute", top = 0, left = "50%", translate = { -0.5, 0 } })
|
||||||
local t = ui.text(root, paging.page .. " / " .. paging.pages, { font = "mono", px = 12, color = "muted" }, pad + inner / 2, py + S.button_sm_h / 2, gui.PIVOT_CENTER)
|
count.done = function(n) tooltip.add(n, paging.total .. " past moons, " .. paging.perPage .. " a page") end
|
||||||
tooltip.add(t, paging.total .. " past moons, " .. paging.perPage .. " a page")
|
kids[#kids + 1] = fg.space(0, 8)
|
||||||
|
kids[#kids + 1] = fg.row({ height = S.button_sm_h, padding = { 0, pad, 0, pad } }, {
|
||||||
|
pager("‹ Newer", paging.page > 1 and paging.page - 1 or nil), fg.fill(),
|
||||||
|
pager("Older ›", paging.page < paging.pages and paging.page + 1 or nil),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
kids[#kids + 1] = fg.space(0, 4)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel.
|
-- The announcements pane at the panel's foot: room for the last 5 (modules/toasts lists
|
||||||
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
|
-- them there), at most half the panel; the moon list gets the rest.
|
||||||
if st.unlocked["new-moon"] then
|
kids[#kids + 1] = fg.space(0, 12)
|
||||||
local by = h - pad - S.button_h
|
kids[#kids + 1] = fg.col({ height = math.min(math.floor(h / 2), 31 + toasts.LIST_H), padding = { 0, pad, 0, pad } }, {
|
||||||
local b, nodes = ui.button(root, "+ New moon", pad, by, { w = inner, price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" })
|
fg.made({ height = 1 }, function(p, lx, ly, lw)
|
||||||
H.link(self, b, { section = "new-moon", moon = v.moon })
|
ui.rect(p, lx, ly, lw, 1, "line")
|
||||||
local extra = { ui.part(nodes, "button", "edge") }
|
end),
|
||||||
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
|
fg.space(0, 10),
|
||||||
if i > 1 then
|
fg.label(root, "Announcements", "cap", 12), fg.space(0, 8),
|
||||||
extra[#extra + 1] = part[1] -- (the price)
|
fg.made({ grow = 1, shrink = 1 }, function(p, px, py, pw, ph)
|
||||||
end
|
self.toasts:attach(ui.rect(p, px, py, pw, ph, "none"), pw, ph)
|
||||||
end
|
end),
|
||||||
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner, S.button_h, extra)
|
})
|
||||||
|
for _, k in ipairs(kids) do
|
||||||
|
k.style.shrink = k.style.shrink or 0
|
||||||
end
|
end
|
||||||
|
fg.build(flex.solve(fg.col({ width = w, height = h, padding = { pad, 0, pad, 0 } }, kids), 0, 0), root)
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+784
-256
File diff suppressed because it is too large.
Load diff
+577
-220
@@ -1,21 +1,54 @@
|
|||||||
-- The Refinery section (state.refinery): slots with their batches (bars fill in real time
|
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect
|
||||||
-- from start to end against the server clock; the state is fetched again when a batch is
|
-- all when more than one batch is ready), the slot cards (running:
|
||||||
-- due, see next_end), recipe rows (inputs, held, a quantity field, Refine and Max), and
|
-- time left and a segmented bar filling in real time against the server clock; ready:
|
||||||
-- the refined stock as chips at the top (the web's; no aside here). The server decides what is finished and
|
-- Collect; idle; "+ Slot n" while more can be bought) and the recipe cards (glyph, tier
|
||||||
-- what a batch makes; batch times shown are the server's (bulkSecs for bulkQty).
|
-- and owned, description, inputs, stats, the bulk batch, quantity, Refine and Max). The
|
||||||
|
-- whole section scrolls, like the web's page. The state is fetched again when a batch is
|
||||||
|
-- due (next_end); the server decides what is finished and what a batch makes.
|
||||||
|
--
|
||||||
|
-- Built flex-first: the page is one tree (modules/flex), described below with text leaves
|
||||||
|
-- (flex_gui.label / para) and boxes whose make builds what has a size (frames, buttons,
|
||||||
|
-- bars) once solved; flex_gui.build then makes the nodes in reading (= focus) order.
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
local tooltip = require("modules.tooltip")
|
local tooltip = require("modules.tooltip")
|
||||||
local test = require("modules.test")
|
local test = require("modules.test")
|
||||||
local scroll = require("components.scroll")
|
local anim = require("modules.anim")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local button = require("components.button")
|
||||||
|
local panel = require("components.panel")
|
||||||
local cards = require("screens.game.cards")
|
local cards = require("screens.game.cards")
|
||||||
|
|
||||||
local S = theme.size
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local function vec(x, y)
|
-- components.bar needs core/ticks, which needs core/session, which needs this module:
|
||||||
return vmath.vector3(x, y, 0)
|
-- required when used (by a name bob doesn't follow).
|
||||||
|
local BAR = "components.bar"
|
||||||
|
local READY = "screens.game.core.ready"
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
-- A batch that finishes (core/ready: on our clock, before the refetch says so) turns its
|
||||||
|
-- slot ready at once: a light runs along its bar (SWEEP s), the inset glow flares and
|
||||||
|
-- settles (FLARE s), the Ready label and Collect pop in (POP s, fx-ready-pop); fx.lua's
|
||||||
|
-- batch_ready lights the edge and plays the sound. All go by the clock from the batch's
|
||||||
|
-- end (ready.batch_age), so the refetch's rebuild carries on where they were. Reduced
|
||||||
|
-- motion: no pop and no sweep.
|
||||||
|
local POP, SWEEP, FLARE = 0.6, 0.7, 1.4
|
||||||
|
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.12 }, { 1, 1 } }
|
||||||
|
|
||||||
|
-- Pops node in about the point (dx, dy) from its position, age seconds in.
|
||||||
|
local function pop(node, dx, dy, age)
|
||||||
|
if anim.reduced or not age or age >= POP then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local p, base = gui.get_position(node), gui.get_scale(node)
|
||||||
|
anim.tween(node, POP, function(k, n)
|
||||||
|
anim.scale_about(n, p, base, dx, dy, anim.keys(POP_SCALE, k, anim.fn.ready_pop))
|
||||||
|
end, -age)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
|
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
|
||||||
@@ -54,111 +87,386 @@ local function item_color(st, name)
|
|||||||
end
|
end
|
||||||
M.item_color = item_color
|
M.item_color = item_color
|
||||||
|
|
||||||
-- The web's faceted crystal: a diamond in the item's color.
|
local function catalog_desc(st, item)
|
||||||
local function glyph(parent, x, y, size, color)
|
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
|
||||||
local g = ui.rect(parent, 0, 0, size, size, color)
|
if r.name == item then
|
||||||
gui.set_pivot(g, gui.PIVOT_CENTER)
|
return r.description or ""
|
||||||
gui.set_position(g, vec(x, -y))
|
end
|
||||||
gui.set_rotation(g, vmath.quat_rotation_z(math.rad(45)))
|
end
|
||||||
local o = ui.outline(g, 0, 0, size, size, "text", 0.35)
|
return ""
|
||||||
gui.set_pivot(o, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(o, vec(0, 0))
|
|
||||||
return g
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Content width (capped) and its left edge, centered in the panel.
|
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
|
||||||
local function frame(w, px, cap)
|
|
||||||
local avail = w - px * 2
|
-- Styles (app.css).
|
||||||
local inner = math.min(avail, cap)
|
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
|
||||||
return inner, px + math.floor((avail - inner) / 2)
|
local BODY14 = { font = "sans", px = 14, color = "text" }
|
||||||
|
local MONO15 = { font = "mono", px = 15, color = "text" }
|
||||||
|
local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true }
|
||||||
|
local BTN_SM = 34 -- .btn-sm height
|
||||||
|
|
||||||
|
-- Tree pieces. Children lists may have holes (`cond and x or nil`).
|
||||||
|
local function box(style, kids, extra)
|
||||||
|
local list = {}
|
||||||
|
for i = 1, table.maxn(kids or {}) do
|
||||||
|
list[#list + 1] = kids[i]
|
||||||
|
end
|
||||||
|
local b = { style = style, children = list }
|
||||||
|
for k, v in pairs(extra or {}) do
|
||||||
|
b[k] = v
|
||||||
|
end
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
local function row(style, kids) style.direction = "row"; return box(style, kids) end
|
||||||
|
local function col(style, kids) style.direction = "column"; return box(style, kids) end
|
||||||
|
local function space(w, h) return { style = { width = w or 0, height = h or 0, shrink = 0 } } end
|
||||||
|
local function fill() return { style = { grow = 1 } } end
|
||||||
|
|
||||||
|
-- An 8 px color square.
|
||||||
|
local function swatch(color)
|
||||||
|
return { style = { width = 8, height = 8, shrink = 0 }, make = function(p, x, y)
|
||||||
|
ui.rect(p, x, y, 8, 8, color)
|
||||||
|
end }
|
||||||
end
|
end
|
||||||
|
|
||||||
local function slot_card(self, root, H, st, slot, x, y, w)
|
-- An invisible node holding its children (for a tooltip over all of them).
|
||||||
local job = slot.job
|
local function group(p, x, y, w, h)
|
||||||
local h = S.slot_h
|
return ui.rect(p, x, y, w, h, "none")
|
||||||
local card = ui.rect(root, x, y, w, h, "panel")
|
end
|
||||||
local edge = job and (job.ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
|
|
||||||
ui.outline(card, 0, 0, w, h, edge)
|
-- The build's scratch parent, for measuring labels of what is built at a size.
|
||||||
if job and job.ready then
|
local scratch
|
||||||
gui.set_color(card, ui.color("ready", 0.06))
|
local function text_w(str, style)
|
||||||
|
local t = ui.text(scratch, str, style, 0, 0)
|
||||||
|
local w = ui.text_width(t)
|
||||||
|
gui.delete_node(t)
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A button box (h tall; its label's width + pad unless style says), made at its rect
|
||||||
|
-- with ui.button opts, then bind(b, nodes) (H.action, tooltips). .btn:disabled: dimmed.
|
||||||
|
local function btn(label, h, opts, bind, style)
|
||||||
|
style = style or {}
|
||||||
|
style.height, style.shrink = h, 0
|
||||||
|
if not style.grow then
|
||||||
|
style.width = style.width or math.floor(text_w(label, BTN) + (opts.pad or 30))
|
||||||
|
end
|
||||||
|
return { style = style, make = function(p, x, y, w)
|
||||||
|
opts.w, opts.h = w, h
|
||||||
|
if opts.disabled then
|
||||||
|
opts.edge = { "line_hi", 0.45 }
|
||||||
|
if opts.primary then
|
||||||
|
opts.text = { "on_cyan", 0.45 }
|
||||||
|
else
|
||||||
|
opts.fill, opts.text = { "cyan", 0.045 }, { "text", 0.45 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local b, nodes = ui.button(p, label, x, y, opts)
|
||||||
|
bind(b, nodes)
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A section label (.section-label: "Slots 2/4").
|
||||||
|
local function section_label(c, str, num)
|
||||||
|
return col({ padding = { 20, 0, 10, 0 } }, {
|
||||||
|
row({}, {
|
||||||
|
fg.label(c, num and (str .. " ") or str, "cap", 16.5),
|
||||||
|
num and fg.label(c, num, CAP_NUM, 16.5) or nil,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The Slots label and, on its right, the batch size and its upgrade (the whole refinery:
|
||||||
|
-- every slot makes batches up to rf.maxBatch; upgrade-batch raises it to rf.nextBatch).
|
||||||
|
-- One button-high row whether or not the upgrade shows; the button keeps about one width
|
||||||
|
-- from level to level, so repeated clicks stay on it (clicker rule).
|
||||||
|
local function slots_label(self, H, c, rf)
|
||||||
|
local kids = {
|
||||||
|
row({ grow = 1, align = "center" }, {
|
||||||
|
fg.label(c, "Slots ", "cap", 16.5), fg.label(c, rf.slotCount .. "/" .. rf.maxSlots, CAP_NUM, 16.5),
|
||||||
|
}),
|
||||||
|
fg.label(c, "Batch size ", "cap", 16.5), fg.label(c, ui.money(rf.maxBatch), CAP_NUM, 16.5),
|
||||||
|
space(12),
|
||||||
|
}
|
||||||
|
if (rf.nextBatch or 0) > 0 then
|
||||||
|
local ok = rf.canUpgradeBatch
|
||||||
|
local label = "Upgrade to " .. ui.money(rf.nextBatch)
|
||||||
|
local opts = { small = true, price = rf.nextBatchPrice, disabled = not ok }
|
||||||
|
kids[#kids + 1] = fg.button(c, label, opts, function(b, nodes)
|
||||||
|
if not ok then
|
||||||
|
tooltip.add(b, rf.upgradeBatchWhyText or "")
|
||||||
|
else
|
||||||
|
cards.pulse(nodes)
|
||||||
|
H.action(self, b, nodes, "upgrade-batch", {})
|
||||||
|
tooltip.add(b, "Every slot makes batches of up to " .. ui.money(rf.nextBatch))
|
||||||
|
end
|
||||||
|
end, { width = math.max(240, fg.button_w(c, label, opts)) })
|
||||||
|
else
|
||||||
|
kids[#kids + 1] = fg.label(c, "Biggest batches", "small", 20)
|
||||||
|
end
|
||||||
|
return col({ padding = { 20, 0, 10, 0 } }, { row({ align = "center", height = S.button_sm_h }, kids) })
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- slot cards
|
||||||
|
|
||||||
|
-- The web's .bar: 8 px of 6 px cells (2 px apart) in a faint cyan, the fill masked into
|
||||||
|
-- the same cells. The fill follows a hidden probe the timer ticks (H.tick_bar); M.update
|
||||||
|
-- lights the cells from its width.
|
||||||
|
-- ready: finished (in the state or on our clock); age: seconds since it finished, when
|
||||||
|
-- that was just now (the sweep).
|
||||||
|
local function cell_bar(self, H, color, job, ready, age)
|
||||||
|
return { style = { height = 8 }, make = function(p, x, y, w)
|
||||||
|
local cells = {}
|
||||||
|
for cx = 0, w - 1, 8 do
|
||||||
|
local cw = math.min(6, w - cx)
|
||||||
|
ui.rect(p, x + cx, y, cw, 8, "cyan", 0.14)
|
||||||
|
cells[#cells + 1] = { node = ui.rect(p, x + cx, y, cw, 8, color), x = cx, w = cw }
|
||||||
|
end
|
||||||
|
local bar = { cells = cells, full = ready, w = w, color = ui.color(color) }
|
||||||
|
if not ready then
|
||||||
|
bar.probe = ui.rect(p, x, y, w, 8, "none")
|
||||||
|
H.tick_bar(self, bar.probe, job.start, job["end"], w)
|
||||||
|
elseif age and age < SWEEP and not anim.reduced then
|
||||||
|
bar.sweep = socket.gettime() - age
|
||||||
|
end
|
||||||
|
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
|
||||||
|
self.rf_slots[job.id].bar = ui.rect(p, x, y, w, 8, "none") -- (fx: where its sparks leave)
|
||||||
|
self.rf_bars[#self.rf_bars + 1] = bar
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The sweep: a bright head runs left to right over the full cells, lighting each to the
|
||||||
|
-- flash color as it passes, then the cells settle back to the item's color.
|
||||||
|
local function sweep_cells(b)
|
||||||
|
local k = (socket.gettime() - b.sweep) / SWEEP
|
||||||
|
if k >= 1 then
|
||||||
|
b.sweep = nil
|
||||||
|
for _, c in ipairs(b.cells) do
|
||||||
|
gui.set_color(c.node, b.color)
|
||||||
end
|
end
|
||||||
ui.text(card, "Slot " .. slot.n, "cap", 12, 16)
|
|
||||||
if not job then
|
|
||||||
ui.text(card, w >= 180 and "Idle. Pick a recipe below." or "Idle", "small", 12, 40)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if job.ready then
|
local head = 1.3 * (1 - (1 - k) ^ 2) - 0.15
|
||||||
ui.text(card, "Ready", { font = "sans_semi", px = 12, color = "ready", tracking = 0.12, upper = true }, w - 12, 16, gui.PIVOT_E)
|
local flash = theme.color.flash
|
||||||
|
for _, c in ipairs(b.cells) do
|
||||||
|
local d = (c.x + c.w / 2) / b.w
|
||||||
|
local lit = math.max(0, 1 - math.abs(d - head) * 7) + (d < head and 0.35 * (1 - k) or 0)
|
||||||
|
gui.set_color(c.node, vmath.lerp(math.min(1, lit), b.color, vmath.vector4(flash.x, flash.y, flash.z, b.color.w)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function light_cells(b)
|
||||||
|
local fw = b.full and math.huge or gui.get_size(b.probe).x
|
||||||
|
for _, c in ipairs(b.cells) do
|
||||||
|
local lit = math.max(0, math.min(c.w, fw - c.x))
|
||||||
|
gui.set_enabled(c.node, lit > 0)
|
||||||
|
if lit > 0 then
|
||||||
|
gui.set_size(c.node, vmath.vector3(lit, 8, 0))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.update(self)
|
||||||
|
for _, b in ipairs(self.rf_bars or {}) do
|
||||||
|
if not b.full then
|
||||||
|
light_cells(b)
|
||||||
|
elseif b.sweep then
|
||||||
|
sweep_cells(b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.teardown(self)
|
||||||
|
self.rf_bars = nil -- (rf_now stays: the next build glides the batches from it)
|
||||||
|
self.rf_slots = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off).
|
||||||
|
local function dashed(p, w, h, color, alpha)
|
||||||
|
for d = 0, w - 1, 5 do
|
||||||
|
ui.rect(p, d, 0, math.min(3, w - d), 1, color, alpha)
|
||||||
|
ui.rect(p, d, h - 1, math.min(3, w - d), 1, color, alpha)
|
||||||
|
end
|
||||||
|
for d = 0, h - 1, 5 do
|
||||||
|
ui.rect(p, 0, d, 1, math.min(3, h - d), color, alpha)
|
||||||
|
ui.rect(p, w - 1, d, 1, math.min(3, h - d), color, alpha)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- An inset glow (box-shadow: inset 0 0 18px): the fade texture along each edge, inwards.
|
||||||
|
local function inset_glow(p, w, h, color, alpha)
|
||||||
|
local d = 14
|
||||||
|
local nodes = {}
|
||||||
|
for _, e in ipairs({ { w / 2, d / 2, w, 0 }, { w / 2, h - d / 2, w, 180 }, { d / 2, h / 2, h, 90 }, { w - d / 2, h / 2, h, -90 } }) do
|
||||||
|
local n = ui.rect(p, 0, 0, e[3], d, color, alpha)
|
||||||
|
gui.play_flipbook(n, "fade")
|
||||||
|
gui.set_pivot(n, gui.PIVOT_CENTER)
|
||||||
|
gui.set_position(n, vmath.vector3(e[1], -e[2], 0))
|
||||||
|
gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4])))
|
||||||
|
gui.set_blend_mode(n, theme.glow_blend())
|
||||||
|
nodes[#nodes + 1] = n
|
||||||
|
end
|
||||||
|
return nodes
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A panel frame (fill and 1 px edge), registered for the entrance and the action sweep.
|
||||||
|
local function frame(self, p, x, y, w, h, edge_color)
|
||||||
|
local f = ui.rect(p, x, y, w, h, "panel")
|
||||||
|
local edge = ui.outline(f, 0, 0, w, h, edge_color)
|
||||||
|
self.frames[#self.frames + 1] = panel.frame(f, edge, w, h)
|
||||||
|
self.fx:box(f)
|
||||||
|
return f, edge
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A slot (.refinery-slot, at least 112 tall, padding 12 + border): idle, running or ready.
|
||||||
|
-- Batches are shown oldest first from the left, idle slots after them ("Slot n" is the
|
||||||
|
-- position), so a new order lands right of the others and collecting the leftmost slides
|
||||||
|
-- the rest left: repeated clicks on one spot collect one batch after another. A batch's
|
||||||
|
-- card glides from where it was (GLIDE): the layout's slot node (outer) sits at the new
|
||||||
|
-- rect at once, the frame inside it moves; the Collect button's clicks go to a box in the
|
||||||
|
-- slot node at the button's final rect, so a click there counts during the glide.
|
||||||
|
local GLIDE = 0.18
|
||||||
|
-- Every slot is as tall as a ready one (its Collect button under the bar), so a batch
|
||||||
|
-- finishing doesn't push the recipes down under the pointer (clicker rule).
|
||||||
|
local SLOT_H = 13 * 2 + 22.5 + 8 + 22 + 8.5 + 8 + 8 + BTN_SM
|
||||||
|
local function slot_card(self, H, c, st, slot, n)
|
||||||
|
local job = slot.job
|
||||||
|
local age = job and require(READY).batch_age(self, job)
|
||||||
|
local ready = job and require(READY).batch_done(self, job) -- (on our clock: display only)
|
||||||
|
local edge = job and (ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
|
||||||
|
local outer -- (the card's slot node, made first: the Collect button's hit box goes in it)
|
||||||
|
local kids
|
||||||
|
if not job then
|
||||||
|
kids = { fg.label(c, "Slot " .. n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
|
||||||
|
else
|
||||||
|
local head = fg.label(c, "Slot " .. n, "cap", 22)
|
||||||
|
local right
|
||||||
|
if ready then
|
||||||
|
right = { style = { height = 22 }, make = function(p, x, y, w, h)
|
||||||
|
local t = require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
|
||||||
|
pop(t, -ui.text_width(t) / 2, 0, age)
|
||||||
|
end }
|
||||||
else
|
else
|
||||||
local left = function(ms) return ui.dur(ms) .. " left" end
|
local left = function(ms) return ui.dur(ms) .. " left" end
|
||||||
local t = ui.text(card, left(job["end"] - H.now(self)), { font = "mono", px = 12, color = "muted" }, w - 12, 16, gui.PIVOT_E)
|
right = fg.label(c, left(job["end"] - H.now(self)), { font = "mono", px = 15, color = "muted" }, 22)
|
||||||
H.tick_text(self, t, job["end"], left, "…")
|
gui.set_pivot(right.node, gui.PIVOT_E) -- (it ticks: pinned by its right edge)
|
||||||
|
H.tick_text(self, right.node, job["end"], left, "…")
|
||||||
end
|
end
|
||||||
local color = item_color(st, job.item)
|
local color = item_color(st, job.item)
|
||||||
ui.rect(card, 12, 32, 8, 8, color)
|
kids = {
|
||||||
local q = ui.text(card, job.qty .. "×", { font = "mono", px = 13, color = "text" }, 26, 36)
|
row({ height = 22.5, justify = "space-between", align = "center" }, { head, right }),
|
||||||
local name = ui.text(card, job.item, { font = "sans", px = 15, color = "text" }, 26 + ui.text_width(q) + 5, 36)
|
space(0, 8),
|
||||||
ui.fit_text(name, w - 12 - (26 + ui.text_width(q) + 5))
|
row({ height = 22, align = "center", gap = 12 }, {
|
||||||
local bw = w - 24
|
swatch(color),
|
||||||
if job.ready then
|
row({ align = "center", gap = 4 }, {
|
||||||
-- Done: the full bar, and Collect under it (the card is lit).
|
fg.label(c, job.qty .. "×", { font = "mono_semi", px = 15, color = "text" }, 22, { shrink = 0 }),
|
||||||
ui.rect(card, 12, 50, bw, 4, color)
|
fg.label(c, job.item, { font = "sans", px = 15, color = "text" }, 22, { fit = true }),
|
||||||
local b, nodes = ui.button(card, "Collect " .. job.qty, 12, h - 2 - S.button_sm_h, { primary = true, small = true, w = bw })
|
}),
|
||||||
H.action(self, b, nodes, "collect", { job = job.id })
|
}),
|
||||||
else
|
space(0, 8.5),
|
||||||
ui.rect(card, 12, 56, bw, 4, "line")
|
cell_bar(self, H, color, job, ready, age),
|
||||||
local fill = ui.rect(card, 12, 56, bw, 4, color)
|
{ style = { grow = 1, min_height = ready and 8 or 0 } },
|
||||||
H.tick_bar(self, fill, job.start, job["end"], bw)
|
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes)
|
||||||
|
local p, size = gui.get_position(b), gui.get_size(b)
|
||||||
|
local hit = ui.rect(outer, p.x, -p.y, size.x, size.y, "none")
|
||||||
|
H.action(self, b, nodes, "collect", { job = job.id }, nil, nil, nil, hit)
|
||||||
|
pop(b, size.x / 2, -size.y / 2, age)
|
||||||
|
end, { width = "100%" }) or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return col({ padding = 13, min_height = SLOT_H }, kids), function(p, x, y, w, h)
|
||||||
|
outer = ui.rect(p, x, y, w, h, "none")
|
||||||
|
local f = frame(self, outer, 0, 0, w, h, edge)
|
||||||
|
if ready then
|
||||||
|
local glow = inset_glow(f, w, h, "ready", 0.08)
|
||||||
|
if age and age < FLARE then
|
||||||
|
-- Just finished: the glow flares and settles to its rest.
|
||||||
|
anim.tween(glow[1], FLARE, function(k)
|
||||||
|
local a = 0.08 + 0.32 * (1 - k) ^ 2
|
||||||
|
for _, n in ipairs(glow) do
|
||||||
|
anim.set(n, "color.w", a)
|
||||||
|
end
|
||||||
|
end, -age)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if job then
|
||||||
|
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
|
||||||
|
self.rf_slots[job.id].frame = f
|
||||||
|
-- Where the card is drawn now (its last rect, less what its glide has done).
|
||||||
|
local was = self.rf_was and self.rf_was[job.id]
|
||||||
|
local now = socket.gettime()
|
||||||
|
local fx, fy = 0, 0
|
||||||
|
if was and not anim.reduced then
|
||||||
|
local u = math.min(1, (now - was.t) / GLIDE)
|
||||||
|
local k = 1 - (1 - u) * (1 - u) -- (EASING_OUTQUAD)
|
||||||
|
fx = was.x + was.fx * (1 - k) - x
|
||||||
|
fy = was.y + was.fy * (1 - k) - y
|
||||||
|
if math.abs(fx) < 0.5 and math.abs(fy) < 0.5 then
|
||||||
|
fx, fy = 0, 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.rf_now[job.id] = { x = x, y = y, fx = fx, fy = fy, t = now }
|
||||||
|
if fx ~= 0 or fy ~= 0 then
|
||||||
|
gui.set_position(f, vmath.vector3(fx, -fy, 0))
|
||||||
|
gui.animate(f, "position", vmath.vector3(0, 0, 0), gui.EASING_OUTQUAD, GLIDE)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return f
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Price centered at (cx, y): the ₥ sign and the number.
|
-- "+ Slot n · ₥price" (.btn-dashed): a dashed cyan edge, the label centered in text_2.
|
||||||
local function credits_center(parent, amount, cx, y, opts)
|
local function buy_slot(self, H, c, rf)
|
||||||
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
|
||||||
local w = ui.credits(probe, amount, 0, 0, opts)
|
|
||||||
gui.delete_node(probe)
|
|
||||||
ui.credits(parent, amount, cx - w / 2, y, opts)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function buy_slot_card(self, root, H, rf, x, y, w)
|
|
||||||
local h = S.slot_h
|
|
||||||
local off = not rf.canBuySlot
|
local off = not rf.canBuySlot
|
||||||
local b, nodes = ui.button(root, "", x, y, { w = w, h = h, disabled = off,
|
local k = off and 0.45 or 1 -- (.btn:disabled opacity)
|
||||||
fill = { "cyan", off and 0.02 or 0.05 }, edge = { "cyan", off and 0.25 or 0.5 } })
|
local label = {
|
||||||
ui.text(b, "+ Slot " .. rf.nextSlot, { font = "sans_semi", px = 15, color = off and "muted" or "cyan", tracking = 0.12, upper = true }, w / 2, h / 2 - 10, gui.PIVOT_CENTER)
|
fg.label(c, "+ Slot " .. rf.nextSlot .. " · ", { font = "sans_semi", px = 15, color = ui.color("text_2", k), tracking = 0.12, upper = true }, 22),
|
||||||
credits_center(b, rf.nextSlotPrice, w / 2, h / 2 + 12, { px = 13, color = off and "muted" or "cyan" })
|
fg.label(c, theme.currency, { font = "sym", px = 14, color = ui.color("cyan", k) }, 22),
|
||||||
|
fg.label(c, ui.money(rf.nextSlotPrice), { font = "mono_semi", px = 15, color = ui.color("cyan", k) }, 22),
|
||||||
|
}
|
||||||
|
return box({ justify = "center", align = "center" }, { box({}, label, { make = anim.group }) }, {
|
||||||
|
make = function(p, x, y, w, h)
|
||||||
|
local b, nodes = ui.button(p, "", x, y, { w = w, h = h, disabled = off, fill = { "cyan", 0 }, edge = { "cyan", 0 } })
|
||||||
|
dashed(b, w, h, off and "line_hi" or "cyan", k)
|
||||||
|
local look = ui.button_looks[b]
|
||||||
|
for _, l in ipairs(look and label or {}) do
|
||||||
|
look.parts[#look.parts + 1] = { l.node, 0 } -- (dimmed while busy)
|
||||||
|
end
|
||||||
if off then
|
if off then
|
||||||
-- Why: the tooltip (the web's title).
|
|
||||||
tooltip.add(b, (rf.buySlotWhyText or "") ~= "" and rf.buySlotWhyText or ("Short " .. ui.money(rf.nextSlotPrice - self.state.credits)))
|
tooltip.add(b, (rf.buySlotWhyText or "") ~= "" and rf.buySlotWhyText or ("Short " .. ui.money(rf.nextSlotPrice - self.state.credits)))
|
||||||
elseif rf.buySlot then
|
elseif rf.buySlot then
|
||||||
-- Asks first, with the server's question (the web's hx-confirm).
|
cards.confirm_server_button(self, b, rf.buySlot, "buy-slot", {}) -- (asks first, the web's hx-confirm)
|
||||||
cards.confirm_server_button(self, b, rf.buySlot, "buy-slot", {})
|
|
||||||
else
|
else
|
||||||
H.action(self, b, nodes, "buy-slot", {})
|
H.action(self, b, nodes, "buy-slot", {})
|
||||||
end
|
end
|
||||||
|
return b
|
||||||
|
end,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Quantity field for a recipe (Druid input, digits only, at most the batch cap
|
---------------------------------------------------------------- recipe cards
|
||||||
-- rf.maxBatch, like the web's max); the text is sent as typed. Returns the input.
|
|
||||||
local function qty_field(self, row, r, x, y, w, h, off, max)
|
-- The quantity field (.input-sm: Druid input, digits only, at most the batch cap
|
||||||
|
-- rf.maxBatch); the text is sent as typed. Disabled: dimmed, no input. Returns its box and
|
||||||
|
-- a function giving the Druid input once made.
|
||||||
|
local function qty_field(self, r, off, max)
|
||||||
|
local made
|
||||||
|
return { style = { width = 64, height = BTN_SM, shrink = 0 }, make = function(p, x, y, w, h)
|
||||||
self.refine_qty = self.refine_qty or {}
|
self.refine_qty = self.refine_qty or {}
|
||||||
local value = self.refine_qty[r.item] or "1"
|
local value = self.refine_qty[r.item] or "1"
|
||||||
local field = ui.rect(row, x, y, w, h, "field")
|
local k = off and 0.45 or 1
|
||||||
local edge = ui.outline(field, 0, 0, w, h, off and "line" or "line_hi")
|
local field = ui.rect(p, x, y, w, h, "field", k)
|
||||||
local txt = ui.text(field, off and "–" or value, { font = "mono", px = 14, color = off and "faint" or "text" }, w / 2, h / 2, gui.PIVOT_CENTER)
|
local edge = ui.outline(field, 0, 0, w, h, "line_hi", k)
|
||||||
|
local txt = ui.text(field, value, { font = "mono", px = 15, color = ui.color("text", k) }, 13, h / 2)
|
||||||
if off then
|
if off then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
gui.set_size(txt, vec((w - 12) / gui.get_scale(txt).x, theme.font_px * 1.4))
|
gui.set_size(txt, vmath.vector3((w - 16) / gui.get_scale(txt).x, theme.font_px * 1.4, 0))
|
||||||
local input = self.druid:new_input(field, txt, gui.KEYBOARD_TYPE_NUMBER_PAD)
|
local input = self.druid:new_input(field, txt, gui.KEYBOARD_TYPE_NUMBER_PAD)
|
||||||
input:set_allowed_characters("[%d]")
|
input:set_allowed_characters("[%d]")
|
||||||
input:set_max_length(max and #tostring(max) or 3)
|
input:set_max_length(max and #tostring(max) or 3)
|
||||||
input.style.on_select = function()
|
input.style.on_select = function() gui.set_color(edge, ui.color("cyan")) end
|
||||||
gui.set_color(edge, ui.color("cyan"))
|
input.style.on_unselect = function() gui.set_color(edge, ui.color("line_hi")) end
|
||||||
end
|
|
||||||
input.style.on_unselect = function()
|
|
||||||
gui.set_color(edge, ui.color("line_hi"))
|
|
||||||
end
|
|
||||||
input:set_text(value)
|
input:set_text(value)
|
||||||
input.on_input_text:subscribe(function(_, text)
|
input.on_input_text:subscribe(function(_, text)
|
||||||
if max and (tonumber(text) or 0) > max then
|
if max and (tonumber(text) or 0) > max then
|
||||||
@@ -168,175 +476,224 @@ local function qty_field(self, row, r, x, y, w, h, off, max)
|
|||||||
self.refine_qty[r.item] = text
|
self.refine_qty[r.item] = text
|
||||||
end)
|
end)
|
||||||
test.register("field:qty:" .. r.item, field, "field", r.item)
|
test.register("field:qty:" .. r.item, field, "field", r.item)
|
||||||
return input
|
made = input
|
||||||
|
end }, function() return made end
|
||||||
end
|
end
|
||||||
|
|
||||||
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
|
-- A stat (.stat: a cap label over a 15 px value).
|
||||||
|
local function stat(c, label, value)
|
||||||
-- A recipe's description (catalog.refined).
|
return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
|
||||||
local function description(st, item)
|
|
||||||
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
|
|
||||||
if r.name == item then
|
|
||||||
return r.description or ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One recipe; returns its height (the description wraps).
|
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
|
||||||
local function recipe_row(self, root, H, st, rf, r, x, y, w)
|
-- a collect's motes land there and the number counts up. Laid out at the final amount.
|
||||||
local row = ui.rect(root, x, y, w, 10, "none")
|
local counted = {}
|
||||||
|
local function own_label(c, r, color)
|
||||||
|
local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
|
||||||
|
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
|
||||||
|
return l
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One recipe card (the web's .card.recipe).
|
||||||
|
local function recipe_card(self, H, c, st, rf, r)
|
||||||
local color = item_color(st, r.item)
|
local color = item_color(st, r.item)
|
||||||
local off = r.why ~= ""
|
local off = r.why ~= ""
|
||||||
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
|
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
|
||||||
|
local owned = (r.owned or 0) > 0
|
||||||
|
|
||||||
-- Line 1: glyph, name, tier and what you own (the web's "Tier N · owned X"); controls
|
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
|
||||||
-- on the right (quantity, Refine, Max). Why it can't run is the buttons' tooltip.
|
-- the name, and on the right what you own, big, in the item's color (a collect's motes
|
||||||
glyph(row, 9, 17, 11, color)
|
-- fly into it and it counts up), shown at 0 too.
|
||||||
local name = ui.text(row, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.08, upper = true }, 26, 17)
|
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
|
||||||
local tx = 26 + ui.text_width(name) + 7
|
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
|
||||||
local tier = ui.text(row, "Tier " .. r.tier, { font = "mono", px = 11, color = "cyan" }, tx, 17)
|
gui.play_flipbook(g, "pentagon")
|
||||||
tx = tx + ui.text_width(tier) + 4
|
gui.play_flipbook(ui.rect(g, 0, 0, 36, 36, "white", 0.25), "pentagon_rim")
|
||||||
local own
|
end }
|
||||||
if (r.owned or 0) > 0 then
|
local head = row({ height = 43.3, gap = 12, align = "start" }, {
|
||||||
own = ui.text(row, "· owned " .. ui.money(r.owned), { font = "sans", px = 13, color = "muted" }, tx, 17)
|
col({ padding_top = 3.6 }, { glyph }),
|
||||||
|
col({ grow = 1, shrink = 1, align = "start" }, {
|
||||||
|
fg.label(c, "Tier " .. r.tier, "cap", 24),
|
||||||
|
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
|
||||||
|
}),
|
||||||
|
col({ align = "end", shrink = 0 }, {
|
||||||
|
fg.label(c, "Owned", "cap", 14),
|
||||||
|
own_label(c, r, owned and color or "muted"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Inputs per unit: need and name, what you hold on the right (red when short).
|
||||||
|
local inputs = {}
|
||||||
|
for _, input in ipairs(r.inputs) do
|
||||||
|
local have = fg.label(c, ui.money(input.have), { font = "mono", px = 14, color = input.have < input.need and "danger" or "text" }, 21, { shrink = 0 })
|
||||||
|
tooltip.add(have.node, "You have " .. ui.money(input.have))
|
||||||
|
inputs[#inputs + 1] = row({ height = 21, align = "center", gap = 8 }, {
|
||||||
|
swatch(item_color(st, input.name)),
|
||||||
|
fg.label(c, ui.money(input.need) .. " " .. input.name, BODY14, 21, { grow = 1, fit = true }),
|
||||||
|
have,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local bh = S.button_sm_h
|
-- Stats: three equal columns.
|
||||||
local by = 2
|
local gain = fg.label(c, string.format("+%.0f%%", r.gain), { font = "mono", px = 15, color = "cyan" }, 22.5)
|
||||||
local mx, mn, mw = ui.button(row, off and "Max" or ("Max " .. r.maxQty), 0, 0, { small = true, disabled = off })
|
if r.inputValue then
|
||||||
gui.set_position(mx, vec(w - mw, -by))
|
tooltip.add(gain.node, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
|
||||||
local rb, rn, rw = ui.button(row, "Refine", 0, 0, { small = true, primary = true, disabled = off })
|
|
||||||
gui.set_position(rb, vec(w - mw - 6 - rw, -by))
|
|
||||||
local fw = 48
|
|
||||||
local input = qty_field(self, row, r, w - mw - 6 - rw - 6 - fw, by, fw, bh, off, rf.maxBatch)
|
|
||||||
if own then
|
|
||||||
ui.fit_text(own, w - mw - 6 - rw - 6 - fw - 10 - tx) -- (left of the quantity, Refine and Max)
|
|
||||||
end
|
end
|
||||||
if not off then
|
local stats = row({ gap = 8 }, {
|
||||||
local refine = H.action(self, rb, rn, "refine", function()
|
stat(c, "Time each", fg.label(c, ui.cycle(r.seconds), MONO15, 22.5)),
|
||||||
|
stat(c, "Sells", row({ gap = 2 }, {
|
||||||
|
fg.label(c, theme.currency, { font = "sym", px = 14, color = "text" }, 22.5),
|
||||||
|
fg.label(c, ui.money(r.price or r.sellPrice), MONO15, 22.5),
|
||||||
|
})),
|
||||||
|
stat(c, "Gain", gain),
|
||||||
|
})
|
||||||
|
|
||||||
|
-- "Batch of 25: 7s · bulk bonus −72%" (.text-xs, numbers in mono), one tooltip.
|
||||||
|
local xs = { font = "sans", px = 11.25, color = "muted" }
|
||||||
|
local xn = { font = "mono", px = 11.25, color = "muted" }
|
||||||
|
local batch = box({ height = 16 }, {
|
||||||
|
fg.label(c, "Batch of ", xs, 16), fg.label(c, tostring(r.bulkQty), xn, 16), fg.label(c, ": ", xs, 16),
|
||||||
|
fg.label(c, ui.cycle(r.bulkSecs), xn, 16), fg.label(c, " · bulk bonus ", xs, 16),
|
||||||
|
fg.label(c, string.format("−%.0f%%", r.bulkBonus), { font = "mono", px = 11.25, color = "cyan" }, 16),
|
||||||
|
}, { make = function(p, x, y, w, h)
|
||||||
|
local n = group(p, x, y, w, h)
|
||||||
|
tooltip.add(n, "Bigger batches take less time per unit")
|
||||||
|
return n
|
||||||
|
end })
|
||||||
|
|
||||||
|
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
|
||||||
|
-- usual click) the card's width under them, primary.
|
||||||
|
local qty, input = qty_field(self, r, off, rf.maxBatch)
|
||||||
|
qty.style.width, qty.style.grow = nil, 1
|
||||||
|
local refine
|
||||||
|
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
|
||||||
|
if off then
|
||||||
|
return tooltip.add(b, why)
|
||||||
|
end
|
||||||
|
refine = H.action(self, b, nodes, "refine", function()
|
||||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||||
end)
|
end, nil, nil, "act:refine?recipe=" .. r.item)
|
||||||
H.action(self, mx, mn, "refine", { recipe = r.item, all = "1" })
|
tooltip.add(b, "Refine the number in the box")
|
||||||
tooltip.add(mx, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
|
||||||
-- Enter in the quantity refines (the web's form).
|
-- Enter in the quantity refines (the web's form).
|
||||||
self.inputs = self.inputs or {}
|
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||||
self.inputs[#self.inputs + 1] = { input = input, submit = refine, step = { min = 1, max = rf.maxBatch } }
|
end)
|
||||||
else
|
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
|
||||||
tooltip.add(rb, why)
|
if off then
|
||||||
tooltip.add(mx, why)
|
return tooltip.add(b, why)
|
||||||
end
|
end
|
||||||
local cy = 32
|
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
|
||||||
|
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
||||||
|
end, { grow = 1 })
|
||||||
|
local form = col({ shrink = 0 }, {
|
||||||
|
row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
|
||||||
|
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
|
||||||
|
})
|
||||||
|
|
||||||
-- The description (wraps).
|
local desc = catalog_desc(st, r.item)
|
||||||
local desc = description(st, r.item)
|
return col({ padding = 17 }, {
|
||||||
if desc ~= "" then
|
head,
|
||||||
cy = cy + H.para(row, desc, { font = "sans", px = 13, color = "muted" }, 26, cy, w - 26) + 6
|
desc ~= "" and space(0, 8) or nil,
|
||||||
|
desc ~= "" and fg.para(c, desc, { font = "sans", px = 14, color = "text_2" }, 19.6) or nil,
|
||||||
|
space(0, 8),
|
||||||
|
col({ gap = 4 }, inputs),
|
||||||
|
space(0, 8),
|
||||||
|
fill(), -- (the footer at the bottom: buttons line up across a row)
|
||||||
|
col({ height = 162 }, { -- (rule, stats, batch line, the form's two rows)
|
||||||
|
{ style = { height = 1 }, make = function(p, x, y, w) ui.rect(p, x, y, w, 1, "line") end },
|
||||||
|
space(0, 12), stats, space(0, 8), batch, fill(), space(0, 8), form,
|
||||||
|
}),
|
||||||
|
}), function(p, x, y, w, h)
|
||||||
|
-- The card (.card): edge brightens on hover (0.15 s); bound before the footer's
|
||||||
|
-- buttons (Druid gives the last made input first).
|
||||||
|
local f, edge = frame(self, p, x, y, w, h, "edge")
|
||||||
|
local base = gui.get_color(edge)
|
||||||
|
button.bind(self, f, nil, {}).hover.on_mouse_hover:subscribe(function(_, on)
|
||||||
|
gui.cancel_animations(edge, "color")
|
||||||
|
gui.animate(edge, "color", on and ui.color("line_hi") or base, gui.EASING_OUTQUAD, 0.15)
|
||||||
|
end)
|
||||||
|
return f
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Inputs per unit (need, then what you hold; red when short).
|
---------------------------------------------------------------- the page
|
||||||
local ix = 26
|
|
||||||
for _, input in ipairs(r.inputs) do
|
|
||||||
ui.rect(row, ix, cy + 4, 7, 7, item_color(st, input.name))
|
|
||||||
local t = ui.text(row, ui.money(input.need) .. " " .. input.name, { font = "sans", px = 14, color = "text_2" }, ix + 12, cy + 8)
|
|
||||||
local have = ui.text(row, ui.money(input.have), { font = "mono", px = 12, color = input.have < input.need and "danger" or "muted" },
|
|
||||||
ix + 12 + ui.text_width(t) + 5, cy + 8)
|
|
||||||
tooltip.add(have, "You have " .. ui.money(input.have))
|
|
||||||
ix = ix + 12 + ui.text_width(t) + 5 + ui.text_width(have) + 16
|
|
||||||
end
|
|
||||||
cy = cy + 21
|
|
||||||
|
|
||||||
-- The bulk batch (server numbers); time each, sell price and gain on the right.
|
|
||||||
local bulk = ui.text(row, string.format("Batch of %d: %s · bulk bonus −%.0f%%", r.bulkQty, ui.cycle(r.bulkSecs), r.bulkBonus),
|
|
||||||
{ font = "sans", px = 13, color = "muted" }, 26, cy + 8)
|
|
||||||
tooltip.add(bulk, "Bigger batches take less time per unit")
|
|
||||||
local gain = ui.text(row, string.format("+%.0f%%", r.gain), { font = "mono", px = 12, color = "up" }, w, cy + 8, gui.PIVOT_E)
|
|
||||||
if r.inputValue then
|
|
||||||
tooltip.add(gain, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
|
|
||||||
end
|
|
||||||
local sw = ui.credits(row, r.price or r.sellPrice, w - ui.text_width(gain) - 10, cy + 8, { pivot = gui.PIVOT_E })
|
|
||||||
tooltip.add(ui.rect(row, w - ui.text_width(gain) - 10 - sw, cy, sw, 16, "none"), "Sells for")
|
|
||||||
local each = ui.text(row, ui.cycle(r.seconds) .. " each", { font = "mono", px = 12, color = "muted" }, w - ui.text_width(gain) - 10 - sw - 10, cy + 8, gui.PIVOT_E)
|
|
||||||
tooltip.add(each, "Time each")
|
|
||||||
ui.fit_text(bulk, w - ui.text_width(gain) - 10 - sw - 10 - ui.text_width(each) - 12 - 26)
|
|
||||||
cy = cy + 24
|
|
||||||
ui.rect(row, 0, cy - 1, w, 1, "line")
|
|
||||||
gui.set_size(row, vec(w, cy))
|
|
||||||
return cy
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.build(self, root, px, py, w, h, H)
|
function M.build(self, root, px, py, w, h, H)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local rf = st.refinery
|
local rf = st.refinery
|
||||||
local inner, x = frame(w, px, S.refinery_max)
|
self.rf_bars = {}
|
||||||
|
self.rf_slots = {} -- by batch id: { frame, bar } (fx.lua's batch_ready)
|
||||||
|
self.frames = self.frames or {}
|
||||||
|
local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge)
|
||||||
|
local c = area.content
|
||||||
|
scratch = c
|
||||||
|
|
||||||
local cy = H.heading(root, x, py, "Refinery")
|
-- Heading (.heading: h1 and its line; Collect all at the bottom right).
|
||||||
if rf.ready > 1 then
|
local heading = row({ align = "end" }, {
|
||||||
local b, nodes, bw = ui.button(root, "Collect all · " .. rf.ready, 0, 0, { primary = true })
|
col({ grow = 1, align = "start" }, {
|
||||||
gui.set_position(b, vec(x + inner - bw, -(py + 4)))
|
fg.label(c, "Refinery", "h1", 41.4),
|
||||||
|
space(0, 4),
|
||||||
|
fg.para(c, "Refined items are needed for higher tool upgrades.", { font = "sans", px = 15, color = "text_2" }, 22.5, { max_width = 470 }),
|
||||||
|
}),
|
||||||
|
rf.ready > 1 and btn("Collect all · " .. rf.ready, S.button_h, { primary = true, pad = 40 }, function(b, nodes)
|
||||||
H.action(self, b, nodes, "collect", {})
|
H.action(self, b, nodes, "collect", {})
|
||||||
end
|
end) or nil,
|
||||||
cy = cy + H.para(root, "Refined items are needed for higher tool upgrades.", { font = "sans", px = 15, color = "text_2" }, x, cy - 6, inner) + 10
|
})
|
||||||
|
|
||||||
-- Refined items owned (the web's .refinery-stock chips), wrapping.
|
-- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
|
||||||
local sx, any = x, false
|
local slots = {}
|
||||||
for _, s in ipairs(rf.stock) do
|
local order = {}
|
||||||
if s.amount > 0 then
|
|
||||||
local chip = ui.rect(root, sx, cy, 1, 20, "none")
|
|
||||||
ui.rect(chip, 0, 6, 8, 8, item_color(st, s.name))
|
|
||||||
local t = ui.text(chip, s.name, { font = "sans_semi", px = 14, color = "text" }, 14, 10)
|
|
||||||
local n = ui.text(chip, ui.money(s.amount), { font = "mono", px = 13, color = "text" }, 14 + ui.text_width(t) + 5, 10)
|
|
||||||
local cw = 14 + ui.text_width(t) + 5 + ui.text_width(n)
|
|
||||||
if sx > x and sx + cw > x + inner then
|
|
||||||
sx, cy = x, cy + 24
|
|
||||||
gui.set_position(chip, vec(sx, -cy))
|
|
||||||
end
|
|
||||||
gui.set_size(chip, vec(cw, 20))
|
|
||||||
tooltip.add(chip, s.name)
|
|
||||||
sx, any = sx + cw + 16, true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if any then
|
|
||||||
cy = cy + 28
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Slots: one row of cards (plus "+ Slot n" while more can be bought).
|
|
||||||
local cap = ui.text(root, "Slots", "cap", x, cy + 6)
|
|
||||||
ui.text(root, rf.slotCount .. "/" .. rf.maxSlots, { font = "mono", px = 12, color = "muted" }, x + ui.text_width(cap) + 8, cy + 6)
|
|
||||||
cy = cy + 20
|
|
||||||
local n = #rf.slots + ((rf.nextSlotPrice or 0) > 0 and 1 or 0)
|
|
||||||
local gap = 12
|
|
||||||
local cw = math.min(S.slot_w_max, math.floor((inner - gap * (n - 1)) / n))
|
|
||||||
for i, slot in ipairs(rf.slots) do
|
for i, slot in ipairs(rf.slots) do
|
||||||
slot_card(self, root, H, st, slot, x + (i - 1) * (cw + gap), cy, cw)
|
order[i] = { slot = slot, i = i }
|
||||||
|
end
|
||||||
|
table.sort(order, function(a, b)
|
||||||
|
local ja, jb = a.slot.job, b.slot.job
|
||||||
|
if (ja ~= nil) ~= (jb ~= nil) then
|
||||||
|
return ja ~= nil
|
||||||
|
end
|
||||||
|
local sa, sb = ja and ja.start or 0, jb and jb.start or 0
|
||||||
|
if sa ~= sb then
|
||||||
|
return sa < sb
|
||||||
|
end
|
||||||
|
return a.i < b.i
|
||||||
|
end)
|
||||||
|
self.rf_was, self.rf_now = self.rf_now, {}
|
||||||
|
for n, o in ipairs(order) do
|
||||||
|
local tree, make = slot_card(self, H, c, st, o.slot, n)
|
||||||
|
tree.make = make
|
||||||
|
slots[#slots + 1] = tree
|
||||||
end
|
end
|
||||||
if (rf.nextSlotPrice or 0) > 0 then
|
if (rf.nextSlotPrice or 0) > 0 then
|
||||||
buy_slot_card(self, root, H, rf, x + #rf.slots * (cw + gap), cy, cw)
|
local b = buy_slot(self, H, c, rf)
|
||||||
|
b.style.min_height = SLOT_H
|
||||||
|
slots[#slots + 1] = b
|
||||||
end
|
end
|
||||||
cy = cy + S.slot_h + 18
|
|
||||||
|
|
||||||
-- Recipes: a row each; two columns when one doesn't fit the panel's height.
|
-- Recipes (.cards).
|
||||||
ui.text(root, "Recipes", "cap", x, cy + 6)
|
local recipes = {}
|
||||||
cy = cy + 16
|
counted = {}
|
||||||
local rows = #rf.recipes
|
for _, r in ipairs(rf.recipes) do
|
||||||
local cols = 1
|
local tree, make = recipe_card(self, H, c, st, rf, r)
|
||||||
local rh = S.recipe_row_h
|
tree.make = make
|
||||||
if cy + rows * rh > h - py and inner >= 900 then
|
recipes[#recipes + 1] = tree
|
||||||
cols = 2
|
|
||||||
end
|
end
|
||||||
local colw = math.floor((inner - (cols - 1) * 24) / cols)
|
|
||||||
local per = math.ceil(rows / cols)
|
local page = col({ gap = 18, width = inner }, {
|
||||||
-- The rows scroll when they still don't fit (short windows), like the web's page.
|
heading,
|
||||||
local area = scroll.area(self, root, 0, cy, w, h - cy - 6, "recipes")
|
slots_label(self, H, c, rf),
|
||||||
local col_y, bottom = {}, 0
|
flex.grid(inner, 200, 12, slots),
|
||||||
for i, r in ipairs(rf.recipes) do
|
section_label(c, "Recipes"),
|
||||||
local c = math.floor((i - 1) / per)
|
flex.grid(inner, S.card_col_min, S.gap, recipes),
|
||||||
local ry = col_y[c] or 0
|
})
|
||||||
col_y[c] = ry + recipe_row(self, area.content, H, st, rf, r, x + c * (colw + 24), ry, colw)
|
fg.build(flex.solve(page, x, y), c)
|
||||||
bottom = math.max(bottom, col_y[c])
|
-- The recipes' owned numbers count what a collect brings (fx.lua collected).
|
||||||
|
for _, e in ipairs(counted) do
|
||||||
|
self.fx:bind(e.key, e.node)
|
||||||
|
gui.set_text(e.node, self.fx:text(e.key, e.amount))
|
||||||
|
end
|
||||||
|
area:finish(page.y + page.h + py)
|
||||||
|
for _, b in ipairs(self.rf_bars) do
|
||||||
|
light_cells(b)
|
||||||
end
|
end
|
||||||
area:finish(bottom + 4)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+547
-375
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
|||||||
|
-- Save codes (the settings menu's "Save code" and "Restore from code"; ../server/api.md
|
||||||
|
-- "Save codes"): a code brings the player's progress to another device, or back here if
|
||||||
|
-- the saved player id is gone. The server keeps only a hash, so a code shows once, when
|
||||||
|
-- it is made; making a new one turns the old one off.
|
||||||
|
-- open_save(g): asks the server whether there is a code (GET /api/save-code), then a
|
||||||
|
-- dialog to make one (action save-code), then the code (Copy, "dialog:ok", only in a
|
||||||
|
-- browser: Defold has no clipboard on desktop).
|
||||||
|
-- open_restore(g): a text field and Restore (action restore): the response is the other
|
||||||
|
-- player's state and id (session.lua treats it as a fresh start; api.lua stores the id).
|
||||||
|
-- Test ids: "dialog:ok" (Make a code / Restore / Copy), "field:restore-code".
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local api = require("modules.api")
|
||||||
|
local test = require("modules.test")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local W = 480
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TEXT = { font = "sans", px = 15, color = "text_2" }
|
||||||
|
|
||||||
|
-- The height a wrapped text takes in the dialog (laid out first: the dialog's height
|
||||||
|
-- comes from it).
|
||||||
|
local function text_h(g, str)
|
||||||
|
local probe, h = ui.wrap(g.dialog.layer, str, TEXT, 0, 0, W - 48)
|
||||||
|
gui.delete_node(probe)
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_code(g, code)
|
||||||
|
local note = "Write it down. On another device: settings (the gear), Restore from code. It is shown only this once; anyone with it can play as you."
|
||||||
|
local nh = text_h(g, note)
|
||||||
|
g.dialog:open({
|
||||||
|
title = "Your save code",
|
||||||
|
w = W,
|
||||||
|
h = 58 + 40 + 14 + nh + 24 + 44 + 24,
|
||||||
|
cancel = "Close",
|
||||||
|
ok = html5 and { label = "Copy", on_ok = function()
|
||||||
|
pcall(html5.run, "navigator.clipboard && navigator.clipboard.writeText('" .. code .. "')")
|
||||||
|
g.toasts:push("Save code copied", "save-code", false)
|
||||||
|
end } or nil,
|
||||||
|
build = function(d, box)
|
||||||
|
ui.text(box, code, { font = "mono", px = 26, color = "cyan" }, W / 2, 58 + 20, gui.PIVOT_CENTER)
|
||||||
|
ui.wrap(box, note, TEXT, 24, 58 + 40 + 14, W - 48)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make(g)
|
||||||
|
g.h.act(g, "save-code", {}, function(err, data)
|
||||||
|
local sc = type(data) == "table" and data.saveCode
|
||||||
|
if not err and type(sc) == "table" and type(sc.code) == "string" then
|
||||||
|
g.save_code = sc.code -- (the last code made, for the test driver)
|
||||||
|
show_code(g, sc.code)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open_save(g)
|
||||||
|
api.get_save_code(function(err, data)
|
||||||
|
local made = not err and type(data) == "table" and type(data.saveCode) == "table" and tonumber(data.saveCode.madeAt) or 0
|
||||||
|
if err then
|
||||||
|
g.toasts:push("Can't reach the server", "error", true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local message
|
||||||
|
if made > 0 then
|
||||||
|
message = "You made a save code on " .. os.date("%Y-%m-%d", math.floor(made / 1000)) ..
|
||||||
|
". It was shown only then. Lost it? Make a new one; the old one stops working."
|
||||||
|
else
|
||||||
|
message = "A save code brings your progress to another device, or back here if this one forgets you. Keep it safe: anyone with it can play as you."
|
||||||
|
end
|
||||||
|
g.dialog:open({
|
||||||
|
title = "Save code",
|
||||||
|
w = W,
|
||||||
|
message = message,
|
||||||
|
ok = { label = made > 0 and "Make a new code" or "Make a code", on_ok = function() make(g) end },
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open_restore(g)
|
||||||
|
local code = ""
|
||||||
|
local msg = "Enter a save code to play as that miner on this device. This device's current miner can only come back with its own code."
|
||||||
|
local mh = text_h(g, msg)
|
||||||
|
local fy, fw, fh = 58 + mh + 14, W - 48, 40
|
||||||
|
g.dialog:open({
|
||||||
|
title = "Restore from code",
|
||||||
|
w = W,
|
||||||
|
h = fy + fh + 24 + 44 + 24,
|
||||||
|
ok = { label = "Restore", on_ok = function()
|
||||||
|
if code:match("%w") then
|
||||||
|
g.h.act(g, "restore", { code = code })
|
||||||
|
end
|
||||||
|
end },
|
||||||
|
build = function(d, box)
|
||||||
|
ui.wrap(box, msg, TEXT, 24, 58, W - 48)
|
||||||
|
local fbox = ui.rect(box, 24, fy, fw, fh, "field")
|
||||||
|
local edge = ui.outline(fbox, 0, 0, fw, fh, "cyan")
|
||||||
|
local hint = ui.text(fbox, "ABCD-EFGH-JKMN-PQRS", { font = "mono", px = 16, color = "faint" }, 12, fh / 2)
|
||||||
|
local txt = ui.text(fbox, "", { font = "mono", px = 16, color = "text" }, 12, fh / 2)
|
||||||
|
gui.set_size(txt, vec((fw - 24) / gui.get_scale(txt).x, 40))
|
||||||
|
local input = d.druid:new_input(fbox, txt, gui.KEYBOARD_TYPE_DEFAULT)
|
||||||
|
input:set_allowed_characters("[%w%- ]")
|
||||||
|
input:set_max_length(24)
|
||||||
|
input.style.on_select = function() gui.set_color(edge, ui.color("cyan")) end
|
||||||
|
input.style.on_unselect = function() gui.set_color(edge, ui.color("line_hi")) end
|
||||||
|
input.on_input_text:subscribe(function(_, text)
|
||||||
|
code = text
|
||||||
|
gui.set_enabled(hint, text == "")
|
||||||
|
end)
|
||||||
|
test.register("field:restore-code", fbox, "field", "Save code")
|
||||||
|
input:select()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,61 +1,190 @@
|
|||||||
-- Display settings (the header's display button): the UI scale (window pixels per layout
|
-- The settings menu (the header's gear): a dropdown (components/dialog `menu`) under the
|
||||||
-- unit, times the display's own scale; the browser's zoom) and fullscreen. Both are saved
|
-- gear, a text button per setting or action with its icon on the right: How to play (the
|
||||||
-- in the settings file (modules/screen). Keys: Ctrl + / Ctrl - / Ctrl 0, F11 (core/keys).
|
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
|
||||||
|
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
|
||||||
|
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
|
||||||
|
-- admins and in debug builds.
|
||||||
|
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
|
||||||
|
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
local screen = require("modules.screen")
|
local screen = require("modules.screen")
|
||||||
|
local sfx = require("modules.sfx")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local anim = require("modules.anim")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
local W = 260
|
||||||
|
local ROW_H = 34
|
||||||
|
local PAD = 6
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
local function pct(s)
|
local function pct(s)
|
||||||
return string.format("%d%%", math.floor(s * 100 + 0.5))
|
return string.format("%d%%", math.floor(s * 100 + 0.5))
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.open(g)
|
-- Icons, drawn in a 20x20 box at (x, y) of p.
|
||||||
g.dialog:open({
|
local ICONS = {
|
||||||
title = "Display",
|
help = function(p, x, y)
|
||||||
w = 440,
|
ui.outline(p, x, y, 20, 20, "nav")
|
||||||
h = 340,
|
ui.text(p, "?", { font = "sans_semi", px = 14, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||||
build = function(d, box, w)
|
end,
|
||||||
local x, y = 24, 64
|
scale = function(p, x, y)
|
||||||
local value
|
ui.text(p, "A", { font = "sans_semi", px = 11, color = "nav" }, x + 5, y + 13, gui.PIVOT_CENTER)
|
||||||
local function show()
|
ui.text(p, "A", { font = "sans_semi", px = 17, color = "nav" }, x + 14, y + 11, gui.PIVOT_CENTER)
|
||||||
ui.style_text(value, { font = "mono", px = 15, color = "text" }, pct(screen.ui_scale))
|
end,
|
||||||
|
fullscreen = function(p, x, y)
|
||||||
|
for _, c in ipairs({ { 1, 1, 1, 1 }, { 13, 1, -1, 1 }, { 1, 13, 1, -1 }, { 13, 13, -1, -1 } }) do
|
||||||
|
local cx, cy = x + c[1] + (c[3] < 0 and 6 or 0), y + c[2] + (c[4] < 0 and 6 or 0)
|
||||||
|
ui.rect(p, c[3] > 0 and cx or cx - 6, c[4] > 0 and cy or cy - 1, 6, 2, "nav")
|
||||||
|
ui.rect(p, c[3] > 0 and cx or cx - 1 - 1, c[4] > 0 and cy or cy - 6, 2, 6, "nav")
|
||||||
end
|
end
|
||||||
|
end,
|
||||||
|
sound = function(p, x, y)
|
||||||
|
local icon = ui.rect(p, x, y, 20, 20, sfx.muted() and "muted" or "cyan")
|
||||||
|
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
|
||||||
|
end,
|
||||||
|
theme = function(p, x, y)
|
||||||
|
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "nav")
|
||||||
|
gui.play_flipbook(icon, theme.light and "moon" or "sun")
|
||||||
|
end,
|
||||||
|
motion = function(p, x, y)
|
||||||
|
local lit = anim.reduced and "muted" or "nav"
|
||||||
|
for i, l in ipairs({ { 4, 5, 14 }, { 1, 9, 18 }, { 6, 13, 11 } }) do
|
||||||
|
ui.rect(p, x + l[1], y + l[2] + (i - 2), l[3], 2, lit)
|
||||||
|
end
|
||||||
|
if anim.reduced then
|
||||||
|
local slash = ui.rect(p, x, y, 20, 2, "muted")
|
||||||
|
gui.set_pivot(slash, gui.PIVOT_CENTER)
|
||||||
|
gui.set_position(slash, vec(x + 10, -(y + 10)))
|
||||||
|
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
-- A floppy disk; restore: the disk with an arrow into it.
|
||||||
|
save = function(p, x, y)
|
||||||
|
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||||
|
ui.rect(p, x + 6, y + 2, 8, 5, "nav")
|
||||||
|
ui.rect(p, x + 5, y + 11, 10, 5, "nav")
|
||||||
|
end,
|
||||||
|
restore = function(p, x, y)
|
||||||
|
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||||
|
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||||
|
end,
|
||||||
|
lab = function(p, x, y)
|
||||||
|
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
|
||||||
|
gui.play_flipbook(icon, "moon")
|
||||||
|
end,
|
||||||
|
grant = function(p, x, y)
|
||||||
|
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- A menu row: a text button the menu's width, the label left, the icon right. Returns the
|
||||||
|
-- button's label node.
|
||||||
|
local function item(d, box, y, label, icon, id, on_click, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local w = W - PAD * 2
|
||||||
|
local b, nodes = d:button(box, label, PAD, y, { variant = "ghost", w = w, h = ROW_H, id = id, disabled = opts.disabled,
|
||||||
|
fill = { "cyan", 0 }, edge = { "cyan", 0 }, on_click = on_click })
|
||||||
|
local t = ui.part(nodes, "button", "text")
|
||||||
|
ui.style_text(t, { font = "sans", px = 14, color = opts.disabled and "muted" or "text" }, label)
|
||||||
|
gui.set_pivot(t, gui.PIVOT_W)
|
||||||
|
gui.set_position(t, vec(12, -ROW_H / 2))
|
||||||
|
ICONS[icon](b, w - 12 - 20, (ROW_H - 20) / 2)
|
||||||
|
return t, b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Opens the menu with its top-right corner at `right` from the page's right edge, `top`
|
||||||
|
-- down (under the gear).
|
||||||
|
function M.open(g, right, top)
|
||||||
|
local st = g.state
|
||||||
|
local help = st and st.unlocked and st.unlocked.help
|
||||||
|
local admin = require("screens.game.admin")
|
||||||
|
local lab = st and admin.allowed(g)
|
||||||
|
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
|
||||||
|
local h = PAD * 2 + rows * ROW_H + 8
|
||||||
|
g.dialog:open({
|
||||||
|
w = W,
|
||||||
|
h = h,
|
||||||
|
menu = { right = right or 16, top = top or 60 },
|
||||||
|
build = function(d, box)
|
||||||
|
local y = PAD
|
||||||
|
local function close_then(fn)
|
||||||
|
return function()
|
||||||
|
d:close("quiet")
|
||||||
|
fn()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function toggled()
|
||||||
|
g.dirty, g.dirty_all = true, true
|
||||||
|
end
|
||||||
|
if help then
|
||||||
|
item(d, box, y, "How to play", "help", "menu:help", close_then(function()
|
||||||
|
g.h.navigate(g, { section = "help", moon = g.view.moon })
|
||||||
|
end))
|
||||||
|
y = y + ROW_H
|
||||||
|
end
|
||||||
|
-- UI scale: the row's label shows it; − and + on its right (the icon's place).
|
||||||
|
local w = W - PAD * 2
|
||||||
|
-- (Ctrl 0 resets it)
|
||||||
|
local t = ui.text(box, "", { font = "sans", px = 14, color = "text" }, PAD + 12, y + ROW_H / 2)
|
||||||
|
ICONS.scale(box, PAD + w - 12 - 20, y + (ROW_H - 20) / 2)
|
||||||
|
local function show()
|
||||||
|
ui.style_text(t, { font = "sans", px = 14, color = "text" }, "UI scale " .. pct(screen.ui_scale))
|
||||||
|
end
|
||||||
|
show()
|
||||||
local function step(dir)
|
local function step(dir)
|
||||||
return function()
|
return function()
|
||||||
screen.step_ui_scale(dir)
|
screen.step_ui_scale(dir)
|
||||||
show()
|
show()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
ui.text(box, "UI scale", "cap", x, y + 6)
|
d:button(box, "+", PAD + w - 40 - 40, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-up", on_click = step(1) })
|
||||||
ui.text(box, "Bigger text and controls; the window keeps its size.", { font = "sans", px = 14, color = "muted" }, x, y + 26)
|
d:button(box, "−", PAD + w - 40 - 80, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-down", on_click = step(-1) })
|
||||||
local by = y + 44
|
y = y + ROW_H
|
||||||
local _, _, mw = d:button(box, "−", x, by, { w = 40, id = "dialog:scale-down", on_click = step(-1) })
|
item(d, box, y, screen.fullscreen() and "Leave fullscreen" or "Fullscreen", "fullscreen", "dialog:fullscreen",
|
||||||
value = ui.text(box, "", { font = "mono", px = 15, color = "text" }, x + mw + 34, by + 18, gui.PIVOT_CENTER)
|
close_then(screen.toggle_fullscreen), { disabled = not screen.can_fullscreen() })
|
||||||
show()
|
y = y + ROW_H
|
||||||
local _, _, pw = d:button(box, "+", x + mw + 68, by, { w = 40, id = "dialog:scale-up", on_click = step(1) })
|
item(d, box, y, sfx.muted() and "Sound: off" or "Sound: on", "sound", "mute", close_then(function()
|
||||||
d:button(box, "Reset", x + mw + 68 + pw + 10, by, { id = "dialog:scale-reset", on_click = step(0) })
|
sfx.toggle_mute()
|
||||||
|
toggled()
|
||||||
y = by + 58
|
end))
|
||||||
ui.text(box, "Fullscreen", "cap", x, y + 6)
|
y = y + ROW_H
|
||||||
ui.text(box, screen.can_fullscreen() and "F11 switches it too." or "Not available in this build.",
|
item(d, box, y, theme.light and "Dark theme" or "Light theme", "theme", "theme", close_then(function()
|
||||||
{ font = "sans", px = 14, color = "muted" }, x, y + 26)
|
theme.toggle()
|
||||||
local fnodes
|
toggled()
|
||||||
local function label()
|
end))
|
||||||
return screen.fullscreen() and "Leave fullscreen" or "Go fullscreen"
|
y = y + ROW_H
|
||||||
|
item(d, box, y, anim.reduced and "Reduced motion: on" or "Reduced motion: off", "motion", "motion", close_then(function()
|
||||||
|
anim.set_reduced_motion(not anim.reduced)
|
||||||
|
sfx.play("toggle")
|
||||||
|
toggled()
|
||||||
|
end))
|
||||||
|
y = y + ROW_H
|
||||||
|
-- Save codes (savecode.lua): progress on another device.
|
||||||
|
item(d, box, y, "Save code", "save", "menu:save-code", close_then(function()
|
||||||
|
require("screens.game.savecode").open_save(g)
|
||||||
|
end), { disabled = not st })
|
||||||
|
y = y + ROW_H
|
||||||
|
item(d, box, y, "Restore from code", "restore", "menu:restore", close_then(function()
|
||||||
|
require("screens.game.savecode").open_restore(g)
|
||||||
|
end), { disabled = not st })
|
||||||
|
y = y + ROW_H
|
||||||
|
if st and st.admin then
|
||||||
|
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
|
||||||
|
require("screens.game.grant").open(g)
|
||||||
|
end))
|
||||||
|
y = y + ROW_H
|
||||||
|
end
|
||||||
|
if lab then
|
||||||
|
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
|
||||||
|
admin.open(g, "moonlab")
|
||||||
|
end), { disabled = g.reveal:active() })
|
||||||
end
|
end
|
||||||
fnodes = select(2, d:button(box, label(), x, y + 44, { w = 180, id = "dialog:fullscreen", disabled = not screen.can_fullscreen(),
|
|
||||||
on_click = function()
|
|
||||||
screen.toggle_fullscreen()
|
|
||||||
-- (the label changes: centered again in the button)
|
|
||||||
local t = ui.part(fnodes, "button", "text")
|
|
||||||
local color = gui.get_color(t)
|
|
||||||
ui.style_text(t, { font = "sans_semi", px = 15, color = "text", tracking = 0.12, upper = true }, label())
|
|
||||||
local p = gui.get_position(t)
|
|
||||||
gui.set_position(t, vmath.vector3(math.floor((180 - ui.text_width(t)) / 2), p.y, p.z))
|
|
||||||
gui.set_color(t, color)
|
|
||||||
end }))
|
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- The right side panel (region): a column right of the main panel (left of the aside when
|
||||||
|
-- that shows), framed like the moon list, holding side panels stacked top to bottom: for
|
||||||
|
-- now the leaderboard. A side panel is a module in M.PANELS with key, include (the state
|
||||||
|
-- block it shows: core/session asks for it while the column shows), unlocked(g), sig(g)
|
||||||
|
-- (what it shows: its block is kept while this stays the same) and build(g, root, x, y,
|
||||||
|
-- w, h). A second one goes in M.PANELS; the column's height is shared evenly.
|
||||||
|
--
|
||||||
|
-- When it shows (core/main.lua lays it out, M.width): on the Market only, once a panel is
|
||||||
|
-- unlocked, never while the aside (the moon pane) shows, and only while the main panel keeps at least
|
||||||
|
-- MAIN_MIN beside it; otherwise it folds away and the main panel gets the width (the
|
||||||
|
-- Market then shows the leaderboard under its content). It depends on the window, the
|
||||||
|
-- aside and the unlocks only, never on counts, so nothing shifts while playing.
|
||||||
|
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
M.PANELS = { require("screens.game.leaderboard") }
|
||||||
|
|
||||||
|
local MAIN_MIN = 680 -- narrowest the main panel gets with the column beside it
|
||||||
|
local W_MIN, W_MAX, W_PART = 240, 320, 0.13 -- clamp(240, 13% of the window, 320)
|
||||||
|
|
||||||
|
-- The panels unlocked now.
|
||||||
|
function M.panels(g)
|
||||||
|
local out = {}
|
||||||
|
for _, p in ipairs(M.PANELS) do
|
||||||
|
if p.unlocked(g) then
|
||||||
|
out[#out + 1] = p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The column's width for a layout W wide with `room` left for the main panel and the
|
||||||
|
-- column together; 0 when it folds away (or no panel is unlocked).
|
||||||
|
function M.width(g, W, room, narrow)
|
||||||
|
if narrow or #M.panels(g) == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local w = math.max(W_MIN, math.min(W_MAX, math.floor(W * W_PART)))
|
||||||
|
if room - w - S.gap < MAIN_MIN then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The state blocks the shown panels need (core/session adds them to every request). Before
|
||||||
|
-- the first layout (the first state) all of them: the column may show at once.
|
||||||
|
function M.includes(g)
|
||||||
|
local out = {}
|
||||||
|
if g.side_w == nil or g.side_w > 0 then
|
||||||
|
for _, p in ipairs(g.side_w == nil and M.PANELS or M.panels(g)) do
|
||||||
|
out[#out + 1] = p.include
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After a build: a shown panel whose block the state doesn't have yet (the window grew,
|
||||||
|
-- the aside closed or it just unlocked) asks for it once (fetch: core/session).
|
||||||
|
function M.missing(g)
|
||||||
|
if (g.side_w or 0) == 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, p in ipairs(M.panels(g)) do
|
||||||
|
if g.state[p.include] == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Builds the column at (x, y), w x h: a framed panel per side panel, stacked.
|
||||||
|
function M.build(g, parent, x, y, w, h, common)
|
||||||
|
local list = M.panels(g)
|
||||||
|
local each = math.floor((h - S.gap * (#list - 1)) / math.max(1, #list))
|
||||||
|
for i, p in ipairs(list) do
|
||||||
|
local py = y + (i - 1) * (each + S.gap)
|
||||||
|
blocks.keep(g, "side:" .. p.key, blocks.sig(common, x, py, w, each, p.sig(g)), parent, function(holder)
|
||||||
|
local _, nodes = ui.panel(holder, x, py, w, each)
|
||||||
|
p.build(g, ui.part(nodes, "panel", "root"), 0, 0, w, each)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
+115
-83
@@ -1,4 +1,4 @@
|
|||||||
-- The Store section (state.store): tool cards and attachment cards with their images,
|
-- The Store section (state.store): tool cards with their images,
|
||||||
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
|
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
|
||||||
-- (the response's buyModal: up to 3 wells to deploy it on).
|
-- (the response's buyModal: up to 3 wells to deploy it on).
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ local tooltip = require("modules.tooltip")
|
|||||||
local cards = require("screens.game.cards")
|
local cards = require("screens.game.cards")
|
||||||
local card = require("components.card")
|
local card = require("components.card")
|
||||||
|
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
local vec = cards.vec
|
local vec = cards.vec
|
||||||
|
|
||||||
@@ -35,59 +38,89 @@ end
|
|||||||
-- The buy modal after buy-tool (popup): the new tool and the best wells for it.
|
-- The buy modal after buy-tool (popup): the new tool and the best wells for it.
|
||||||
local function buy_modal(self, modal, item)
|
local function buy_modal(self, modal, item)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local rows = modal.suggestions or {}
|
local rows_ = modal.suggestions or {}
|
||||||
local empty = modal.noMoons or modal.allEmpty or #rows == 0
|
local empty = modal.noMoons or modal.allEmpty or #rows_ == 0
|
||||||
local h = 92 + (empty and 70 or (22 + #rows * 58)) + 72
|
local h = 92 + (empty and 70 or (22 + #rows_ * 58)) + 72
|
||||||
local function build(p, box, W)
|
local function build(p, box, W)
|
||||||
-- Head: the picture left of the title (dialog title_x) and the yield line under it.
|
-- Head: the picture left of the title (dialog title_x) and the yield line under it.
|
||||||
cards.image(box, item and item.image, 24, 14, 56)
|
local kids = {
|
||||||
ui.text(box, "+" .. ui.money(modal.yield) .. " per " .. ui.cycle(modal.cycleSecs) .. " cycle"
|
fg.row({ height = 56, align = "start" }, {
|
||||||
|
fg.made({ width = 56, height = 56, shrink = 0 }, function(q, x, y) cards.image(q, item and item.image, x, y, 56) end),
|
||||||
|
fg.space(14),
|
||||||
|
fg.col({ padding_top = 38 }, {
|
||||||
|
fg.label(box, "+" .. ui.money(modal.yield) .. " per " .. ui.cycle(modal.cycleSecs) .. " cycle"
|
||||||
.. (cards.reach(st, modal.reach) ~= "" and (" · " .. cards.reach(st, modal.reach)) or ""),
|
.. (cards.reach(st, modal.reach) ~= "" and (" · " .. cards.reach(st, modal.reach)) or ""),
|
||||||
{ font = "mono", px = 13, color = "muted" }, 24 + 56 + 14, 60)
|
{ font = "mono", px = 13, color = "muted" }, 16),
|
||||||
local y = 92
|
}),
|
||||||
|
}),
|
||||||
|
fg.space(0, 22),
|
||||||
|
}
|
||||||
|
local function add(b)
|
||||||
|
kids[#kids + 1] = b
|
||||||
|
end
|
||||||
if empty then
|
if empty then
|
||||||
local msg_ = modal.noMoons and "You don't own a moon yet. Claim one to start mining."
|
local msg_ = modal.noMoons and "You don't own a moon yet. Claim one to start mining."
|
||||||
or "All your wells are empty. Claim a new moon for fresh supply."
|
or "All your wells are empty. Claim a new moon for fresh supply."
|
||||||
cards.para(box, msg_, { font = "sans", px = 15, color = "text_2" }, 24, y, W - 48)
|
add(fg.text_para(box, msg_, { font = "sans", px = 15, color = "text_2" }))
|
||||||
local bx = 24
|
add(fg.fill())
|
||||||
|
local short = st.credits < st.moonPrice
|
||||||
|
local buttons = {}
|
||||||
if modal.noMoons then
|
if modal.noMoons then
|
||||||
local _, _, mw = p:button(box, "Moons", bx, h - 24 - S.button_h, { id = "dialog:moons",
|
buttons[#buttons + 1] = fg.made({ width = fg.button_w(box, "Moons", {}), height = S.button_h, shrink = 0 }, function(q, x, y)
|
||||||
|
p:button(q, "Moons", x, y, { id = "dialog:moons",
|
||||||
on_click = function()
|
on_click = function()
|
||||||
self.dialog:close("cancel")
|
self.dialog:close("cancel")
|
||||||
self.h.navigate(self, { section = "moons", moon = self.view.moon })
|
self.h.navigate(self, { section = "moons", moon = self.view.moon })
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
bx = bx + mw + 10
|
end)
|
||||||
end
|
end
|
||||||
local short = st.credits < st.moonPrice
|
local claim = { primary = true, price = st.moonPrice, disabled = short }
|
||||||
local _, _, bw = p:button(box, "Claim moon", bx, h - 24 - S.button_h, {
|
buttons[#buttons + 1] = fg.box({ width = fg.button_w(box, "Claim moon", claim), height = S.button_h, shrink = 0 }, {
|
||||||
primary = true, price = st.moonPrice, disabled = short, id = "dialog:buy-moon",
|
|
||||||
on_click = function() p.reply("buy-moon", {}) end,
|
|
||||||
})
|
|
||||||
if short then
|
|
||||||
-- Why it's off (the web's title; tooltips don't show over a dialog).
|
-- Why it's off (the web's title; tooltips don't show over a dialog).
|
||||||
M.need_text(box, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", bx + bw, h - 24 - S.button_h - 12)
|
short and fg.made({ position = "absolute", top = -20, right = 0, width = 0, height = 16 }, function(q, x, y)
|
||||||
end
|
M.need_text(q, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", x, y + 8)
|
||||||
return bx - 24 + bw
|
end) or nil,
|
||||||
end
|
}, { make = function(q, x, y)
|
||||||
ui.text(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 24, y + 6)
|
claim.id = "dialog:buy-moon"
|
||||||
y = y + 22
|
claim.on_click = function() p.reply("buy-moon", {}) end
|
||||||
for _, sg in ipairs(rows) do
|
p:button(q, "Claim moon", x, y, claim)
|
||||||
local row = ui.rect(box, 24, y, W - 48, 50, "cyan", 0.04)
|
return nil
|
||||||
ui.outline(row, 0, 0, W - 48, 50, "line")
|
end })
|
||||||
ui.rect(row, 12, 14, 8, 8, sg.resource)
|
add(fg.row({ gap = 10 }, buttons))
|
||||||
local n = ui.text(row, sg.resource, { font = "sans_semi", px = 15, color = "text" }, 28, 18)
|
else
|
||||||
ui.label(row, "· " .. sg.moonName, { font = "sans", px = 14, color = "muted" }, 28 + ui.text_width(n) + 6, 18)
|
add(fg.label(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 12))
|
||||||
ui.label(row, (sg.reasonText or REASONS[sg.reason] or sg.reason) .. " · " .. ui.money(sg.supply) .. " left · +"
|
add(fg.space(0, 10))
|
||||||
.. theme.currency .. ui.money(sg.credits) .. "/cycle", { font = "sans", px = 12, color = "text_2" }, 12, 36)
|
local rows = {}
|
||||||
local b, _, bw = p:button(row, "Deploy", 0, 0, { primary = true, small = true, id = "dialog:stake:" .. sg.wellId,
|
for _, sg in ipairs(rows_) do
|
||||||
|
rows[#rows + 1] = fg.row({ height = 50, padding = { 9, 10, 0, 12 }, align = "start" }, {
|
||||||
|
fg.col({ grow = 1, shrink = 1, padding_top = 1 }, {
|
||||||
|
fg.row({ height = 16, align = "center" }, {
|
||||||
|
fg.made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, sg.resource) end),
|
||||||
|
fg.space(8),
|
||||||
|
fg.label(box, sg.resource, { font = "sans_semi", px = 15, color = "text" }, 16), fg.space(6),
|
||||||
|
fg.label(box, "· " .. sg.moonName, { font = "sans", px = 14, color = "muted" }, 16),
|
||||||
|
}),
|
||||||
|
fg.space(0, 2),
|
||||||
|
fg.label(box, (sg.reasonText or REASONS[sg.reason] or sg.reason) .. " · " .. ui.money(sg.supply) .. " left · +"
|
||||||
|
.. theme.currency .. ui.money(sg.credits) .. "/cycle", { font = "sans", px = 12, color = "text_2" }, 16),
|
||||||
|
}),
|
||||||
|
fg.made({ width = fg.button_w(box, "Deploy", { primary = true, small = true }), height = S.button_sm_h, shrink = 0 }, function(q, x, y)
|
||||||
|
p:button(q, "Deploy", x, y, { primary = true, small = true, id = "dialog:stake:" .. sg.wellId,
|
||||||
on_click = function()
|
on_click = function()
|
||||||
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
|
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
gui.set_position(b, vec(W - 48 - 10 - bw, -9))
|
end),
|
||||||
y = y + 58
|
}, { make = function(q, x, y, rw, rh)
|
||||||
|
local r = ui.rect(q, x, y, rw, rh, "cyan", 0.04)
|
||||||
|
ui.outline(r, 0, 0, rw, rh, "line")
|
||||||
|
return r
|
||||||
|
end })
|
||||||
end
|
end
|
||||||
|
add(fg.col({ gap = 8 }, rows))
|
||||||
|
end
|
||||||
|
fg.build(flex.solve(fg.col({ width = W, height = h, padding = { 14, 24, 24, 24 } }, kids), 0, 0), box)
|
||||||
end
|
end
|
||||||
self.dialog:open({
|
self.dialog:open({
|
||||||
title = (item and item.name or "Tool") .. " ready", w = 520, h = h, cancel = "Later", title_x = 24 + 56 + 14,
|
title = (item and item.name or "Tool") .. " ready", w = 520, h = h, cancel = "Later", title_x = 24 + 56 + 14,
|
||||||
@@ -95,6 +128,28 @@ local function buy_modal(self, modal, item)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A store card's footer, bw wide (a tree): a rule, the stats (30 tall), Buy (its price;
|
||||||
|
-- disabled when short, with why under it, right-aligned; the line's space is always kept).
|
||||||
|
-- bind(b, nodes).
|
||||||
|
function M.buy_footer(f, bw, stats, price, short, why, bind)
|
||||||
|
local items = {}
|
||||||
|
for _, it in ipairs(stats) do
|
||||||
|
items[#items + 1] = fg.inline(f, 30, function(p, x, y) return cards.stat(p, it[1], it[2], x, y) end)
|
||||||
|
end
|
||||||
|
fg.build(flex.solve(fg.col({ width = bw }, {
|
||||||
|
fg.made({ position = "absolute", top = 0, left = 0, right = 0, height = 1 }, function(p, x, y, w)
|
||||||
|
ui.rect(p, x, y, w, 1, "line")
|
||||||
|
end),
|
||||||
|
fg.row({ height = 30, column_gap = 16 }, items),
|
||||||
|
fg.button(f, "Buy", { primary = true, small = true, price = price, disabled = short }, bind, { width = bw }),
|
||||||
|
-- The need line's place, kept whether it shows or not: Buy sits at the same height in
|
||||||
|
-- every card, and a card doesn't change size when credits come and go.
|
||||||
|
fg.reserve({ height = 20 }, short and fg.made({ width = bw, height = 20 }, function(p, x, y, w)
|
||||||
|
M.need_text(p, why, x + w, y + 10)
|
||||||
|
end) or nil),
|
||||||
|
}), 0, 0), f)
|
||||||
|
end
|
||||||
|
|
||||||
local function tool_card(self, H, st, c, t)
|
local function tool_card(self, H, st, c, t)
|
||||||
c:image(t.image)
|
c:image(t.image)
|
||||||
c:title(t.name)
|
c:title(t.name)
|
||||||
@@ -112,18 +167,13 @@ local function tool_card(self, H, st, c, t)
|
|||||||
c:text(line, { font = "mono", px = 12, color = "muted" })
|
c:text(line, { font = "mono", px = 12, color = "muted" })
|
||||||
end
|
end
|
||||||
local short = not t.canAfford
|
local short = not t.canAfford
|
||||||
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
|
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
|
||||||
ui.rect(f, 0, 0, bw, 1, "line")
|
M.buy_footer(f, bw, {
|
||||||
local sx = 0
|
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned },
|
||||||
sx = sx + cards.stat(f, "Yield", "+" .. ui.money(t.yield), sx, 15) + 16
|
}, t.price, short, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"),
|
||||||
sx = sx + cards.stat(f, "Cycle", ui.cycle(t.cycleSecs), sx, 15) + 16
|
function(b, nodes)
|
||||||
cards.stat(f, "Owned", t.owned, sx, 15)
|
|
||||||
local b, nodes = ui.button(f, "Buy", 0, 30, { primary = true, small = true, w = bw, price = t.price, disabled = short })
|
|
||||||
if short then
|
if short then
|
||||||
tooltip.add(b, t.whyText)
|
tooltip.add(b, t.whyText)
|
||||||
end
|
|
||||||
if short then
|
|
||||||
M.need_text(f, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"), bw, 30 + S.button_sm_h + 10)
|
|
||||||
else
|
else
|
||||||
H.action(self, b, nodes, "buy-tool", { id = tostring(t.storeItemId) }, function(err, data)
|
H.action(self, b, nodes, "buy-tool", { id = tostring(t.storeItemId) }, function(err, data)
|
||||||
if not err and type(data) == "table" and data.buyModal then
|
if not err and type(data) == "table" and data.buyModal then
|
||||||
@@ -132,28 +182,6 @@ local function tool_card(self, H, st, c, t)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
|
||||||
|
|
||||||
local function gear_card(self, H, st, c, a)
|
|
||||||
c:image(a.image)
|
|
||||||
c:title(a.name)
|
|
||||||
c:text(a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 14, color = "violet_hi" }, 22)
|
|
||||||
c:para(a.description, { font = "sans", px = 13, color = "text_2" })
|
|
||||||
c:gap(4)
|
|
||||||
local short = not a.canAfford
|
|
||||||
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
|
|
||||||
ui.rect(f, 0, 0, bw, 1, "line")
|
|
||||||
local sx = cards.stat(f, "Spare", a.spare, 0, 15) + 16
|
|
||||||
cards.stat(f, "Equipped", a.equipped, sx, 15)
|
|
||||||
local b, nodes = ui.button(f, "Buy", 0, 30, { primary = true, small = true, w = bw, price = a.price, disabled = short })
|
|
||||||
if short then
|
|
||||||
tooltip.add(b, a.whyText)
|
|
||||||
end
|
|
||||||
if short then
|
|
||||||
M.need_text(f, (a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"), bw, 30 + S.button_sm_h + 10)
|
|
||||||
else
|
|
||||||
H.action(self, b, nodes, "buy-attachment", { item = a.name })
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -162,23 +190,27 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
local sto = st.store
|
local sto = st.store
|
||||||
local area, x, inner, y = cards.page(self, root, px, py, w, h, "store")
|
local area, x, inner, y = cards.page(self, root, px, py, w, h, "store")
|
||||||
local c = area.content
|
local c = area.content
|
||||||
y = H.heading(c, x, y, "Store")
|
-- (cards are kept while what they show stays the same: modules/blocks)
|
||||||
y = y + cards.para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.",
|
local function shared()
|
||||||
{ font = "sans", px = 15, color = "muted" }, x, y, math.min(inner, S.text_max)) + 20
|
return { st.credits, st.moonPrice, st.catalogVersion, self.inflight, self.view }
|
||||||
|
|
||||||
ui.text(c, "Tools", "cap", x, y + 6)
|
|
||||||
y = y + 20
|
|
||||||
y = card.grid(self, c, x, y, inner, sto.tools, function(cd, t)
|
|
||||||
tool_card(self, H, st, cd, t)
|
|
||||||
end)
|
|
||||||
if #(sto.attachments or {}) > 0 then
|
|
||||||
y = y + 8
|
|
||||||
ui.text(c, "Attachments", "cap", x, y + 6)
|
|
||||||
y = y + 20
|
|
||||||
y = card.grid(self, c, x, y, inner, sto.attachments, function(cd, a)
|
|
||||||
gear_card(self, H, st, cd, a)
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
local function cap(label, before)
|
||||||
|
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
|
||||||
|
end
|
||||||
|
y = fg.flow(c, x, y, inner, {
|
||||||
|
fg.col({}, {
|
||||||
|
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
|
||||||
|
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
|
||||||
|
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
|
||||||
|
fg.space(0, 20),
|
||||||
|
}),
|
||||||
|
cap("Tools", 0),
|
||||||
|
function(cy)
|
||||||
|
return card.grid(self, c, x, cy, inner, sto.tools, function(cd, t)
|
||||||
|
tool_card(self, H, st, cd, t)
|
||||||
|
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
|
||||||
|
end,
|
||||||
|
})
|
||||||
area:finish(y + py)
|
area:finish(y + py)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- The Market's ticker tape (the web's .mk-tape): every quote's symbol, price and 24h
|
-- The Market's ticker tape (the web's .mk-tape): every quote's symbol, price and 24h
|
||||||
-- change, scrolling left in a clipped strip; a click picks the quote. The strip is
|
-- change, scrolling left in a clipped strip; a click picks the quote. The strip is
|
||||||
-- rebuilt with every state, so its phase comes from a clock kept on the screen
|
-- rebuilt when its quotes change (a block), so its phase comes from a clock kept on the screen
|
||||||
-- (g.tape_t0): a refresh doesn't jump it back to the start. It pauses under the mouse
|
-- (g.tape_t0): a refresh doesn't jump it back to the start. It pauses under the mouse
|
||||||
-- (app.css .mk-tape:hover): the clock shifts by the time it held. Still with reduced motion.
|
-- (app.css .mk-tape:hover): the clock shifts by the time it held. Still with reduced motion.
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Checks (make check). Fails on:
|
"""Checks (make check). Fails on:
|
||||||
|
|
||||||
|
- the flex solver's unit tests (scripts/flex_test.lua)
|
||||||
- Lua syntax errors (luajit -bl on every .lua, .script, .gui_script, .render_script)
|
- Lua syntax errors (luajit -bl on every .lua, .script, .gui_script, .render_script)
|
||||||
- stale debug flags: game.project [mm] values that aren't the shipped defaults, and
|
- stale debug flags: game.project [mm] values that aren't the shipped defaults, and
|
||||||
DEBUG markers left in sources or shaders
|
DEBUG markers left in sources or shaders
|
||||||
@@ -11,8 +12,10 @@
|
|||||||
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
||||||
script errors
|
script errors
|
||||||
|
|
||||||
The scenario part runs scripts/drive.py sections --fixture against MM_API (default
|
MM_API (the server to check against, e.g. http://localhost:3251) must be set: there is no
|
||||||
http://localhost:3000) when that server answers, after a bob build; otherwise it checks
|
default, since the checks create a fixture player there (never point it at a server whose
|
||||||
|
database you care about). The scenario part runs scripts/drive.py sections --fixture against
|
||||||
|
it when that server answers, after a bob build; otherwise it checks
|
||||||
the newest dist/drive/*/game.log there is (and says so). `--static` skips it.
|
the newest dist/drive/*/game.log there is (and says so). `--static` skips it.
|
||||||
"""
|
"""
|
||||||
import glob
|
import glob
|
||||||
@@ -226,10 +229,23 @@ def check_scenario(api):
|
|||||||
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_flex():
|
||||||
|
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
fail("flex_test.lua failed:\n" + r.stdout[-2000:])
|
||||||
|
else:
|
||||||
|
print(r.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
api = os.environ.get("MM_API", "http://localhost:3000").rstrip("/")
|
api = os.environ.get("MM_API", "").rstrip("/")
|
||||||
|
if not api:
|
||||||
|
sys.exit("check.py: set MM_API to a test server (e.g. MM_API=http://localhost:3251); "
|
||||||
|
"it creates a fixture player there, so there is no default")
|
||||||
check_lua()
|
check_lua()
|
||||||
check_flags()
|
check_flags()
|
||||||
|
check_flex()
|
||||||
check_fonts(api)
|
check_fonts(api)
|
||||||
if "--static" not in sys.argv:
|
if "--static" not in sys.argv:
|
||||||
check_scenario(api)
|
check_scenario(api)
|
||||||
|
|||||||
+42
-3
@@ -11,6 +11,8 @@
|
|||||||
first, then the game's gui_script (its
|
first, then the game's gui_script (its
|
||||||
on_reload rebuilds the screen; modules/hot.lua)
|
on_reload rebuilds the screen; modules/hot.lua)
|
||||||
(--all: every Lua module again)
|
(--all: every Lua module again)
|
||||||
|
scripts/dev.py run [engine args...] the same in the foreground, output also on the
|
||||||
|
terminal (`make run`); reload works on it too
|
||||||
scripts/dev.py stop stop it (by its pid)
|
scripts/dev.py stop stop it (by its pid)
|
||||||
scripts/dev.py status
|
scripts/dev.py status
|
||||||
|
|
||||||
@@ -61,11 +63,14 @@ def read(name, default=None):
|
|||||||
|
|
||||||
|
|
||||||
def alive(pid):
|
def alive(pid):
|
||||||
|
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
|
||||||
|
# number can be reused by another process (stop would kill that one's group).
|
||||||
try:
|
try:
|
||||||
os.kill(int(pid), 0)
|
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
||||||
return True
|
cmd = f.read()
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
|
||||||
|
|
||||||
|
|
||||||
def snapshot():
|
def snapshot():
|
||||||
@@ -86,7 +91,7 @@ def post(port, path, body):
|
|||||||
return urllib.request.urlopen(req, timeout=5).read()
|
return urllib.request.urlopen(req, timeout=5).read()
|
||||||
|
|
||||||
|
|
||||||
def start(args):
|
def start(args, fg=False):
|
||||||
os.makedirs(DEV, exist_ok=True)
|
os.makedirs(DEV, exist_ok=True)
|
||||||
pid = read("pid")
|
pid = read("pid")
|
||||||
if pid and alive(pid):
|
if pid and alive(pid):
|
||||||
@@ -125,6 +130,9 @@ def start(args):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
log = os.path.join(DEV, "game.log")
|
log = os.path.join(DEV, "game.log")
|
||||||
|
if fg:
|
||||||
|
run_fg(cmd, env, log)
|
||||||
|
return
|
||||||
with open(log, "w") as f:
|
with open(log, "w") as f:
|
||||||
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
|
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
|
||||||
start_new_session=True)
|
start_new_session=True)
|
||||||
@@ -148,6 +156,35 @@ def start(args):
|
|||||||
print("dev game pid %d, engine service port %s, log %s" % (proc.pid, port, log))
|
print("dev game pid %d, engine service port %s, log %s" % (proc.pid, port, log))
|
||||||
|
|
||||||
|
|
||||||
|
def run_fg(cmd, env, log):
|
||||||
|
# Output to the terminal and the log; Ctrl+C stops the game.
|
||||||
|
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True)
|
||||||
|
with open(os.path.join(DEV, "pid"), "w") as f:
|
||||||
|
f.write(str(proc.pid))
|
||||||
|
try:
|
||||||
|
with open(log, "wb") as f:
|
||||||
|
for line in proc.stdout:
|
||||||
|
f.write(line)
|
||||||
|
f.flush()
|
||||||
|
sys.stdout.buffer.write(line)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
m = re.search(rb"Engine service started on port (\d+)", line)
|
||||||
|
if m:
|
||||||
|
with open(os.path.join(DEV, "port"), "w") as p:
|
||||||
|
p.write(m.group(1).decode())
|
||||||
|
with open(SNAP, "w") as p:
|
||||||
|
json.dump(snapshot(), p)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait()
|
||||||
|
for n in ("pid", "port"):
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(DEV, n))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def reload(everything=False):
|
def reload(everything=False):
|
||||||
port, pid = read("port"), read("pid")
|
port, pid = read("port"), read("pid")
|
||||||
if not port or not alive(pid):
|
if not port or not alive(pid):
|
||||||
@@ -227,6 +264,8 @@ def main():
|
|||||||
cmd = sys.argv[1]
|
cmd = sys.argv[1]
|
||||||
if cmd == "start":
|
if cmd == "start":
|
||||||
start(sys.argv[2:])
|
start(sys.argv[2:])
|
||||||
|
elif cmd == "run":
|
||||||
|
start(sys.argv[2:], fg=True)
|
||||||
elif cmd == "reload":
|
elif cmd == "reload":
|
||||||
reload("--all" in sys.argv)
|
reload("--all" in sys.argv)
|
||||||
elif cmd == "stop":
|
elif cmd == "stop":
|
||||||
|
|||||||
+71
-10
@@ -11,18 +11,19 @@ Usage:
|
|||||||
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
||||||
with run(game, args).
|
with run(game, args).
|
||||||
Options:
|
Options:
|
||||||
--api URL server (default http://localhost:3000; MM_API env)
|
--api URL server (or MM_API; required, no default: a test server, never :3000)
|
||||||
--size WxH window and display size (default 1280x800)
|
--size WxH window and display size (default 1280x800)
|
||||||
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
||||||
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
||||||
--keep don't stop the game at the end (prints its pid and port)
|
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
|
||||||
|
port and the display)
|
||||||
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
||||||
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
||||||
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
||||||
--log FILE the attached game's log (default dist/dev/game.log)
|
--log FILE the attached game's log (default dist/dev/game.log)
|
||||||
--config K=V extra engine config (mm.player=..., repeatable)
|
--config K=V extra engine config (mm.player=..., repeatable)
|
||||||
--fixture make a test player first (scripts/fixture.py against --api) and play
|
--fixture make a test player first (scripts/fixture.py against --api) and play as
|
||||||
as it; the scenario gets its ids as game.fixture
|
it; the scenario gets its ids as game.fixture
|
||||||
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
||||||
|
|
||||||
The game it starts saves its files (player ids, settings) under <out>/data (or
|
The game it starts saves its files (player ids, settings) under <out>/data (or
|
||||||
@@ -34,6 +35,10 @@ In a scenario:
|
|||||||
game.eval("g.view.section"), game.elements(), game.state(), game.set(k, v),
|
game.eval("g.view.section"), game.elements(), game.state(), game.set(k, v),
|
||||||
game.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
|
game.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
|
||||||
game.lua(src) (raw run_script, <1 KB).
|
game.lua(src) (raw run_script, <1 KB).
|
||||||
|
Layout (scripts/ui_lint.py has the rules): game.dump(name) (the screen's laid-out nodes,
|
||||||
|
saved as <out>/<name>.layout.json), game.lint(name) (dump + lint, prints the issues,
|
||||||
|
saves <name>.lint.json), game.click_check(id, allow=[prefixes]) (dump, click, idle,
|
||||||
|
dump: the clicker rule). Issues so far: game.issues. Scenarios `dump` and `lint`.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -48,6 +53,8 @@ import urllib.request
|
|||||||
|
|
||||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||||
ROOT = os.path.dirname(HERE)
|
ROOT = os.path.dirname(HERE)
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
import ui_lint # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def varint(n):
|
def varint(n):
|
||||||
@@ -86,6 +93,7 @@ class Game:
|
|||||||
self.window_id = None
|
self.window_id = None
|
||||||
self.test_dir = None # where the game writes its replies (mm.test_dir)
|
self.test_dir = None # where the game writes its replies (mm.test_dir)
|
||||||
self.args = None # the driver's options, when it started the game (restart)
|
self.args = None # the driver's options, when it started the game (restart)
|
||||||
|
self.issues = [] # ui_lint issues from lint / click_check, each with "where"
|
||||||
|
|
||||||
# ------------------------------------------------------------ transport
|
# ------------------------------------------------------------ transport
|
||||||
def lua(self, src):
|
def lua(self, src):
|
||||||
@@ -173,6 +181,37 @@ class Game:
|
|||||||
def state(self):
|
def state(self):
|
||||||
return self.ok(self.cmd("state"), "state")["state"]
|
return self.ok(self.cmd("state"), "state")["state"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ layout lint
|
||||||
|
def dump(self, name=None):
|
||||||
|
d = self.ok(self.cmd("dump"), "dump")["dump"]
|
||||||
|
if name:
|
||||||
|
with open(os.path.join(self.out, name + ".layout.json"), "w") as f:
|
||||||
|
json.dump(d, f, ensure_ascii=False)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _report(self, name, issues):
|
||||||
|
for i in issues:
|
||||||
|
i["where"] = name
|
||||||
|
self.issues += issues
|
||||||
|
with open(os.path.join(self.out, name + ".lint.json"), "w") as f:
|
||||||
|
json.dump(issues, f, indent=1, ensure_ascii=False)
|
||||||
|
print(" lint %s: %d issue(s)" % (name, len(issues)))
|
||||||
|
for line in ui_lint.text(issues).splitlines() if issues else []:
|
||||||
|
print(" " + line)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def lint(self, name="screen", min_hit=24):
|
||||||
|
return self._report(name, ui_lint.lint(self.dump(name), min_hit))
|
||||||
|
|
||||||
|
def click_check(self, id_, allow=(), settle=1.0, name=None):
|
||||||
|
name = name or "click-" + re.sub(r"[^A-Za-z0-9_.-]+", "_", id_)
|
||||||
|
before = self.dump(name + ".before")
|
||||||
|
r = self.click(id_)
|
||||||
|
self.idle()
|
||||||
|
time.sleep(settle)
|
||||||
|
after = self.dump(name + ".after")
|
||||||
|
return self._report(name, ui_lint.diff(before, after, r.get("id") or id_, allow))
|
||||||
|
|
||||||
def set(self, k, v):
|
def set(self, k, v):
|
||||||
return self.ok(self.cmd("set", k, v), "set " + k)
|
return self.ok(self.cmd("set", k, v), "set " + k)
|
||||||
|
|
||||||
@@ -236,7 +275,8 @@ class Game:
|
|||||||
self.window_origin()
|
self.window_origin()
|
||||||
if self.window_id:
|
if self.window_id:
|
||||||
self.xdo("windowfocus", "--sync", self.window_id)
|
self.xdo("windowfocus", "--sync", self.window_id)
|
||||||
# (the engine polls keys once a frame: a press and release between two frames is lost)
|
# (the engine polls keys once a frame; sticky keys (mmwindow) keep a press shorter
|
||||||
|
# than a frame, held like a hand anyway)
|
||||||
self.xdo("keydown", name)
|
self.xdo("keydown", name)
|
||||||
time.sleep(0.08)
|
time.sleep(0.08)
|
||||||
self.xdo("keyup", name)
|
self.xdo("keyup", name)
|
||||||
@@ -342,7 +382,7 @@ def main():
|
|||||||
ap = argparse.ArgumentParser(add_help=True)
|
ap = argparse.ArgumentParser(add_help=True)
|
||||||
ap.add_argument("scenario")
|
ap.add_argument("scenario")
|
||||||
ap.add_argument("rest", nargs="*")
|
ap.add_argument("rest", nargs="*")
|
||||||
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
|
ap.add_argument("--api", default=os.environ.get("MM_API"))
|
||||||
ap.add_argument("--size", default="1280x800")
|
ap.add_argument("--size", default="1280x800")
|
||||||
ap.add_argument("--out")
|
ap.add_argument("--out")
|
||||||
ap.add_argument("--bundle", action="store_true")
|
ap.add_argument("--bundle", action="store_true")
|
||||||
@@ -353,16 +393,37 @@ def main():
|
|||||||
ap.add_argument("--fixture", action="store_true")
|
ap.add_argument("--fixture", action="store_true")
|
||||||
ap.add_argument("--fixture-file")
|
ap.add_argument("--fixture-file")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
if not args.api:
|
||||||
|
# (No default: the game makes a player and plays on whatever server this is.)
|
||||||
|
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
|
||||||
|
|
||||||
if args.attach:
|
if args.attach:
|
||||||
disp = os.path.join(ROOT, "dist/dev/display")
|
disp = os.path.join(ROOT, "dist/dev/display")
|
||||||
if os.path.exists(disp):
|
if os.path.exists(disp):
|
||||||
os.environ["DISPLAY"] = open(disp).read().strip() # the headless make dev
|
os.environ["DISPLAY"] = open(disp).read().strip() # the headless make dev
|
||||||
elif not os.environ.get("DISPLAY_FOR_DRIVE"):
|
elif not os.environ.get("DISPLAY_FOR_DRIVE"):
|
||||||
# Re-run inside a fresh Xvfb display of the wanted size.
|
# Re-run inside a fresh Xvfb display of the wanted size. Xvfb picks a free display
|
||||||
cmd = ["xvfb-run", "-a", "-s", "-screen 0 %sx24" % args.size, sys.executable] + sys.argv
|
# itself (-displayfd): xvfb-run -a races when two runs start together, and both
|
||||||
env = dict(os.environ, DISPLAY_FOR_DRIVE="1")
|
# games then share one display (each run's clicks land on the other's window).
|
||||||
sys.exit(subprocess.call(cmd, env=env))
|
r, w = os.pipe()
|
||||||
|
xvfb = subprocess.Popen(["Xvfb", "-displayfd", str(w), "-screen", "0", "%sx24" % args.size, "-nolisten", "tcp"],
|
||||||
|
pass_fds=(w,), stderr=subprocess.DEVNULL)
|
||||||
|
os.close(w)
|
||||||
|
num = os.read(r, 32).decode().strip()
|
||||||
|
os.close(r)
|
||||||
|
if not num:
|
||||||
|
raise SystemExit("Xvfb didn't start")
|
||||||
|
env = dict(os.environ, DISPLAY_FOR_DRIVE="1", DISPLAY=":" + num)
|
||||||
|
try:
|
||||||
|
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
||||||
|
finally:
|
||||||
|
if args.keep:
|
||||||
|
# The kept game draws on this display: leave it running too.
|
||||||
|
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
|
||||||
|
else:
|
||||||
|
xvfb.terminate()
|
||||||
|
xvfb.wait()
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
path = find_scenario(args.scenario)
|
path = find_scenario(args.scenario)
|
||||||
name = os.path.splitext(os.path.basename(path))[0]
|
name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
|
|||||||
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
|
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
|
||||||
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
|
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
|
||||||
credits, resources and refined items granted, two moons, several tools (some drilling,
|
credits, resources and refined items granted, two moons, several tools (some drilling,
|
||||||
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
|
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
|
||||||
--plain: just a new player (nothing granted), for the first-moon views.
|
--plain: just a new player (nothing granted), for the first-moon views.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
|
|||||||
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
|
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
|
||||||
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
|
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
|
||||||
admin("resources", resource=r, amount=400)
|
admin("resources", resource=r, amount=400)
|
||||||
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
|
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
|
||||||
admin("tool", item=item, tier=tier)
|
admin("tool", item=item, **{"yield": yl, "cycle": cl})
|
||||||
|
|
||||||
act("buy-moon")
|
act("buy-moon")
|
||||||
d = act("buy-moon")
|
d = act("buy-moon")
|
||||||
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
|
|||||||
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
|
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
|
||||||
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
|
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
|
||||||
|
|
||||||
act("buy-attachment", item="Turbo Core")
|
# The idle PD-1 a level up on each track (its yield track twice).
|
||||||
act("buy-attachment", item="Diamond Bit")
|
|
||||||
act("buy-attachment", item="Diamond Bit")
|
|
||||||
idle = by_item[1][1]["id"]
|
idle = by_item[1][1]["id"]
|
||||||
act("equip", item="Turbo Core", to=idle)
|
act("upgrade", tool=idle, track="yield")
|
||||||
|
act("upgrade", tool=idle, track="yield")
|
||||||
|
act("upgrade", tool=idle, track="cycle")
|
||||||
|
|
||||||
act("buy-slot")
|
act("buy-slot")
|
||||||
act("refine", recipe="Dusk Alloy", qty=5)
|
act("refine", recipe="Dusk Alloy", qty=5)
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- Unit tests for modules/flex.lua (plain Lua: `lua scripts/flex_test.lua` or luajit, from
|
||||||
|
-- the client folder or anywhere). Exits 1 on a failure.
|
||||||
|
|
||||||
|
local here = (arg and arg[0] or ""):match("^(.*)/scripts/[^/]*$") or "."
|
||||||
|
package.path = here .. "/?.lua;" .. package.path
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local B, L = flex.box, flex.leaf
|
||||||
|
|
||||||
|
local failed, count = 0, 0
|
||||||
|
|
||||||
|
local function rect(b)
|
||||||
|
return { b.x, b.y, b.w, b.h }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function eq(name, got, want)
|
||||||
|
count = count + 1
|
||||||
|
local ok = #got == #want
|
||||||
|
for i = 1, #want do
|
||||||
|
if not got[i] or math.abs(got[i] - want[i]) > 1e-6 then
|
||||||
|
ok = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not ok then
|
||||||
|
failed = failed + 1
|
||||||
|
print(("FAIL %s: got {%s}, want {%s}"):format(name, table.concat(got, ", "), table.concat(want, ", ")))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A fixed-size leaf, and text: n chars of 10 px each, 20 px lines, wrapping by chars.
|
||||||
|
local function fixed(w, h, style)
|
||||||
|
style = style or {}
|
||||||
|
style.width, style.height = style.width or w, style.height or h
|
||||||
|
return B(style)
|
||||||
|
end
|
||||||
|
local function text(n, style)
|
||||||
|
return L(function(avail)
|
||||||
|
local per = math.max(1, math.floor(avail / 10))
|
||||||
|
local lines = math.ceil(n / per)
|
||||||
|
return math.min(n, per) * 10, lines * 20
|
||||||
|
end, style)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Row, gap, padding, content size.
|
||||||
|
do
|
||||||
|
local a, b = fixed(50, 20), fixed(30, 40)
|
||||||
|
local r = flex.solve(B({ gap = 10, padding = { 5, 6, 7, 8 } }, { a, b }), 100, 200)
|
||||||
|
eq("row size", rect(r), { 100, 200, 8 + 50 + 10 + 30 + 6, 5 + 40 + 7 })
|
||||||
|
eq("row a (stretched)", rect(a), { 108, 205, 50, 20 })
|
||||||
|
eq("row b", rect(b), { 168, 205, 30, 40 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Stretch: auto height takes the line's height.
|
||||||
|
do
|
||||||
|
local a, b = B({ width = 50 }), fixed(30, 40)
|
||||||
|
flex.solve(B({}, { a, b }), 0, 0)
|
||||||
|
eq("stretch a", rect(a), { 0, 0, 50, 40 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Column: children stretch to the width; text wraps to it.
|
||||||
|
do
|
||||||
|
local t, f = text(25), fixed(nil, 10)
|
||||||
|
local r = flex.solve(B({ direction = "column", width = 100, padding = 10, gap = 4 }, { t, f }), 0, 0)
|
||||||
|
eq("column text", rect(t), { 10, 10, 80, 80 }) -- 8 per line, 4 lines
|
||||||
|
eq("column fixed", rect(f), { 10, 94, 80, 10 })
|
||||||
|
eq("column size", rect(r), { 0, 0, 100, 114 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Justify.
|
||||||
|
do
|
||||||
|
local function j(mode)
|
||||||
|
local a, b = fixed(20, 10), fixed(30, 10)
|
||||||
|
flex.solve(B({ width = 100, justify = mode }, { a, b }), 0, 0)
|
||||||
|
return { a.x, b.x }
|
||||||
|
end
|
||||||
|
eq("justify start", j("start"), { 0, 20 })
|
||||||
|
eq("justify end", j("end"), { 50, 70 })
|
||||||
|
eq("justify center", j("center"), { 25, 45 })
|
||||||
|
eq("justify space-between", j("space-between"), { 0, 70 })
|
||||||
|
eq("justify space-around", j("space-around"), { 12.5, 57.5 })
|
||||||
|
eq("justify space-evenly", j("space-evenly"), { 50 / 3, 50 / 3 * 2 + 20 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Align and align_self.
|
||||||
|
do
|
||||||
|
local a, b, c, d = fixed(10, 10), fixed(10, 10, { align_self = "end" }), fixed(10, 10, { align_self = "center" }), B({ width = 10, align_self = "stretch" })
|
||||||
|
flex.solve(B({ height = 50, align = "start" }, { a, b, c, d }), 0, 0)
|
||||||
|
eq("align start", rect(a), { 0, 0, 10, 10 })
|
||||||
|
eq("align_self end", rect(b), { 10, 40, 10, 10 })
|
||||||
|
eq("align_self center", rect(c), { 20, 20, 10, 10 })
|
||||||
|
eq("align_self stretch", rect(d), { 30, 0, 10, 50 })
|
||||||
|
local e = fixed(10, 10)
|
||||||
|
flex.solve(B({ direction = "column", width = 50, align = "end" }, { e }), 0, 0)
|
||||||
|
eq("column align end", rect(e), { 40, 0, 10, 10 })
|
||||||
|
local t = text(3)
|
||||||
|
flex.solve(B({ direction = "column", width = 50, align = "center" }, { t }), 0, 0)
|
||||||
|
eq("column align center (fit content)", rect(t), { 10, 0, 30, 20 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Grow (by factor), basis, shrink (weighted by basis), min/max freezing.
|
||||||
|
do
|
||||||
|
local a, b, c = fixed(10, 10, { grow = 1 }), fixed(10, 10, { grow = 3 }), fixed(20, 10)
|
||||||
|
flex.solve(B({ width = 120 }, { a, b, c }), 0, 0)
|
||||||
|
eq("grow 1", rect(a), { 0, 0, 30, 10 })
|
||||||
|
eq("grow 3", rect(b), { 30, 0, 70, 10 })
|
||||||
|
eq("grow 0", rect(c), { 100, 0, 20, 10 })
|
||||||
|
|
||||||
|
local d, e = fixed(10, 10, { basis = 60 }), fixed(10, 10, { basis = "50%", grow = 1 })
|
||||||
|
flex.solve(B({ width = 200 }, { d, e }), 0, 0)
|
||||||
|
eq("basis px", rect(d), { 0, 0, 60, 10 })
|
||||||
|
eq("basis % + grow", rect(e), { 60, 0, 140, 10 })
|
||||||
|
|
||||||
|
local f, g = fixed(100, 10), fixed(50, 10, { shrink = 1 })
|
||||||
|
flex.solve(B({ width = 120 }, { f, g }), 0, 0)
|
||||||
|
eq("shrink weighted a", rect(f), { 0, 0, 80, 10 })
|
||||||
|
eq("shrink weighted b", rect(g), { 80, 0, 40, 10 })
|
||||||
|
|
||||||
|
local h, i = fixed(100, 10, { shrink = 0 }), fixed(50, 10)
|
||||||
|
flex.solve(B({ width = 120 }, { h, i }), 0, 0)
|
||||||
|
eq("shrink 0", { h.w, i.w }, { 100, 20 })
|
||||||
|
|
||||||
|
local k, l = fixed(10, 10, { grow = 1, max_width = 30 }), fixed(10, 10, { grow = 1 })
|
||||||
|
flex.solve(B({ width = 100 }, { k, l }), 0, 0)
|
||||||
|
eq("max freezes, rest grows", { k.w, l.w }, { 30, 70 })
|
||||||
|
|
||||||
|
local m, n = fixed(100, 10, { min_width = 90 }), fixed(100, 10)
|
||||||
|
flex.solve(B({ width = 120 }, { m, n }), 0, 0)
|
||||||
|
eq("min freezes, rest shrinks", { m.w, n.w }, { 90, 30 })
|
||||||
|
|
||||||
|
local o = fixed(10, 10, { grow = 0.5 })
|
||||||
|
flex.solve(B({ width = 110 }, { o }), 0, 0)
|
||||||
|
eq("grow under 1 takes its share", { o.w }, { 60 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Width/height in px, %, auto; min/max height; root max_width.
|
||||||
|
do
|
||||||
|
local a, b = B({ width = "25%", height = "50%" }), B({ width = "auto", height = 10 })
|
||||||
|
flex.solve(B({ width = 200, height = 80, align = "start" }, { a, b }), 0, 0)
|
||||||
|
eq("% sizes", rect(a), { 0, 0, 50, 40 })
|
||||||
|
eq("auto width, nothing inside", rect(b), { 50, 0, 0, 10 })
|
||||||
|
local c = B({ width = 10, min_height = 30 })
|
||||||
|
local r = flex.solve(B({ direction = "column", max_height = 20 }, { c }), 0, 0)
|
||||||
|
eq("min_height", rect(c), { 0, 0, 10, 30 })
|
||||||
|
eq("max_height", { r.h }, { 20 })
|
||||||
|
local t = text(30)
|
||||||
|
local r2 = flex.solve(B({ max_width = 105, padding = 5 }, { t }), 0, 0)
|
||||||
|
eq("max_width caps the text", rect(r2), { 0, 0, 100, 4 * 20 + 10 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Text in a row with a fixed sibling: shrinks (min-width 0) and wraps taller.
|
||||||
|
do
|
||||||
|
local t, f = text(10, { grow = 1 }), fixed(40, 20, { shrink = 0 })
|
||||||
|
flex.solve(B({ width = 100 }, { t, f }), 0, 0)
|
||||||
|
eq("row text shrinks", rect(t), { 0, 0, 60, 40 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wrap: lines, gaps, equal heights per line (stretch), fixed-width cards like a grid.
|
||||||
|
do
|
||||||
|
local kids = {}
|
||||||
|
for i = 1, 5 do
|
||||||
|
kids[i] = B({ width = 90, direction = "column" }, { fixed(nil, i == 2 and 50 or 30) })
|
||||||
|
end
|
||||||
|
local r = flex.solve(B({ width = 300, wrap = true, gap = 10 }, kids), 0, 0)
|
||||||
|
eq("wrap 1", rect(kids[1]), { 0, 0, 90, 50 })
|
||||||
|
eq("wrap 3", rect(kids[3]), { 200, 0, 90, 50 })
|
||||||
|
eq("wrap 4 (line 2)", rect(kids[4]), { 0, 60, 90, 30 })
|
||||||
|
eq("wrap 5", rect(kids[5]), { 100, 60, 90, 30 })
|
||||||
|
eq("wrap size", rect(r), { 0, 0, 300, 90 })
|
||||||
|
local s = B({ width = 90, height = 20, align_self = "start" })
|
||||||
|
local t = fixed(90, 40)
|
||||||
|
flex.solve(B({ width = 300, wrap = true }, { s, t }), 0, 0)
|
||||||
|
eq("wrap align_self start keeps its height", rect(s), { 0, 0, 90, 20 })
|
||||||
|
-- Column wrap with a definite height.
|
||||||
|
local a, b, c = fixed(20, 30), fixed(20, 30), fixed(25, 30)
|
||||||
|
flex.solve(B({ direction = "column", height = 70, wrap = true, gap = 5, align = "start" }, { a, b, c }), 0, 0)
|
||||||
|
eq("column wrap", rect(c), { 25, 0, 25, 30 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Nested: a card (column) with a footer pushed down by a growing spacer.
|
||||||
|
do
|
||||||
|
local head, spacer, foot = fixed(nil, 20), B({ grow = 1 }), fixed(nil, 30)
|
||||||
|
flex.solve(B({ direction = "column", width = 100, height = 120, padding = 10 }, { head, spacer, foot }), 0, 0)
|
||||||
|
eq("footer at the bottom", rect(foot), { 10, 80, 80, 30 })
|
||||||
|
-- A row with a column inside: the column's height is its content.
|
||||||
|
local t = text(12)
|
||||||
|
local col = B({ direction = "column", grow = 1 }, { t })
|
||||||
|
local img = fixed(40, 40, { shrink = 0 })
|
||||||
|
local r = flex.solve(B({ width = 100, align = "start" }, { img, col }), 0, 0)
|
||||||
|
eq("nested text", rect(t), { 40, 0, 60, 40 })
|
||||||
|
eq("nested height", { r.h }, { 40 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- display none, ids, gui_pos.
|
||||||
|
do
|
||||||
|
local a, hid, b = fixed(10, 10, nil), B({ display = "none", width = 99 }), fixed(10, 10)
|
||||||
|
a.id = "a"
|
||||||
|
flex.solve(B({ gap = 5 }, { a, hid, b }), 0, 0)
|
||||||
|
eq("display none skipped", { b.x }, { 15 })
|
||||||
|
eq("display none no rect", { hid.w == nil and 1 or 0 }, { 1 })
|
||||||
|
local idx = flex.index(flex.solve(B({}, { a }), 0, 0))
|
||||||
|
eq("index", { idx.a.w }, { 10 })
|
||||||
|
local r = { x = 10, y = 20, w = 100, h = 40 }
|
||||||
|
eq("gui_pos NW", { flex.gui_pos(r, 8) }, { 10, -20 })
|
||||||
|
eq("gui_pos W", { flex.gui_pos(r, 7) }, { 10, -40 })
|
||||||
|
eq("gui_pos E", { flex.gui_pos(r, 3) }, { 110, -40 })
|
||||||
|
eq("gui_pos CENTER rel", { flex.gui_pos(r, 0, 10, 20) }, { 50, -20 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- columns, grid
|
||||||
|
do
|
||||||
|
eq("columns 3", { flex.columns(924, 200, 12) }, { 4, 222 })
|
||||||
|
eq("columns at least 1", { flex.columns(150, 200, 12) }, { 1, 150 })
|
||||||
|
local a, b, c = L(function() return 10, 30 end), fixed(10, 50), fixed(10, 20) -- (a stretches)
|
||||||
|
local g = flex.solve(flex.grid(100, 40, 10, { a, b, c }), 0, 0)
|
||||||
|
eq("grid a", rect(a), { 0, 0, 45, 50 })
|
||||||
|
eq("grid b", rect(b), { 55, 0, 45, 50 })
|
||||||
|
eq("grid c next row", rect(c), { 0, 60, 45, 20 })
|
||||||
|
eq("grid size", { g.w, g.h }, { 100, 80 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CSS grid subset: fixed, fr and auto columns, gaps, row height from the tallest cell, span.
|
||||||
|
do
|
||||||
|
local name, sup, drill, st = L(function() return 70, 20 end), fixed(nil, 30), text(5), fixed(50, 20)
|
||||||
|
local w2 = L(function() return 40, 20 end)
|
||||||
|
local spanned = fixed(nil, 10, { span = 2 })
|
||||||
|
local g = flex.solve(B({ columns = { "auto", 100, "1fr", "auto" }, column_gap = 10, row_gap = 4, width = 400, padding = 5 },
|
||||||
|
{ name, sup, drill, st, w2, spanned }), 0, 0)
|
||||||
|
-- auto col 1: max(70, 40) = 70; col 4: 50; fr: 390 - 70 - 100 - 50 - 30 = 140
|
||||||
|
eq("grid auto col", rect(name), { 5, 5, 70, 30 })
|
||||||
|
eq("grid fixed col", rect(sup), { 85, 5, 100, 30 })
|
||||||
|
eq("grid fr col (stretched)", rect(drill), { 195, 5, 140, 30 })
|
||||||
|
eq("grid auto end col", rect(st), { 345, 5, 50, 20 })
|
||||||
|
eq("grid row 2", rect(w2), { 5, 39, 70, 20 })
|
||||||
|
eq("grid span", rect(spanned), { 85, 39, 250, 10 })
|
||||||
|
eq("grid size", { g.w, g.h }, { 400, 5 + 30 + 4 + 20 + 5 })
|
||||||
|
-- auto width: fr columns size to content.
|
||||||
|
local a, b2 = L(function() return 30, 10 end), L(function() return 20, 12 end)
|
||||||
|
local g2 = flex.solve(B({ columns = { "1fr", "auto" }, gap = 6, align = "center" }, { a, b2 }), 0, 0)
|
||||||
|
eq("grid fit size", { g2.w, g2.h }, { 56, 12 })
|
||||||
|
eq("grid align center", rect(a), { 0, 1, 30, 10 })
|
||||||
|
local c1 = L(function() return 30, 10 end, { justify_self = "end" })
|
||||||
|
flex.solve(B({ columns = { 100 }, rows = { 24 } }, { c1 }), 0, 0)
|
||||||
|
eq("grid justify end, fixed row", rect(c1), { 70, 0, 30, 24 })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Absolute children, anchor, aspect ratio, scroll content size.
|
||||||
|
do
|
||||||
|
local badge = fixed(16, 12, { position = "absolute", top = -6, right = -6 })
|
||||||
|
local mid = fixed(20, 10, { position = "absolute", left = "50%", top = "50%", translate = { -0.5, -0.5 } })
|
||||||
|
local fill = B({ position = "absolute", left = 2, right = 2, top = 0, bottom = 0 })
|
||||||
|
local a = fixed(30, 20)
|
||||||
|
local r = flex.solve(B({ width = 100, padding = 4 }, { a, badge, mid, fill }), 10, 10)
|
||||||
|
eq("abs out of flow", { r.h }, { 28 })
|
||||||
|
eq("abs top right", rect(badge), { 10 + 100 + 6 - 16, 4, 16, 12 })
|
||||||
|
eq("abs centered", rect(mid), { 10 + 40, 10 + 9, 20, 10 })
|
||||||
|
eq("abs inset", rect(fill), { 12, 10, 96, 28 })
|
||||||
|
local num = L(function() return 24, 14 end, { left = "50%", bottom = "100%", translate = { -0.5, 0 } })
|
||||||
|
flex.anchor({ x = 100, y = 50, w = 80, h = 30 }, num)
|
||||||
|
eq("anchor above, centered", rect(num), { 128, 36, 24, 14 })
|
||||||
|
local img = B({ aspect_ratio = 2 })
|
||||||
|
local img2 = B({ height = 30, aspect_ratio = 1.5 })
|
||||||
|
flex.solve(B({ direction = "column", width = 120 }, { img, B({}, { img2 }) }), 0, 0)
|
||||||
|
eq("aspect stretched width", rect(img), { 0, 0, 120, 60 })
|
||||||
|
eq("aspect from height", rect(img2), { 0, 60, 45, 30 })
|
||||||
|
local rows = {}
|
||||||
|
for i = 1, 5 do
|
||||||
|
rows[i] = fixed(nil, 40)
|
||||||
|
end
|
||||||
|
local sc = flex.solve(B({ direction = "column", height = 100, padding = 5, gap = 10, overflow = "scroll" }, rows), 0, 0)
|
||||||
|
eq("scroll no shrink", { rows[5].y, rows[5].h }, { 5 + 4 * 50, 40 })
|
||||||
|
eq("scroll content size", { flex.content_size(sc) }, { 10, 250 })
|
||||||
|
end
|
||||||
|
|
||||||
|
if failed > 0 then
|
||||||
|
print(("flex: %d of %d failed"):format(failed, count))
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
print(("flex: %d ok"):format(count))
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// Checks the Lua moon generator (modules/moon/terrain.lua) against the server's
|
|
||||||
// terrain.js: same random draws, traits and colors, and the same mesh within float32
|
|
||||||
// rounding. Needs node and luajit.
|
|
||||||
//
|
|
||||||
// node scripts/moon-check.mjs [resolution] [seeds...]
|
|
||||||
//
|
|
||||||
// terrain.js is read from $MM_SERVER (default ../server).
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
||||||
const server = process.env.MM_SERVER || path.join(root, "..", "server");
|
|
||||||
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
|
|
||||||
const [res = "32", ...rest] = process.argv.slice(2);
|
|
||||||
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9"];
|
|
||||||
|
|
||||||
const lua = `
|
|
||||||
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
|
|
||||||
local T = require("modules.moon.terrain")
|
|
||||||
local function num(x) return string.format("%.17g", x) end
|
|
||||||
local function arr(a) local o = {} for k = 1, #a do o[k] = num(a[k]) end return "[" .. table.concat(o, ",") .. "]" end
|
|
||||||
local out = {}
|
|
||||||
for i = 2, #arg do -- arg[1]: resolution, then the seeds
|
|
||||||
local seed = arg[i]
|
|
||||||
local s = T.merge_settings({})
|
|
||||||
local r = T.rng(T.hash_string(seed))
|
|
||||||
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
|
|
||||||
local tr = T.traits(s, seed)
|
|
||||||
local p = T.resolve(s, seed)
|
|
||||||
p.resolution = tonumber(arg[1])
|
|
||||||
local m = T.generate(p)
|
|
||||||
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s}',
|
|
||||||
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
|
|
||||||
num(tr.maxRadius), arr(m.positions), arr(m.normals), arr(m.colors))
|
|
||||||
end
|
|
||||||
print("[" .. table.concat(out, ",") .. "]")
|
|
||||||
`;
|
|
||||||
const got = JSON.parse(execFileSync("luajit", ["-", res, ...seeds], { input: lua, maxBuffer: 1 << 30 }).toString());
|
|
||||||
|
|
||||||
let failed = false;
|
|
||||||
seeds.forEach((seed, i) => {
|
|
||||||
const s = T.mergeSettings({});
|
|
||||||
const r = T.rng(T.hashString(seed));
|
|
||||||
const draws = Array.from({ length: 16 }, () => r());
|
|
||||||
const t = T.moonTraits(s, seed);
|
|
||||||
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
|
|
||||||
const p = T.resolveMoon(s, seed);
|
|
||||||
p.resolution = Number(res);
|
|
||||||
const m = T.generateMoon(p);
|
|
||||||
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
|
|
||||||
const d = {
|
|
||||||
positions: maxDiff(m.positions, got[i].positions),
|
|
||||||
normals: maxDiff(m.normals, got[i].normals),
|
|
||||||
colors: maxDiff(m.colors, got[i].colors),
|
|
||||||
};
|
|
||||||
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
|
|
||||||
const ok = exact && d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4;
|
|
||||||
failed ||= !ok;
|
|
||||||
console.log(`${ok ? "ok " : "FAIL"} ${seed} ${t.technique}: draws and traits ${exact ? "identical" : "DIFFER"}, max diff positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`);
|
|
||||||
});
|
|
||||||
process.exit(failed ? 1 : 0);
|
|
||||||
@@ -26,7 +26,7 @@ def run(game, args):
|
|||||||
game.navigate("{section='new-moon'}")
|
game.navigate("{section='new-moon'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
n0 = game.eval("#st.moons")
|
n0 = game.eval("#st.moons")
|
||||||
game.click("act:buy-moon?include=new-moon")
|
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||||
card_done(game)
|
card_done(game)
|
||||||
first = game.eval("g.reveal.r.data.moonId")
|
first = game.eval("g.reveal.r.data.moonId")
|
||||||
els = [e["id"] for e in game.elements()]
|
els = [e["id"] for e in game.elements()]
|
||||||
@@ -53,7 +53,7 @@ def run(game, args):
|
|||||||
admin(game, "credits", amount=round(extra, 2), op="remove")
|
admin(game, "credits", amount=round(extra, 2), op="remove")
|
||||||
game.navigate("{section='new-moon'}")
|
game.navigate("{section='new-moon'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
game.click("act:buy-moon?include=new-moon")
|
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||||
card_done(game)
|
card_done(game)
|
||||||
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
|
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
|
||||||
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
|
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Card sections (needs --fixture): a click on a tool card opens its drill panel, a click
|
"""Card sections (needs --fixture): a click on a tool row (Equipment) opens its drill panel, a click
|
||||||
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
|
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
|
||||||
close are focusable. Shots of each."""
|
close are focusable. Shots of each."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""The confirm dialog (needs --fixture): open it from the drill panel's spare attachment
|
"""The confirm dialog (needs --fixture): open it from the refinery's buy-slot button
|
||||||
Sell, shots mid-open and open, Esc cancels, open again, OK sells (a toast)."""
|
(sells ask in place now: sellask.py), shots mid-open and open, Esc cancels, open again,
|
||||||
|
OK buys (a toast)."""
|
||||||
|
|
||||||
|
|
||||||
def open_sell(game, shot=None):
|
def open_sell(game, shot=None):
|
||||||
"""Click Sell until the dialog opens: a rebuild (a drill finishing) can drop a press."""
|
"""Click until the dialog opens: a rebuild (a drill finishing) can drop a press."""
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
game.scroll_to("confirm:sell-attachment")
|
game.scroll_to("confirm:buy-slot")
|
||||||
game.idle()
|
game.idle()
|
||||||
game.click("confirm:sell-attachment")
|
game.click("confirm:buy-slot")
|
||||||
if shot:
|
if shot:
|
||||||
game.shot(shot, settle=0.08)
|
game.shot(shot, settle=0.08)
|
||||||
shot = None
|
shot = None
|
||||||
@@ -21,7 +22,7 @@ def open_sell(game, shot=None):
|
|||||||
def run(game, args):
|
def run(game, args):
|
||||||
fx = game.fixture
|
fx = game.fixture
|
||||||
game.idle()
|
game.idle()
|
||||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
game.navigate("{section='refinery'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
open_sell(game, "dialog-opening")
|
open_sell(game, "dialog-opening")
|
||||||
game.shot("dialog-open", settle=0.6)
|
game.shot("dialog-open", settle=0.6)
|
||||||
@@ -32,5 +33,5 @@ def run(game, args):
|
|||||||
game.sleep(0.5)
|
game.sleep(0.5)
|
||||||
game.click("dialog:ok")
|
game.click("dialog:ok")
|
||||||
game.wait_for("not g.dialog.root and not g.busy")
|
game.wait_for("not g.dialog.root and not g.busy")
|
||||||
game.expect(game.log_has(r"mm: toast Sold"), "selling shows a toast")
|
game.expect(game.log_has(r"mm: toast"), "buying shows a toast")
|
||||||
game.shot("dialog-after", settle=0.3)
|
game.shot("dialog-after", settle=0.3)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
|
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
|
||||||
directory: it saves the settings file): the header's display button opens the dialog;
|
directory: it saves the settings file): the header's gear opens the settings menu;
|
||||||
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
|
+ / - change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
|
||||||
dialog's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
|
menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
|
||||||
silent while the window is hidden."""
|
silent while the window is hidden."""
|
||||||
|
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ def run(game, args):
|
|||||||
pre = (args[0] + "-") if args else ""
|
pre = (args[0] + "-") if args else ""
|
||||||
game.idle()
|
game.idle()
|
||||||
W0 = game.eval("g.size[1]")
|
W0 = game.eval("g.size[1]")
|
||||||
game.click("display")
|
game.click("settings")
|
||||||
game.wait_for("g.dialog:is_open()", timeout=5)
|
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||||
game.shot(pre + "display-dialog", settle=0.8)
|
game.shot(pre + "settings-menu", settle=0.8)
|
||||||
game.click("dialog:scale-up")
|
game.click("dialog:scale-up")
|
||||||
game.click("dialog:scale-up")
|
game.click("dialog:scale-up")
|
||||||
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
|
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
|
||||||
@@ -19,7 +19,8 @@ def run(game, args):
|
|||||||
W1 = game.eval("g.size[1]")
|
W1 = game.eval("g.size[1]")
|
||||||
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
|
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
|
||||||
game.shot(pre + "display-125", settle=0.8)
|
game.shot(pre + "display-125", settle=0.8)
|
||||||
game.click("dialog:scale-reset")
|
game.click("dialog:scale-down")
|
||||||
|
game.click("dialog:scale-down")
|
||||||
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
|
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
|
||||||
game.key("key_esc")
|
game.key("key_esc")
|
||||||
game.wait_for("not g.dialog:is_open()", timeout=5)
|
game.wait_for("not g.dialog:is_open()", timeout=5)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""The drill panel during onboarding (needs --fixture-file of a plain player:
|
||||||
|
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
|
||||||
|
Equipment, a tool row opens its panel (stats, the tracks' ladders) without the Store's
|
||||||
|
parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
|
||||||
|
the same panel has them. Shots "before" and "after", each linted."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from lists import admin, call # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def labels(game):
|
||||||
|
"""Every shown text on screen, lower case."""
|
||||||
|
return {(n.get("text") or "").lower() for n in game.dump()["nodes"] if n.get("text")}
|
||||||
|
|
||||||
|
|
||||||
|
def open_panel(game, tool):
|
||||||
|
game.navigate("{section='equipment'}")
|
||||||
|
game.idle()
|
||||||
|
game.click("drill:" + tool)
|
||||||
|
game.wait_for("g.view.drill ~= '' and g.state.equipment.drill ~= nil", 10)
|
||||||
|
game.idle()
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
game.idle()
|
||||||
|
admin(game, "credits", amount=5000, op="grant")
|
||||||
|
r = call(game, "POST", "/api/act/buy-moon", {})
|
||||||
|
game.expect(r.get("ok"), "buy-moon: %s" % r.get("error"))
|
||||||
|
st = r["state"]
|
||||||
|
tool = st["tools"][0]["id"]
|
||||||
|
well = st["moon"]["wells"][0]["id"]
|
||||||
|
r = call(game, "POST", "/api/act/stake", {"tool": tool, "target": well})
|
||||||
|
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||||
|
u = r["state"]["unlocked"]
|
||||||
|
game.expect(u["equipment"] and not u["store"], "after the first deploy: %s" % u)
|
||||||
|
|
||||||
|
open_panel(game, tool)
|
||||||
|
game.sleep(2) # (the wallet counts down from the moon purchase)
|
||||||
|
game.shot("before")
|
||||||
|
game.lint("before")
|
||||||
|
texts = labels(game)
|
||||||
|
game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
|
||||||
|
game.expect("sell drill" not in texts, "sell drill hidden before the Store")
|
||||||
|
ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
|
||||||
|
game.expect(not ups, "no upgrade before the Store: %s" % ups)
|
||||||
|
|
||||||
|
# First claim (Market), first sale (Store).
|
||||||
|
admin(game, "finish-stakes")
|
||||||
|
r = call(game, "POST", "/api/act/claim", {"claim-well": well})
|
||||||
|
game.expect(r.get("ok"), "claim: %s" % r.get("error"))
|
||||||
|
res = [x for x in r["state"]["resources"] if x["amount"] > 0][0]["name"]
|
||||||
|
r = call(game, "POST", "/api/act/sell", {"resource": res, "all": "1"})
|
||||||
|
game.expect(r.get("ok"), "sell: %s" % r.get("error"))
|
||||||
|
game.expect(r["state"]["unlocked"]["store"], "store unlocked after the sale")
|
||||||
|
|
||||||
|
open_panel(game, tool)
|
||||||
|
game.sleep(3) # (the sale's effects and the wallet)
|
||||||
|
game.shot("after")
|
||||||
|
game.lint("after")
|
||||||
|
texts = labels(game)
|
||||||
|
for t in ("yield per cycle", "sell drill"):
|
||||||
|
game.expect(t in texts, "%s shown after the Store" % t)
|
||||||
|
ups = {e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")}
|
||||||
|
for kind in ("yield", "cycle"):
|
||||||
|
game.expect(any(("track=" + kind) in i for i in ups), "%s upgrade after the Store: %s" % (kind, ups))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""The current screen's layout dump and its lint (no fixture needed; works with --attach).
|
||||||
|
Args: [view] [name]: a view to go to first ("{section='market'}"), the file name (default
|
||||||
|
"screen"). Writes <out>/<name>.layout.json and <name>.lint.json, prints the issues."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
game.idle()
|
||||||
|
if args and args[0].startswith("{"):
|
||||||
|
game.navigate(args[0])
|
||||||
|
game.idle()
|
||||||
|
game.sleep(1.0)
|
||||||
|
args = args[1:]
|
||||||
|
game.lint(args[0] if args else "screen")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""The Equipment table (needs --fixture): a row per tool, copies too (the fixture's two
|
||||||
|
PD-1s), one mining, one ready; the idle PD-1's cycle track taken to its top (Max in place
|
||||||
|
of its button) and the credits cut to a few hundred (some upgrades short). Shots of the
|
||||||
|
table, a row's drill panel, a row's Sell asking in place (Cancel on Sell's spot), and two
|
||||||
|
clicks on an affordable upgrade button (click_check: it stays put). Each linted. Args: an
|
||||||
|
optional name prefix."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from lists import admin, call # noqa: E402
|
||||||
|
from sellask import ask, covers, rect_of # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
fx = game.fixture
|
||||||
|
pre = (args[0] + "-") if args else ""
|
||||||
|
idle = fx["tool_idle"]
|
||||||
|
game.idle()
|
||||||
|
for _ in range(12):
|
||||||
|
r = call(game, "POST", "/api/act/upgrade", {"tool": idle, "track": "cycle"})
|
||||||
|
if not r.get("ok"):
|
||||||
|
break
|
||||||
|
st = call(game, "GET", "/api/state?s=equipment")["state"]
|
||||||
|
tr = [t for t in st["equipment"]["tools"] if t["id"] == idle][0]["tracks"][1]
|
||||||
|
game.expect(tr["next"] is None, "cycle track at its top: %s/%s" % (tr["level"], tr["max"]))
|
||||||
|
# One mining for sure (the fixture's stakes can find their well empty): an idle tool
|
||||||
|
# other than the PD-1s on the second moon.
|
||||||
|
ms = call(game, "GET", "/api/state?s=moons&moon=" + fx["moon2"])["state"]
|
||||||
|
wells = [w for w in ms["moon"]["wells"] if w["supply"] > 0]
|
||||||
|
spare = [t for t in st["equipment"]["tools"] if not t["stake"] and t["storeItemId"] != 1]
|
||||||
|
if wells and spare:
|
||||||
|
r = call(game, "POST", "/api/act/stake", {"tool": spare[0]["id"], "target": wells[0]["id"]})
|
||||||
|
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||||
|
admin(game, "credits", amount=max(1, st["credits"] - 260), op="remove")
|
||||||
|
game.navigate("{section='equipment'}")
|
||||||
|
game.idle()
|
||||||
|
game.sleep(1)
|
||||||
|
game.shot(pre + "eqrows-table", settle=1.0)
|
||||||
|
game.lint(pre + "eqrows-table")
|
||||||
|
|
||||||
|
# A row opens its drill panel.
|
||||||
|
game.scroll_to("drill:" + idle)
|
||||||
|
game.idle()
|
||||||
|
game.click("drill:" + idle)
|
||||||
|
game.wait_for("view.drill == '%s' and not g.busy" % idle)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "eqrows-panel", settle=1.0)
|
||||||
|
game.lint(pre + "eqrows-panel")
|
||||||
|
|
||||||
|
# A row's Sell asks in place.
|
||||||
|
sell = "confirm:sell-tool:" + idle
|
||||||
|
game.scroll_to(sell)
|
||||||
|
game.idle()
|
||||||
|
before = rect_of(game, sell)
|
||||||
|
ask(game, sell)
|
||||||
|
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
|
||||||
|
game.expect(covers(rect_of(game, sell + ":cancel"), before), "Cancel takes Sell's spot")
|
||||||
|
game.shot(pre + "eqrows-asking", settle=0.3)
|
||||||
|
game.lint(pre + "eqrows-asking")
|
||||||
|
game.key("key_esc")
|
||||||
|
game.wait_for("g.ask == nil", 5)
|
||||||
|
game.idle()
|
||||||
|
|
||||||
|
# Two clicks on an affordable upgrade keep hitting the same row's button.
|
||||||
|
ups = [e for e in game.elements() if e["id"].startswith("act:upgrade?") and e.get("enabled", True)]
|
||||||
|
game.expect(ups, "an affordable upgrade")
|
||||||
|
if ups:
|
||||||
|
id_ = ups[0]["id"]
|
||||||
|
for n in range(2):
|
||||||
|
if game.eval("el(%r)" % id_):
|
||||||
|
game.click_check(id_, name=pre + "eqrows-click-%d" % n)
|
||||||
|
game.shot(pre + "eqrows-after", settle=1.0)
|
||||||
@@ -44,13 +44,14 @@ def run(game, args):
|
|||||||
game.wait_for("not g.dialog:is_open()", 5)
|
game.wait_for("not g.dialog:is_open()", 5)
|
||||||
game.idle()
|
game.idle()
|
||||||
|
|
||||||
# A confirm: OK has the focus, Enter sells (after a mouse click).
|
# A confirm: OK has the focus, Enter buys (after a mouse click). (Sells ask in place:
|
||||||
game.navigate("{section='equipment', drill='%s'}" % game.fixture["tool_idle"])
|
# sellask.py.)
|
||||||
|
game.navigate("{section='refinery'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
game.scroll_to("confirm:sell-attachment")
|
game.scroll_to("confirm:buy-slot")
|
||||||
game.idle()
|
game.idle()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
game.click("confirm:sell-attachment")
|
game.click("confirm:buy-slot")
|
||||||
try:
|
try:
|
||||||
game.wait_for("g.dialog:is_open()", 6)
|
game.wait_for("g.dialog:is_open()", 6)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Moon grades (needs --fixture and an MM_DEV=1 server: grants through the admin page).
|
||||||
|
The new-moon panel lists a row per grade with its Claim button (shot); the grade toggle
|
||||||
|
next to "+ New moon" changes the grade it buys and its price without moving it; it buys
|
||||||
|
that grade, the reveal shows the grade and Buy another names it (shot); the moon list
|
||||||
|
row and the moon heading show the grade badge (shot)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from lists import admin # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
game.idle()
|
||||||
|
admin(game, "credits", amount=5000, op="grant")
|
||||||
|
game.navigate("{section='new-moon'}")
|
||||||
|
game.idle()
|
||||||
|
grades = game.eval("(function() local t = {} for _, e in ipairs(st.newMoon.grades) do t[#t+1] = e.grade .. (e.canAfford and '+' or '-') end return table.concat(t, ',') end)()")
|
||||||
|
print("grades:", grades)
|
||||||
|
els = [e["id"] for e in game.elements()]
|
||||||
|
game.expect(any(i.startswith("newmoon:grade:") for i in els), "grade rows registered")
|
||||||
|
game.shot("grades-panel", settle=0.4)
|
||||||
|
g0 = game.eval("require('screens.game.moonlist').grade(g)")
|
||||||
|
game.click("newmoon:grade")
|
||||||
|
game.idle()
|
||||||
|
g1 = game.eval("require('screens.game.moonlist').grade(g)")
|
||||||
|
game.expect(g0 != g1, "toggle changes the grade: %s -> %s" % (g0, g1))
|
||||||
|
game.click("newmoon:grade")
|
||||||
|
game.idle()
|
||||||
|
game.shot("grades-toggled", settle=0.3)
|
||||||
|
gb = game.eval("require('screens.game.moonlist').grade(g)")
|
||||||
|
game.click("newmoon:buy")
|
||||||
|
game.wait_for("g.reveal:active() and g.reveal:card_done()", 40)
|
||||||
|
game.sleep(0.4)
|
||||||
|
got = game.eval("g.reveal.r.data.grade .. '|' .. (g.reveal.r.data.next.grade or '')")
|
||||||
|
game.expect(got == gb + "|" + gb, "bought grade %s: %s" % (gb, got))
|
||||||
|
game.shot("grades-reveal", settle=0.3)
|
||||||
|
game.key("key_esc")
|
||||||
|
game.wait_for("not g.reveal:active()", 10)
|
||||||
|
game.idle()
|
||||||
|
game.shot("grades-moon", settle=0.4)
|
||||||
@@ -28,6 +28,7 @@ def alive(game, what):
|
|||||||
target = next(t for t in TABS if t != section)
|
target = next(t for t in TABS if t != section)
|
||||||
b0 = game.eval("g.builds")
|
b0 = game.eval("g.builds")
|
||||||
n0 = game.eval("g.engine_inputs or 0")
|
n0 = game.eval("g.engine_inputs or 0")
|
||||||
|
game.eval("(function() g.il_touch = {} return true end)()")
|
||||||
ok = False
|
ok = False
|
||||||
game.os_click("tab:" + target) # (once: a rebuild between the press and the release carries it)
|
game.os_click("tab:" + target) # (once: a rebuild between the press and the release carries it)
|
||||||
for _ in range(15): # (the navigation started: with fake latency it lands seconds later)
|
for _ in range(15): # (the navigation started: with fake latency it lands seconds later)
|
||||||
@@ -36,18 +37,20 @@ def alive(game, what):
|
|||||||
break
|
break
|
||||||
game.sleep(0.1)
|
game.sleep(0.1)
|
||||||
got = game.eval("g.engine_inputs or 0") - n0
|
got = game.eval("g.engine_inputs or 0") - n0
|
||||||
|
if not ok: # (what the engine delivered: touch presses and releases, where, over what)
|
||||||
|
print(" touches:", game.eval("g.il_touch"))
|
||||||
print(" %s: %d engine inputs, builds %d before the click" % (what, got, b0))
|
print(" %s: %d engine inputs, builds %d before the click" % (what, got, b0))
|
||||||
game.expect(ok and got > 0, "%s: a real click reaches the page (%d inputs)" % (what, got))
|
game.expect(ok and got > 0, "%s: a real click reaches the page (%d inputs)" % (what, got))
|
||||||
game.idle()
|
game.idle()
|
||||||
|
|
||||||
|
|
||||||
def open_confirm(game, fx):
|
def open_confirm(game, fx):
|
||||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
game.navigate("{section='refinery'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
game.scroll_to("confirm:sell-attachment")
|
game.scroll_to("confirm:buy-slot")
|
||||||
game.idle()
|
game.idle()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
game.click("confirm:sell-attachment")
|
game.click("confirm:buy-slot")
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
if game.eval("g.dialog:is_open()"):
|
if game.eval("g.dialog:is_open()"):
|
||||||
game.sleep(0.4) # (opened: its animation done)
|
game.sleep(0.4) # (opened: its animation done)
|
||||||
@@ -58,10 +61,10 @@ def open_confirm(game, fx):
|
|||||||
|
|
||||||
|
|
||||||
def open_display(game):
|
def open_display(game):
|
||||||
"""The header's display button, a real click (again if a rebuild dropped it)."""
|
"""The header's settings gear (its menu), a real click (again if a rebuild dropped it)."""
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
game.idle()
|
game.idle()
|
||||||
game.os_click("display")
|
game.os_click("settings")
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
if game.eval("g.dialog:is_open()"):
|
if game.eval("g.dialog:is_open()"):
|
||||||
game.sleep(0.4)
|
game.sleep(0.4)
|
||||||
@@ -81,6 +84,10 @@ def backdrop(game):
|
|||||||
|
|
||||||
def run(game, args):
|
def run(game, args):
|
||||||
fx = game.fixture
|
fx = game.fixture
|
||||||
|
game.eval("(function() local m=require('screens.game.core.main') if not m.il_i then m.il_i=m.on_input "
|
||||||
|
"m.on_input=function(g,a,x) if a==hash('touch') and (x.pressed or x.released) and g.il_touch then "
|
||||||
|
"table.insert(g.il_touch,{p=x.pressed and 1 or 0,x=x.x,y=x.y,b=g.builds,d=g.dialog.root and 1 or 0,"
|
||||||
|
"busy=g.busy and 1 or 0,f=socket.gettime()}) end return m.il_i(g,a,x) end end return true end)()")
|
||||||
if "reduced" in args:
|
if "reduced" in args:
|
||||||
game.set("reduced_motion", "1")
|
game.set("reduced_motion", "1")
|
||||||
game.idle()
|
game.idle()
|
||||||
@@ -203,7 +210,7 @@ def run(game, args):
|
|||||||
for how in ("close", "esc"):
|
for how in ("close", "esc"):
|
||||||
game.navigate("{section='new-moon'}")
|
game.navigate("{section='new-moon'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
game.click("act:buy-moon?include=new-moon")
|
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||||
game.wait_for("g.reveal:card_done()", 30)
|
game.wait_for("g.reveal:card_done()", 30)
|
||||||
game.sleep(0.4)
|
game.sleep(0.4)
|
||||||
if how == "close":
|
if how == "close":
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ def run(game, args):
|
|||||||
els = [e for e in game.elements() if e["id"].startswith("field:qty:")]
|
els = [e for e in game.elements() if e["id"].startswith("field:qty:")]
|
||||||
if els:
|
if els:
|
||||||
item = els[0]["id"][len("field:qty:"):]
|
item = els[0]["id"][len("field:qty:"):]
|
||||||
|
game.scroll_to(els[0]["id"]) # (the recipe cards are under the slots, like the web's)
|
||||||
game.click(els[0]["id"])
|
game.click(els[0]["id"])
|
||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
game.key("key_backspace")
|
game.key("key_backspace")
|
||||||
@@ -101,6 +102,7 @@ def run(game, args):
|
|||||||
mx = game.eval("st.refinery.maxBatch")
|
mx = game.eval("st.refinery.maxBatch")
|
||||||
got = game.eval("g.refine_qty['%s']" % item)
|
got = game.eval("g.refine_qty['%s']" % item)
|
||||||
game.expect(got == str(mx), "quantity caps at the batch size (%s, max %s)" % (got, mx))
|
game.expect(got == str(mx), "quantity caps at the batch size (%s, max %s)" % (got, mx))
|
||||||
|
game.scroll_to(els[0]["id"])
|
||||||
game.click(els[0]["id"])
|
game.click(els[0]["id"])
|
||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
game.key("key_backspace")
|
game.key("key_backspace")
|
||||||
@@ -116,6 +118,7 @@ def run(game, args):
|
|||||||
game.expect(game.log_has(r"mm: toast") and game.eval("#st.jobs") >= jobs, "Enter in the quantity refines")
|
game.expect(game.log_has(r"mm: toast") and game.eval("#st.jobs") >= jobs, "Enter in the quantity refines")
|
||||||
maxes = [e for e in game.elements() if e["id"].startswith("act:refine?all=1")]
|
maxes = [e for e in game.elements() if e["id"].startswith("act:refine?all=1")]
|
||||||
if maxes:
|
if maxes:
|
||||||
|
game.scroll_to(maxes[0]["id"])
|
||||||
game.hover(maxes[0]["id"])
|
game.hover(maxes[0]["id"])
|
||||||
game.sleep(0.8)
|
game.sleep(0.8)
|
||||||
tip = game.eval(TIP)
|
tip = game.eval(TIP)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Layout lint of every section (needs --fixture), then the clicker rule on a few actions
|
||||||
|
(claim a well, collect a batch, sell, buy a tool, upgrade).
|
||||||
|
Run at each size (`make lint` does 1280x800 and 1920x1080). Writes <out>/<view>.lint.json
|
||||||
|
and lint-summary.json; prints the issues. Args: an optional name prefix (the size)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
fx = game.fixture
|
||||||
|
pre = (args[0] + "-") if args else ""
|
||||||
|
moon, well, drill = fx["moon"], fx["well"], fx["drill_mining"]
|
||||||
|
views = [
|
||||||
|
("moons", "{section='moons', moon='%s'}" % moon),
|
||||||
|
("well", "{section='moons', moon='%s', well='%s'}" % (moon, well)),
|
||||||
|
("equipment", "{section='equipment'}"),
|
||||||
|
("drill", "{section='equipment', drill='%s'}" % drill),
|
||||||
|
("store", "{section='store'}"),
|
||||||
|
("market", "{section='market', pick='Sollux'}"),
|
||||||
|
("refinery", "{section='refinery'}"),
|
||||||
|
("advisors", "{section='advisors'}"),
|
||||||
|
("leaderboard", "{section='leaderboard'}"),
|
||||||
|
("help", "{section='help'}"),
|
||||||
|
("new-moon", "{section='new-moon'}"),
|
||||||
|
]
|
||||||
|
game.set("reduced_motion", "true")
|
||||||
|
game.idle()
|
||||||
|
for name, view in views:
|
||||||
|
game.navigate(view)
|
||||||
|
game.idle()
|
||||||
|
game.sleep(0.5)
|
||||||
|
game.lint(pre + name)
|
||||||
|
# The clicker rule: the first of each, where the fixture has one.
|
||||||
|
clicks = [
|
||||||
|
("moons", "act:claim?claim-well"),
|
||||||
|
("refinery", "act:collect?job"),
|
||||||
|
("market", "act:sell?pick"),
|
||||||
|
("store", "act:buy-tool?id"),
|
||||||
|
("equipment", "act:upgrade?tool"),
|
||||||
|
]
|
||||||
|
for name, id_ in clicks:
|
||||||
|
game.navigate(dict(views)[name])
|
||||||
|
game.idle()
|
||||||
|
game.sleep(0.5)
|
||||||
|
if game.eval("el(%r)" % id_):
|
||||||
|
game.click_check(id_, name=pre + "click-" + name)
|
||||||
|
with open(os.path.join(game.out, pre + "lint-summary.json"), "w") as f:
|
||||||
|
json.dump(game.issues, f, indent=1, ensure_ascii=False)
|
||||||
|
counts = {}
|
||||||
|
for i in game.issues:
|
||||||
|
counts[i["rule"]] = counts.get(i["rule"], 0) + 1
|
||||||
|
print("lint total:", counts)
|
||||||
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user