Compare commits

...
25 Commits
Author SHA1 Message Date
JosephFerano 0bf4779903 Client: shared market quotes; advisors and recent selling as personal lines on the ticket; check.py and drive.py --fixture need an explicit server 2026-10-20 22:39:43 +07:00
JosephFerano ba09caf66f Shared market quotes; advisors and selling pressure apply to the payout only 2026-10-20 22:35:15 +07:00
JosephFerano 70ebafa31d Client AGENTS: what is laid out with flex now; flex_gui tree pieces 2026-10-20 21:56:22 +07:00
JosephFerano 742e071434 Client: reveal card laid out by a flex tree (parts made at their boxes; animated groups placed there) 2026-10-20 21:54:54 +07:00
JosephFerano 11d94d38f0 Client: moon sale panel as a tree piece of the Moons section (summary, note, lines grid) 2026-10-20 21:52:58 +07:00
JosephFerano d1d9d51d5d Client: banner, toasts (body and stack), dialog panel and buy modal flex-first 2026-10-20 21:51:04 +07:00
JosephFerano ed59c6ae86 Client: new-moon teaser flex-first (claim frame, wells and class hints with wrapping chips) 2026-10-20 21:46:22 +07:00
JosephFerano 4c93f0471f Client: moon list panel flex-first (Active/Past tabs, search, rows box, pager, + New moon pinned) 2026-10-20 21:45:10 +07:00
JosephFerano 9c9a2697a8 Client: Help and Leaderboard flex-first (steps, rows, list that shrinks to the panel) 2026-10-20 21:42:18 +07:00
JosephFerano 7b6f0b75f9 Client: header flex-first (brand, tabs in slots, Claim all, second nav row when it doesn't fit) 2026-10-20 21:39:09 +07:00
JosephFerano c7839bb2ef Client: Market flex-first (heading stats, quote cards grid, detail panel, sell ticket and payout as trees; ticket panel in a slot for the sale) 2026-10-20 21:36:53 +07:00
JosephFerano 29c8034bf0 Client: Advisors flex-first (stat panels, seats grid, bench, card footers, advisor panel and preview) 2026-10-20 21:32:32 +07:00
JosephFerano b7c6a1c264 Client: Store and Equipment pages flex-first (fg.flow with card grids, buy footers, tool card name row and status); flex_gui.inline, flow 2026-10-20 21:29:07 +07:00
JosephFerano 6fb4392438 Client: drill panel flex-first (head, stats, upgrade, tier ladder as a grid, slots, spares, sale); equipment stats/upgrade as tree pieces; flex_gui.measured 2026-10-20 21:26:42 +07:00
JosephFerano 92c23f574f Client: aside well panel flex-first (deploy cards, drill lines, claims), viewport and captions as trees 2026-10-20 21:22:01 +07:00
JosephFerano 4a4897ffb0 Client: Moons section flex-first (wells table as a grid, heading, past moon, first-moon pitch); flex_gui tree pieces, button boxes, rich labels; H.claim_wait_w 2026-10-20 21:18:06 +07:00
JosephFerano 1ea59b5d70 Client flex: grid tracks, absolute/anchor, aspect_ratio, scroll content size (with tests) 2026-10-20 21:09:17 +07:00
JosephFerano e805f97a4c client AGENTS.md: new UI is flex-first; port hand-placed sections when touched 2026-10-20 18:09:50 +07:00
JosephFerano bdc8d311be Client: Refinery slot head 22.5 tall (as before); AGENTS: flex-first sections 2026-10-20 18:05:18 +07:00
JosephFerano 9d512f5abc Client: Refinery rewritten flex-first (one tree, nodes made from the solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano 8293fdca22 Client: flex.columns/grid, flex_gui label/para leaves and build (make nodes from a solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano a21d5a4371 Client: AGENTS Layout (flex) section and FLEX-NOTES 2026-10-20 17:43:47 +07:00
JosephFerano b4b6d22113 Client: header wallet cluster laid out with flex 2026-10-20 17:42:52 +07:00
JosephFerano 7ec64c39a3 Client: flex_gui (apply a solved tree to slot nodes, optional glide); Card and the Refinery laid out with flex 2026-10-20 17:39:13 +07:00
JosephFerano 9d4b712d8b Client: flex, a pure flexbox layout solver, with unit tests in make check 2026-10-20 17:24:48 +07:00
39 changed files with 4483 additions and 2275 deletions

No files matched your search

+54 -3
View File
@@ -164,7 +164,7 @@ Build new UI from these instead of coordinate math. Each file's header documents
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`,
- `card.lua`: image column + text column laid out with flex (below; 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
@@ -209,6 +209,57 @@ 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.
## Layout (flex) (`modules/flex.lua`, `modules/flex_gui.lua`)
Prefer it to coordinate math (`26 + ui.text_width(name) + 7`) for new or reworked UI.
Every section, the aside (well panel, drill panel, advisor panel), header, moon list,
banner, toasts, dialogs (and the buy modal), the new-moon teaser and the reveal card are
laid out with it; component internals (chips, stat rows, charts, moon rows) still place
their own parts.
- `flex.lua` is a pure solver (no Defold calls; unit tests `scripts/flex_test.lua`, in
`make check`): a tree of `{ style, children, measure = fn(avail_w) -> w, h, node, id }`;
CSS names in snake_case (`direction`, `gap`/`row_gap`/`column_gap`, `padding`,
`justify`, `align`/`align_self`, `grow`/`shrink`/`basis`, `width`/`height` px, "N%",
"auto", `min_*`/`max_*`, `wrap`, `display = "none"`). `flex.solve(tree, x, y, w, h)`
sets absolute `x, y, w, h` on every box: top-left origin, y down (like `ui.*` calls);
`flex.gui_pos(box, pivot)` gives the GUI position. Deviations from CSS (border-box, min
sizes 0, auto = fit-content, no margins) are at the top of the file.
- `flex.apply(tree, nodes, { glide = secs })` (flex_gui) sets position (by the node's
pivot) and size (unless `box.size == false`; divided by scale) on each box's `node`,
relative to the nearest ancestor box with a node, only when it changed; it never
creates, deletes or reparents. `glide` tweens moved nodes (FLIP), off under reduced
motion; default snap. `flex_gui.text_measure(node[, lh])` measures a text node.
- Rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a label);
animations own what's inside (like CSS transform vs layout). Never give a box a node
that something animates by position or size. Kept blocks (modules/blocks) aren't
re-laid out: solve only what you build.
- Widgets whose look is built at a size (ui.button, bars, dashed edges) are built after
the solve, at their box's rect (`{ style = { grow = 1 } }` without a node, then read
`box.x/w`); text is cut (`ui.fit_text(node, box.w)`) after it.
- New or reworked UI is flex-first; don't add hand-placed coordinates. When you touch a
hand-placed section, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Flex-first (the Refinery): describe the whole section as one tree with
`flex_gui.label`/`para` leaves and boxes with `make = fn(parent, x, y, w, h)` for what is
built at a size, `flex.grid` for card grids, then `flex_gui.build(flex.solve(tree, x, y),
parent)` makes the nodes in tree order (= focus order).
- flex_gui tree pieces: `box/row/col/space/fill/made`; `label` (rich text when it has
symbols), `text_para` (H.para's leading), `node_label` (an existing text node);
`button` (a ui.button box, width from a probe, then `bind(b, nodes, w)`); `inline(parent,
h, fn)` for one-line components that return their width (chips, ui.credits, stat
rows: fn(probe, 0, 0, true) must register nothing); `measured(parent, fn)` for one that
returns its height; `flow(parent, x, y, w, parts)` mixes trees with components that lay
themselves out in place (card.grid's kept cards). `H.claim_wait_w` gives a claim
countdown's width. `make` rects are whole pixels; leaves aren't rounded.
- Beyond flexbox (flex.lua header): `columns = {...}` makes a box a CSS grid (tracks px,
"N%", "auto", "Nfr"; `span`, `rows`, `justify_items`/`justify_self`; row height from
the tallest cell): tables. `position = "absolute"` with `left/right/top/bottom` and
`translate = {fx, fy}` places a child over its parent's box; `flex.anchor(rect, box)`
places a box that way against any solved rect (badges, floating numbers, bursts).
`aspect_ratio` (image boxes). `overflow = "scroll"` (children don't shrink) and
`flex.content_size(box)` for a scroll area's content size.
- Patterns: a label is a leaf of its line-box height (pivot W lands on its middle); a
pinned footer is a `grow = 1` spacer before it; equal-height card rows are `wrap = true`
with the default `align = "stretch"`; grid columns: give items the column width.
## 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`,
@@ -491,7 +542,7 @@ layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
## Checks (`make check`, scripts/check.py)
Fails on: Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
Fails on: the flex solver's unit tests (`luajit scripts/flex_test.lua`); Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
@@ -500,7 +551,7 @@ generator (`node scripts/moon-check.mjs [resolution] [seeds...]`:
`mmterrain/src/terrain.c`, built for the host with `cc` and loaded into luajit through the
FFI, and terrain.lua's settings side, against terrain.js; needs node, luajit, cc); and engine warnings in a `sections` scenario log (font batches, characters, text entries,
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
against `MM_API` (default http://localhost:3000) when it answers; otherwise the newest
against `MM_API` (required, no default: the checks make a fixture player there, so use a test server, never the dev one) when it answers; otherwise the newest
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
+70
View File
@@ -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.
+1 -1
View File
@@ -68,7 +68,7 @@ drive: $(BOB) .internal/lib/dependencies.json
scripts/drive.py $(DRIVE) $(SCENARIO)
# 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
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
+69 -42
View File
@@ -1,6 +1,6 @@
-- 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
-- height comes from its wrapped lines), with an optional footer block pinned to the
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
-- 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
-- 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
@@ -20,10 +20,9 @@
-- c.edge_hover = { "ready", 0.7 } -- its hover
-- end)
--
-- Rules from druid-notes.md: the layout node is an invisible box (no slice9), pivot NW;
-- it's refreshed synchronously once the card is built (Layout never resizes children:
-- widths are set here); text nodes are measured by their text (pivot W lines, wrapped
-- NW paragraphs).
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
-- what's inside a slot is placed by hand, relative to it.
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -32,6 +31,8 @@ local anim = require("modules.anim")
local button = require("components.button")
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
@@ -66,24 +67,25 @@ function M.new(g, parent, x, y, w, opts)
opts = opts or {}
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 c = setmetatable({ g = g, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad }, 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")
if m > 0 then
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
end
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 pad = opts.pad or PAD
c.bw = w - pad * 2
c.body = anim.group(c.root, pad, opts.pad or 12, c.bw, 1)
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
c.body = anim.group(c.root, PAD, m + 14, c.bw, 1)
else
c.padding = { 12, PAD, 14, PAD }
c.bw = w - m - PAD * 2
c.body = anim.group(c.root, m + PAD, 12, c.bw, 1)
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.body = anim.group(c.root, 0, 0, c.bw, 1)
return c
end
@@ -95,29 +97,29 @@ function M:image(url)
self.image_node = node
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
self:gap(gap)
end
self.layout:add(node)
return node
self.items[#self.items + 1] = box
return box.node
end
-- Vertical space.
function M:gap(px)
local n = anim.group(self.body, 0, 0, 1, px)
self.layout:add(n)
self.items[#self.items + 1] = { style = { height = px } }
end
-- 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)
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
local t = ui.label(slot, str, style or "body", 0, 8)
if not ui.needs_rich(str) then
ui.fit_text(t, self.bw)
end
self.layout:add(slot)
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
return t
end
@@ -126,20 +128,29 @@ function M:title(str)
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
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)
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)
gui.set_line_break(n, true)
local s = gui.get_scale(n).x
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
-- 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)
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
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
@@ -150,10 +161,7 @@ function M:block(h, fn, gap)
end
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
local used = fn(n, self.bw)
if not h then
gui.set_size(n, vec(self.bw, used or 0))
end
return add(self, n, gap)
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
end
-- Something clickable inside the card's text (a link): fn runs once the card's own click
@@ -169,29 +177,48 @@ function M:footer(h, fn)
self.footer_h, self.footer_fn = h, fn
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.
function M:refresh()
self.layout:refresh_layout(true)
local body_h = self.layout:get_size().y
if self.stacked then
self.natural = self.m + 14 + body_h + self.footer_h + 14
elseif self.m == 0 then
self.natural = (self.pad or 12) + body_h + self.footer_h + (self.pad or 14)
else
self.natural = math.max(self.m, 12 + body_h + self.footer_h + 14)
end
local t = self:tree()
flex.apply(t)
self.natural = t.h
return self.natural
end
-- The card's final height (the tallest in its row): sizes the box and places the footer.
function M:set_height(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))
if self.stacked then
ui.rect(self.root, 0, self.m, self.w, 1, "line")
elseif self.m > 0 then
gui.set_size(self.media, vec(self.m, h))
ui.rect(self.root, self.m, 0, 1, h, "line")
end
if self.selected then
@@ -214,8 +241,8 @@ function M:set_height(h)
fn()
end
if self.footer_fn then
local pad = self.pad or PAD
local f = anim.group(self.root, self.stacked and PAD or (self.m + pad), h - (self.pad or 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)
end
end
@@ -270,7 +297,7 @@ function M.grid(g, parent, x, y, inner, items, make, opts)
-- 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)
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
+25 -12
View File
@@ -29,6 +29,8 @@ local anim = require("modules.anim")
local sfx = require("modules.sfx")
local button = require("components.button")
local test = require("modules.test")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
@@ -141,23 +143,35 @@ function M:open(opts)
local g_title = anim.group(box, 0, 0)
local g_body = 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
opts.build(self, g_body, w, h)
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
local by = h - 24 - S.button_h
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
local cb, _, cw = self:button(g_actions, cancel_label, 0, 0, {
local by = cancel_box.y
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
id = "dialog:cancel", on_click = function() d:close("cancel") end,
})
gui.set_position(cb, vec(w - 24 - cw, -by))
if opts.ok then
local ok = opts.ok
local danger = ok.danger
local ob, _, ow
ob, _, ow = self:button(g_actions, ok.label or "OK", 0, 0, {
if ok then
local ob
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
id = "dialog:ok", price = ok.price,
variant = danger and "danger" or "primary",
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
@@ -173,7 +187,6 @@ function M:open(opts)
end
end,
})
gui.set_position(ob, vec(w - 24 - cw - 10 - ow, -by))
end
self.by = by
+692
View File
@@ -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
+308
View File
@@ -0,0 +1,308 @@
-- 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
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. 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 = opts or {}
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 = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = n, size = false, fit = fit, 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, ...).
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)
return { node = n, shift = (lh - line) / 2, style = opts or {}, measure = M.text_measure(n, lh) }
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 = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = node, size = false, fit = fit, 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
ui.fit_text(node, 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
+7 -2
View File
@@ -516,7 +516,8 @@ M.register("redrill", function(st, p, ctx)
return true
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)
local q = st.market and find(st.market.quotes, p.resource, "item")
if not q or not q.unpressed or not q.base then
@@ -534,8 +535,12 @@ M.register("sell", function(st, p, ctx)
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).
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.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.refresh_market(st)
return true
+25 -11
View File
@@ -10,6 +10,8 @@
local ui = require("modules.ui")
local theme = require("modules.theme")
local anim = require("modules.anim")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
M.__index = M
@@ -57,11 +59,14 @@ end
-- it.root: an invisible group the stack moves (place); it.body: the toast, which the
-- entrance and exit move, squash and fade inside it.
function M:place(animate)
local y = self.h - MARGIN
for i = #self.items, 1, -1 do
local it = self.items[i]
y = y - it.h
local pos = vmath.vector3(0, -y, 0)
-- A column at the bottom of the page (newest last), GAP apart.
local boxes = {}
for i, it in ipairs(self.items) do
boxes[i] = { style = { height = it.h, shrink = 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
gui.animate(it.root, "position.y", pos.y, gui.EASING_OUTCUBIC, 0.25)
else
@@ -70,7 +75,6 @@ function M:place(animate)
-- Older toasts dim (still opaque).
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))
y = y - GAP
end
end
@@ -193,11 +197,21 @@ function M:push(message, kind, is_error, opts)
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.rect(body, 0, 0, 3, h, accent)
local icon = ui.outline(body, 11, math.floor(h / 2 - 14), 28, 28, accent)
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)
gui.set_parent(line, body, false)
gui.set_position(line, vmath.vector3(TEXT_X, -math.floor((h - th) / 2), 0))
-- 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.text(icon, k[2], { font = "sym", px = 16, color = accent }, 14, 14, gui.PIVOT_CENTER)
end },
{ 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)
it.flash = ui.rect(body, 0, 0, w, h, "white", 0)
gui.set_blend_mode(it.flash, theme.glow_blend())
+285 -233
View File
@@ -15,12 +15,16 @@ local scroll = require("components.scroll")
local store = require("screens.game.store")
local card = require("components.card")
local button = require("components.button")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local vec = cards.vec
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 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
end
-- Bonus chips ("Drill speed +10%"), wrapping at w; returns the number of lines.
local function chips(parent, bonuses, x, y, w)
local cx, lines = x, 1
-- Bonus chips ("Drill speed +10%"), wrapping (lines 24 tall, 6 apart). A tree piece.
local function chips(parent, bonuses)
local kids = {}
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 tw = cards.tag(probe, label, 0, 0)
gui.delete_node(probe)
if cx > x and cx + tw > x + w then
cx, lines = x, lines + 1
end
if parent then
local cw, box = cards.tag(parent, label, cx, y + (lines - 1) * 24)
if b.capped then
tooltip.add(box, "At the cap")
kids[#kids + 1] = fg.inline(parent, 24, function(p, x, y, probing)
local cw, tag = cards.tag(p, label, x, y)
if b.capped and not probing then
tooltip.add(tag, "At the cap")
end
cx = cx + cw + 6
end
return cw
end)
end
return lines
return row({ wrap = true, column_gap = 6 }, kids)
end
local function stat_box(parent, x, y, w, h, label, fill)
local _, nodes = ui.panel(parent, x, y, w, h)
local r = ui.part(nodes, "panel", "root")
ui.text(r, label, "cap", 14, 18)
fill(r)
-- A stat panel (ui.panel): a cap label, then content (a tree piece) under it.
local function stat_box(parent, w, label, content)
return col({ width = w, padding = { 10, 14, 8, 14 }, shrink = 0 }, {
fg.label(parent, label, "cap", 16), space(0, 4), content,
}, { make = function(p, x, y, bw, bh)
local _, nodes = ui.panel(p, x, y, bw, bh)
return ui.part(nodes, "panel", "root")
end })
end
-- 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 owned ones from seatList (who sits there, or empty), then the next one to buy and
-- the locked ones. Returns the y under it.
local function board(self, H, ad, parent, x, y, inner)
-- the locked ones. A grid; each tile is built at its size, its content a tree inside.
local function board(self, H, ad, parent, inner)
local list = ad.seatList or {}
local n = math.max(ad.maxSeats or 0, #list)
local gap = 10
local cols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
local tw = math.floor((inner - (cols - 1) * gap) / cols)
local ncols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
local tw = math.floor((inner - (ncols - 1) * gap) / ncols)
local th = 86
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
local tx = x + (i - 1) % cols * (tw + gap)
local ty = y + math.floor((i - 1) / cols) * (th + gap)
local seat = list[i]
local a = seat and seat.advisor ~= "" and find(ad, seat.advisor)
local owned = seat ~= nil
local num = seat and seat.n or i
local tile = ui.rect(parent, tx, ty, tw, th, a and "panel" or "none")
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 seat_lbl = ui.text(tile, "Seat " .. num, { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 10, 12)
if a then
gui.set_position(seat_lbl, vec(tw - 8 - ui.text_width(seat_lbl), -12))
local img = th - 2
local media = ui.rect(tile, 1, 1, img, img, "bg")
cards.image(media, a.image, 0, 0, img)
local nx = img + 12
local nm = ui.text(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, nx, 32)
ui.fit_text(nm, tw - nx - 8)
local tt = ui.text(tile, a.title, { font = "sans", px = 12, color = "muted" }, nx, 50)
ui.fit_text(tt, tw - nx - 8)
local ef = ui.text(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, nx, 68)
ui.fit_text(ef, tw - nx - 8)
button.bind(self, tile, nil, { id = "seat:" .. num, label = a.name, on_click = function()
open(self, H, a.name)
end })
elseif owned then
ui.text(tile, "Empty", { font = "sans", px = 14, color = "muted" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
elseif i == ad.nextSeat and ad.nextSeatPrice > 0 then
-- 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 probe = ui.rect(tile, 0, 0, 1, 1, "none")
local cw = ui.credits(probe, ad.nextSeatPrice, 0, 0, { px = 14 })
gui.delete_node(probe)
ui.credits(tile, ad.nextSeatPrice, (tw - cw) / 2, th / 2 + 14, { px = 14, color = ad.canBuySeat and "cyan" or "muted" })
gui.set_color(edge, ui.color("cyan", ad.canBuySeat and 0.6 or 0.25))
if ad.canBuySeat then
gui.set_color(tile, ui.color("cyan", 0.04))
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
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 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 tree
if a then
local img = h - 2
local fit = { fit = true, shrink = 1 }
tree = row({ width = w, height = h, padding = 1 }, {
made({ width = img, height = img, shrink = 0 }, function(q, ix, iy)
cards.image(ui.rect(q, ix, iy, img, img, "bg"), a.image, 0, 0, img)
end),
space(11),
col({ grow = 1, shrink = 1, padding = { 23, 7, 0, 0 } }, {
fg.label(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, fit), space(0, 2),
fg.label(tile, a.title, { font = "sans", px = 12, color = "muted" }, 16, fit), space(0, 2),
fg.label(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, 16, fit),
}),
fg.label(tile, "Seat " .. num, SEAT, 12, { position = "absolute", top = 6, right = 8 }),
})
else
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
-- The next seat to buy: the tile is the button.
local color = ad.canBuySeat and "cyan" or "muted"
center = {
space(0, 0),
fg.label(tile, "+ Add seat", { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, 16),
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))
if ad.canBuySeat then
gui.set_color(tile, ui.color("cyan", 0.04))
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
end
else
center = { fg.label(tile, "Locked", { font = "sans", px = 13, color = "faint" }, 16), space(0, 0) }
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
else
ui.text(tile, "Locked", { font = "sans", px = 13, color = "faint" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
end
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 y + math.ceil(n / cols) * (th + gap) - gap
return box({ columns = cols, gap = gap }, tiles)
end
-- 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
-- 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.
local function bench(self, H, ad, parent, x, y, inner)
-- the lowest free seat. A tree piece (nil when none).
local function bench(self, H, ad, parent, inner)
local list = benched(ad)
if #list == 0 then
return y
return nil
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 rh = 50
local rows = {}
for _, a in ipairs(list) do
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)
ui.outline(row, 0, 0, inner, rh, on and "cyan" or "line", on and 0.7 or nil)
local b, nodes, bw = ui.button(row, "Seat", 0, 0, { primary = true, small = true, disabled = not a.canSeat })
gui.set_position(b, vec(inner - 10 - bw, -(rh - S.button_sm_h) / 2))
local hit = ui.rect(row, 0, 0, inner - bw - 20, rh, "none")
local nm = ui.text(row, a.name, { font = "sans_semi", px = 15, color = "text" }, 12, 18)
ui.fit_text(nm, inner - bw - 40)
ui.text(row, effect_of(a), { font = "mono", px = 12, color = "muted" }, 12, 35)
H.link(self, hit, { section = "advisors", moon = self.view.moon, adv = a.name })
if a.canSeat then
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
elseif (a.seatWhyText or "") ~= "" then
tooltip.add(b, a.seatWhyText)
end
y = y + rh + 6
local bw = fg.button_w(parent, "Seat", { primary = true, small = true, disabled = not a.canSeat })
rows[#rows + 1] = row({ height = rh, padding = { 0, 10, 0, 12 }, align = "center" }, {
-- (the row's text links to the panel; Seat takes its own clicks)
made({ position = "absolute", left = 0, top = 0, bottom = 0, right = bw + 20 }, function(p, x, y, w, h)
H.link(self, ui.rect(p, x, y, w, h, "none"), { section = "advisors", moon = self.view.moon, adv = a.name })
end),
col({ grow = 1, shrink = 1, padding_right = 18, padding_top = 10, align_self = "start" }, {
fg.label(parent, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
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
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
elseif (a.seatWhyText or "") ~= "" then
tooltip.add(b, a.seatWhyText)
end
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
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
local function advisor_card(self, H, ad, c, a)
@@ -228,23 +264,29 @@ local function advisor_card(self, H, ad, c, a)
c:gap(4)
local why, why_color = hire_block(a)
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
local kids
if a.hired then
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,
edge = seat and { "ready", 0.5 } or nil })
H.link(self, b, { section = "advisors", moon = self.view.moon, adv = a.name }, "manage:" .. a.name)
return
end
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 })
if not can then
tooltip.add(b, a.whyText)
end
if can then
H.action(self, b, nodes, "hire", { advisor = a.name })
elseif why then
store.need_text(f, why, bw, S.button_sm_h + 10, why_color)
kids = { fg.button(f, (seat and ("Seat " .. a.seat) or "Not seated") .. " · Manage", { small = true,
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)
end, { width = bw }) }
else
local can = a.canHire
kids = {
fg.button(f, "Hire", { primary = true, small = true, price = a.price, disabled = not can }, function(b, nodes)
if can then
H.action(self, b, nodes, "hire", { advisor = a.name })
else
tooltip.add(b, a.whyText)
end
end, { width = bw }),
(not can and why) and made({ 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)
if a.hired then
c.edge_color, c.edge_hover = { "ready", 0.45 }, { "ready", 0.7 }
@@ -266,39 +308,21 @@ function M.build(self, root, px, py, w, h, H)
local ad = st.advisors
local area, x, inner, y = cards.page(self, root, px, py, w, h, "advisors")
local c = area.content
y = H.heading(c, x, y, "Advisors")
local right = 0
if ad.nextSeatPrice > 0 then
local _, bw = buy_seat(self, ad, c, x + inner, py + 10, { right = true, small = true })
right = bw + 16
right = fg.button_w(c, "+ Seat", { small = true, price = ad.nextSeatPrice }) + 16
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 probe = ui.rect(c, 0, 0, 1, 1, "none")
local lines = chips(probe, ad.bonuses, 0, 0, sw - 28)
gui.delete_node(probe)
local bh = 62 + (lines - 1) * 24
stat_box(c, x, y, sw, bh, "Seats filled", function(r)
local t = ui.text(r, ad.hired, { font = "mono_semi", px = 20, color = "text" }, 14, 42)
ui.text(r, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 14 + ui.text_width(t), 42)
end)
stat_box(c, x + sw + 12, y, sw, bh, "Active bonuses", function(r)
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)
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
local seats_line = row({ height = 24, align = "center" }, {
fg.label(c, tostring(ad.hired), { font = "mono_semi", px = 20, color = "text" }, 24),
fg.label(c, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 24),
})
local bonuses = #(ad.bonuses or {}) == 0
and row({ height = 24, align = "center" }, { fg.label(c, "None yet", { font = "sans", px = 15, color = "muted" }, 24) })
or chips(c, ad.bonuses)
local function cap(label, after)
return col({}, { fg.label(c, label, "cap", 12), space(0, after) })
end
-- (hired advisors no longer in the config have no card, like the web)
local listed = {}
for _, a in ipairs(ad.advisors) do
@@ -306,48 +330,80 @@ function M.build(self, root, px, py, w, h, H)
listed[#listed + 1] = a
end
end
y = card.grid(self, c, x, y, inner, listed, function(cd, a)
advisor_card(self, H, ad, cd, a)
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 })
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)
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)
end
---------------------------------------------------------------- aside
-- A line of the panel: text on the left (wrapping), a small button on the right.
-- fill(parent, x, y, w) draws the text and returns its height. Returns the y under it.
local function line(c, pad, y, inner, label, opts, fill)
local b, nodes, bw = ui.button(c, label, 0, 0, { small = true, primary = opts.primary, variant = opts.variant })
local th = fill(c, pad, y, inner - bw - 12)
local hgt = math.max(th, S.button_sm_h)
gui.set_position(b, vec(pad + inner - bw, -(y + (hgt - S.button_sm_h) / 2)))
opts.bind(b, nodes)
return y + hgt + 8
-- A line of the panel: text on the left (a tree piece), a small button on the right,
-- centered on it; 8 under it.
local function line(c, label, opts, piece)
return col({}, {
row({ align = "center", gap = 12, min_height = S.button_sm_h }, {
col({ grow = 1, shrink = 1 }, { piece }),
fg.button(c, label, { small = true, primary = opts.primary, variant = opts.variant }, opts.bind),
}),
space(0, 8),
})
end
local function name_effect(a)
return function(c, x, y, w)
local nm = ui.text(c, a.name, { font = "sans_semi", px = 14, color = "text" }, x, y + 8)
ui.fit_text(nm, w)
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
local function name_effect(c, a)
return col({ height = 34 }, {
fg.label(c, a.name, { font = "sans_semi", px = 14, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
fg.label(c, effect_of(a), { font = "mono", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
end
local function small_para(str)
return function(c, x, y, w)
return cards.para(c, str, { font = "sans", px = 13, color = "muted" }, x, y, w)
end
local function small_para(c, str)
return fg.text_para(c, str, { font = "sans", px = 13, color = "muted" })
end
local function heading(c, pad, y, inner, str)
ui.rect(c, pad, y, inner, 1, "line")
ui.text(c, str, H3, pad, y + 20)
return y + 34
-- A rule, then the heading (H3) 20 under it; 34 in all.
local function heading(c, str)
return col({ padding_top = 12 }, {
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
-- A hired advisor's panel (the web's advisor-panel).
@@ -356,26 +412,27 @@ local function panel(self, H, ad, a, root, w, h)
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor:" .. a.name)
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 size = math.min(inner, 140)
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
ui.outline(media, 0, 0, size, size, seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1)
cards.image(media, a.image, 0, 0, size)
y = y + size + 14
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
y = y + 24
y = y + cards.para(c, a.description, { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 10
y = H.fact(c, "Seat", pad, y, inner, seat > 0 and tostring(seat) or "Not seated")
y = H.fact(c, "Bonus", pad, y, inner, seat > 0 and "Active" or "Off until seated", { color = seat > 0 and "ready" or "muted" })
y = y + 6
y = y + cards.para(c, "Changes count from your next drill, sale or batch.", { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 14
local kids = {
fg.button(c, "×", { small = true, w = 32 }, function(b)
H.link(self, b, { section = "advisors", moon = self.view.moon }, "advisor:close")
end, { width = 32, position = "absolute", top = 0, right = 0 }),
fg.label(c, a.title, "cap", 12), space(0, 4),
fg.label(c, a.name, "h2", 20), space(0, 8),
media(a, math.min(inner, 140), seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1),
space(0, 14),
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
fg.text_para(c, a.description, { font = "sans", px = 13, color = "muted" }), space(0, 10),
made({ height = 20 }, function(p, x, y, iw) H.fact(p, "Seat", x, y, iw, seat > 0 and tostring(seat) or "Not seated") end),
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.
local moves = {}
@@ -385,55 +442,53 @@ local function panel(self, H, ad, a, root, w, h)
end
end
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
local other = s.advisor ~= "" and find(ad, s.advisor)
local function fill(cc, x, yy, ww)
local n = ui.text(cc, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, x, yy + 17)
local nx = x + math.max(22, ui.text_width(n) + 10)
if other then
return name_effect(other)(cc, nx, yy, ww - (nx - x))
end
ui.text(cc, "Empty", { font = "sans", px = 14, color = "muted" }, nx, yy + 17)
return 34
end
local piece = row({ height = 34, align = "center" }, {
fg.label(c, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, 34, { min_width = 22, padding_right = 10 }),
other and name_effect(c, other) or fg.label(c, "Empty", { font = "sans", px = 14, color = "muted" }, 34),
})
if other then
y = line(c, pad, y, inner, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
add(line(c, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = other.name })
end }, fill)
end }, piece))
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 })
end }, fill)
end }, piece))
end
end
y = y + 6
add(space(0, 6))
end
if seat > 0 then
local swaps = benched(ad)
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
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 })
end }, name_effect(x))
end }, name_effect(c, x)))
end
y = y + 6
add(space(0, 6))
end
y = heading(c, pad, y, inner, "Unseat")
y = line(c, pad, y, inner, "Unseat", { bind = function(b, nodes)
add(heading(c, "Unseat"))
add(line(c, "Unseat", { bind = function(b, nodes)
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
y = heading(c, pad, y, inner, "Dismiss")
y = line(c, pad, y, inner, "Dismiss", { variant = "danger", bind = function(b)
add(heading(c, "Dismiss"))
add(line(c, "Dismiss", { variant = "danger", bind = function(b)
if a.dismiss then
cards.confirm_server_button(self, b, a.dismiss, "dismiss-advisor", { advisor = a.name })
end
end }, small_para("Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded."))
area:finish(y + pad)
end }, small_para(c, "Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded.")))
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, pad), c)
area:finish(tree.y + tree.h + pad)
return true
end
@@ -443,36 +498,33 @@ local function preview(self, H, a, root, w, h)
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor")
local c = area.content
local y = pad
local size = math.min(inner, 300)
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
ui.outline(media, 0, 0, size, size, "line", 1)
cards.image(media, a.image, 0, 0, size)
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.dirty = true
end })
y = y + size + 18
ui.text(c, a.title, "cap", pad, y + 6)
ui.text(c, a.name, "h2", pad, y + 26)
y = y + 42
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
y = y + 24
y = y + cards.para(c, a.description, { font = "sans", px = 14, color = "text_2" }, pad, y, inner) + 14
ui.rect(c, pad, y, inner, 1, "line")
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
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
area:finish(y + pad)
local tree = col({ width = inner }, {
media(a, math.min(inner, 300), "line", 1),
fg.button(c, "×", { small = true, w = 32 }, function(b)
button.bind(self, b, nil, { id = "advisor:close", label = "Close", on_click = function()
self.advisor_pick = nil
self.dirty = true
end })
end, { width = 32, position = "absolute", top = 0, right = 0 }),
space(0, 18),
fg.label(c, a.title, "cap", 12), space(0, 4),
fg.label(c, a.name, "h2", 20), space(0, 6),
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
fg.text_para(c, a.description, { font = "sans", px = 14, color = "text_2" }), space(0, 14),
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end), space(0, 9),
fg.button(c, "Hire", { primary = true, price = a.price, disabled = not a.canHire }, function(b, nodes)
if a.canHire then
H.action(self, b, nodes, "hire", { advisor = a.name })
end
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
end
+344 -235
View File
@@ -20,6 +20,8 @@ local anim = require("modules.anim")
local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
@@ -110,62 +112,104 @@ local function effect_of(st, name)
end
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The width of a text in a style (a probe).
local function text_w(c, str, style)
local t = ui.text(c, str, style, 0, 0)
local w = ui.text_width(t)
gui.delete_node(t)
return w
end
-- A text link (H.text_link) as a box h tall.
local function link_box(g, c, H, str, style, h, v, id)
return made({ width = text_w(c, str, style), height = h, shrink = 0 }, function(p, x, y)
H.text_link(g, p, str, style, x, y + h / 2, v, id)
end)
end
-- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w)
H.fact(p, label, x, y, w, value, opts)
end)
end
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" }
-- 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 })
-- .deploy-card.is-short / .is-spoken). The card is a button built at its width; its
-- content is a tree solved inside it.
local function deploy_card(g, c, well, grp, H)
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
return made({ height = hgt }, function(p, x, y, inner)
local b, bn = ui.button(p, "", x, y, { 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 gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16) or nil
if reach then
local label = reach_label(g.state, grp.reach)
if label ~= "" then
tooltip.add(rt, label)
reach.done = function(n) tooltip.add(n, label) end
end
end
local gets = fg.label(b, gets_s, M12, 16)
gets.done = function(n)
tooltip.add(n, "What a cycle gets here after the drills already on it"
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
end
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
or "Well spoken for: nothing left after current drills", { font = "sans", px = 12, color = "ready" }, 14, { fit = true, shrink = 1 }) or nil
local tree = row({ width = inner, height = hgt, padding = { 0, 12, 0, 6 } }, {
-- The tool's picture (grey when spoken for), its attachments as pips under it.
col({ width = 32, shrink = 0, padding_top = 4 }, {
made({ width = 32, height = 32 }, function(q, ix, iy)
local img = ui.rect(q, ix, iy, 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(q, ix, iy, 32, 32, "off", 0.6)
end
end
end),
n_att > 0 and made({ position = "absolute", left = 16 - n_att * 13 / 2, top = hgt - 8, width = 0, height = 0 }, function(q, ix, iy)
cards.pips(q, n_att, n_att, ix, iy)
end) or nil,
}),
space(8),
col({ grow = 1, shrink = 1, padding_top = 5 }, {
row({ height = 16, align = "center" }, {
fg.label(b, grp.name, CARD_NAME, 16, { fit = true, shrink = 1, max_width = inner - 150 - 46 + 12 }),
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16),
grp.count > 1 and space(6) or nil,
grp.count > 1 and fg.label(b, "×" .. grp.count, M11, 16) or nil,
}),
row({ height = 16, align = "center" }, { gets, reach and space(5) or nil, reach }),
note and space(0, 0) or nil, note,
}),
space(8),
col({ shrink = 0, justify = "center" }, {
fg.label(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 16),
}),
})
fg.build(flex.solve(tree, 0, 0), b)
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)
@@ -179,79 +223,178 @@ local function deploy_cards(g, root, pad, cy, inner, well, H)
end
end)
end
cy = cy + hgt + 6
end
return cy
end)
end
-- The selected well under the viewport; returns the height used (it scrolls).
local function well_panel(g, root, pad, cy, inner, H)
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
-- thumbnail, name, tier and the attachments' tags; what it gets; time left or Ready.
local function drill_line(g, c, st, d, H, enters)
local name, tier = moons.tool_name(st, 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")
if tool and tool.image then
images.set(thumb, tool.image)
end
if d.state == "none" then
-- Gets nothing: the picture greys (app.css .drill-line.is-none: grayscale, .6),
-- a veil in the panel's color over it.
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
end
local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
local tt = ui.text(dr, "T" .. tier, M11, x0 + ui.text_width(nt) + 6, 8)
-- Tags while they fit (and leave 50 for the time).
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) }
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
for _, a in ipairs(tool and tool.attachments or {}) do
if tx + 60 > inner - 50 then
break
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
head[#head + 1] = made({ width = aw, height = 16, shrink = 0 }, function(q, ax, ay)
cards.tag(q, a, ax, ay + 8)
local fx_text = effect_of(st, a)
if fx_text then
tooltip.add(ui.rect(q, ax, ay, aw, 16, "none"), fx_text)
end
end)
head[#head + 1] = space(4)
tx = tx + aw + 4
end
-- 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 line2
if d.state == "none" then
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
local tag_style = { color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true }
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
local _, tw = chip.new(probe, "Nothing left", 0, 0, tag_style)
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")
end),
}
else
local short = d.state ~= "ok" and "ready" or "muted"
line2 = { fg.label(dr, ui.runs({
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
{ 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.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
}), { font = "sans", px = 12, color = "white", markup = true }, 16) }
end
local right = made({ position = "absolute", right = 0, top = 6, width = 0, height = 16 }, function(q, rx, ry)
if d.ready then
bar.ready_label(q, "Ready", rx, ry + 8, gui.PIVOT_E)
else
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)
end
end)
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
-- A row of claim buttons (Claim / its countdown, and Claim and redeploy beside it).
local function claim_row(kids)
return row({ height = S.button_h, gap = 8 }, kids)
end
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H)
local st = g.state
local moon = st.moon
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"))
local c = root
local wrow = find(moon.wells, w.id) or { supply = 0, drills = {}, ready = 0 }
local dry = wrow.supply <= 0
local kids = {}
local function add(b)
if b then
kids[#kids + 1] = b
end
end
cy = cy + 26
-- (tapped out: the heading greys and its dot dims, app.css .side.depleted)
local h2 = fg.label(c, w.resource .. " well", "h2", 18)
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
gui.set_color(h2.node, ui.color("muted"))
end
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
moons.supply_bar(root, pad, cy, inner, row, moons.supply_max(st))
cy = cy + 14
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st)) 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).
if (row.noteText or "") ~= "" then
if (wrow.noteText or "") ~= "" then
local runs = {}
if (row.statusText or "") ~= "" then
runs[#runs + 1] = { row.statusText .. ". ", "ready" }
if (wrow.statusText or "") ~= "" then
runs[#runs + 1] = { wrow.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
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" }
add(fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) }))
add(space(0, 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" })
add(fact(H, "Supply left", ui.money(wrow.supply)))
if (wrow.demand or 0) > 0 then
add(fact(H, "After current drills", ui.money(wrow.remain), { color = wrow.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).
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 cx = pad
cy = cy + 6
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(root, 0, 0, 1, 1, "none")
local probe = ui.rect(c, 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
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
cy = cy + 24
add(space(0, 7))
add(row({ wrap = true, column_gap = 6, row_gap = 6 }, chips))
add(space(0, 5))
end
cy = cy + 6
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 #(row.wide or {}) > 0 then
if wide and #(wrow.wide or {}) > 0 then
for _, d in ipairs(wide.drills or {}) do
if contains(row.wide, d.id) then
if contains(wrow.wide, d.id) then
wide_here[#wide_here + 1] = d
end
end
@@ -259,175 +402,129 @@ local function well_panel(g, root, pad, cy, inner, H)
-- 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
add(fg.label(c, "Deploy a tool", "h3", 12))
add(space(0, 6))
if #w.idle > 0 then
cy = deploy_cards(g, root, pad, cy, inner, w, H)
local cards_ = {}
for _, grp in ipairs(w.idle) do
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H)
end
add(col({ gap = 6 }, cards_))
add(space(0, 6))
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
add(row({ height = 40, align = "start" }, {
row({ height = S.button_sm_h, grow = 1, shrink = 1, align = "center", padding_right = 8 }, {
fg.label(c, "Better:", "small", 16), space(4),
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, better.resource) end),
space(4),
link_box(g, c, H, better.resource .. " well", "small", 16,
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well"),
fg.label(c, rest, "small", 16, { fit = not ui.needs_rich(rest), shrink = 1 }),
}),
fg.button(c, "Deploy there", { small = true, primary = true }, function(b, bn)
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
end),
}))
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
add(row({ height = 16, align = "center" }, {
fg.label(c, "No idle tools.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.store and link_box(g, c, H, "Buy one in the store", { font = "sans", px = 13 }, 16, { section = "store", moon = g.view.moon }) or nil,
st.unlocked.store and fg.label(c, ".", "small", 16) or nil,
}))
add(space(0, 6))
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 thumb = ui.rect(dr, 0, 3, 28, 28, "none")
if tool and tool.image then
images.set(thumb, tool.image)
end
if d.state == "none" then
-- Gets nothing: the picture greys (app.css .drill-line.is-none: grayscale, .6),
-- a veil in the panel's color over it.
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
end
local x0 = 36
local nt = ui.text(dr, name, { font = "sans_semi", px = 14, color = "text" }, x0, 8)
local tt = ui.text(dr, "T" .. tier, { font = "mono", px = 11, color = "muted" }, x0 + ui.text_width(nt) + 6, 8)
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
for _, a in ipairs(tool and tool.attachments or {}) do
if tx + 60 > inner - 50 then
break
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
-- 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 gets
if d.state == "none" then
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
gets = "+" .. ui.money(d.amount) .. "/cycle" .. reach
local lead = ui.text(dr, gets, { font = "sans", px = 12, color = "muted" }, x0, 24)
local tag, tw = chip.new(dr, "Nothing left", x0 + ui.text_width(lead) + 6, 24,
{ color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true })
tooltip.add(tag, "Nothing left by its turn: stops and goes idle when the well runs dry")
gets = nil
else
local short = d.state ~= "ok" and "ready" or "muted"
gets = ui.runs({
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
{ 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.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
})
end
if gets then
ui.text(dr, gets, { font = "sans", px = 12, color = "white", markup = true }, x0, 24)
end
if d.ready then
bar.ready_label(dr, "Ready", inner, 14, gui.PIVOT_E)
else
local t = ui.text(dr, ui.dur(d["end"] - H.now(g)), { font = "mono", px = 12, color = "muted" }, inner, 14, gui.PIVOT_E)
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
end
row_c.enter(g, "drill:" .. d.toolId, dr, inner, 34)
cy = cy + 36
end
local drills = {}
for _, d in ipairs(wrow.drills) do
drills[#drills + 1] = d
end
cy = cy + 8
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))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
local amount = ready.amount(g, row, row.supply)
if row.ready > 0 then
local b, bn, bw = ui.button(root, "Claim " .. ui.money(amount), pad, cy, { primary = true })
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
if not dry and st.unlocked.redeploy then
local b2, bn2 = ui.button(root, "Claim and redeploy", pad + bw + 8, cy)
H.action(g, b2, bn2, "claim", { ["claim-well"] = w.id, redeploy = "1" })
end
cy = cy + S.button_h + 10
elseif row.nextEnd then
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
local claims = {}
local amount = ready.amount(g, wrow, wrow.supply)
if wrow.ready > 0 then
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(amount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
end),
(not dry and st.unlocked.redeploy) and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id, redeploy = "1" })
end) or nil,
})
elseif wrow.nextEnd then
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wrow.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 })
end)
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
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
end),
st.unlocked.redeploy and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
end) or nil,
})
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
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
end)
end
end
if (row.redrill or 0) > 0 and not dry and st.unlocked.redrill then
local b, bn = ui.button(root, "Redrill" .. (row.redrill > 1 and (" " .. row.redrill) or ""), pad, cy, { variant = "redrill" })
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
cy = cy + S.button_h + 10
if (wrow.redrill or 0) > 0 and not dry and st.unlocked.redrill then
claims[#claims + 1] = claim_row({
fg.button(c, "Redrill" .. (wrow.redrill > 1 and (" " .. wrow.redrill) or ""), { variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
end),
})
end
return cy
add(col({ gap = 10, padding_bottom = #claims > 0 and 10 or 0 }, claims))
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
-- The moon the viewport shows: the selected moon in every section (but New moon).
@@ -457,6 +554,14 @@ function M.row_viewport(w)
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
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
-- 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
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
@@ -480,9 +585,15 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
inner = w - pad * 2
cy = pad + 10
else
-- The square viewport, centered, leaving 300 under it for the panel.
local vp = math.min(inner, h - pad * 2 - 300)
viewport(g, root, pad + (inner - vp) / 2, pad, vp, shown_moon(g))
cy = pad + vp + 18
local top = col({ width = w, padding = { pad, pad, 0, pad }, align = "center" }, {
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
local limit = h - pad
if g.reveal:hiding() then
@@ -503,14 +614,12 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
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 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)
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig."
or "Pick a well in the table to deploy tools and claim resources.")
elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then
ui.text(root, "Unknown moon", "h2", pad, cy + 9)
H.para(root, "Scan resolution too low to make out the surface. Claim it to see what it holds.", "small", pad, cy + 28, inner)
caption(root, pad, cy, inner, "Unknown moon", "Scan resolution too low to make out the surface. Claim it to see what it holds.")
end
return p
end
+42 -51
View File
@@ -5,6 +5,8 @@
-- padding 6px 16px).
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
@@ -22,75 +24,64 @@ function M.time_left(ms)
return string.format("%dm left", mins)
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 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")
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 node, tw = ui.label(root, b.text, style, 14, ROW_H / 2)
local w, h = 14 + tw, ROW_H
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
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 left
if b.kind == "event" and (b.left or "") ~= "" then
local text = "— " .. b.left
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
text = "— " .. M.time_left(ends - H.now(self))
left.done = function(n)
H.tick_text(self, n, ends, function(ms)
return "— " .. M.time_left(ms)
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
end
end
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)
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
end
w = math.max(w, lx + ui.text_width(left))
end
return root, math.min(w, max_w), h
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
-- 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)
local st = self.state
if not st.banner or #st.banner == 0 then
return 0
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 rows, row = {}, { w = 0, h = 0 }
local items = {}
for _, b in ipairs(st.banner) do
local node, w, h = item(self, H, root, 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
row.w = row.w + (#row > 0 and GAP_X or 0) + w
row.h = math.max(row.h or 0, h)
row[#row + 1] = { node = node, w = w }
items[#items + 1] = item(self, H, parent, b, max_w)
end
rows[#rows + 1] = row
local y = PAD_Y
for i, r in ipairs(rows) do
local x = math.floor((W - r.w) / 2)
for _, it in ipairs(r) do
gui.set_position(it.node, vmath.vector3(x, -y, 0))
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
local tree = fg.col({ width = W, padding = { PAD_Y, PAD_X } }, {
fg.row({ wrap = true, justify = "center", align = "start", column_gap = GAP_X, row_gap = GAP_Y }, items),
fg.made({ position = "absolute", left = 0, right = 0, bottom = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "line_hi")
end),
}, { make = function(p, x, y, w, h)
return ui.rect(p, x, y, w, h, "cyan", 0.08)
end })
fg.build(flex.solve(tree, 0, 0), parent)
return tree.h
end
return M
+21 -1
View File
@@ -253,6 +253,26 @@ function M.claim_wait(g, parent, x, y, ends, opts)
return b, bw
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
-- and number), made by ui.button(b, nodes); widens the button. Returns its new width.
function M.badge(b, nodes, n)
@@ -303,7 +323,7 @@ end
-- The H table section modules get.
function M.table()
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,
para = M.para, heading = M.heading, fact = M.fact,
tick_text = ticks.tick_text, tick_bar = ticks.tick_bar, tick_num = ticks.tick_num,
+334 -232
View File
@@ -14,6 +14,9 @@ local store = require("screens.game.store")
local chip = require("components.chip")
local card = require("components.card")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local vec = cards.vec
@@ -35,9 +38,11 @@ local function drill_view(self, id)
return { section = "equipment", drill = id, moon = self.view.moon, well = self.view.well }
end
-- Stats with the next upgrade's changes: Tier, Yield, Reach (when it grows), Cycle.
-- Wraps at w; returns the height used.
local function stats(st, parent, t, x, y, w)
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- Stats with the next upgrade's changes: Tier, Yield, Reach (when it grows), Cycle,
-- wrapping (rows 20 tall, 16 apart). A tree piece.
local function stats_box(st, parent, t)
local u = t.preview
local items = {}
if u then
@@ -48,107 +53,132 @@ local function stats(st, parent, t, x, y, w)
items[#items + 1] = { "Reach", tostring(u.reachFrom), cards.wells(st, u.reachTo) }
end
items[#items + 1] = { "Cycle", ui.cycle(t.cycleSecs), u and u.secsTo ~= u.secsFrom and ui.cycle(u.secsTo) or nil }
local cx, cy = x, y
local kids = {}
for _, it in ipairs(items) do
-- Measure on a throwaway parent, then place (wrap when it doesn't fit).
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local iw = cards.stat_up(probe, it[1], it[2], it[3], 0, 0)
gui.delete_node(probe)
if cx > x and cx + iw > x + w then
cx, cy = x, cy + 20
end
cards.stat_up(parent, it[1], it[2], it[3], cx, cy + 8)
cx = cx + iw + 16
kids[#kids + 1] = made({ width = iw, height = 20, shrink = 0 }, function(p, x, y)
cards.stat_up(p, it[1], it[2], it[3], x, y + 8)
end)
end
return cy - y + 20
return row({ wrap = true, column_gap = 16 }, kids)
end
-- The upgrade button (or "Max tier"), what's short and the parts. Returns the height.
local function upgrade(self, H, st, parent, t, x, y, w, small)
-- The same at (x, y), w wide (inside a card block); returns the height used.
local function stats(st, parent, t, x, y, w)
local tree = stats_box(st, parent, t)
tree.style.width = w
fg.build(flex.solve(tree, x, y), parent)
return tree.h
end
-- The upgrade button (or "Max tier"), what's short and the parts. A tree piece w wide.
local function upgrade_box(self, H, st, parent, t, w, small)
local n = t.next
if not n then
ui.text(parent, "Max tier", "small", x, y + 10)
return 22
return col({ width = w }, { fg.label(parent, "Max tier", "small", 20), space(0, 2) })
end
local ok = n.canAfford
local b, nodes = ui.button(parent, "Upgrade to T" .. n.tier, x, y, { small = small, w = w, price = n.price, disabled = not ok })
if not ok then
tooltip.add(b, n.blockedText)
end
local h = small and S.button_sm_h or S.button_h
if ok then
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id })
end
h = h + 4
local kids = {
fg.button(parent, "Upgrade to T" .. n.tier, { small = small, price = n.price, disabled = not ok }, function(b, nodes)
if not ok then
tooltip.add(b, n.blockedText)
else
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id })
end
end, { width = w }),
space(0, 4),
}
-- Credits short: the server's shortText under the button (parts short: the tooltip only).
local why = n.blocked ~= "" and (n.shortText or "") or ""
if why ~= "" then
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local lh = cards.para(probe, why, { font = "sans", px = 12, color = "danger" }, 0, 0, w)
gui.delete_node(probe)
if lh <= 18 then
store.need_text(parent, why, x + w, y + h + 8)
else
cards.para(parent, why, { font = "sans", px = 12, color = "danger" }, x, y + h, w)
end
h = h + math.max(18, lh + 2)
local style = { font = "sans", px = 12, color = "danger" }
kids[#kids + 1] = fg.measured(parent, function(p, x, y, bw)
local probe = ui.rect(p, 0, 0, 1, 1, "none")
local lh = cards.para(probe, why, style, 0, 0, bw)
gui.delete_node(probe)
if lh <= 18 then
store.need_text(p, why, x + bw, y + 8)
else
cards.para(p, why, style, x, y, bw)
end
return math.max(18, lh + 2)
end)
end
if #(n.parts or {}) > 0 then
h = h + 2 + cards.parts(st, parent, n.parts, x, y + h + 2, w)
kids[#kids + 1] = space(0, 2)
kids[#kids + 1] = fg.measured(parent, function(p, x, y, bw)
return cards.parts(st, p, n.parts, x, y, bw)
end)
end
return h
return col({ width = w }, kids)
end
-- A tool's status: where its drill is, or how many copies are idle.
-- The same at (x, y) (a card's footer); returns the height used.
local function upgrade(self, H, st, parent, t, x, y, w, small)
local tree = upgrade_box(self, H, st, parent, t, w, small)
fg.build(flex.solve(tree, x, y), parent)
return tree.h
end
-- A tool's status (a 20 tall row at (x, y - 10), w wide): where its drill is (its
-- resource dot, "<moon> · <resource>", returned as a link target) and time left or Ready,
-- or how many copies are idle.
local function status(self, H, st, parent, t, x, y, w)
local d = t.stake
local where_link
local kids, where_link = {}, nil
if d then
local moon = find(st.moons, "id", d.moonId)
ui.rect(parent, x, y - 4, 8, 8, d.resource)
local where = (moon and (moon.name .. " · ") or "") .. d.resource
local lbl, lw = ui.label(parent, where, { font = "sans", px = 13, color = "text_2" }, x + 14, y)
if not ui.needs_rich(where) then
ui.fit_text(lbl, w - 14 - 110)
lw = ui.text_width(lbl)
end
where_link = { node = lbl, x = x, y = y, w = lw + 14, view = { section = "moons", moon = d.moonId, well = d.wellId } }
local after = ""
if t.count > 1 then
after = "all " .. t.count .. " drilling"
end
local rx = x + w
if after ~= "" then
local an = ui.text(parent, " · " .. after, { font = "sans", px = 13, color = "muted" }, rx, y, gui.PIVOT_E)
rx = rx - ui.text_width(an)
local lbl = fg.label(parent, where, { font = "sans", px = 13, color = "text_2" }, 20,
{ fit = true, shrink = 1, max_width = w - 14 - 110 })
lbl.done = function(n, b)
where_link = { node = n, x = x, y = y, w = b.w + 14, view = { section = "moons", moon = d.moonId, well = d.wellId } }
end
local right
if d.ready then
ui.text(parent, "Ready", { font = "sans_semi", px = 13, color = "ready" }, rx, y, gui.PIVOT_E)
right = fg.label(parent, "Ready", { font = "sans_semi", px = 13, color = "ready" }, 20)
else
local node = ui.text(parent, ui.dur(d["end"] - H.now(self)) .. " left", { font = "mono", px = 12, color = "muted" }, rx, y, gui.PIVOT_E)
H.tick_text(self, node, d["end"], function(left)
return ui.dur(left) .. " left"
end, "due")
right = fg.label(parent, ui.dur(d["end"] - H.now(self)) .. " left", { font = "mono", px = 12, color = "muted" }, 20)
right.done = function(n)
H.tick_text(self, n, d["end"], function(left)
return ui.dur(left) .. " left"
end, "due")
end
end
return where_link
kids = {
made({ width = 8, height = 8, shrink = 0 }, function(p, rx, ry) ui.rect(p, rx, ry, 8, 8, d.resource) end),
space(6), lbl, fg.fill(), right,
t.count > 1 and fg.label(parent, " · all " .. t.count .. " drilling", { font = "sans", px = 13, color = "muted" }, 20) or nil,
}
else
local busy = t.count - t.idle
kids = { fg.label(parent, busy > 0 and (t.idle .. " idle · " .. busy .. " drilling") or "Idle", { font = "sans", px = 13, color = "muted" }, 20) }
end
local busy = t.count - t.idle
ui.text(parent, busy > 0 and (t.idle .. " idle · " .. busy .. " drilling") or "Idle", { font = "sans", px = 13, color = "muted" }, x, y)
fg.build(flex.solve(row({ width = w, height = 20, align = "center" }, kids), x, y - 10), parent)
return where_link
end
local function tool_card(self, H, st, c, t, open)
c:image(t.image)
-- Name, ×count, tier chip on the right.
c:block(22, function(p, bw)
local tier_chip, cwid = chip.new(p, "T" .. t.tier .. "/" .. t.maxTier, bw, 8, { font = "mono", px = 11, text = "text_2", pad = 5, right = true })
local name = ui.text(p, t.name, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 0, 8)
local cnt = t.count > 1 and ("×" .. t.count) or nil
tooltip.add(tier_chip, "Tier")
ui.fit_text(name, bw - cwid - 8 - (cnt and 30 or 0))
if cnt then
ui.text(p, cnt, { font = "mono", px = 13, color = "muted" }, ui.text_width(name) + 6, 8)
end
fg.build(flex.solve(row({ width = bw, height = 16, align = "center" }, {
fg.label(p, t.name, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 16, { fit = true, shrink = 1 }),
cnt and space(6) or nil,
cnt and fg.label(p, cnt, { font = "mono", px = 13, color = "muted" }, 16) or nil,
fg.fill(), space(8),
fg.inline(p, 16, function(q, x, y, probing)
local tier_chip, cwid = chip.new(q, "T" .. t.tier .. "/" .. t.maxTier, x, y, { font = "mono", px = 11, text = "text_2", pad = 5 })
if not probing then
tooltip.add(tier_chip, "Tier")
end
return cwid
end),
}), 0, 0), p)
end)
c:block(t.reach > 1 and 22 or 28, function(p, bw)
local l = status(self, H, st, p, t, 0, 10, bw)
@@ -210,19 +240,17 @@ function M.build(self, root, px, py, w, h, H)
local open = st.unlocked.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment")
local c = area.content
y = H.heading(c, x, y, "Equipment")
ui.text(c, ui.plural(#st.tools, "tool") .. "." .. (open and " Pick one to equip attachments." or ""),
{ font = "sans", px = 15, color = "muted" }, x, y + 2)
if open then
local b, _, bw = ui.button(c, "Buy tools", 0, 0, { small = true })
gui.set_position(b, vec(x + inner - bw, -(py + 6)))
H.link(self, b, { section = "store", moon = self.view.moon })
end
y = y + 26
if #eq.tools == 0 then
ui.text(c, "You don't own any tools yet.", "small", x, y + 8)
y = y + 30
end
y = fg.flow(c, x, y, inner, { col({}, {
fg.label(c, "Equipment", "h1", 40),
fg.label(c, ui.plural(#st.tools, "tool") .. "." .. (open and " Pick one to equip attachments." or ""),
{ font = "sans", px = 15, color = "muted" }, 12),
space(0, 18),
open and fg.button(c, "Buy tools", { small = true }, function(b)
H.link(self, b, { section = "store", moon = self.view.moon })
end, { position = "absolute", top = 8, right = 0 }) or nil,
#eq.tools == 0 and fg.label(c, "You don't own any tools yet.", "small", 16) or nil,
#eq.tools == 0 and space(0, 14) or nil,
}) })
y = card.grid(self, c, x, y, inner, eq.tools, function(cd, t)
tool_card(self, H, st, cd, t, open)
end, { key = function(t) return "eq:tool:" .. tostring(t.id) end, sig = function(t)
@@ -235,18 +263,62 @@ end
---------------------------------------------------------------- drill panel (aside)
local function h3(parent, text, x, y)
ui.text(parent, text, "h3", x, y + 6)
return y + 20
-- A heading (h3) and the 8 px under it.
local function h3(c, text)
return col({}, { fg.label(c, text, "h3", 12), space(0, 8) })
end
-- A line with text on the left (title, sub) and a button on the right; returns the y under.
local function line(parent, x, y, w, title, sub, sub_style)
local t = ui.label(parent, title, { font = "sans", px = 15, color = "text" }, x, y + 10)
if sub and sub ~= "" then
ui.label(parent, sub, sub_style or { font = "mono", px = 12, color = "muted" }, x, y + 28)
-- A 1 px rule over the top of a box (absolute: it takes no room).
local function rule()
return 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)
end
-- A ui.credits amount (₥ and the number) as a box h tall.
local function credits_box(c, amount, opts, h)
local probe = ui.rect(c, 0, 0, 1, 1, "none")
local cw = ui.credits(probe, amount, 0, 0, opts)
gui.delete_node(probe)
return made({ width = cw, height = h, shrink = 0 }, function(p, x, y)
ui.credits(p, amount, x, y + h / 2, opts)
end)
end
local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1, upper = true }
-- The tier ladder: a grid (Tier, Yield, Reach, Cycle at 0, 20, 42, 66 % of the row's
-- inner width, ₥/h at the right), the current tier and the next lit.
local function ladder(st, c, d, inner)
local fr = { 0, 0.2, 0.42, 0.66, 1 }
local cols = {}
for i = 1, 4 do
cols[i] = math.floor((inner - 16) * fr[i + 1]) - math.floor((inner - 16) * fr[i])
end
return t
cols[5] = "1fr"
local heads = {}
for i, lbl in ipairs({ "Tier", "Yield", "Reach", "Cycle" }) do
heads[i] = fg.label(c, lbl, TIER_HEAD, 12)
end
local head = box({ columns = cols, height = 14, padding = { 0, 8, 0, 8 }, align = "start" }, heads)
-- (₥/h: its left edge 22 before the rate column's right edge)
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
local rows = {}
for _, r in ipairs(d.ladder) do
local color = r.current and "text" or (r.next and "stat_up" or "text_2")
local font = r.next and "mono_semi" or "mono"
local kids = {}
for i, v in ipairs({ "T" .. r.tier, ui.money(r.yield), cards.wells(st, r.reach), ui.cycle(r.cycleSecs) }) do
kids[i] = fg.label(c, v, { font = font, px = 12, color = color }, 20)
end
kids[5] = fg.label(c, cards.compact(r.rate), { font = font, px = 12, color = r.next and "stat_up" or "muted" }, 20, { justify_self = "end" })
local lit = r.current and "cyan" or (r.next and "stat_up") or nil
rows[#rows + 1] = box({ columns = cols, rows = { 20 }, padding = { 0, 8, 0, 8 } }, kids, { make = lit and function(p, x, y, w)
ui.rect(p, x, y, w, 20, lit, 0.08)
ui.rect(p, x, y, 2, 20, lit)
end or nil })
end
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
end
function M.aside(self, root, w, h, H)
@@ -258,9 +330,9 @@ function M.aside(self, root, w, h, H)
local t = find(st.equipment.tools, "id", d.toolId)
if not t then
-- The drill's copy shares a row with others: the row shows another id.
for _, row in ipairs(st.equipment.tools) do
if row.selected then
t = row
for _, r in ipairs(st.equipment.tools) do
if r.selected then
t = r
end
end
end
@@ -272,179 +344,209 @@ function M.aside(self, root, w, h, H)
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "drill")
local c = area.content
local y = pad
-- Head: image, name, tier, close.
local img = 84
local media = ui.rect(c, pad, y, img, img, "bg")
ui.outline(media, 0, 0, img, img, "line")
cards.image(media, tool.image or t.image, 0, 0, img)
local tx = pad + img + 14
local tw = inner - img - 14
local name = ui.text(c, t.name, "h2", tx, y + 14)
ui.fit_text(name, tw - 40)
ui.text(c, "T" .. t.tier, { font = "mono", px = 13, color = "muted" }, tx, y + 38)
if d.mining then
cards.para(c, "Mining now. Changes apply from its next drill.", { font = "sans", px = 13, color = "muted" }, tx, y + 52, tw)
local kids = {}
local function add(b)
kids[#kids + 1] = b
end
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 = "equipment", moon = self.view.moon, well = self.view.well })
y = y + img + 14
-- Head: image, name, tier (and "Mining now"), close.
local img = 84
local tw = inner - img - 14
add(row({ height = img }, {
made({ width = img, height = img, shrink = 0 }, function(p, x, y)
local media = ui.rect(p, x, y, img, img, "bg")
ui.outline(media, 0, 0, img, img, "line")
cards.image(media, tool.image or t.image, 0, 0, img)
end),
space(14),
col({ grow = 1, shrink = 1 }, {
fg.label(c, t.name, "h2", 28, { fit = true, shrink = 1, max_width = tw - 40 }),
fg.label(c, "T" .. t.tier, { font = "mono", px = 13, color = "muted" }, 20),
d.mining and space(0, 4) or nil,
d.mining and fg.measured(c, function(p, x, y, bw)
return cards.para(p, "Mining now. Changes apply from its next drill.", { font = "sans", px = 13, color = "muted" }, x, y, bw)
end) or nil,
}),
fg.button(c, "×", { small = true, w = 32 }, function(b)
H.link(self, b, { section = "equipment", moon = self.view.moon, well = self.view.well })
end, { width = 32, position = "absolute", top = 0, right = 0 }),
}))
add(space(0, 14))
-- Stats and the upgrade.
ui.rect(c, pad, y, inner, 1, "line")
y = y + 6 + stats(st, c, t, pad, y + 4, inner) + 4
add(col({ padding = { 4, 0, 6, 0 } }, { rule(), stats_box(st, c, t) }))
local u = t.preview
if u and u.rateTo > u.rateFrom + 0.5 then
-- "Income ₥1k/h ▸ ₥2k/h · pays back in 5m": the fonts have no arrow, cards.arrow draws it.
local a = "Income " .. theme.currency .. cards.compact(u.rateFrom) .. "/h"
local b = theme.currency .. cards.compact(u.rateTo) .. "/h"
.. (u.paybackSecs and (" · pays back in " .. cards.span(u.paybackSecs)) or "")
local _, aw = ui.label(c, a, { font = "mono", px = 12, color = "muted" }, pad, y + 8)
cards.arrow(c, pad + aw + 5, y + 8)
ui.label(c, b, { font = "mono", px = 12, color = "muted" }, pad + aw + 18, y + 8)
y = y + 22
local m12 = { font = "mono", px = 12, color = "muted" }
add(row({ height = 16, align = "center" }, {
fg.label(c, "Income " .. theme.currency .. cards.compact(u.rateFrom) .. "/h", m12, 16), space(5),
made({ width = 13, height = 16, shrink = 0 }, function(p, x, y) cards.arrow(p, x, y + 8) end),
fg.label(c, theme.currency .. cards.compact(u.rateTo) .. "/h"
.. (u.paybackSecs and (" · pays back in " .. cards.span(u.paybackSecs)) or ""), m12, 16),
}))
add(space(0, 6))
end
y = y + 4 + upgrade(self, H, st, c, t, pad, y + 4, inner, false) + 8
add(space(0, 4))
add(upgrade_box(self, H, st, c, t, inner, false))
add(space(0, 8))
-- Tier ladder.
if #(d.ladder or {}) > 0 then
y = h3(c, "Tiers", pad, y)
local cols = { 0, 0.2, 0.42, 0.66, 1 }
local heads = { "Tier", "Yield", "Reach", "Cycle", theme.currency .. "/h" }
for i, lbl in ipairs(heads) do
local cx = pad + 8 + math.floor((inner - 16) * cols[i])
if i == 5 then
local n = ui.label(c, lbl, { font = "sans_semi", px = 10, color = "muted", tracking = 0.1, upper = true }, 0, y + 6)
gui.set_position(n, vec(cx - 22, -(y + 6)))
else
ui.text(c, lbl, { font = "sans_semi", px = 10, color = "muted", tracking = 0.1, upper = true }, cx, y + 6)
end
end
y = y + 14
for _, r in ipairs(d.ladder) do
local color = r.current and "text" or (r.next and "stat_up" or "text_2")
if r.current then
ui.rect(c, pad, y, inner, 20, "cyan", 0.08)
ui.rect(c, pad, y, 2, 20, "cyan")
elseif r.next then
ui.rect(c, pad, y, inner, 20, "stat_up", 0.08)
ui.rect(c, pad, y, 2, 20, "stat_up")
end
local font = r.next and "mono_semi" or "mono"
local vals = { "T" .. r.tier, ui.money(r.yield), cards.wells(st, r.reach), ui.cycle(r.cycleSecs) }
for i, v in ipairs(vals) do
ui.text(c, v, { font = font, px = 12, color = color }, pad + 8 + math.floor((inner - 16) * cols[i]), y + 10)
end
ui.text(c, cards.compact(r.rate), { font = font, px = 12, color = r.next and "stat_up" or "muted" }, pad + inner - 8, y + 10, gui.PIVOT_E)
y = y + 21
end
y = y + 10
add(h3(c, "Tiers"))
add(ladder(st, c, d, inner))
end
-- Idle copies while this one mines: deploy one on its best well.
if d.idleBest and d.idleBest ~= "" then
local to = d.deployTo
-- "N idle <tool>" opens that copy's panel (the web's IdleURL), then its tier.
local _, lw = H.text_link(self, c, d.idleSame .. " idle " .. t.name, { font = "sans", px = 15 }, pad, y + 10,
drill_view(self, d.idleBest), "idle-copy")
local copy = find(st.tools, "id", d.idleBest)
local tier = copy and copy.tier or t.tier
ui.text(c, "T" .. tier, { font = "mono", px = 12, color = "muted" }, pad + lw + 6, y + 10)
ui.label(c, to and ("Best well: " .. to.resource .. " on " .. to.moonName) or "No well to deploy on", "small", pad, y + 28)
if to then
local b, nodes, bw = ui.button(c, "Deploy one", 0, 0, { small = true, primary = true })
gui.set_position(b, vec(pad + inner - bw, -(y + 3)))
H.action(self, b, nodes, "stake", { tool = d.idleBest, target = to.wellId, s = "moons", moon = to.moonId, well = to.wellId })
end
y = y + 60
local link = d.idleSame .. " idle " .. t.name
local lstyle = { font = "sans", px = 15 }
local probe = fg.label(c, link, lstyle, 20)
local lw = probe.measure()
gui.delete_node(probe.node)
add(col({ height = 60 }, {
row({ height = 20, align = "center" }, {
-- "N idle <tool>" opens that copy's panel (the web's IdleURL), then its tier.
made({ width = lw, height = 20, shrink = 0 }, function(p, x, y)
H.text_link(self, p, link, lstyle, x, y + 10, drill_view(self, d.idleBest), "idle-copy")
end),
space(6),
fg.label(c, "T" .. (copy and copy.tier or t.tier), { font = "mono", px = 12, color = "muted" }, 20),
}),
fg.label(c, to and ("Best well: " .. to.resource .. " on " .. to.moonName) or "No well to deploy on", "small", 16),
to and fg.button(c, "Deploy one", { small = true, primary = true }, function(b, nodes)
H.action(self, b, nodes, "stake", { tool = d.idleBest, target = to.wellId, s = "moons", moon = to.moonId, well = to.wellId })
end, { position = "absolute", top = 3, right = 0 }) or nil,
}))
end
-- Slots.
y = h3(c, "Slots", pad, y)
for _, s in ipairs(d.slots) do
ui.rect(c, pad, y, inner, 1, "line")
ui.text(c, s.n, { font = "mono", px = 12, color = "muted" }, pad + 2, y + 21)
if s.attachment ~= "" then
local a = catalog_attachment(st, s.attachment)
local icon = ui.rect(c, pad + 18, y + 5, 32, 32, "bg")
cards.image(icon, a.image, 0, 0, 32)
line(c, pad + 58, y + 2, inner, s.attachment, a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 12, color = "violet_hi" })
local b, nodes, bw = ui.button(c, "Unequip", 0, 0, { small = true })
gui.set_position(b, vec(pad + inner - bw, -(y + 5)))
H.action(self, b, nodes, "unequip", { item = s.attachment, to = d.toolId })
add(h3(c, "Slots"))
local slots = {}
for _, sl in ipairs(d.slots) do
local rest
if sl.attachment ~= "" then
local a = catalog_attachment(st, sl.attachment)
rest = {
col({ padding_top = 5, shrink = 0 }, {
made({ width = 32, height = 32 }, function(p, x, y)
cards.image(ui.rect(p, x, y, 32, 32, "bg"), a.image, 0, 0, 32)
end),
}),
space(8),
col({ grow = 1, shrink = 1, padding_top = 2 }, {
fg.label(c, sl.attachment, { font = "sans", px = 15, color = "text" }, 20),
fg.label(c, a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 12, color = "violet_hi" }, 16),
}),
col({ padding_top = 5 }, {
fg.button(c, "Unequip", { small = true }, function(b, nodes)
H.action(self, b, nodes, "unequip", { item = sl.attachment, to = d.toolId })
end),
}),
}
else
ui.text(c, "Empty", { font = "sans", px = 15, color = "muted" }, pad + 22, y + 21)
rest = { space(4), row({ align = "center" }, { fg.label(c, "Empty", { font = "sans", px = 15, color = "muted" }, 16) }) }
end
y = y + 42
local r = row({ height = 42 }, {
rule(),
row({ width = 18, padding_left = 2, align = "center", shrink = 0 }, { fg.label(c, sl.n, { font = "mono", px = 12, color = "muted" }, 16) }),
})
for _, k in ipairs(rest) do
r.children[#r.children + 1] = k
end
slots[#slots + 1] = r
end
y = y + 8
add(col({}, slots))
add(space(0, 8))
-- Spare attachments: equip (with the stats it'd give), sell one.
y = h3(c, "Spare attachments", pad, y)
add(h3(c, "Spare attachments"))
if #d.spares == 0 then
ui.text(c, "No spare attachments.", "small", pad, y + 16)
local b, _, bw = ui.button(c, "Buy some in the store", 0, 0, { small = true })
gui.set_position(b, vec(pad + inner - bw, -y))
H.link(self, b, { section = "store", moon = self.view.moon })
y = y + 40
add(row({ height = 40, align = "start" }, {
row({ grow = 1, height = S.button_sm_h, align = "center" }, { fg.label(c, "No spare attachments.", "small", 16) }),
fg.button(c, "Buy some in the store", { small = true }, function(b)
H.link(self, b, { section = "store", moon = self.view.moon })
end),
}))
end
local m12 = function(color) return { font = "mono", px = 12, color = color } end
for _, sp in ipairs(d.spares) do
ui.rect(c, pad, y, inner, 1, "line")
local a = catalog_attachment(st, sp.name)
local icon = ui.rect(c, pad, y + 6, 36, 36, "bg")
cards.image(icon, a.image, 0, 0, 36)
local nm = ui.text(c, sp.name, { font = "sans", px = 15, color = "text" }, pad + 46, y + 14)
if sp.count > 1 then
ui.text(c, "×" .. sp.count, { font = "mono", px = 12, color = "muted" }, pad + 46 + ui.text_width(nm) + 6, y + 14)
end
-- What the drill would do with it: effect, then cycle and yield (stat_up where better).
local ex = pad + 46
local eff = ui.text(c, a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 12, color = "violet_hi" }, ex, y + 32)
ex = ex + ui.text_width(eff) + 6
local then_ = ui.text(c, "· then", { font = "mono", px = 12, color = "muted" }, ex, y + 32)
ex = ex + ui.text_width(then_) + 5
local cyc = ui.text(c, ui.cycle(sp.cycleSecs), { font = "mono", px = 12, color = sp.cycleSecs < t.cycleSecs and "stat_up" or "text_2" }, ex, y + 32)
ex = ex + ui.text_width(cyc)
ui.text(c, ", +" .. ui.money(sp.yield), { font = "mono", px = 12, color = sp.yield > t.yield + 1e-9 and "stat_up" or "text_2" }, ex, y + 32)
local sell, _, sw = ui.button(c, "Sell", 0, 0, { small = true, variant = "danger" })
tooltip.add(sell, "Sell one for " .. theme.currency .. ui.money(sp.resale))
gui.set_position(sell, vec(pad + inner - sw, -(y + 8)))
cards.confirm_server_button(self, sell, sp.confirm, "sell-attachment", { item = sp.name })
local eb, en, ew = ui.button(c, "Equip", 0, 0, { small = true, primary = true, disabled = not sp.canEquip })
if not sp.canEquip then
tooltip.add(eb, sp.whyText)
end
gui.set_position(eb, vec(pad + inner - sw - 6 - ew, -(y + 8)))
if sp.canEquip then
H.action(self, eb, en, "equip", { item = sp.name, to = d.toolId })
else
ui.text(c, sp.whyText, { font = "sans", px = 11, color = "muted" },
pad + inner - sw - 6, y + 46, gui.PIVOT_E)
end
y = y + (sp.canEquip and 50 or 58)
local sw = fg.button_w(c, "Sell", { small = true, variant = "danger" })
add(row({ height = sp.canEquip and 50 or 58 }, {
rule(),
col({ padding_top = 6, shrink = 0 }, {
made({ width = 36, height = 36 }, function(p, x, y)
cards.image(ui.rect(p, x, y, 36, 36, "bg"), a.image, 0, 0, 36)
end),
}),
space(10),
col({ grow = 1, shrink = 1, padding_top = 6 }, {
row({ height = 16, align = "center" }, {
fg.label(c, sp.name, { font = "sans", px = 15, color = "text" }, 16),
sp.count > 1 and space(6) or nil,
sp.count > 1 and fg.label(c, "×" .. sp.count, m12("muted"), 16) or nil,
}),
space(0, 2),
-- What the drill would do with it: effect, then cycle and yield (stat_up where better).
row({ height = 16, align = "center" }, {
fg.label(c, a.effectText or cards.effect(a.effect, "attachment"), m12("violet_hi"), 16), space(6),
fg.label(c, "· then", m12("muted"), 16), space(5),
fg.label(c, ui.cycle(sp.cycleSecs), m12(sp.cycleSecs < t.cycleSecs and "stat_up" or "text_2"), 16),
fg.label(c, ", +" .. ui.money(sp.yield), m12(sp.yield > t.yield + 1e-9 and "stat_up" or "text_2"), 16),
}),
}),
row({ gap = 6, padding_top = 8, align = "start" }, {
fg.button(c, "Equip", { small = true, primary = true, disabled = not sp.canEquip }, function(b, nodes)
if sp.canEquip then
H.action(self, b, nodes, "equip", { item = sp.name, to = d.toolId })
else
tooltip.add(b, sp.whyText)
end
end),
fg.button(c, "Sell", { small = true, variant = "danger" }, function(b)
tooltip.add(b, "Sell one for " .. theme.currency .. ui.money(sp.resale))
cards.confirm_server_button(self, b, sp.confirm, "sell-attachment", { item = sp.name })
end),
}),
not sp.canEquip and fg.label(c, sp.whyText, { font = "sans", px = 11, color = "muted" }, 16,
{ position = "absolute", top = 38, right = sw + 6 }) or nil,
}))
end
y = y + 8
add(space(0, 8))
-- Sell the drill.
y = h3(c, "Sell drill", pad, y)
ui.rect(c, pad, y, inner, 1, "line")
local lbl = ui.text(c, "Sells for", { font = "sans", px = 15, color = "text" }, pad, y + 14)
ui.credits(c, d.resale, pad + ui.text_width(lbl) + 6, y + 14, { px = 15 })
add(h3(c, "Sell drill"))
local why = d.noSale ~= "" and d.noSaleText or nil
local note = "Part of what it and its upgrades cost. Refined parts aren't refunded"
.. (#(d.gear or {}) > 0 and "; its attachments go back to your inventory." or ".")
local sb, _, sbw = ui.button(c, "Sell", 0, 0, { small = true, disabled = why ~= nil, variant = "danger" })
tooltip.add(sb, why)
gui.set_position(sb, vec(pad + inner - sbw, -(y + 6)))
local nh = cards.para(c, note, { font = "sans", px = 12, color = "muted" }, pad, y + 28, inner - sbw - 12)
if not why then
cards.confirm_server_button(self, sb, d.confirm, "sell-tool", { tool = d.toolId })
end
y = y + 32 + nh + pad
area:finish(y)
add(row({}, {
rule(),
col({ grow = 1, shrink = 1, padding_right = 12 }, {
row({ height = 28, align = "center" }, {
fg.label(c, "Sells for", { font = "sans", px = 15, color = "text" }, 16), space(6),
credits_box(c, d.resale, { px = 15 }, 16),
}),
fg.measured(c, function(p, x, y, bw)
return cards.para(p, note, { font = "sans", px = 12, color = "muted" }, x, y, bw)
end),
space(0, 4),
}),
col({ padding_top = 6 }, {
fg.button(c, "Sell", { small = true, disabled = why ~= nil, variant = "danger" }, function(b)
tooltip.add(b, why)
if not why then
cards.confirm_server_button(self, b, d.confirm, "sell-tool", { tool = d.toolId })
end
end),
}),
}))
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, pad), c)
area:finish(tree.y + tree.h + pad)
return true
end
+110 -90
View File
@@ -20,6 +20,10 @@ local M = {}
-- Header sections in the web's order; each shows once state.unlocked has it.
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
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)
return vmath.vector3(x, y, 0)
end
@@ -45,14 +49,23 @@ local function wallet(g, parent, W)
gui.set_text(probe, ui.money(st.credits))
num_w = math.max(num_w, ui.text_width(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
local bal_w = math.floor(num_w + 16 + 32 + 4)
local bal_x = W - S.header_pad_x - bal_w
local _, bal = ui.panel(parent, bal_x, 16, bal_w, 36)
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36)
push(bal_panel, bal_w, 36)
local bal_root = ui.part(bal, "panel", "root")
ui.text(bal_root, theme.currency, "sym", 16, 18)
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
local cx = bal_x - 22
local gap = 22
for i = #st.resources, 1, -1 do
local r = st.resources[i]
local root, nodes = ui.clone("chip", parent, 0, 0)
@@ -61,16 +74,16 @@ local function wallet(g, parent, W)
local tw = ui.text_width(t)
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
local w = 14 + math.max(tw, ui.text_width(t))
cx = cx - w
gui.set_position(root, vec(cx, -24))
gui.set_size(root, vec(w, 20))
push(root, w, 20, gap)
gap = 18
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
g.fx:bind(r.name, t, root)
tooltip.add(root, r.name)
cx = cx - 18
end
-- Sound on/off (saved).
local mute = ui.outline(parent, cx - 28 + 14, 20, 28, 28, "line_hi")
local mute = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(mute, 28, 28, gap == 18 and 4 or 8)
local icon = ui.rect(mute, 4, 4, 20, 20, sfx.muted() and "muted" or "cyan")
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
test.register("mute", mute, "button", "mute")
@@ -80,8 +93,8 @@ local function wallet(g, parent, W)
g.dirty, g.dirty_all = true, 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 tgl = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(tgl, 28, 28, 8)
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")
@@ -92,8 +105,8 @@ local function wallet(g, parent, W)
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 mot = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(mot, 28, 28, 8)
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)
@@ -112,8 +125,8 @@ local function wallet(g, parent, W)
g.dirty, g.dirty_all = true, 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")
local disp = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(disp, 28, 28, 8)
ui.outline(disp, 6, 7, 16, 11, "nav")
ui.rect(disp, 11, 20, 6, 1, "nav")
test.register("display", disp, "button", "display")
@@ -121,118 +134,125 @@ local function wallet(g, parent, W)
g.druid:new_button(disp, function()
require("screens.game.settings").open(g)
end):set_animations_disabled()
return dx - 10
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
flex.apply(tree)
return row[1].x - 10
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)
local st = g.state
x = S.header_pad_x
local brand = ui.text(parent, "Moon Miners", "brand", x, 24)
local cap = ui.text(parent, "Extraction command", "brand_cap", x, 44)
local brand_w = math.max(ui.text_width(brand), ui.text_width(cap))
local pad = S.header_pad_x
local brand = fg.label(parent, "Moon Miners", "brand", 48)
local cap = fg.label(parent, "Extraction command", "brand_cap", 16, { position = "absolute", top = 36, left = 0 })
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
-- web); on a second row when they don't fit.
local tabs = {}
local nav_w = 0
-- Sections (tabs), as they unlock (none until Equipment, like the web).
local section = g.view.section
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
for _, name in ipairs(M.SECTIONS) do
if st.unlocked[name] then
local root, nodes = ui.clone("tab", parent, 0, 0)
local t = ui.part(nodes, "tab", "text")
ui.style_text(t, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, name)
local w = math.floor(ui.text_width(t) + 24)
local label = fg.label(parent, name, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, 44)
local w = math.floor(label.measure() + 24)
gui.delete_node(label.node)
-- (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 badge = count > 0 and count or nil
if badge then
w = w + 26
end
tabs[#tabs + 1] = { name = name, root = root, nodes = nodes, w = w, badge = badge }
names[#names + 1] = name
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(badge and (w - 26) / 2 or 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
chip.new(root, badge, w - 34, 22, { color = "ready", font = "mono", px = 12, min_w = 22, pad = 0 })
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,
})
if name == "help" then
tooltip.add(root, "How to play")
end
g.tab_nodes[name] = root
tabs_c.reveal(g, "nav", name, root, t, w, 44)
end }
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
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
tabs_c.revealed(g, "nav", names)
-- "+ 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).
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.
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
local claim = claim_all and { style = {}, make = function(p, cx, cy)
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.action(g, b, nodes, "claim", {})
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 })
end
end } or nil
local nav = row({ align = "start" }, { row({ gap = 2 }, tabs), claim and space(10) or nil, claim })
-- On the brand's row when it fits before the wallet, else on a row of its own.
local fits = pad + brand_w + 28 + nav_w <= wallet_x
local header_h = fits and S.header_h or S.header_h + 40
local tree = col({ width = W, height = header_h }, {
row({ height = fits and header_h or S.header_h - 8, padding_left = pad, align = "start" }, {
box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }),
fits 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
ready.watch(g)
ui.rect(parent, 0, header_h - 1, W, 1, "edge")
return header_h
end
+25 -14
View File
@@ -4,6 +4,8 @@
local theme = require("modules.theme")
local ui = require("modules.ui")
local cards = require("screens.game.cards")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
@@ -13,23 +15,32 @@ function M.build(self, root, px, py, w, h, H)
local avail = w - px * 2
local inner = math.min(avail, S.text_max)
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" }
-- Steps: the number right-aligned at 22, the text from 32.
local steps = {}
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)
cy = cy + H.para(root, step, style, x + 32, cy, inner - 32) + 12
steps[#steps + 1] = fg.row({ align = "start", width = inner }, {
-- (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
cy = cy + 6
if hp.note and hp.note ~= "" then
cy = cy + H.para(root, hp.note, { font = "sans", px = 14, color = "muted" }, x, cy, inner) + 18
end
-- 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)
cards.confirm_server_button(self, b, hp.newGame or {
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.",
}, "new-game", {})
local note = hp.note and hp.note ~= ""
local tree = fg.col({ width = inner, align = "start" }, {
fg.label(root, "How to play", "h1", 40), fg.space(0, 12),
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).
fg.button(root, "Start a new game", {}, function(b)
cards.confirm_server_button(self, b, hp.newGame or {
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.",
}, "new-game", {})
end),
})
fg.build(flex.solve(tree, x, py), root)
end
return M
+51 -40
View File
@@ -5,25 +5,34 @@
local theme = require("modules.theme")
local ui = require("modules.ui")
local list = require("components.list")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
local ROW_H = 38
-- A row (ROW_H tall, w wide) at (x, y): rank right-aligned in 52, the name (and "You"),
-- credits at the right; the player's own row lit.
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)
if r.me then
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
end
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)
local name = ui.text(bg, r.name, { font = "sans", px = 15, color = "text" }, 76, ROW_H / 2)
ui.fit_text(name, w - 76 - 180)
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)
end
ui.credits(bg, r.credits, w - 14, ROW_H / 2, { pivot = gui.PIVOT_E, px = 14 })
local h = ROW_H
fg.build(flex.solve(fg.row({ width = w, height = h, padding_right = 14, align = "center" }, {
fg.row({ width = 52, justify = "end", shrink = 0 }, {
fg.label(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, h),
}),
fg.space(24),
fg.label(bg, r.name, { font = "sans", px = 15, color = "text" }, h, { fit = true, shrink = 1, max_width = w - 76 - 180 }),
r.me and fg.space(10) or nil,
r.me and fg.label(bg, "You", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, h) or nil,
fg.fill(),
fg.inline(bg, h, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 14 })) end),
}), 0, 0), bg)
end
function M.build(self, root, px, py, w, h, H)
@@ -31,40 +40,42 @@ function M.build(self, root, px, py, w, h, H)
local avail = w - px * 2
local inner = math.min(avail, S.leaderboard_max)
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)
ui.text(root, "Miner", "cap", x + 76, cy + 6)
ui.text(root, "Credits", "cap", x + inner - 14, cy + 6, gui.PIVOT_E)
cy = cy + 16
ui.rect(root, x, cy, inner, 1, "edge")
local bottom = h - py - (lb.you and ROW_H * 2 or 0) - (lb.me and 34 or 0)
local list_h = math.max(ROW_H, math.min(#lb.top * ROW_H, bottom - cy))
if #lb.top > 0 then
local l
l = list.new(self, root, x, cy, inner, list_h, {
key = "leaderboard", row_h = ROW_H,
create = function(r)
local holder = ui.rect(l.content, 0, 0, inner, ROW_H, "none")
row(holder, r, 0, 0, inner)
return holder
end,
})
l:set_data(lb.top)
end
cy = cy + list_h
if lb.you then
local fix = function(b) b.style.shrink = 0; return b end
-- The list shrinks (and scrolls) when the panel is too short for every row; what's
-- under it stays in view.
local tree = fg.col({ width = inner, height = h - py * 2 }, {
-- (the line starts 6 into the heading's 44; 16 under it after the line's 6)
fix(fg.col({ height = 38 }, { fg.label(root, "Leaderboard", "h1", 40, { shrink = 0 }) })),
fix(fg.text_para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".",
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) })),
fg.space(0, 22),
fix(fg.row({ height = 16, align = "start" }, {
fg.row({ width = 52, justify = "end", shrink = 0 }, { fg.label(root, "Rank", "cap", 12) }),
fg.space(24), fg.label(root, "Miner", "cap", 12), fg.fill(),
fg.label(root, "Credits", "cap", 12), fg.space(14),
})),
#lb.top > 0 and fg.made({ height = #lb.top * ROW_H, min_height = ROW_H }, function(p, lx, ly, lw, lh)
ui.rect(p, lx, ly, lw, 1, "edge")
local l
l = list.new(self, p, lx, ly, lw, lh, {
key = "leaderboard", row_h = ROW_H,
create = function(r)
local holder = ui.rect(l.content, 0, 0, lw, ROW_H, "none")
row(holder, r, 0, 0, lw)
return holder
end,
})
l:set_data(lb.top)
end) or fg.made({ height = ROW_H }, function(p, lx, ly, lw) ui.rect(p, lx, ly, lw, 1, "edge") end),
-- (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)
cy = cy + ROW_H
row(root, lb.you, x, cy, inner)
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
lb.you and fix(fg.row({ width = 52, height = ROW_H, justify = "end" }, {
fg.label(root, "···", { font = "sans", px = 15, color = "muted" }, ROW_H),
})) or nil,
lb.you and fg.made({ height = ROW_H, shrink = 0 }, function(p, rx, ry, rw) row(p, lb.you, rx, ry, rw) end) or nil,
lb.me and fix(fg.col({}, { fg.space(0, 14), fg.label(root, "Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players), "small", 16) })) or nil,
fg.fill(),
})
fg.build(flex.solve(tree, x, py), root)
end
return M
+395 -294
View File
@@ -16,6 +16,8 @@ local ticker = require("screens.game.ticker")
local sale = require("screens.game.sale")
local scroll = require("components.scroll")
local blocks = require("modules.blocks")
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
-- section scrolls (app.css @media max-width 1100px: .mk-grid one column).
@@ -23,6 +25,8 @@ local STACK_W = 1100
local M = {}
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
@@ -115,7 +119,8 @@ local function tint(node, color)
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)
return q.boostText or mult(q.boost or 1)
end
@@ -130,87 +135,111 @@ local function quote_view(self, item)
return { section = "market", moon = self.view.moon, pick = item }
end
-- A quote card: name (or symbol when narrow), what you hold, price, change and sparkline.
local function card(self, H, root, x, y, w, h, q, on)
local st = self.state
local c = ui.rect(root, 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(c, 0, 0, w, h, on and "cyan" or "edge")
local empty = q.balance <= 0
local col = color_of(st, q.item)
ui.rect(c, 10, 13, 8, 8, col)
local wide = w >= 150
-- The name (the symbol when narrow), the symbol and "Refined" under it when wide.
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" }
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
-- Price and change on one line when the card is wide, else on two.
local py = 50
local pw = ui.credits(c, q.price, 10, py, { px = 13, color = "text" })
local cy = wide and py or py + 16
change(c, q, wide and (w - 10) or 10, cy, { pivot = wide and gui.PIVOT_E or nil, px = 11 })
if q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001) then
if wide then
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 10 + pw + 6, py)
else
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, w - 10, cy, gui.PIVOT_E)
end
end
-- (kept while the series is the same: it moves on with the 5-minute tick)
local spark_color = (q.change >= 0) and "up" or "danger"
blocks.keep(self, "mk:spark:" .. q.item, blocks.sig(w, h, q.spark, spark_color), c, function(r)
chart.spark(r, 10, h - 20, w - 20, 13, q.spark, spark_color)
end)
-- 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))
if not on then
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(edge, ui.color(over and "line_hi" or "edge"))
end)
end
self.quote_nodes[q.item] = c
self.quote_edges[q.item] = edge
-- A quote's 24h change as an inline box h tall.
local function change_box(c, q, h, opts)
return fg.inline(c, h, function(p, x, y) return change(p, q, x, y, opts) end)
end
-- A group of cards ("Resources", "Refined items"), up to 5 per row. Returns the new y.
local function cards(self, H, root, x, y, inner, quotes, title)
ui.text(root, title, "cap", x, y + 6)
y = y + 18
-- A ui.credits amount as an inline box h tall.
local function credits_box(c, amount, opts, h)
return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
end
-- A quote card (w x h): name (or symbol when narrow), what you hold, price, change and
-- sparkline. A tree piece; the card's node takes the hover and the click.
local function card(self, H, c, w, h, q, on)
local st = self.state
local empty = q.balance <= 0
local wide = w >= 150
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 = 12, color = empty and "muted" or "text", tracking = 0.06 }, 14, { fit = true, shrink = 1 })
local hold = fg.label(c, ui.money(q.balance), { font = "mono", px = 11, color = empty and "faint" or "text_2" }, 14)
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
-- What it's worth (≈ ₥X) under the holding; "Refined" under the name when there's room.
local muted = empty and "faint" or "muted"
local value = credits_box(c, q.value or 0, { px = 10, color = muted }, 14)
local vw = value.style.width
local sub
if wide and w - 34 - vw - 20 > 50 then
sub = fg.label(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 14,
{ fit = true, shrink = 1, max_width = w - 34 - vw - 20 })
else
name.done = function(n) tooltip.add(n, q.refined and (q.item .. " · Refined") or q.item) end
end
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" }, 14)
local lines
-- Price and change on one line when the card is wide, else on two.
if wide then
lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(), change_box(c, q, 14, { px = 11 }) }) }
else
lines = {
row({ height = 14 }, { price }), space(0, 2),
row({ height = 14 }, { change_box(c, q, 14, { px = 11 }), fg.fill(), boost }),
}
end
local spark_color = (q.change >= 0) and "up" or "danger"
local kids = {
row({ height = 14, 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(), hold,
}),
row({ height = 14, padding_left = 14 }, {
sub, fg.fill(), fg.label(c, "≈", { font = "sym", px = 10, color = muted }, 14), space(3), value,
}),
space(0, 5),
-- (kept while the series is the same: it moves on with the 5-minute tick)
made({ position = "absolute", left = 10, top = h - 20, width = w - 20, height = 13 }, 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, 13, q.spark, spark_color)
end)
end),
}
for _, l in ipairs(lines) do
kids[#kids + 1] = l
end
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.
local b = H.link(self, cn, quote_view(self, q.item))
if not on then
b.hover.on_mouse_hover:subscribe(function(_, over)
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"))
end)
end
self.quote_nodes[q.item] = cn
self.quote_edges[q.item] = edge
return cn
end })
end
-- A group of cards ("Resources", "Refined items"), up to 5 per row: a grid.
local function cards(self, H, c, inner, quotes, title)
local per = math.min(5, #quotes)
local gap = 8
local w = math.floor((inner - gap * (per - 1)) / per)
local h = w >= 150 and 82 or 90
for i, q in ipairs(quotes) do
local col = (i - 1) % per
local row = math.floor((i - 1) / per)
card(self, H, root, x + col * (w + gap), y + row * (h + gap), w, h, q, q.item == self.state.market.selected)
local cols, kids = {}, {}
for i = 1, per do
cols[i] = w
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
-- A stat in the heading row: caption over a value; fill(parent, x, y) draws the value
-- and returns its width. Placed right to left; returns the new right edge.
local function stat(root, right, y, label, fill)
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
-- A stat in the heading row: a caption over a value (a tree piece).
local function stat(c, label, value)
return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
end
function M.build(self, root, px, py, w, h, H, opts)
@@ -223,26 +252,24 @@ function M.build(self, root, px, py, w, h, H, opts)
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
root = area.content
end
local c = root
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
self.quote_nodes, self.quote_edges = {}, {}
-- Ticker tape across the panel.
local cy = 14
-- Ticker tape across the panel (kept while the quotes are the same).
local tape = {}
for i, q in ipairs(mk.quotes) do
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
end
cy = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, w, tape), root, function(r)
return ticker.build(self, r, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
H.navigate(self, quote_view(self, item))
end)
end) + 8
local function ticker_part(cy)
return cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, w, tape), root, function(r)
return ticker.build(self, r, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
H.navigate(self, quote_view(self, item))
end)
end) + 8
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
-- room, else on a line of their own under it.
local movers = {}
@@ -253,52 +280,62 @@ function M.build(self, root, px, py, w, h, H, opts)
end
end
end
local function mover(b, x, y, q)
ui.rect(b, x, y - 4, 8, 8, color_of(st, q.item))
local n = ui.text(b, q.item, { font = "sans", px = 15, color = "text" }, x + 14, y)
return 14 + ui.text_width(n) + 6 + change(b, q, x + 14 + ui.text_width(n) + 6, y), n
end
-- A mover links to its quote; its name brightens under the mouse (.mk-stat-link:hover).
local function link(b, x, y, w, q, name)
local hit = ui.rect(b, x, y - 10, w, 20, "none")
local btn = H.link(self, hit, quote_view(self, q.item))
btn.hover.on_mouse_hover:subscribe(function(_, over)
tint(name, ui.color(over and "cyan_hi" or "text"))
end)
end
if inner >= 700 then
for _, mv in ipairs(movers) do
right = stat(root, right, top + 6, mv.label, function(b, x, y)
local mw, n = mover(b, x, y, mv.q)
link(b, x, y, mw, mv.q, n)
return mw
-- A mover: its dot, name and change; links to its quote, the name brightens under the
-- mouse (.mk-stat-link:hover).
local function mover(q)
local name = fg.label(c, q.item, { font = "sans", px = 15, color = "text" }, 20)
return row({ height = 20, align = "center", shrink = 0 }, {
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), change_box(c, q, 20),
}, { 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))
btn.hover.on_mouse_hover:subscribe(function(_, over)
tint(name.node, ui.color(over and "cyan_hi" or "text"))
end)
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
return hit
end })
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)
local held = row({ height = 20, align = "center" }, {
fg.label(c, tostring(mk.held or 0), { font = "mono_semi", px = 18, color = "text" }, 20),
fg.label(c, " / " .. #mk.quotes, { font = "mono", px = 13, color = "muted" }, 20),
})
local total = fg.inline(c, 20, function(p, x, y, probing)
local cw, num = ui.credits(p, mk.total, x, y, { px = 18, color = "text", font = "mono_semi" })
if not probing then
H.tick_num(self, num, "mk:total", mk.total)
end
return cw
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.
local raw, refined = {}, {}
@@ -309,16 +346,19 @@ function M.build(self, root, px, py, w, h, H, opts)
raw[#raw + 1] = q
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
for _, x in ipairs(mk.quotes) do
if x.item == mk.selected then
q = x
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 area then
area:finish(cy + 18)
@@ -333,102 +373,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))
if stacked then
tw, dw, bh = inner, inner, 320
area:finish(cy + bh * 2 + 12 + 18)
end
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 _, dn = ui.panel(root, ox, cy, dw, bh)
local d = ui.part(dn, "panel", "root")
ui.rect(d, 14, 14, 12, 12, col)
local name = ui.text(d, q.item, "h2", 34, 20)
local nx = 34 + ui.text_width(name) + 10
---------------------------------------------------------------- detail
-- Name row: dot, name, Refined, "SYM / ₥" (when it fits); the live event's chip on
-- the name's row, or under the price when that's full.
local nm = fg.label(c, q.item, "h2", 16)
local nx = 34 + nm.measure() + 10
local refined_chip
if q.refined then
local _, rw = chip.new(d, "Refined", nx, 20, { color = "violet", alpha = 0.5, text = "violet_hi", upper = true })
nx = nx + rw + 8
local style = { color = "violet", alpha = 0.5, text = "violet_hi", upper = true }
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
local sym = ui.text(d, (q.symbol or "") .. " / ", "cap", nx, 21)
local cur = ui.text(d, theme.currency, { font = "sym", px = 10, color = "muted" }, nx + ui.text_width(sym), 21)
if nx + ui.text_width(sym) + ui.text_width(cur) > dw - 14 then
gui.delete_node(sym) -- (no room: the symbol is on the card and the ticket)
gui.delete_node(cur)
local sym = fg.label(c, (q.symbol or "") .. " / ", "cap", 16)
local cur = fg.label(c, theme.currency, { font = "sym", px = 10, color = "muted" }, 16)
local sym_fits = nx + sym.measure() + cur.measure() <= dw - 14
if not sym_fits then
gui.delete_node(sym.node) -- (no room: the symbol is on the card and the ticket)
gui.delete_node(cur.node)
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
if boosted == nil then
boosted = (q.boost or 1) > 1.001
end
local ev_low = boosted and dw < 340
local event
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 ew = ui.text_width(ev) + 16
ev_low = dw < 340
local ex, ey = ev_low and 14 or (dw - 14 - ew), ev_low and 66 or 9
gui.set_position(ev, vec(ex + ew - 8, -(ey + 11)))
tooltip.add(ui.outline(d, ex, ey, ew, 22, "ready"), "A live event boosts this sell price")
local ev = fg.label(c, "Event " .. boost_text(q), { font = "mono", px = 11, color = "ready", tracking = 0.1, upper = true }, 22)
local ew = ev.measure() + 16
event = box({ position = "absolute", width = ew, height = 22, padding_left = 8,
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)
local o = ui.outline(p, x, y, ew, 22, "ready")
tooltip.add(o, "A live event boosts this sell price")
return o
end })
end
local pw = ui.credits(d, q.price, 14, 52, { px = 26, color = "text", font = "mono_semi" })
local cw = change(d, q, 14 + pw + 14, 54, { px = 13 })
ui.text(d, "24h", { font = "mono", px = 12, color = "muted" }, 14 + pw + 14 + cw + 5, 54)
local y = ev_low and 100 or 74
-- What makes the price: market rate × live event × advisors × recent selling.
local pressed, advised = q.pressed, q.advised
if pressed == nil then -- (older servers)
pressed, advised = (q.factor or 1) < 0.995, (q.advisor or 1) > 1.001
end
local pct = q.pressureText or string.format("-%d%%", math.floor((1 - (q.factor or 1)) * 100 + 0.5))
if pressed then
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
local events = price_events(st, q.item)
local ch = ohl_y - 16 - (y + 4)
blocks.keep(self, "mk:chart", blocks.sig(y, dw, ch, up, mk.chart, events), d, function(r)
chart.chart(r, 14, y + 4, dw - 28, ch, mk.chart, {
color = up and "up" or "danger", events = events, pointers = self,
})
-- What makes the quote (shared by every player): market rate × live event. The
-- personal lines (advisors, recent selling) are on the ticket.
local notes = {}
if boosted then
local sx = "Market " .. theme.currency .. ui.money(q.rate) .. " × live event " .. boost_text(q)
notes[#notes + 1] = fg.measured(c, function(p, x, y, ww)
local _, lh = ui.wrap(p, sx, { font = "sans", px = 12, color = "muted" }, x, y, ww)
return math.max(18, lh + 4)
end)
end
-- The chart takes the room between the notes and the open/high/low row.
local chart_box = { style = { grow = 1 } }
if mk.chart and mk.chart.item == q.item then
local events = price_events(st, q.item)
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
-- 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.
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")
---------------------------------------------------------------- sell ticket
local tin = tw - 28
ui.text(t, tw >= 260 and "Sell order · Market" or "Sell order", "cap", 14, 18)
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 t, tr -- (the ticket's panel root; tr: its node, which the sale moves)
local empty = q.balance <= 0
local f = field.get(self, "sell")
if f.item ~= q.item then
@@ -436,7 +485,8 @@ function M.build(self, root, px, py, w, h, H, opts)
end
-- 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 payout
payout = function()
@@ -451,102 +501,153 @@ function M.build(self, root, px, py, w, h, H, opts)
sale_n = nil
local py0 = tight and 140 or 146
local sold = sale.active(self, q.item)
local kids
if sold then
ui.text(pay, "You receive", "cap", 14, py0)
local hero = anim.group(pay, 14, py0 + 22)
local glow = ui.rect(hero, -16, -24, 10, 48, "ready", 0)
gui.play_flipbook(glow, "glow")
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
gui.set_size(glow, vec(cw + 32, 48))
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,
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
return
end
local typed = f.text ~= "" and (tonumber(f.text) or 0) or nil
local amount = typed or q.balance
local factor = pressure_factor(st, q, amount)
local unit = (q.unpressed or q.price) * factor
ui.text(pay, (typed or empty) and "You receive" or "Sell all pays", "cap", 14, py0)
if advised then
ui.text(pay, "Advisors " .. advisor_text(q), { font = "mono", px = 11, color = "ready" }, tw - 14, py0, gui.PIVOT_E)
end
local total = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
local _, tn = ui.credits(pay, total, 14, py0 + 22, { px = 22, color = "ready", font = "mono_semi" })
if not typed then
pay_tick = H.tick_num(self, tn, "mk:value", total)
end
if empty then
return -- (the note under it says why)
end
local uw = ui.credits(pay, unit, 14, py0 + 45, { px = 11, color = "muted", suffix = " / unit" })
-- Recent selling (market.js): the averaged factor for the amount typed, else the
-- whole balance; shown from 1%.
local pct = math.floor((1 - factor) * 100 + 0.5)
local press = pct >= 1 and string.format("−%d%%", pct) or nil
if press then
if 14 + uw + 160 < tw then
ui.text(pay, "· " .. press .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14 + uw + 6, py0 + 45)
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)
kids = {
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)
gui.play_flipbook(glow, "glow")
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
gui.set_size(glow, vec(cw + 32, 48))
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,
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
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 amount = typed or q.balance
local factor = pressure_factor(st, q, amount)
local unit = (q.unpressed or q.price) * factor
local tot = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
kids = {
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
return cw
end),
}
if not empty then
local unit_box = credits_box(pay, unit, { px = 11, color = "muted", suffix = " / unit" }, 16)
local uw = unit_box.style.width
-- Recent selling (market.js): the averaged factor for the amount typed, else
-- the whole balance; shown from 1%.
local pc = math.floor((1 - factor) * 100 + 0.5)
local press = pc >= 1 and string.format("−%d%%", pc) or nil
local danger = { font = "sans", px = 12, color = "danger" }
kids[#kids + 1] = space(0, 7)
local beside = press and 14 + uw + 200 < tw
kids[#kids + 1] = row({ height = 16, align = "center" }, {
unit_box, beside and space(6) or nil,
beside and fg.label(pay, "· " .. press .. " from your recent selling", danger, 16) or nil,
})
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
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 input = field.new(self, t, "sell", 14, 52, tin, 36, {
hint = "0", font = "mono", hint_font = "mono", allowed = "[%d%.]",
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)
on_change = function()
sale.stop(self) -- (touching the ticket ends the power-down)
payout()
end,
on_submit = function()
if sell_now and tonumber(f.text) and tonumber(f.text) > 0 then
sell_now()
end
end,
})
self.sell_input = input
-- Quick amounts fill the field.
local input
local qw = math.floor((tin - 12) / 3)
for i, fr in ipairs({ { "25%", 0.25 }, { "50%", 0.5 }, { "Max", 1 } }) do
local b = ui.button(t, fr[1], 14 + (i - 1) * (qw + 6), 96, { small = true, w = qw, disabled = empty })
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
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)
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)
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
-- Clear the amount once it sold, but not while more sells are queued (prediction).
local function clear(err)
if err 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
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()
return { resource = q.item, amount = f.text, pick = q.item }
end, clear)
H.action(self, b2, n2, "sell", { resource = q.item, all = "1", pick = q.item }, clear)
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%.]",
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)
on_change = function()
sale.stop(self) -- (touching the ticket ends the power-down)
payout()
end,
on_submit = function()
if sell_now and tonumber(f.text) and tonumber(f.text) > 0 then
sell_now()
end
end,
})
self.sell_input = input
end),
space(0, 8),
-- Quick amounts fill the field.
row({ gap = 6 }, quick),
made({ height = 0 }, function() payout() end),
fg.fill(),
empty and fg.label(c, "You don't hold any " .. q.item .. ".", "small", 16, { fit = true, shrink = 1 }) or nil,
empty and space(0, 6) or nil,
row({ gap = 8 }, {
fg.button(c, "Sell " .. (q.symbol or ""), { primary = true, disabled = empty }, function(b1, n1)
if not empty then
sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item }
end, clear)
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)
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)
if area then
area:finish(panels.y + panels.h + 18)
end
-- A sale (fx.lua calls this after the build that shows it, with what it earned): the
+104 -80
View File
@@ -10,6 +10,7 @@ local ui = require("modules.ui")
local tooltip = require("modules.tooltip")
local test = require("modules.test")
local blocks = require("modules.blocks")
local fg = require("modules.flex_gui")
local M = {}
@@ -17,64 +18,61 @@ local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The panel at (x, y), w wide. Returns the new y.
function M.build(self, H, root, x, y, w, moon)
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The panel, w wide: a tree piece (the Moons section puts it under the wells table).
function M.box(self, H, c, moon, w)
local sale = moon.sale
if not sale then
return y
end
self.sale_open = self.sale_open or {}
local open = self.sale_open[moon.id]
local box = ui.rect(root, x, y, w, 10, "cyan", 0.03)
local edge = ui.outline(box, 0, 0, w, 10, "edge")
self.fx:box(box) -- (the sale's sweep covers the panel)
local inline = function(fn) return fg.inline(c, 16, fn) end
-- Summary: toggle, offer, delta; Sell moon on the right.
local hit = ui.rect(box, 0, 0, w - 140, 48, "none")
local cx = 14
local chev = ui.text(box, "›", { font = "sym", px = 16, color = "muted" }, cx, 23, gui.PIVOT_CENTER)
if open then
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
cx = cx + 16
local t = ui.text(box, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, cx, 24)
cx = cx + ui.text_width(t) + 8
local o = ui.text(box, "· offer", { font = "sans", px = 14, color = "muted" }, cx, 24)
cx = cx + ui.text_width(o) + 8
cx = cx + ui.credits(box, sale.total, cx, 24, { px = 15, color = "text", font = "mono_semi" }) + 10
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
local profit = sale.delta > 0
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
local dn = ui.label(box, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, cx, 24)
if not ui.needs_rich(dtxt) then
ui.fit_text(dn, w - 140 - cx)
end
local b, bn, bw = ui.button(box, "Sell moon", 0, 0, { 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 })
gui.set_position(b, vec(w - 10 - bw, -8))
if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText)
end
if sale.canSell then
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
if self.autoplay then
local function reg()
local summary = box({ height = 48 }, {
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
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
end),
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, {
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8),
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8),
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10),
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }),
}),
fg.button(c, "Sell moon", { 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 }, function(b)
if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText)
end
if sale.canSell then
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
if self.autoplay then
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
end)
local function reg()
if self.autoplay then
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
end)
end
end
reg()
blocks.note(reg) -- (a kept block registers again)
end
end
reg()
blocks.note(reg) -- (a kept block registers again)
end
end
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, { position = "absolute", top = 8, right = 10 }),
})
-- Fetch the state again when the hold ends (the server enables the sale).
if self.sale_timer then
@@ -88,48 +86,74 @@ function M.build(self, H, root, x, y, w, moon)
end)
end
local cy = 48
-- Details: the note and a line per well.
-- Details: the note and a line per well (a grid: well, left, price, value).
local details
if open then
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 "")
.. ". 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 "")
local _, nh = ui.wrap(box, note, { font = "sans", px = 13, color = "muted" }, 14, cy + 4, math.min(w - 28, theme.size.text_max))
cy = cy + nh + 12
if sale.holdEnd and sale.holdEnd > H.now(self) then
local hold = sale.holdEnd and sale.holdEnd > H.now(self)
local sellable
if hold then
-- (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)
H.tick_text(self, n, sale.holdEnd, function(left)
return "Sellable in " .. ui.dur(left) .. "."
end, "Sellable in 0:00.")
cy = cy + 20
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)
return "Sellable in " .. ui.dur(left) .. "."
end, "Sellable in 0:00.")
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)
local cols = { "1fr", 100, 100, 100 }
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
cy = cy + 16
ui.rect(box, 14, cy, inner, 1, "edge")
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
ui.rect(box, 16, cy + 11, 8, 8, l.resource)
ui.text(box, l.resource, "body", 32, cy + 15)
ui.text(box, ui.money(l.supply), "num", 14 + cols[2], cy + 15, gui.PIVOT_E)
ui.credits(box, l.rate, 14 + cols[3], cy + 15, { pivot = gui.PIVOT_E })
ui.credits(box, l.value, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
cy = cy + 30
ui.rect(box, 14, cy, inner, 1, "line")
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
row({ padding_left = 2, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, l.resource) end),
space(8), fg.label(c, l.resource, "body", 16),
}),
right(fg.label(c, ui.money(l.supply), "num", 16)), credits(l.rate), credits(l.value),
line(30),
})
end
ui.text(box, "Market value", { font = "sans_semi", px = 14, color = "text" }, 32, cy + 15)
ui.credits(box, sale.market, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
cy = cy + 34
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
row({ padding_left = 18 }, { fg.label(c, "Market value", { font = "sans_semi", px = 14, color = "text" }, 16) }),
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
cy = cy + 4
gui.set_size(box, vec(w, cy))
gui.set_size(edge, vec(w, cy))
return y + cy
return col({ width = w, padding_bottom = 4 }, { summary, 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
return M
+82 -59
View File
@@ -21,6 +21,8 @@ local tabs = require("components.tabs")
local list_c = require("components.list")
local row_c = require("components.row")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
@@ -265,25 +267,17 @@ function M.build(self, H, parent, x, y, w, h)
local root = ui.panel(parent, x, y, w, h)
local pad = S.panel_pad
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).
-- 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
local tx = pad
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 tab_boxes = {}
for _, tab in ipairs({ { "Active", #st.moons, false }, { "Past", past_total, true } }) do
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 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
if tab[3] == false then
local first = st.moons[1]
@@ -292,28 +286,39 @@ function M.build(self, H, parent, x, y, w, h)
-- (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 }
end
if not to then
tooltip.add(hit, "Moons you mine out go here")
else
tabs.tab(self, hit, t, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
H.navigate(self, to)
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
past_flash(self, root, t)
label.done = function(n) past_flash(self, root, n) end
end
tx = tx + full + 22
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
tooltip.add(hit, "Moons you mine out go here")
else
tabs.tab(self, hit, label.node, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
H.navigate(self, to)
end })
end
return nil
end })
end
ui.rect(root, pad, cy + 30, inner, 1, "line")
cy = cy + 44
kids[#kids + 1] = fg.col({ height = 44 }, {
fg.row({ gap = 22, padding_left = pad - 4 }, tab_boxes),
fg.made({ position = "absolute", top = 30, left = pad, right = pad, height = 1 }, function(p, lx, ly, lw)
ui.rect(p, lx, ly, lw, 1, "line")
end),
})
end
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
if paging then
bottom = bottom - S.button_sm_h - 12
end
local list_h -- (the rows' box: what's left between the search and the bottom)
local rows, note = nil, nil
local slot = S.moon_row_h + 6
local ghosts -- the mined-out copies shown in this list, by where they go
@@ -346,7 +351,7 @@ function M.build(self, H, parent, x, y, w, h)
end
self.ml_ghosts = #ghosts.list > 0 and ghosts or nil
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,
focus_id = function(moon) return "moon:" .. moon.id end,
create = function(moon, index)
@@ -396,51 +401,69 @@ 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).
local find = field.get(self, "find")
local rows_y
if #list > 0 and st.unlocked.store then
ui.text(root, "Find a moon", "cap", pad, cy + 6)
cy = cy + 16
local rows_y = cy + 34 + 12
field.new(self, root, "find", pad, cy, inner, 34, {
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
on_change = blocks.bound(function(text) -- (what it makes is the moon list's: modules/blocks)
build_rows(text, rows_y)
kids[#kids + 1] = fg.col({ padding = { 0, pad, 0, pad } }, {
fg.label(root, "Find a moon", "cap", 12), fg.space(0, 4),
fg.made({ height = 34 }, function(p, fx, fy, fw, fh)
field.new(self, p, "find", fx, fy, fw, fh, {
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
on_change = blocks.bound(function(text) -- (what it makes is the moon list's: modules/blocks)
build_rows(text, rows_y)
end),
})
end),
fg.space(0, 12),
})
cy = rows_y
end
build_rows(find.text, cy)
-- The rows (a virtualized list) take the room down to the pager and "+ New moon".
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).
if paging then
local py = bottom + 8
local function pager(label, page, x, right)
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
H.link(self, b, { section = "moons", past = true, page = page })
end
return bw
local function pager(label, page)
return fg.button(root, label, { small = true, disabled = not page }, function(b)
if page then
H.link(self, b, { section = "moons", past = true, page = page })
end
end)
end
pager("‹ Newer", paging.page > 1 and paging.page - 1 or nil, pad)
pager("Older ›", paging.page < paging.pages and paging.page + 1 or nil, pad + inner, true)
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)
tooltip.add(t, paging.total .. " past moons, " .. paging.perPage .. " a page")
local count = fg.label(root, paging.page .. " / " .. paging.pages, { font = "mono", px = 12, color = "muted" }, S.button_sm_h,
{ position = "absolute", top = 0, left = "50%", translate = { -0.5, 0 } })
count.done = function(n) tooltip.add(n, paging.total .. " past moons, " .. paging.perPage .. " a page") end
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
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel.
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
if st.unlocked["new-moon"] then
local by = h - pad - S.button_h
local b, nodes = ui.button(root, "+ New moon", pad, by, { w = inner, price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" })
H.link(self, b, { section = "new-moon", moon = v.moon })
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, S.button_h, extra)
kids[#kids + 1] = fg.space(0, 10)
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad } }, {
fg.button(root, "+ New moon", { price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" }, function(b, nodes)
H.link(self, b, { section = "new-moon", moon = v.moon })
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, S.button_h, extra)
end, { width = inner }),
})
end
for _, k in ipairs(kids) do
k.style.shrink = k.style.shrink or 0
end
fg.build(flex.solve(fg.col({ width = w, height = h, padding = { pad, 0, pad, 0 } }, kids), 0, 0), root)
end
return M
+268 -194
View File
@@ -13,6 +13,8 @@ local bar = require("components.bar")
local row_c = require("components.row")
local scroll = require("components.scroll")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
@@ -101,69 +103,87 @@ function M.tool_name(st, id)
return t and t.name or "Tool", t and t.tier or 0
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
local MONO13 = { font = "mono", px = 13, color = "muted" }
local DESC = { font = "sans", px = 15, color = "text_2" }
local function empty_moons(g, root, px, py, inner, H)
local cy = H.heading(root, px, py, "Claim your first moon")
local rules = g.state.rules or {}
local range = rules.wellsMin and rules.wellsMax and (rules.wellsMin == rules.wellsMax and tostring(rules.wellsMin)
or (rules.wellsMin .. "–" .. rules.wellsMax)) .. " " or ""
cy = cy + H.para(root, "A moon comes with " .. range .. "resource wells. Deploy mining tools on its wells, claim what they dig up, and sell it in the market.",
{ font = "sans", px = 15, color = "text_2" }, px, cy, inner) + 16
local b = ui.button(root, "Scan for a moon", px, cy, { primary = true, price = g.state.moonPrice })
H.link(g, b, { section = "new-moon" })
local page = col({ width = inner, align = "start" }, {
fg.label(root, "Claim your first moon", "h1", 40), space(0, 4),
fg.text_para(root, "A moon comes with " .. range .. "resource wells. Deploy mining tools on its wells, claim what they dig up, and sell it in the market.",
DESC, { max_width = math.min(inner, S.text_max) }),
space(0, 16),
fg.button(root, "Scan for a moon", { primary = true, price = g.state.moonPrice }, function(b)
H.link(g, b, { section = "new-moon" })
end),
})
fg.build(flex.solve(page, px, py), root)
end
local function past_moon(g, root, px, py, inner, moon, H)
local item = find(g.state.past, moon.id) or {}
local cy = py
ui.text(root, moon.id, { font = "mono", px = 13, color = "muted" }, px, cy + 8)
cy = cy + 18
ui.label(root, moon.name, "h1", px, cy + 20)
cy = cy + 44
local done = "Mined out"
if (item.finishedOn or "") ~= "" then
done = done .. " · " .. item.finishedOn -- (the server's date)
elseif item.finishedAt then
done = done .. " · " .. (os.date("%b %d", math.floor(item.finishedAt / 1000)):gsub(" 0", " "))
end
ui.text(root, done, { font = "sans_semi", px = 14, color = "muted", tracking = 0.1, upper = true }, px, cy + 6)
cy = cy + 26
ui.text(root, "Resource", "cap", px, cy + 6)
ui.text(root, "Mined", "cap", px + inner, cy + 6, gui.PIVOT_E)
cy = cy + 16
ui.rect(root, px, cy, inner, 1, "edge")
-- A 1 px rule over the top of the next row (absolute: it takes no room).
local line = function(color, top)
return made({ position = "absolute", top = top, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, color)
end)
end
-- Mined table: a row per resource (38 tall, a line at its bottom edge), then the total.
local lines = { line("edge", 0) }
local total = 0
for _, r in ipairs(item.mined or {}) do
ui.rect(root, px + 2, cy + 15, 8, 8, r.name)
ui.text(root, r.name, "body", px + 18, cy + 19)
ui.text(root, ui.money(r.amount), "num", px + inner, cy + 19, gui.PIVOT_E)
lines[#lines + 1] = row({ height = 38, align = "center", padding = { 0, 0, 0, 2 } }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, r.name) end),
space(8), fg.label(root, r.name, "body", 16), fg.fill(),
fg.label(root, ui.money(r.amount), "num", 16),
line("line", 38),
})
total = total + r.amount
cy = cy + 38
ui.rect(root, px, cy, inner, 1, "line")
end
ui.text(root, "Total", { font = "sans_semi", px = 15, color = "text" }, px + 18, cy + 19)
ui.text(root, ui.money(total), "num", px + inner, cy + 19, gui.PIVOT_E)
H.para(root, "Its wells are empty. It still counts toward the price of your next moon.", "small", px, cy + 48, inner)
local tree = col({ width = inner }, {
fg.label(root, moon.id, MONO13, 16), space(0, 2),
fg.label(root, moon.name, "h1", 40), space(0, 4),
fg.label(root, done, { font = "sans_semi", px = 14, color = "muted", tracking = 0.1, upper = true }, 12), space(0, 14),
row({ height = 12 }, { fg.label(root, "Resource", "cap", 12), fg.fill(), fg.label(root, "Mined", "cap", 12) }),
space(0, 4),
col({}, lines),
row({ height = 38, align = "center", padding = { 0, 0, 0, 18 } }, {
fg.label(root, "Total", { font = "sans_semi", px = 15, color = "text" }, 16), fg.fill(),
fg.label(root, ui.money(total), "num", 16),
}),
space(0, 10),
fg.text_para(root, "Its wells are empty. It still counts toward the price of your next moon.", "small", { max_width = math.min(inner, S.text_max) }),
})
fg.build(flex.solve(tree, px, py), root)
end
-- Timer bars for a drill queue, oldest first (the web's drill-stack): thin bars 40 wide
-- stacked, filling while it runs, full when ready, tinted by what it gets (ok, short,
-- none); a multi-well drill has a diamond cap. At most 4 bars, then "+N" beside them.
-- row: the row's key (a multi-well drill has a bar on every row it reaches). y is the
-- stack's middle. Returns its width.
-- stack's middle.
local MAX_BARS, BAR_W, BAR_H, BAR_GAP = 4, 40, 3, 2
local function drill_bars(g, parent, x, y, drills, row, no_more)
local MORE = { font = "mono", px = 11, color = "muted" }
local function drill_bars(g, parent, x, y, drills, row_key)
local n = math.min(#drills, MAX_BARS)
local more = #drills - n
local top = y - math.floor((n * (BAR_H + BAR_GAP) - BAR_GAP) / 2)
for i = 1, n do
local d = drills[i]
local color = d.state == "short" and "ready" or "cyan"
local by = top + (i - 1) * (BAR_H + BAR_GAP)
local wide = (d.reach or 1) > 1
-- Keyed by row and tool (one drill each), so a restarted or corrected drill eases.
bar.timer(g, parent, x, by, BAR_W, BAR_H, { start = d.start, ends = d["end"], ready = d.ready, color = color,
none = d.state == "none", key = "bar:" .. row .. ":" .. d.toolId })
if wide then
none = d.state == "none", key = "bar:" .. row_key .. ":" .. d.toolId })
if (d.reach or 1) > 1 then
-- The diamond cap (app.css .drill-bar.is-wide::after).
local cap = ui.rect(parent, 0, 0, 5, 5, d.state == "none" and "muted" or color)
gui.set_pivot(cap, gui.PIVOT_CENTER)
@@ -171,12 +191,6 @@ local function drill_bars(g, parent, x, y, drills, row, no_more)
gui.set_rotation(cap, vmath.quat_rotation_z(math.rad(45)))
end
end
local w = BAR_W + 4
if more > 0 and not no_more then
local t = ui.text(parent, "+" .. more, { font = "mono", px = 11, color = "muted" }, x + w + 2, y)
w = w + 2 + ui.text_width(t)
end
return w
end
local function drill_label(st, drills)
@@ -189,50 +203,90 @@ local function drill_label(st, drills)
return ui.plural(#drills, "drill")
end
-- One row of the wells table (a well, or the multi-well group).
local function well_row(g, root, px, cy, inner, cols, max, moon, well, H)
local st, v = g.state, g.view
local row, nodes = ui.clone("well_row", root, px, cy)
local p = function(n) return ui.part(nodes, "well_row", n) end
gui.set_size(row, vec(inner, S.well_row_h))
gui.set_size(p("line"), vec(inner, 1))
gui.set_enabled(p("bar"), false)
gui.set_enabled(p("bar_bg"), false)
gui.set_position(p("status"), vec(inner - 10, -26))
gui.set_enabled(p("status"), false)
local group = well.wide_group or well
if well.wide_group then
gui.set_enabled(p("dot"), false)
ui.style_text(p("name"), "cap", "Multi-well")
-- "X left on the moon", wrapping onto a second line when the column is narrow.
ui.style_text(p("supply"), { font = "mono", px = 12, color = "muted" }, ui.money(group.cap))
local lw = ui.text_width(p("supply"))
local one = cols[2] + lw + 4 + 96 <= cols[3] - 8
gui.set_position(p("supply"), vec(cols[2], one and -26 or -18))
local rest = ui.text(row, "left on the moon", { font = "sans", px = 12, color = "muted" }, one and (cols[2] + lw + 5) or cols[2], one and 26 or 35)
ui.fit_text(rest, cols[3] - 8 - (one and (cols[2] + lw + 5) or cols[2]))
else
local dry = well.supply <= 0
local selected = well.id == v.well
-- Row click selects the well (bound first: the buttons on it take precedence).
row_c.bind(g, row, inner, S.well_row_h, { selected = selected, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
gui.set_color(p("dot"), ui.color(well.resource, dry and 0.4 or 1))
ui.style_text(p("name"), { font = "sans", px = 15, color = dry and "muted" or "text" }, well.resource)
if dry then
gui.set_position(p("supply"), vec(cols[2], -26))
ui.style_text(p("supply"), { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, "Tapped out")
else
M.supply_bar(row, cols[2], 20, cols[3] - cols[2] - 14, well, max)
gui.set_position(p("supply"), vec(cols[2], -36))
ui.style_text(p("supply"), { font = "mono", px = 12, color = "muted" }, ui.money(well.supply))
H.tick_num(g, p("supply"), "supply:" .. well.id, well.supply)
-- The drill column of a row, built at its width (52 tall): the stacked timer bars for
-- every drill on it, multi-well ones included (the web's Queue), and the label of its
-- own drills (DrillLabel) beside them, or under them (after "+N") when there's no room
-- (decided at its width). Its tooltip (DrillsTitle): each drill, oldest first, and what
-- it gets. pad: room kept before the status buttons.
local function drill_cell(g, queue, fates, label, row_key, pad)
return made({ grow = 1, shrink = 1, padding_right = pad }, function(p, x, y, w, h)
local dw = w - pad
if #fates > 0 then
tooltip.add(ui.rect(p, x, y + 6, math.max(40, dw), h - 12, "none"), table.concat(fates, "\n"))
end
g.well_nodes[well.id] = row
local n = math.min(#queue, MAX_BARS)
local stack_h = n * (BAR_H + BAR_GAP)
local more = #queue - n
local function bars(beside)
local more_l = beside and more > 0 and fg.label(p, "+" .. more, MORE, 16) or nil
return row({ shrink = 0, align = "center" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
more_l and space(2) or nil, more_l,
})
end
local tree
if #queue == 0 then
tree = row({ height = h, align = "center" }, {
fg.label(p, "Empty", { font = "sans", px = 14, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
else
local style = { font = "sans", px = 12, color = "text_2" }
local probe = fg.label(p, label, style, 16)
local lw = probe.measure()
gui.delete_node(probe.node)
if BAR_W + 4 + (more > 0 and 18 or 0) + 6 + lw <= dw then
tree = row({ height = h, align = "center" }, {
bars(true), space(6), fg.label(p, label, style, 16, { fit = true, shrink = 1 }),
})
else
-- (narrow: "+N" leads the label under the bars)
tree = col({ height = h, padding_top = 13, align = "start" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
space(0, 1),
fg.label(p, (more > 0 and ("+" .. more .. " ") or "") .. label, style, 16, { fit = true, shrink = 1 }),
})
end
end
fg.build(flex.solve(tree, x, y, dw), p)
end)
end
-- Table columns (468 wide at 1280x800; wider tables spread the columns): the supply
-- column up to the web's 200 px, the drill column takes what the row's buttons leave.
local function table_cols(inner)
local c2 = math.max(118, math.floor(inner * 0.25))
return { c2, math.max(92, math.min(212, math.floor(inner * 0.2))), "1fr", "auto" }
end
-- One row of the wells table (a well, or the multi-well group): a grid row 52 tall, its
-- row node (hover, selection, entrance) in a slot. enters: row entrances to run once the
-- page is built (they veil the row's content).
local function well_row(g, c, cols, max, moon, well, H, enters)
local st, v = g.state, g.view
local group = well.wide_group or well
local key = well.id or "wide"
local function made_row(parent, x, y, w, h)
local slot = ui.rect(parent, x, y, w, h, "none")
local r = ui.rect(slot, 0, 0, w, h, "cyan", 0)
ui.rect(r, 0, h - 1, w, 1, "line")
if not well.wide_group then
-- Row click selects the well (bound first: the buttons on it take precedence).
row_c.bind(g, r, w, h, { selected = well.id == v.well, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
g.well_nodes[well.id] = r
end
-- A drill was just deployed here: the row slides in (fx-new).
enters[#enters + 1] = function()
row_c.enter(g, "well:" .. key, r, w, h, { base = gui.get_color(r) })
end
return r
end
-- Drill column: a timer bar per drill and a label; its tooltip (the web's DrillsTitle):
-- each drill on the well, multi-well ones included, oldest first, and what it gets.
-- Drill queue: its own drills, and multi-well ones that reach it, oldest first.
local drills = group.drills or {}
local queue = {}
for _, d in ipairs(drills) do
@@ -254,158 +308,178 @@ local function well_row(g, root, px, cy, inner, cols, max, moon, well, H)
for _, d in ipairs(queue) do
fates[#fates + 1] = d.fate
end
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel)
-- Name and supply cells.
local ML = { font = "mono", px = 12, color = "muted" }
local name_cell, supply_cell
if well.wide_group then
name_cell = row({ padding_left = 26, align = "center" }, { fg.label(c, "Multi-well", "cap", 16) })
-- "X left on the moon", wrapping onto a second line when the column is narrow.
supply_cell = col({ padding_right = 8, justify = "center" }, {
row({ wrap = true, column_gap = 5, row_gap = 1 }, {
fg.label(c, ui.money(group.cap), ML, 16),
fg.label(c, "left on the moon", { font = "sans", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
}),
})
else
local dry = well.supply <= 0
name_cell = row({ padding_left = 26, align = "center" }, {
made({ position = "absolute", left = 10, top = 22, width = 8, height = 8 }, function(q, x, y)
ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1)
end),
fg.label(c, well.resource, { font = "sans", px = 15, color = dry and "muted" or "text" }, 16),
})
if dry then
supply_cell = row({ align = "center" }, {
fg.label(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, 16),
})
else
local sup = fg.label(c, ui.money(well.supply), ML, 16)
sup.done = function(n) H.tick_num(g, n, "supply:" .. well.id, well.supply) end
supply_cell = col({ padding = { 20, 14, 0, 0 } }, {
made({ height = 6 }, function(q, x, y, w) M.supply_bar(q, x, y, w, well, max) end),
space(0, 2), sup,
})
end
end
-- Status (right): Claim (ready), Claim countdown (mining), Redrill (idle tools that
-- mined it), then Manage (View when tapped out), like the web's row actions.
-- (the amount moves on as more of its drills finish on our clock: core/ready)
local scope = well.wide_group and { ["claim-wide"] = moon.id } or { ["claim-well"] = well.id }
local amount = ready.amount(g, group, well.wide_group and group.cap or well.supply)
local right = inner - 8
if not well.wide_group then
local manage, mnodes, mw = ui.button(row, well.supply <= 0 and "View" or "Manage", 0, 0, { small = true })
gui.set_position(manage, vec(right - mw, -10))
right = right - mw - 6
local to = { section = "moons", moon = moon.id, well = well.id }
button.bind(g, manage, mnodes, { id = "manage:" .. well.id, on_click = function() H.navigate(g, to) end })
end
local b, bw
local status = {}
if group.ready > 0 then
local bn
b, bn, bw = ui.button(row, "Claim " .. ui.money(amount), 0, 0, { primary = true, small = true })
gui.set_position(b, vec(right - bw, -10))
H.action(g, b, bn, "claim", scope)
status[#status + 1] = fg.button(c, "Claim " .. ui.money(amount), { primary = true, small = true }, function(b, bn)
H.action(g, b, bn, "claim", scope)
end)
elseif group.nextEnd then
b, bw = H.claim_wait(g, row, right, 10, group.nextEnd, { small = true, right = true, key = "wait:" .. (well.id or "wide"),
params = scope, moon = moon.id, done = amount > 0 and ("Claim " .. ui.money(amount)) or nil })
local wopts = { small = true, right = true, key = "wait:" .. key, params = scope, moon = moon.id,
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil }
local bw = H.claim_wait_w(g, c, group.nextEnd, wopts)
status[#status + 1] = made({ width = bw, height = S.button_sm_h, shrink = 0 }, function(q, x, y, w)
H.claim_wait(g, q, x + w, y, group.nextEnd, wopts)
end)
elseif not well.wide_group and (well.redrill or 0) > 0 and well.supply > 0 and st.unlocked.redrill then
local bn
b, bn, bw = ui.button(row, "Redrill", 0, 0, { small = true, variant = "redrill" })
gui.set_position(b, vec(right - bw, -10))
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
status[#status + 1] = fg.button(c, "Redrill", { small = true, variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
end)
end
if not well.wide_group then
local to = { section = "moons", moon = moon.id, well = well.id }
status[#status + 1] = fg.button(c, well.supply <= 0 and "View" or "Manage", { small = true }, function(b, bn)
button.bind(g, b, bn, { id = "manage:" .. well.id, on_click = function() H.navigate(g, to) end })
end)
end
local left = right - (bw and (bw + 6) or 0) -- (the drill column ends before the buttons)
-- Drill column: the stacked timer bars for every drill on it, multi-well ones included
-- (the web's Queue), and the label of its own drills (DrillLabel) beside them, or under
-- them when the column is narrow. Its tooltip (DrillsTitle): each drill, oldest first,
-- and what it gets.
local dw = left - 8 - cols[3]
if #fates > 0 then
tooltip.add(ui.rect(row, cols[3], 6, math.max(40, dw), S.well_row_h - 12, "none"), table.concat(fates, "\n"))
end
if #queue > 0 then
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel)
ui.style_text(p("drill"), { font = "sans", px = 12, color = "text_2" }, label)
local lw = ui.text_width(p("drill"))
local n = math.min(#queue, MAX_BARS)
local stack_h = n * (BAR_H + BAR_GAP)
local probe_more = #queue > MAX_BARS and 18 or 0
if BAR_W + 4 + probe_more + 6 + lw <= dw then
local sw = drill_bars(g, row, cols[3], 26, queue, well.id or "wide")
gui.set_position(p("drill"), vec(cols[3] + sw + 6, -26))
ui.fit_text(p("drill"), dw - sw - 6)
else
-- (narrow: "+N" leads the label under the bars)
drill_bars(g, row, cols[3], 13 + math.floor(stack_h / 2), queue, well.id or "wide", true)
if #queue > MAX_BARS then
gui.set_text(p("drill"), "+" .. (#queue - MAX_BARS) .. " " .. label)
end
gui.set_position(p("drill"), vec(cols[3], -(stack_h + 22)))
ui.fit_text(p("drill"), dw)
end
else
gui.set_position(p("drill"), vec(cols[3], -26))
ui.style_text(p("drill"), { font = "sans", px = 14, color = "muted" }, "Empty")
ui.fit_text(p("drill"), dw)
end
-- A drill was just deployed here: the row slides in (fx-new).
row_c.enter(g, "well:" .. (well.id or "wide"), row, inner, S.well_row_h, { base = gui.get_color(row) })
return box({ columns = cols, rows = { S.well_row_h }, padding_right = 8 }, {
name_cell, supply_cell,
drill_cell(g, queue, fates, label, key, #status > 0 and 14 or 8),
row({ gap = 6, align = "center" }, status),
}, { make = made_row })
end
local function moon_view(g, root, px, py, inner, H)
local function rating_chip(c, r)
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
local edge, alpha, fill = "line_hi", 1, nil
local color = "muted"
if r.tier == "good" then
edge, alpha, color = "cyan", 0.5, "cyan"
elseif r.tier == "great" or r.tier == "exceptional" then
edge, color = "ready", "ready"
fill = r.tier == "exceptional" and 0.15 or nil
end
local style = { font = "sym", px = 10, color = color }
local probe = ui.rect(c, 0, 0, 1, 1, "none")
local _, tw = ui.label(probe, "★" .. r.stars, style, 0, 0)
gui.delete_node(probe)
local cw = math.floor(tw) + 10
return made({ width = cw, height = 16, shrink = 0 }, function(p, x, y)
local b = ui.outline(p, x, y, cw, 16, edge, alpha)
if fill then
gui.move_below(ui.rect(b, 0, 0, cw, 16, edge, fill), b)
end
ui.label(b, "★" .. r.stars, style, 5, 8)
tooltip.add(b, (r.label or r.tier) .. " moon · top " .. (r.top or (100 - (r.pct or 0))) .. "%")
end)
end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- wells table, then the sale. Returns its bottom.
local function moon_view(g, c, px, py, inner, H)
local st = g.state
local moon = st.moon
-- Heading: id, name, class and rating, description; Claim on the right.
local cy = py
ui.text(root, moon.id, { font = "mono", px = 13, color = "muted" }, px, cy + 8)
cy = cy + 18
ui.label(root, moon.name, "h1", px, cy + 20)
cy = cy + 44
-- The class line ("<type> class"), but not for moons named after their type (from
-- before moon names), like the web.
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
local cls = ui.text(root, has_class and (moon.kind .. " class") or "", "cap", px, cy + 6)
if moon.rating then
-- Rating: the web's rating chip ("★3" in the tier's color; its tooltip the label
-- and percentile).
local r = moon.rating
local edge, alpha, fill = "line_hi", 1, nil
local color = "muted"
if r.tier == "good" then
edge, alpha, color = "cyan", 0.5, "cyan"
elseif r.tier == "great" or r.tier == "exceptional" then
edge, color = "ready", "ready"
fill = r.tier == "exceptional" and 0.15 or nil
end
local sx = px + (has_class and ui.text_width(cls) + 6 or 0)
local probe = ui.rect(root, 0, 0, 1, 1, "none")
local _, tw = ui.label(probe, "★" .. r.stars, { font = "sym", px = 10, color = color }, 0, 0)
gui.delete_node(probe)
local cw = math.floor(tw) + 10
local box = ui.outline(root, sx, cy - 2, cw, 16, edge, alpha)
if fill then
gui.move_below(ui.rect(box, 0, 0, cw, 16, edge, fill), box)
end
ui.label(box, "★" .. r.stars, { font = "sym", px = 10, color = color }, 5, 8)
tooltip.add(box, (r.label or r.tier) .. " moon · top " .. (r.top or (100 - (r.pct or 0))) .. "%")
end
local head = { fg.label(c, moon.id, MONO13, 16), space(0, 2), fg.label(c, moon.name, "h1", 40) }
if has_class or moon.rating then
cy = cy + 20
head[#head + 1] = space(0, 2)
head[#head + 1] = row({ height = 16, align = "center" }, {
has_class and fg.label(c, moon.kind .. " class", "cap", 12) or nil,
has_class and moon.rating and space(6) or nil,
moon.rating and rating_chip(c, moon.rating) or nil,
})
head[#head + 1] = space(0, 6)
else
head[#head + 1] = space(0, 4)
end
if moon.description and moon.description ~= "" then
cy = cy + H.para(root, moon.description, { font = "sans", px = 15, color = "text_2" }, px, cy, inner - 150) + 4
head[#head + 1] = fg.text_para(c, moon.description, DESC, { max_width = math.min(inner - 150, S.text_max) })
head[#head + 1] = space(0, 4)
end
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
local n_ready = ready.drills(g, moon.id)
if moon.ready > 0 then
local b, nodes, bw = ui.button(root, "Claim " .. ui.plural(n_ready, "drill"), 0, 0, { primary = true })
gui.set_position(b, vec(px + inner - bw, -(py + 26)))
H.action(g, b, nodes, "claim", { ["claim-moon"] = moon.id })
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
gui.set_position(b, vec(x - bw, -y))
H.action(g, b, nodes, "claim", { ["claim-moon"] = moon.id })
end)
elseif moon.nextEnd then
H.claim_wait(g, root, px + inner, py + 26, moon.nextEnd, { right = true, key = "wait:moon",
params = { ["claim-moon"] = moon.id }, moon = moon.id,
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
H.claim_wait(g, p, x, y, moon.nextEnd, { right = true, key = "wait:moon",
params = { ["claim-moon"] = moon.id }, moon = moon.id,
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
end)
end
cy = math.max(cy, py + 80) + 14
-- Wells table (468 wide at 1280x800; wider tables spread the columns): the supply
-- column up to the web's 200 px, the drill column takes what the row buttons leave.
-- Wells table: the header, an edge line, a row per well and the multi-well group.
local max = M.supply_max(st)
local c2 = math.max(118, math.floor(inner * 0.25))
local cols = { 0, c2, c2 + math.max(92, math.min(212, math.floor(inner * 0.2))), inner }
local head = { "Well", "Supply left", "Drill", "Status" }
for i, label in ipairs(head) do
ui.text(root, label, "cap", px + cols[i], cy + 6, i == 4 and gui.PIVOT_E or gui.PIVOT_W)
end
cy = cy + 16
ui.rect(root, px, cy, inner, 1, "edge")
local rows = {}
local cols = table_cols(inner)
local enters = {}
local tbl = {
box({ columns = cols, height = 16, align = "start" }, {
fg.label(c, "Well", "cap", 12), fg.label(c, "Supply left", "cap", 12), fg.label(c, "Drill", "cap", 12),
fg.label(c, "Status", "cap", 12, { justify_self = "end" }),
}),
made({ position = "absolute", top = 16, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "edge")
end),
}
for _, well in ipairs(moon.wells) do
rows[#rows + 1] = well
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, well, H, enters)
end
if moon.wide then
rows[#rows + 1] = { wide_group = moon.wide }
end
for _, well in ipairs(rows) do
well_row(g, root, px, cy, inner, cols, max, moon, well, H)
cy = cy + S.well_row_h
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, { wide_group = moon.wide }, H, enters)
end
-- Selling the moon (once the store is open).
if st.unlocked.store and moon.sale then
cy = moon_sale.build(g, H, root, px, cy + 18, inner, moon)
local sale = st.unlocked.store and moon.sale
local page = col({ width = inner }, {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
col({}, tbl),
sale and space(0, 18) or nil,
sale and moon_sale.box(g, H, c, moon, inner) or nil,
})
fg.build(flex.solve(page, px, py), c)
for _, f in ipairs(enters) do
f()
end
return cy
return page.y + page.h
end
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
+384 -362
View File
@@ -1,30 +1,34 @@
-- The Refinery section (state.refinery), laid out like the web's refinery.html: the heading
-- (Collect all when more than one batch is ready), the refined stock chips, the slot cards
-- (running: time left and a segmented bar that fills in real time against the server
-- clock; ready: Collect; idle; "+ Slot n" while more can be bought) and the recipe cards
-- (glyph, tier and owned, description, inputs, stats, the bulk batch, quantity, Refine and
-- Max). The whole section scrolls, like the web's page; nothing is capped (the web's main
-- isn't). The state is fetched again when a batch is due (next_end); the server decides
-- what is finished and what a batch makes.
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect
-- all when more than one batch is ready), the refined stock chips, the slot cards (running:
-- time left and a segmented bar filling in real time against the server clock; ready:
-- Collect; idle; "+ Slot n" while more can be bought) and the recipe cards (glyph, tier
-- 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 ui = require("modules.ui")
local blocks = require("modules.blocks")
local tooltip = require("modules.tooltip")
local test = require("modules.test")
local card = require("components.card")
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 M = {}
-- components.bar needs core/ticks, which needs core/session, which needs this module:
-- required when used (by a name bob doesn't follow; other modules ship it).
-- required when used (by a name bob doesn't follow).
local BAR = "components.bar"
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local S = theme.size
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
function M.next_end(st)
@@ -62,77 +66,116 @@ local function item_color(st, name)
end
M.item_color = item_color
-- app.css numbers (layout px).
local GAP = 18 -- .main's gap between blocks
local SLOT_GAP = 12 -- .refinery-slots gap
local SLOT_MIN = 200 -- .refinery-slots minmax(200px, 1fr)
local SLOT_H = 112 -- .refinery-slot min-height
local SLOT_PAD = 13 -- its padding 12 + the 1 px border
local BTN_H = 34 -- .btn-sm
local CARD_PAD = 17 -- .card-body padding 16 + the card's border
local FOOTER_H = 126 -- stats (54), 8, the bulk line (16), 14, the form (34)
local function cap_text(parent, str, x, y, pivot)
return ui.text(parent, str, "cap", x, y, pivot)
local function catalog_desc(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
-- Text that wraps at w with lines lh apart (CSS line-height), its first line box at y;
-- returns its height.
local function wrap(parent, str, style, x, y, w, lh)
local n = ui.text(parent, str, style, x, y, gui.PIVOT_NW)
local s = gui.get_scale(n).x
local font = gui.get_font_resource(gui.get_font(n))
local one = resource.get_text_metrics(font, "Ag")
local line = (one.max_ascent + one.max_descent) * s
gui.set_line_break(n, true)
gui.set_leading(n, lh / line)
gui.set_size(n, vec(w / s, 2000 / s))
gui.set_position(n, vec(x, -(y + (lh - line) / 2)))
local m = resource.get_text_metrics(font, gui.get_text(n), { line_break = true, width = w / s, leading = 1 })
local lines = math.max(1, math.floor(m.height / (one.max_ascent + one.max_descent) + 0.5))
return lines * lh
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- Styles (app.css).
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
local BODY14 = { font = "sans", px = 14, color = "text" }
local MONO14 = { font = "mono", 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
-- Width of a label in a style, without keeping it.
local function measure(parent, str, style)
local t = ui.text(parent, str, style, 0, 0)
-- An invisible node holding its children (for a tooltip over all of them).
local function group(p, x, y, w, h)
return ui.rect(p, x, y, w, h, "none")
end
-- The build's scratch parent, for measuring labels of what is built at a size.
local scratch
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 small button (.btn-sm: 34 tall, 14 px padding, the 15 px label). opts as ui.button's.
local function btn_sm(parent, label, x, y, opts)
opts.h = BTN_H
if opts.disabled then -- (.btn:disabled: line_hi edge, everything at .45)
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
-- 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
opts.w = opts.w or math.floor(measure(parent, label, { font = "sans_semi", px = 15, tracking = 0.12, upper = true }) + 30)
return ui.button(parent, label, x, y, opts)
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
---------------------------------------------------------------- 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.
local function cell_bar(self, parent, x, y, w, color, job, H)
local cells = {}
for cx = 0, w - 1, 8 do
local cw = math.min(6, w - cx)
ui.rect(parent, x + cx, y, cw, 8, "cyan", 0.14)
cells[#cells + 1] = { node = ui.rect(parent, x + cx, y, cw, 8, color), x = cx, w = cw }
end
if job.ready then
self.rf_bars[#self.rf_bars + 1] = { cells = cells, full = true }
return
end
local probe = ui.rect(parent, x, y, w, 8, "none")
H.tick_bar(self, probe, job.start, job["end"], w)
self.rf_bars[#self.rf_bars + 1] = { cells = cells, probe = probe }
local function cell_bar(self, H, color, job)
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 = job.ready }
if not job.ready then
bar.probe = ui.rect(p, x, y, w, 8, "none")
H.tick_bar(self, bar.probe, job.start, job["end"], w)
end
self.rf_bars[#self.rf_bars + 1] = bar
end }
end
local function light_cells(b)
@@ -141,7 +184,7 @@ local function light_cells(b)
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, vec(lit, 8))
gui.set_size(c.node, vmath.vector3(lit, 8, 0))
end
end
end
@@ -158,276 +201,276 @@ function M.teardown(self)
self.rf_bars = nil
end
-- A dashed 1 px border (the web's border-style: dashed; 3 on, 2 off).
local function dashed(parent, w, h, color, alpha)
local function run(x, y, len, horizontal)
for d = 0, len - 1, 5 do
local l = math.min(3, len - d)
if horizontal then
ui.rect(parent, x + d, y, l, 1, color, alpha)
else
ui.rect(parent, x, y + d, 1, l, color, alpha)
end
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
run(0, 0, w, true)
run(0, h - 1, w, true)
run(0, 0, h, false)
run(w - 1, 0, h, false)
end
-- Texts side by side from x (y their middle): { str, style } each; returns the right end.
local function line(parent, x, y, list)
local nodes = {}
for _, it in ipairs(list) do
local t = ui.text(parent, it[1], it[2], x, y)
local tw = ui.text_width(t)
local spaces = #it[1]:match("( *)$")
if spaces > 0 then -- (trailing spaces don't count in the metrics)
tw = measure(parent, it[1] .. "|", it[2]) - measure(parent, "|", it[2])
end
x = x + tw
nodes[#nodes + 1] = t
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
return x, nodes
end
-- An inset glow (box-shadow: inset 0 0 18px): the fade texture along each edge, inwards.
local function inset_glow(parent, w, h, color, alpha)
local function inset_glow(p, w, h, color, alpha)
local d = 14
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(parent, 0, 0, e[3], d, color, alpha)
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, vec(e[1], -e[2]))
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())
end
end
local function slot_card(self, root, H, st, slot, x, y, w, h)
-- 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.
local function slot_card(self, H, c, st, slot)
local job = slot.job
local fill = ui.rect(root, x, y, w, h, "panel")
local edge = ui.outline(fill, 0, 0, w, h, job and (job.ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line"))
if job and job.ready then
inset_glow(fill, w, h, "ready", 0.08)
end
self.frames[#self.frames + 1] = panel.frame(fill, edge, w, h)
self.fx:box(fill)
local p = SLOT_PAD
local ready = job and job.ready
local edge = job and (ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
local kids
if not job then
cap_text(fill, "Slot " .. slot.n, p, p + 8)
wrap(fill, "Idle. Pick a recipe below.", "small", p, p + 16.5 + 8, w - 2 * p, 19.5)
return
end
cap_text(fill, "Slot " .. slot.n, p, p + 12)
if job.ready then
require(BAR).ready_label(fill, "Ready", w - p, p + 11, gui.PIVOT_E, 15)
kids = { fg.label(c, "Slot " .. slot.n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
else
local left = function(ms) return ui.dur(ms) .. " left" end
local t = ui.text(fill, left(job["end"] - H.now(self)), { font = "mono", px = 15, color = "muted" }, w - p, p + 11, gui.PIVOT_E)
H.tick_text(self, t, job["end"], left, "…")
local head = fg.label(c, "Slot " .. slot.n, "cap", 22)
local right
if ready then
right = { style = { height = 22 }, make = function(p, x, y, w, h)
require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
end }
else
local left = function(ms) return ui.dur(ms) .. " left" end
right = fg.label(c, left(job["end"] - H.now(self)), { font = "mono", px = 15, color = "muted" }, 22)
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
local color = item_color(st, job.item)
kids = {
row({ height = 22.5, justify = "space-between", align = "center" }, { head, right }),
space(0, 8),
row({ height = 22, align = "center", gap = 12 }, {
swatch(color),
row({ align = "center", gap = 4 }, {
fg.label(c, job.qty .. "×", { font = "mono_semi", px = 15, color = "text" }, 22, { shrink = 0 }),
fg.label(c, job.item, { font = "sans", px = 15, color = "text" }, 22, { fit = true }),
}),
}),
space(0, 8.5),
cell_bar(self, H, color, job),
{ style = { grow = 1, min_height = ready and 8 or 0 } },
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes)
H.action(self, b, nodes, "collect", { job = job.id })
end, { width = "100%" }) or nil,
}
end
local color = item_color(st, job.item)
local iy = p + 22.5 + 8 + 11
ui.rect(fill, p, iy - 4, 8, 8, color)
local q = ui.text(fill, job.qty .. "×", { font = "mono_semi", px = 15, color = "text" }, p + 20, iy)
local nx = p + 20 + ui.text_width(q) + 4
local name = ui.text(fill, job.item, { font = "sans", px = 15, color = "text" }, nx, iy)
ui.fit_text(name, w - p - nx)
cell_bar(self, fill, p, p + 22.5 + 8 + 22.5 + 8, w - 2 * p, color, job, H)
if job.ready then
local b, nodes = btn_sm(fill, "Collect " .. job.qty, p, h - p - BTN_H, { primary = true, w = w - 2 * p })
H.action(self, b, nodes, "collect", { job = job.id })
return col({ padding = 13, min_height = 112 }, kids), function(p, x, y, w, h)
local f = frame(self, p, x, y, w, h, edge)
if ready then
inset_glow(f, w, h, "ready", 0.08)
end
return f
end
end
-- "+ Slot n · ₥price" (.btn-dashed): transparent, a dashed cyan edge, the label in text_2.
local function buy_slot_card(self, root, H, rf, x, y, w, h)
-- "+ Slot n · ₥price" (.btn-dashed): a dashed cyan edge, the label centered in text_2.
local function buy_slot(self, H, c, rf)
local off = not rf.canBuySlot
local k = off and 0.45 or 1 -- (.btn:disabled opacity)
local b, nodes = ui.button(root, "", x, y, { w = w, h = h, disabled = off, fill = { "cyan", 0 }, edge = { "cyan", 0 } })
local label = { font = "sans_semi", px = 15, color = ui.color("text_2", k), tracking = 0.12, upper = true }
local price = { font = "mono_semi", px = 15, color = ui.color("cyan", k) }
local list = {
{ "+ Slot " .. rf.nextSlot .. " · ", label },
{ theme.currency, { font = "sym", px = 14, color = ui.color("cyan", k) } },
{ ui.money(rf.nextSlotPrice), price },
local label = {
fg.label(c, "+ Slot " .. rf.nextSlot .. " · ", { font = "sans_semi", px = 15, color = ui.color("text_2", k), tracking = 0.12, upper = true }, 22),
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),
}
local probe = ui.rect(b, 0, 0, 1, 1, "none")
local tw = line(probe, 0, 0, list)
gui.delete_node(probe)
local _, nodes2 = line(b, math.floor((w - tw) / 2), h / 2, list)
local look = ui.button_looks[b]
for _, n in ipairs(look and nodes2 or {}) do
look.parts[#look.parts + 1] = { n, 0 } -- (dimmed while busy)
end
dashed(b, w, h, off and "line_hi" or "cyan", k)
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)))
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", {})
else
H.action(self, b, nodes, "buy-slot", {})
end
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
tooltip.add(b, (rf.buySlotWhyText or "") ~= "" and rf.buySlotWhyText or ("Short " .. ui.money(rf.nextSlotPrice - self.state.credits)))
elseif rf.buySlot then
cards.confirm_server_button(self, b, rf.buySlot, "buy-slot", {}) -- (asks first, the web's hx-confirm)
else
H.action(self, b, nodes, "buy-slot", {})
end
return b
end,
})
end
-- The quantity field (.input-sm, 64 wide: Druid input, digits only, at most the batch cap
-- rf.maxBatch, like the web's max); the text is sent as typed. Disabled: dimmed, no input.
local function qty_field(self, parent, r, x, y, w, h, off, max)
self.refine_qty = self.refine_qty or {}
local value = self.refine_qty[r.item] or "1"
local k = off and 0.45 or 1
local field = ui.rect(parent, x, y, w, h, "field", k)
local edge = ui.outline(field, 0, 0, w, h, "line_hi", k)
local txt = ui.text(field, value, { font = "mono", px = 15, color = "text" }, 13, h / 2)
if off then
gui.set_color(txt, ui.color("text", k))
return
end
gui.set_size(txt, vec((w - 16) / gui.get_scale(txt).x, theme.font_px * 1.4))
local input = self.druid:new_input(field, txt, gui.KEYBOARD_TYPE_NUMBER_PAD)
input:set_allowed_characters("[%d]")
input:set_max_length(max and #tostring(max) or 3)
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:set_text(value)
input.on_input_text:subscribe(function(_, text)
if max and (tonumber(text) or 0) > max then
input:set_text(tostring(max)) -- (comes back here with the cap)
---------------------------------------------------------------- recipe cards
-- 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 {}
local value = self.refine_qty[r.item] or "1"
local k = off and 0.45 or 1
local field = ui.rect(p, x, y, w, h, "field", k)
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
return
end
self.refine_qty[r.item] = text
end)
test.register("field:qty:" .. r.item, field, "field", r.item)
return input
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)
input:set_allowed_characters("[%d]")
input:set_max_length(max and #tostring(max) or 3)
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:set_text(value)
input.on_input_text:subscribe(function(_, text)
if max and (tonumber(text) or 0) > max then
input:set_text(tostring(max)) -- (comes back here with the cap)
return
end
self.refine_qty[r.item] = text
end)
test.register("field:qty:" .. r.item, field, "field", r.item)
made = input
end }, function() return made end
end
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- A recipe's description (catalog.refined).
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 ""
-- A stat (.stat: a cap label over a 15 px value).
local function stat(c, label, value)
return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
end
-- The faceted crystal (.recipe-glyph: a 36 px pentagon at .85, a faint light rim inside).
local function glyph(parent, x, y, color)
local g = ui.rect(parent, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon")
local rim = ui.rect(g, 0, 0, 36, 36, "white", 0.25)
gui.play_flipbook(rim, "pentagon_rim")
end
-- One recipe card (components/card, no image): the web's .card.recipe.
local function recipe_card(self, H, st, rf, c, r)
-- 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 off = r.why ~= ""
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
local owned = (r.owned or 0) > 0
-- Head: the glyph, "Tier N · owned X" over the name.
c:block(43.3, function(n, w)
glyph(n, 0, 3.6, color)
local x = 48
local _, parts = line(n, x, 12, {
{ "Tier " .. r.tier .. (((r.owned or 0) > 0) and " · owned " or ""), "cap" },
{ ((r.owned or 0) > 0) and ui.money(r.owned) or "", { font = "mono", px = 11, color = "muted", tracking = 0.2 } },
})
local name = ui.text(n, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, x, 22.5 + 10.4)
ui.fit_text(name, w - x)
return 43.3
end)
-- The description (14 px, line-height 1.4).
local desc = description(st, r.item)
if desc ~= "" then
c:block(function(n, w)
return wrap(n, desc, { font = "sans", px = 14, color = "text_2" }, 0, 0, w, 19.6)
end, 8)
end
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N ·
-- owned X" over the name.
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon")
gui.play_flipbook(ui.rect(g, 0, 0, 36, 36, "white", 0.25), "pentagon_rim")
end }
local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, align = "start" }, {
row({}, {
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
owned and fg.label(c, ui.money(r.owned), CAP_NUM, 24) or nil,
}),
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
}),
})
-- Inputs per unit: need and name, what you hold on the right (red when short).
c:block(function(n, w)
local y = 0
for i, input in ipairs(r.inputs) do
if i > 1 then
y = y + 4
end
ui.rect(n, 0, y + 6.5, 8, 8, item_color(st, input.name))
local t = ui.text(n, ui.money(input.need) .. " " .. input.name, { font = "sans", px = 14, color = "text" }, 16, y + 10.5)
local have = ui.text(n, ui.money(input.have), { font = "mono", px = 14, color = input.have < input.need and "danger" or "text" }, w, y + 10.5, gui.PIVOT_E)
ui.fit_text(t, w - 16 - ui.text_width(have) - 8)
tooltip.add(have, "You have " .. ui.money(input.have))
y = y + 21
end
return y
end, 8)
c:gap(8)
-- Footer: stats, the bulk batch, the form.
c:footer(FOOTER_H, function(f, w)
ui.rect(f, 0, 0, w, 1, "line")
local colw = (w - 16) / 3
local function stat(i, label, build)
local x = (i - 1) * (colw + 8)
cap_text(f, label, x, 13 + 8.25)
return build(x, 13 + 18.5 + 11.25)
end
stat(1, "Time each", function(x, y)
ui.text(f, ui.cycle(r.seconds), { font = "mono", px = 15, color = "text" }, x, y)
end)
stat(2, "Sells", function(x, y)
ui.credits(f, r.price or r.sellPrice, x, y, { px = 15 })
end)
stat(3, "Gain", function(x, y)
local g = ui.text(f, string.format("+%.0f%%", r.gain), { font = "mono", px = 15, color = "cyan" }, x, y)
if r.inputValue then
tooltip.add(g, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
end
end)
-- "Batch of 25: 7s · bulk bonus −72%" (.text-xs, numbers in mono).
local xs = { font = "sans", px = 11.25, color = "muted" }
local xn = { font = "mono", px = 11.25, color = "muted" }
local right = line(f, 0, 62 + 8, {
{ "Batch of ", xs }, { tostring(r.bulkQty), xn }, { ": ", xs }, { ui.cycle(r.bulkSecs), xn },
{ " · bulk bonus ", xs }, { string.format("−%.0f%%", r.bulkBonus), { font = "mono", px = 11.25, color = "cyan" } },
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,
})
tooltip.add(ui.rect(f, 0, 62, math.min(w, right), 16, "none"), "Bigger batches take less time per unit")
end
-- The form: quantity, Refine (takes the rest), Max.
local fy = FOOTER_H - BTN_H
local mx, mn, mw = btn_sm(f, off and "Max" or ("Max " .. r.maxQty), 0, fy, { disabled = off })
gui.set_position(mx, vec(w - mw, -fy))
local rw = w - 64 - 6 - 6 - mw
local rb, rn = btn_sm(f, "Refine", 70, fy, { primary = true, disabled = off, w = rw })
local input = qty_field(self, f, r, 0, fy, 64, BTN_H, off, rf.maxBatch)
if not off then
local refine = H.action(self, rb, rn, "refine", function()
-- Stats: three equal columns.
local gain = fg.label(c, string.format("+%.0f%%", r.gain), { font = "mono", px = 15, color = "cyan" }, 22.5)
if r.inputValue then
tooltip.add(gain.node, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
end
local stats = row({ gap = 8 }, {
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: quantity, Refine (takes the rest), Max.
local qty, input = qty_field(self, r, off, rf.maxBatch)
local refine
local form = row({ height = BTN_SM, gap = 6 }, {
qty,
btn("Refine", BTN_SM, { primary = true, 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"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end)
H.action(self, mx, mn, "refine", { recipe = r.item, all = "1" })
tooltip.add(mx, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
end, { grow = 1 }),
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input, submit = refine, step = { min = 1, max = rf.maxBatch } })
else
tooltip.add(rb, why)
tooltip.add(mx, why)
end
end)
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end),
})
local desc = catalog_desc(st, r.item)
return col({ padding = 17 }, {
head,
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 = 126 }, {
{ 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(), 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
---------------------------------------------------------------- the page
function M.build(self, root, px, py, w, h, H)
local st = self.state
local rf = st.refinery
@@ -435,87 +478,66 @@ function M.build(self, root, px, py, w, h, H)
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
-- Heading (.heading: h1 and its line; Collect all at the bottom right).
ui.text(c, "Refinery", "h1", x, y + 20.7)
wrap(c, "Refined items are needed for higher tool upgrades.", { font = "sans", px = 15, color = "text_2" }, x, y + 45.4, math.min(inner, 470), 22.5)
if rf.ready > 1 then
local label = "Collect all · " .. rf.ready
local b, nodes, bw = ui.button(c, label, 0, 0, { primary = true })
gui.set_position(b, vec(x + inner - bw, -(y + 67.9 - 44)))
H.action(self, b, nodes, "collect", {})
end
y = y + 67.9 + GAP
local heading = row({ align = "end" }, {
col({ grow = 1, align = "start" }, {
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", {})
end) or nil,
})
-- Refined items owned (.refinery-stock chips), wrapping.
local sx, any = x, false
-- Refined items owned (.refinery-stock: a swatch, the name and the amount), wrapping.
local chips = {}
for _, s in ipairs(rf.stock) do
if s.amount > 0 then
local chip = ui.rect(c, sx, y, 1, 21, "none")
ui.rect(chip, 0, 6.5, 8, 8, item_color(st, s.name))
local right = line(chip, 14, 10.5, {
{ s.name .. " ", { font = "sans", px = 14, color = "text" } },
{ ui.money(s.amount), { font = "mono", px = 14, color = "text" } },
})
if sx > x and sx + right > x + inner then
sx, y = x, y + 21 + 8
gui.set_position(chip, vec(sx, -y))
end
gui.set_size(chip, vec(right, 21))
tooltip.add(chip, s.name)
sx, any = sx + right + 16, true
chips[#chips + 1] = box({ height = 21, align = "center" }, {
swatch(item_color(st, s.name)), space(6),
fg.label(c, s.name .. " ", BODY14, 21), fg.label(c, ui.money(s.amount), MONO14, 21),
}, { make = function(p, ...)
local n = group(p, ...)
tooltip.add(n, s.name)
return n
end })
end
end
if any then
y = y + 21 + GAP
end
-- Slots (.refinery-slots: columns of at least 200, the row as tall as its tallest).
local function label(str, num)
local right = line(c, x, y + 20 + 8.25, { { num and (str .. " ") or str, "cap" } })
if num then
ui.text(c, num, { font = "mono", px = 11, color = "muted", tracking = 0.2 }, right, y + 20 + 8.25)
end
y = y + 20 + 16.5 + 10 + GAP
end
label("Slots", rf.slotCount .. "/" .. rf.maxSlots)
local items = {}
-- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
local slots = {}
for _, slot in ipairs(rf.slots) do
items[#items + 1] = slot
local tree, make = slot_card(self, H, c, st, slot)
tree.make = make
slots[#slots + 1] = tree
end
local buy = (rf.nextSlotPrice or 0) > 0
if buy then
items[#items + 1] = "buy"
if (rf.nextSlotPrice or 0) > 0 then
local b = buy_slot(self, H, c, rf)
b.style.min_height = 112
slots[#slots + 1] = b
end
local cols = math.max(1, math.floor((inner + SLOT_GAP) / (SLOT_MIN + SLOT_GAP)))
local sw = (inner - SLOT_GAP * (cols - 1)) / cols
for i = 1, #items, cols do
local rh = SLOT_H
for j = i, math.min(#items, i + cols - 1) do
local it = items[j]
if it ~= "buy" and it.job and it.job.ready then
rh = math.max(rh, 137)
end
end
for j = i, math.min(#items, i + cols - 1) do
local sx2 = math.floor(x + (j - i) * (sw + SLOT_GAP) + 0.5)
local cw = math.floor(x + (j - i) * (sw + SLOT_GAP) + sw + 0.5) - sx2
if items[j] == "buy" then
buy_slot_card(self, c, H, rf, sx2, y, cw, rh)
else
slot_card(self, c, H, st, items[j], sx2, y, cw, rh)
end
end
y = y + rh + SLOT_GAP
end
y = y - SLOT_GAP + GAP
-- Recipes (.cards).
label("Recipes")
y = card.grid(self, c, x, y, inner, rf.recipes, function(cd, r)
recipe_card(self, H, st, rf, cd, r)
end, { media = 0, pad = CARD_PAD })
area:finish(y - 16 + py)
local recipes = {}
for _, r in ipairs(rf.recipes) do
local tree, make = recipe_card(self, H, c, st, rf, r)
tree.make = make
recipes[#recipes + 1] = tree
end
local page = col({ gap = 18, width = inner }, {
heading,
#chips > 0 and row({ wrap = true, column_gap = 16, row_gap = 8, align = "start" }, chips) or nil,
section_label(c, "Slots", rf.slotCount .. "/" .. rf.maxSlots),
flex.grid(inner, 200, 12, slots),
section_label(c, "Recipes"),
flex.grid(inner, S.card_col_min, S.gap, recipes),
})
fg.build(flex.solve(page, x, y), c)
area:finish(page.y + page.h + py)
for _, b in ipairs(self.rf_bars) do
light_cells(b)
end
+216 -167
View File
@@ -31,6 +31,8 @@ local anim = require("modules.anim")
local moon_view = require("modules.moon.moon")
local test = require("modules.test")
local tooltip = require("modules.tooltip")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
M.__index = M
@@ -80,92 +82,99 @@ function M.build_teaser(g, root, px, py, inner)
local st = g.state
local nm = st.newMoon or { price = st.moonPrice, canAfford = st.credits >= st.moonPrice,
after = st.credits - st.moonPrice, need = math.max(0, st.moonPrice - st.credits) }
local cy = py
local kicker = ui.text(root, "Uncharted signal · long-range scan", { font = "sans_semi", px = 11, color = MIST, tracking = 0.2, upper = true }, px, cy + 6)
gui.animate(kicker, "color.w", 0.45, gui.EASING_INOUTSINE, 1.2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
cy = cy + 18
ui.text(root, "Unknown moon", "h1", px, cy + 20)
cy = cy + 44
cy = cy + g.h.para(root, "Something is out there. Its surface, its wells and how rich they run stay hidden until you claim it. What will you find?",
{ font = "sans", px = 15, color = "text_2" }, px, cy, inner) + 22
local S = theme.size
local row, col, space, made = fg.row, fg.col, fg.space, fg.made
local function panel_box(style, kids)
return col(style, kids, { make = function(p, x, y, w, h)
local _, nodes = ui.panel(p, x, y, w, h)
return ui.part(nodes, "panel", "root")
end })
end
local function credits(amount, opts, h)
return fg.inline(root, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
end
local kicker = fg.label(root, "Uncharted signal · long-range scan", { font = "sans_semi", px = 11, color = MIST, tracking = 0.2, upper = true }, 12)
kicker.done = function(n)
gui.animate(n, "color.w", 0.45, gui.EASING_INOUTSINE, 1.2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end
-- Grid: the claim frame (1fr, at least 220) and the hints (2fr).
local gap = 14
local bw = math.max(220, math.floor((inner - gap) / 3))
local hx = px + bw + gap
local hw = inner - bw - gap
local bh = 196
local _, buy = ui.panel(root, px, cy, bw, bh)
local b0 = ui.part(buy, "panel", "root")
local glow = ui.rect(b0, bw - 150, -40, 180, 150, "violet", 0.16)
gui.play_flipbook(glow, "glow")
ui.text(b0, "Claim price", "cap", 18, 24)
ui.credits(b0, nm.price, 18, 58, { px = 34, color = "text", font = "mono_semi" })
local small = "small"
local left
if nm.canAfford then
local t = ui.text(b0, "Leaves you", "small", 18, 92)
ui.credits(b0, nm.after, 18 + ui.text_width(t) + 6, 92, { px = 13, color = "muted" })
left = { fg.label(root, "Leaves you", small, 16), space(6), credits(nm.after, { px = 13, color = "muted" }, 16) }
else
local t = ui.text(b0, "You need", "small", 18, 92)
local w = ui.credits(b0, nm.need, 18 + ui.text_width(t) + 6, 92, { px = 13, color = "ready" })
ui.text(b0, "more", "small", 18 + ui.text_width(t) + 12 + w, 92)
end
local b, nodes = ui.button(b0, "Claim and reveal", 18, bh - 18 - theme.size.button_h,
{ primary = true, w = bw - 36, disabled = not nm.canAfford })
if not nm.canAfford then
tooltip.add(b, (nm.whyText or "") ~= "" and nm.whyText or "Not enough credits") -- (the web's title)
end
if nm.canAfford then
-- The web's nm-glow: a breathing halo around the button.
local halo = ui.rect(b, -12, -12, bw - 36 + 24, theme.size.button_h + 24, "cyan", 0.0)
gui.play_flipbook(halo, "glow")
gui.move_below(halo, ui.part(nodes, "button", "edge"))
gui.animate(halo, "color.w", 0.35, gui.EASING_INOUTSINE, 1, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
g.h.action(g, b, nodes, "buy-moon", { include = "new-moon" })
left = { fg.label(root, "You need", small, 16), space(6), credits(nm.need, { px = 13, color = "ready" }, 16), space(6),
fg.label(root, "more", small, 16) }
end
local buy = panel_box({ width = bw, height = bh, padding = 18, shrink = 0 }, {
made({ position = "absolute", left = bw - 150, top = -40, width = 180, height = 150 }, function(p, x, y, w, h)
gui.play_flipbook(ui.rect(p, x, y, w, h, "violet", 0.16), "glow")
end),
fg.label(root, "Claim price", "cap", 12), space(0, 18),
credits(nm.price, { px = 34, color = "text", font = "mono_semi" }, 20), space(0, 16),
row({ height = 16, align = "center" }, left),
fg.fill(),
fg.button(root, "Claim and reveal", { primary = true, disabled = not nm.canAfford }, function(b, nodes)
if not nm.canAfford then
tooltip.add(b, (nm.whyText or "") ~= "" and nm.whyText or "Not enough credits") -- (the web's title)
return
end
-- The web's nm-glow: a breathing halo around the button.
local halo = ui.rect(b, -12, -12, bw - 36 + 24, S.button_h + 24, "cyan", 0.0)
gui.play_flipbook(halo, "glow")
gui.move_below(halo, ui.part(nodes, "button", "edge"))
gui.animate(halo, "color.w", 0.35, gui.EASING_INOUTSINE, 1, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
g.h.action(g, b, nodes, "buy-moon", { include = "new-moon" })
end, { width = bw - 36 }),
})
-- Hints: wells (and what they can hold), then the classes it could be (full width).
local half = math.floor((hw - gap) / 2)
local narrow = half < 230
local ww = narrow and hw or half
local _, h1 = ui.panel(root, hx, cy, ww, 92)
local h1r = ui.part(h1, "panel", "root")
ui.text(h1r, "Wells", "cap", 16, 22)
local ww = half < 230 and hw or half
-- (the web's WellRange: "3" when the bounds are equal)
local range = (nm.wellsMin and nm.wellsMax) and (nm.wellsMin == nm.wellsMax and tostring(nm.wellsMin)
or (nm.wellsMin .. "–" .. nm.wellsMax)) or "2–5"
local big = ui.text(h1r, range, { font = "mono_semi", px = 22, color = "text" }, 16, 50)
local of = ui.text(h1r, "of these resources", { font = "sans", px = 14, color = "text_2" }, 16 + ui.text_width(big) + 8, 52)
ui.fit_text(of, ww - 32 - ui.text_width(big) - 8)
local dx = 16
local dots = {}
for _, r in ipairs(nm.resources or {}) do
ui.rect(h1r, dx, 70, 8, 8, r)
dx = dx + 12
dots[#dots + 1] = made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, r) end)
end
local wells = panel_box({ width = ww, height = 92, padding = { 16, 16, 0, 16 } }, {
fg.label(root, "Wells", "cap", 12), space(0, 12),
row({ height = 20, align = "start" }, {
fg.label(root, range, { font = "mono_semi", px = 22, color = "text" }, 20), space(8),
fg.label(root, "of these resources", { font = "sans", px = 14, color = "text_2" }, 24, { fit = true, shrink = 1 }),
}),
space(0, 10),
row({ gap = 4 }, dots),
})
-- Its class could be: dashed chips that wrap (the frame grows with them).
local chips = {}
for _, name in ipairs(nm.types or {}) do
chips[#chips + 1] = row({ height = 22, padding = { 0, 8, 0, 8 }, shrink = 0 }, {
fg.label(root, name, { font = "sans_semi", px = 13, color = "muted", tracking = 0.08, upper = true }, 22),
}, { make = function(p, x, y, w, h)
ui.outline(p, x, y, w, h, "line_hi")
end })
end
local classes = panel_box({ width = hw, min_height = bh - 92 - gap, padding = { 16, 16, 14, 16 } }, {
fg.label(root, "Its class could be", "cap", 12), space(0, 10),
row({ wrap = true, column_gap = 6, row_gap = 6 }, chips),
})
-- Its class could be: dashed chips that wrap.
local types = nm.types or {}
local tw = hw
local _, h3 = ui.panel(root, hx, cy + 92 + gap, tw, bh - 92 - gap)
local chips_w = tw
local h3r = ui.part(h3, "panel", "root")
ui.text(h3r, "Its class could be", "cap", 16, 22)
local tx, ty = 16, 38
for _, name in ipairs(types) do
local t = ui.text(h3r, name, { font = "sans_semi", px = 13, color = "muted", tracking = 0.08, upper = true }, 0, 0)
local w = ui.text_width(t) + 16
if tx + w > tw - 16 then
tx, ty = 16, ty + 28
end
gui.set_position(t, v3(tx + 8, -(ty + 11)))
ui.outline(h3r, tx, ty, w, 22, "line_hi")
tx = tx + w + 6
end
-- Grow the frame when the chips need more rows.
local need = ty + 22 + 14
if need > bh - 92 - gap then
ui.size_panel(h3, tw, need)
end
local tree = col({ width = inner }, {
kicker, space(0, 6),
fg.label(root, "Unknown moon", "h1", 40), space(0, 4),
fg.text_para(root, "Something is out there. Its surface, its wells and how rich they run stay hidden until you claim it. What will you find?",
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) }),
space(0, 22),
row({ gap = gap, align = "start" }, { buy, col({ width = hw, gap = gap, shrink = 0 }, { wells, classes }) }),
})
fg.build(flex.solve(tree, px, py), root)
end
-- The "?" over the shrouded moon (the web's CSS ::after on data-mystery): it breathes.
@@ -579,59 +588,148 @@ function M:build_card()
gui.set_slice9(corners, vmath.vector4(10, 10, 10, 10))
local el = {} -- animated elements: { node, kind, delay, dur, base }
local cy = CARD_PAD
local kicker = text(body, "New moon claimed", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.2, upper = true }, CARD_PAD, cy + 6)
-- The layout is a tree (modules/flex) solved first; then each part is made at its box
-- (the animated ones as groups placed there: the animation moves them from there).
local B = flex.box
local function fixed(w_, h_) return { style = { width = w_, height = h_, shrink = 0 } } end
local function vspace(h_) return { style = { height = h_, shrink = 0 } } end
-- Badge size (its label).
local label = d.rating and d.rating.label or ""
local probe = ui.text(body, label, { font = "sans_semi", px = 12, tracking = 0.16, upper = true }, 0, 0)
local bw, bh = math.max(88, math.ceil(ui.text_width(probe)) + 20), 46
gui.delete_node(probe)
-- Trait chips (their widths), the description (made now, placed after), the wells.
local trait_style = { font = "sans", px = 13, color = thi }
local trait_boxes = {}
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
local t = text(body, tr, trait_style, 0, 0)
ui.fit_text(t, inner - 16)
trait_boxes[i] = fixed(ui.text_width(t) + 16, 22)
gui.delete_node(t)
end
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
local well_boxes = {}
for i = 1, #(d.wells or {}) do
well_boxes[i] = fixed(inner, 26)
end
-- The foot's buttons are made now (in a group placed after the solve): their widths.
local foot = group(body, 0, 0)
local close_b, close_nodes, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true })
for _, n in pairs(close_nodes) do
gui.set_inherit_alpha(n, true)
end
r.close_btn = close_b
local next_moon = r.data.next
local aw
if next_moon and next_moon.why ~= "locked" then
local price = next_moon.price
local ok = next_moon.canBuy == true
local ab, an
ab, an, aw = ui.button(foot, "Buy another", 0, 0, { disabled = not ok })
for _, n in pairs(an) do
gui.set_inherit_alpha(n, true)
end
-- Its tint as an opaque color over the card (children multiply by the root's alpha).
local tint = ok and vmath.lerp(0.1, C.field, C.cyan) or vmath.lerp(0.05, C.field, C.line)
gui.set_color(ab, vmath.vector4(tint.x, tint.y, tint.z, 1))
-- The price after the label (ui.button's own price parts don't follow the alpha).
local pt = ui.part(an, "button", "text")
local hgt = theme.size.button_h
local sep = text(ab, "·", { font = "sans_semi", px = 15, color = ok and "text" or "muted" }, 0, 0)
local sym = text(ab, theme.currency, { font = "sym", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local num = text(ab, ui.money(price), { font = "mono", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local row = B({ align = "center", height = hgt, padding = { 0, 20 } }, {
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(8, 0),
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(8, 0),
{ node = sym, size = false, style = { width = ui.text_width(sym), height = hgt } }, fixed(1, 0),
{ node = num, size = false, style = { width = ui.text_width(num), height = hgt } },
})
flex.solve(row, 0, 0)
aw = math.floor(row.w)
for _, k in ipairs(row.children) do
if k.node then
gui.set_position(k.node, v3(k.x + (aw - row.w) / 2, -hgt / 2))
end
end
gui.set_size(ab, v3(aw, hgt))
gui.set_size(ui.part(an, "button", "edge"), v3(aw, hgt))
r.another_btn, r.another_nodes, r.another_ok, r.another_why = ab, an, ok, next_moon.whyText
end
local has_class = d.kind and d.kind ~= "" and d.kind ~= d.name
local tree = B({ direction = "column", width = w, padding = CARD_PAD }, {
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(14),
B({ height = 52, shrink = 0 }, {
B({ width = 52, height = 52, shrink = 0 }, {}, "planet"), fixed(14, 0),
B({ direction = "column", grow = 1 }, { B({ height = 36 }, {}, "name"), B({ height = 12 }, {}, "class") }),
}),
vspace(16),
B({ height = 56, shrink = 0 }, {
B({ width = bw, height = 52, shrink = 0 }, {}, "badge"), fixed(12, 0),
B({ grow = 1, shrink = 1 }, {}, "head"),
}),
vspace(14),
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(8),
#trait_boxes > 0 and B({ direction = "column", shrink = 0 }, {
B({ wrap = true, column_gap = 6, row_gap = 6 }, trait_boxes, "traits"), vspace(10),
}) or vspace(0),
B({ height = dh, shrink = 0 }, {}, "desc"), vspace(14),
B({ direction = "column", shrink = 0 }, well_boxes), vspace(12),
B({ height = theme.size.button_h, justify = "end", shrink = 0 }, {
aw and fixed(aw, theme.size.button_h) or fixed(0, 0), fixed(aw and 10 or 0, 0), fixed(close_w, theme.size.button_h),
}, "foot"),
})
flex.solve(tree, 0, 0)
local at = flex.index(tree)
local kicker = text(body, "New moon claimed", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.2, upper = true }, at.kicker.x, at.kicker.y + 6)
el[#el + 1] = { node = kicker, kind = "rise", delay = 0.06 }
cy = cy + 26
-- Head: the planet and the name (typed in), its class under it.
local planet = group(body, CARD_PAD, cy)
local pb = at.planet
local planet = group(body, pb.x, pb.y)
local pglow = node_box(planet, -14, 14, 80, 80, rgba(C.cyan, 0.3), "glow")
gui.set_blend_mode(pglow, theme.glow_blend())
node_box(planet, 0, 0, 52, 52, ui.moon_tint(d.moonId), "sphere")
el[#el + 1] = { node = planet, kind = "rise", delay = 0.12 }
local nx = CARD_PAD + 52 + 14
local nb = at.name
local px = 30
local letters, nw = name_letters(body, d.name or "", nx, cy + 18, px)
if nw > inner - 66 then
local letters, nw = name_letters(body, d.name or "", nb.x, nb.y + nb.h / 2, px)
if nw > nb.w then
for _, l in ipairs(letters) do
gui.delete_node(l.node)
end
px = math.floor(px * (inner - 66) / nw)
letters = name_letters(body, d.name or "", nx, cy + 18, px)
px = math.floor(px * nb.w / nw)
letters = name_letters(body, d.name or "", nb.x, nb.y + nb.h / 2, px)
end
r.letters = letters
for i, l in ipairs(letters) do
for i in ipairs(letters) do
local t0 = 0.18 + (i - 1) * 0.04
self:at(t0, function()
self:play("tick", { rate = sfx.semitones(i - 1), gain = 0.8 })
end)
end
if d.kind and d.kind ~= "" and d.kind ~= d.name then
local cls = text(body, d.kind .. " class", { font = "sans_semi", px = 12, color = "muted", tracking = 0.2, upper = true }, nx, cy + 42)
if has_class then
local cls = text(body, d.kind .. " class", { font = "sans_semi", px = 12, color = "muted", tracking = 0.2, upper = true }, at.class.x, at.class.y + 6)
el[#el + 1] = { node = cls, kind = "rise", delay = 0.3 }
end
cy = cy + 52 + 16
-- The verdict: badge (stars and tier), headline and sub. Placed now, shown last.
local verdict_y = cy
local badge = group(body, 0, cy + 26)
local label = d.rating and d.rating.label or ""
local probe = ui.text(body, label, { font = "sans_semi", px = 12, tracking = 0.16, upper = true }, 0, 0)
local bw, bh = math.max(88, math.ceil(ui.text_width(probe)) + 20), 46
gui.delete_node(probe)
local verdict_y = at.badge.y
local badge = group(body, at.badge.x + bw / 2, at.badge.y + 26)
local bbg = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba(tc, (tier == "poor" or tier == "fair") and 0 or 0.1))
local bedge = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba((tier == "poor" or tier == "fair") and C.line_hi or tc, 1), "outline")
gui.set_slice9(bedge, vmath.vector4(2, 2, 2, 2))
local stars = d.rating and d.rating.stars or 0
for i = 1, 5 do
local s = text(badge, "★", { font = "sym", px = 13, color = tc }, (i - 3) * 15, -10, gui.PIVOT_CENTER)
local st_ = text(badge, "★", { font = "sym", px = 13, color = tc }, (i - 3) * 15, -10, gui.PIVOT_CENTER)
if i > stars then
gui.set_color(s, rgba(C.text_2, 0.25))
gui.set_color(st_, rgba(C.text_2, 0.25))
end
end
text(badge, d.rating and d.rating.label or "", { font = "sans_semi", px = 12, color = thi, tracking = 0.16, upper = true }, 0, 10, gui.PIVOT_CENTER)
gui.set_position(badge, v3(CARD_PAD + bw / 2, -(cy + 26)))
text(badge, label, { font = "sans_semi", px = 12, color = thi, tracking = 0.16, upper = true }, 0, 10, gui.PIVOT_CENTER)
gui.set_euler(badge, v3(0, 0, 3))
local shimmer
if big then
@@ -639,16 +737,15 @@ function M:build_card()
shimmer = node_box(badge, -bw / 2, -bh / 2, 24, bh, rgba(GOLD_HI, 0), "glow")
gui.set_blend_mode(shimmer, theme.glow_blend())
end
local hx = CARD_PAD + bw + 12
local head = group(body, hx, cy)
local hb = at.head
local head = group(body, hb.x, hb.y)
local hl = text(head, d.headline or "", { font = "sans_bold", px = 21, color = tier == "poor" and C.text_2 or thi }, 0, 16)
ui.fit_text(hl, inner - bw - 12)
ui.fit_text(hl, hb.w)
local sub = text(head, d.sub or "", { font = "sans", px = 13, color = "muted" }, 0, 38)
ui.fit_text(sub, inner - bw - 12)
cy = cy + 56 + 14
ui.fit_text(sub, hb.w)
-- Worth: its wells at market prices, and that over its price.
local worth = group(body, CARD_PAD, cy)
local worth = group(body, at.worth.x, at.worth.y)
local parts = { { "Worth", { font = "sans", px = 15, color = "text_2" } }, { gap = 5 },
{ "≈", { font = "sym", px = 14, color = "text_2" } }, { gap = 5 },
{ theme.currency, { font = "sym", px = 14, color = "text" } }, { gap = 1 },
@@ -662,34 +759,22 @@ function M:build_card()
end
end
line(worth, parts, 0, 9)
cy = cy + 26
-- Traits: chips in the tier's color.
local traits = {}
if type(d.traits) == "table" and #d.traits > 0 then
local tx, ty = 0, 0
local row = group(body, CARD_PAD, cy)
for _, tr in ipairs(d.traits) do
local chip = group(row, 0, 0)
local t = text(chip, tr, { font = "sans", px = 13, color = thi }, 8, 11)
ui.fit_text(t, inner - 16)
local cw = ui.text_width(t) + 16
if tx > 0 and tx + cw > inner then
tx, ty = 0, ty + 28
end
gui.set_position(chip, v3(tx, -ty))
local o = node_box(chip, 0, 0, cw, 22, rgba(tc, 0.4), "outline")
gui.set_slice9(o, vmath.vector4(2, 2, 2, 2))
traits[#traits + 1] = chip
tx = tx + cw + 6
end
cy = cy + ty + 32
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
local tb = trait_boxes[i]
local chip = group(body, tb.x, tb.y)
local t = text(chip, tr, trait_style, 8, 11)
ui.fit_text(t, inner - 16)
local o = node_box(chip, 0, 0, tb.w, 22, rgba(tc, 0.4), "outline")
gui.set_slice9(o, vmath.vector4(2, 2, 2, 2))
traits[#traits + 1] = chip
end
-- Description.
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, CARD_PAD, cy, inner)
gui.set_position(desc, v3(at.desc.x, -at.desc.y))
el[#el + 1] = { node = desc, kind = "rise", delay = 0.52 }
cy = cy + dh + 14
-- Wells: they pop in one by one, their bars filling.
local wells = {}
@@ -697,70 +782,35 @@ function M:build_card()
local bar_x, num_w = 132, 64
local bar_w = inner - bar_x - num_w - 10
for i, wl in ipairs(d.wells or {}) do
local row = group(body, CARD_PAD, cy)
local wb = well_boxes[i]
local row = group(body, wb.x, wb.y)
local full = wl.full
node_box(row, 0, 7, 8, 8, theme.resource[wl.resource] or C.cyan)
text(row, wl.resource, { font = "sans", px = 15, color = "text" }, 16, 11)
node_box(row, bar_x, 8, bar_w, 6, C.line)
if full then
local fg = node_box(row, bar_x - 6, 0, bar_w + 12, 22, rgba(C.ready, 0.25), "glow")
gui.set_blend_mode(fg, theme.glow_blend())
local fgl = node_box(row, bar_x - 6, 0, bar_w + 12, 22, rgba(C.ready, 0.25), "glow")
gui.set_blend_mode(fgl, theme.glow_blend())
end
local fw = math.max(2, math.floor(bar_w * clamp01(wl.supply / max)))
local fill = node_box(row, bar_x, 8, fw, 6, theme.resource[wl.resource] or C.cyan)
local nx2 = inner
local maxt = text(row, "/" .. ui.money(max), { font = "mono", px = 12, color = "faint" }, nx2, 11, gui.PIVOT_E)
local maxt = text(row, "/" .. ui.money(max), { font = "mono", px = 12, color = "faint" }, inner, 11, gui.PIVOT_E)
text(row, ui.money(wl.supply), { font = full and "mono_semi" or "mono", px = 13, color = full and "ready" or "text" },
nx2 - ui.text_width(maxt), 11, gui.PIVOT_E)
inner - ui.text_width(maxt), 11, gui.PIVOT_E)
wells[#wells + 1] = { node = row, fill = fill, fw = fw }
local t0 = 0.64 + (i - 1) * 0.12
self:at(t0, function()
self:play("arrive", { rate = sfx.semitones(({ 0, 4, 7, 12, 16, 19 })[math.min(i, 6)]), gain = 0.7 })
end)
cy = cy + 26
end
cy = cy + 12
-- Foot: Buy another (the next moon, straight away) and Start mining.
local foot = group(body, CARD_PAD, cy)
local close_b, close_nodes, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true })
gui.set_position(close_b, v3(inner - close_w, 0))
for _, n in pairs(close_nodes) do
gui.set_inherit_alpha(n, true)
end
r.close_btn = close_b
local next_moon = r.data.next
if next_moon and next_moon.why ~= "locked" then
local price = next_moon.price
local ok = next_moon.canBuy == true
local ab, an, aw = ui.button(foot, "Buy another", 0, 0, { disabled = not ok })
for _, n in pairs(an) do
gui.set_inherit_alpha(n, true)
end
-- Its tint as an opaque color over the card (children multiply by the root's alpha).
local tint = ok and vmath.lerp(0.1, C.field, C.cyan) or vmath.lerp(0.05, C.field, C.line)
gui.set_color(ab, vmath.vector4(tint.x, tint.y, tint.z, 1))
-- The price after the label (ui.button's own price parts don't follow the alpha).
local pt = ui.part(an, "button", "text")
local lw = ui.text_width(pt)
local sep = text(ab, "·", { font = "sans_semi", px = 15, color = ok and "text" or "muted" }, 0, 0)
local sym = text(ab, theme.currency, { font = "sym", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local num = text(ab, ui.money(price), { font = "mono", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local content = lw + 8 + ui.text_width(sep) + 8 + ui.text_width(sym) + 1 + ui.text_width(num)
aw = math.floor(content + 40)
local x0 = (aw - content) / 2
local hgt = theme.size.button_h
gui.set_position(pt, v3(x0, -hgt / 2))
x0 = x0 + lw + 8
gui.set_position(sep, v3(x0, -hgt / 2))
x0 = x0 + ui.text_width(sep) + 8
gui.set_position(sym, v3(x0, -hgt / 2))
x0 = x0 + ui.text_width(sym) + 1
gui.set_position(num, v3(x0, -hgt / 2))
gui.set_size(ab, v3(aw, hgt))
gui.set_size(ui.part(an, "button", "edge"), v3(aw, hgt))
gui.set_position(ab, v3(inner - close_w - 10 - aw, 0))
r.another_btn, r.another_nodes, r.another_ok, r.another_why = ab, an, ok, next_moon.whyText
local fb = at.foot
gui.set_position(foot, v3(fb.x, -fb.y))
local fk = fb.children
gui.set_position(close_b, v3(fk[3].x - fb.x, 0))
if r.another_btn then
gui.set_position(r.another_btn, v3(fk[1].x - fb.x, 0))
end
-- Keyboard focus: Start mining first (Enter after Tab closes, as a click would).
test.begin_build("reveal")
@@ -770,8 +820,7 @@ function M:build_card()
test.register("reveal:buy", r.another_btn, "button", "Buy another")
end
test.set_scope("page")
cy = cy + theme.size.button_h + CARD_PAD
local h = cy
local h = tree.h
for _, n in ipairs({ bg, edge, corners }) do
gui.set_size(n, v3(w, h))
+141 -91
View File
@@ -8,6 +8,9 @@ local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards")
local card = require("components.card")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
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.
local function buy_modal(self, modal, item)
local st = self.state
local rows = modal.suggestions or {}
local empty = modal.noMoons or modal.allEmpty or #rows == 0
local h = 92 + (empty and 70 or (22 + #rows * 58)) + 72
local rows_ = modal.suggestions or {}
local empty = modal.noMoons or modal.allEmpty or #rows_ == 0
local h = 92 + (empty and 70 or (22 + #rows_ * 58)) + 72
local function build(p, box, W)
-- 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)
ui.text(box, "+" .. ui.money(modal.yield) .. " per " .. ui.cycle(modal.cycleSecs) .. " cycle"
.. (cards.reach(st, modal.reach) ~= "" and (" · " .. cards.reach(st, modal.reach)) or ""),
{ font = "mono", px = 13, color = "muted" }, 24 + 56 + 14, 60)
local y = 92
local kids = {
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 ""),
{ font = "mono", px = 13, color = "muted" }, 16),
}),
}),
fg.space(0, 22),
}
local function add(b)
kids[#kids + 1] = b
end
if empty then
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."
cards.para(box, msg_, { font = "sans", px = 15, color = "text_2" }, 24, y, W - 48)
local bx = 24
if modal.noMoons then
local _, _, mw = p:button(box, "Moons", bx, h - 24 - S.button_h, { id = "dialog:moons",
on_click = function()
self.dialog:close("cancel")
self.h.navigate(self, { section = "moons", moon = self.view.moon })
end,
})
bx = bx + mw + 10
end
add(fg.text_para(box, msg_, { font = "sans", px = 15, color = "text_2" }))
add(fg.fill())
local short = st.credits < st.moonPrice
local _, _, bw = p:button(box, "Claim moon", bx, h - 24 - S.button_h, {
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).
M.need_text(box, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", bx + bw, h - 24 - S.button_h - 12)
local buttons = {}
if modal.noMoons then
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()
self.dialog:close("cancel")
self.h.navigate(self, { section = "moons", moon = self.view.moon })
end,
})
end)
end
return bx - 24 + bw
end
ui.text(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 24, y + 6)
y = y + 22
for _, sg in ipairs(rows) do
local row = ui.rect(box, 24, y, W - 48, 50, "cyan", 0.04)
ui.outline(row, 0, 0, W - 48, 50, "line")
ui.rect(row, 12, 14, 8, 8, sg.resource)
local n = ui.text(row, sg.resource, { font = "sans_semi", px = 15, color = "text" }, 28, 18)
ui.label(row, "· " .. sg.moonName, { font = "sans", px = 14, color = "muted" }, 28 + ui.text_width(n) + 6, 18)
ui.label(row, (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" }, 12, 36)
local b, _, bw = p:button(row, "Deploy", 0, 0, { primary = true, small = true, id = "dialog:stake:" .. sg.wellId,
on_click = function()
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
end,
})
gui.set_position(b, vec(W - 48 - 10 - bw, -9))
y = y + 58
local claim = { primary = true, price = st.moonPrice, disabled = short }
buttons[#buttons + 1] = fg.box({ width = fg.button_w(box, "Claim moon", claim), height = S.button_h, shrink = 0 }, {
-- Why it's off (the web's title; tooltips don't show over a dialog).
short and fg.made({ position = "absolute", top = -20, right = 0, width = 0, height = 16 }, function(q, x, y)
M.need_text(q, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", x, y + 8)
end) or nil,
}, { make = function(q, x, y)
claim.id = "dialog:buy-moon"
claim.on_click = function() p.reply("buy-moon", {}) end
p:button(q, "Claim moon", x, y, claim)
return nil
end })
add(fg.row({ gap = 10 }, buttons))
else
add(fg.label(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 12))
add(fg.space(0, 10))
local rows = {}
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()
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
end,
})
end),
}, { 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
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
self.dialog:open({
title = (item and item.name or "Tool") .. " ready", w = 520, h = h, cancel = "Later", title_x = 24 + 56 + 14,
@@ -95,6 +128,23 @@ local function buy_modal(self, modal, item)
})
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). 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 }),
short and fg.made({ 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)
c:image(t.image)
c:title(t.name)
@@ -113,24 +163,20 @@ local function tool_card(self, H, st, c, t)
end
local short = not t.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 = 0
sx = sx + cards.stat(f, "Yield", "+" .. ui.money(t.yield), sx, 15) + 16
sx = sx + cards.stat(f, "Cycle", ui.cycle(t.cycleSecs), sx, 15) + 16
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
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
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
buy_modal(self, data.buyModal, t)
end
end)
end
M.buy_footer(f, bw, {
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned },
}, t.price, short, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"),
function(b, nodes)
if short then
tooltip.add(b, t.whyText)
else
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
buy_modal(self, data.buyModal, t)
end
end)
end
end)
end)
end
@@ -142,18 +188,15 @@ local function gear_card(self, H, st, c, a)
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
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
function(b, nodes)
if short then
tooltip.add(b, a.whyText)
else
H.action(self, b, nodes, "buy-attachment", { item = a.name })
end
end)
end)
end
@@ -162,27 +205,34 @@ function M.build(self, root, px, py, w, h, H)
local sto = st.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "store")
local c = area.content
y = H.heading(c, x, y, "Store")
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.",
{ font = "sans", px = 15, color = "muted" }, x, y, math.min(inner, S.text_max)) + 20
ui.text(c, "Tools", "cap", x, y + 6)
y = y + 20
-- (cards are kept while what they show stays the same: modules/blocks)
local function shared()
return { st.credits, st.moonPrice, st.catalogVersion, self.inflight, self.view }
end
y = card.grid(self, c, x, y, 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 })
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, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end
local attachments = #(sto.attachments or {}) > 0
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. Equip attachments 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,
attachments and cap("Attachments", 8) or nil,
attachments and function(cy)
return card.grid(self, c, x, cy, inner, sto.attachments, function(cd, a)
gear_card(self, H, st, cd, a)
end, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
end or nil,
})
area:finish(y + py)
end
+19 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""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)
- stale debug flags: game.project [mm] values that aren't the shipped defaults, and
DEBUG markers left in sources or shaders
@@ -13,8 +14,10 @@
over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors
The scenario part runs scripts/drive.py sections --fixture against MM_API (default
http://localhost:3000) when that server answers, after a bob build; otherwise it checks
MM_API (the server to check against, e.g. http://localhost:3251) must be set: there is no
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.
"""
import glob
@@ -237,10 +240,23 @@ def check_moon():
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
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():
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_flags()
check_flex()
check_moon()
check_fonts(api)
if "--static" not in sys.argv:
+5 -2
View File
@@ -21,8 +21,8 @@ Options:
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api) and play
as it; the scenario gets its ids as game.fixture
--fixture make a test player first (scripts/fixture.py against --api, which must
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
--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
@@ -375,6 +375,9 @@ def main():
if not args.attach:
args.config.append("mm.player=" + fixture["player"])
elif args.fixture:
if "--api" not in sys.argv and not os.environ.get("MM_API"):
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
"to a test server (no default)")
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
args.config.append("mm.player=" + fixture["player"])
print("fixture player", fixture["player"])
+277
View File
@@ -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))
+11 -8
View File
@@ -59,14 +59,17 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
- `market.go`: market prices. The quote (`marketQuote`) is shared, the same for every
player: config price × a deterministic drift (name + 5-minute tick, within
±`marketVolatility`) × live price events. What a sale pays (`sellPayout`) is personal:
quote × the sell bonus (advisors) × the player's selling pressure (`marketPressure`: their
recent sales of the item, in base-price credits, decaying with a half-life, table
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
average over the pressure it adds). Personal modifiers never change the quote, cards,
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
for you", "−4% from your recent selling"). View model for the Market section (ticker,
watchlist, chart, sell ticket; `pick=` in the URL selects a quote).
`web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
+1 -1
View File
@@ -23,7 +23,7 @@ load-config:
check:
test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs
+19 -17
View File
@@ -244,7 +244,7 @@ type APIBetterWell struct {
type APIMarket struct {
Quotes []APIQuote `json:"quotes"`
Selected string `json:"selected"`
Total float64 `json:"total"` // value of everything held
Total float64 `json:"total"` // value of everything held, at the quotes
Held int `json:"held"` // kinds held
Best string `json:"best"` // biggest 24h gain
Worst string `json:"worst"` // biggest 24h loss
@@ -252,31 +252,35 @@ type APIMarket struct {
}
type APIQuote struct {
Item string `json:"item"`
Refined bool `json:"refined"`
Balance float64 `json:"balance"`
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
Rate float64 `json:"rate"`
Boost float64 `json:"boost"`
Item string `json:"item"`
Refined bool `json:"refined"`
Balance float64 `json:"balance"`
Price float64 `json:"price"` // the shared quote: rate × boost, the same for every player
Rate float64 `json:"rate"`
Boost float64 `json:"boost"`
// Personal, on the payout only: the sell bonus (advisors), the selling pressure and its
// factor now.
Advisor float64 `json:"advisor"`
Pressure float64 `json:"pressure"`
Factor float64 `json:"factor"`
Unit float64 `json:"unit"` // what one unit pays this player now: price × advisor × factor
Open float64 `json:"open"` // market rate 24h ago
Change float64 `json:"change"` // percent over 24h
High float64 `json:"high"`
Low float64 `json:"low"`
Value float64 `json:"value"`
// For the sell ticket: the price before selling pressure, the base price (pressure is
// counted in base-price credits) and what selling everything held pays now.
Value float64 `json:"value"` // balance × price
// For the sell ticket: what a unit pays this player before selling pressure (price ×
// advisor), the base price (pressure is counted in base-price credits) and what selling
// everything held pays them now.
Symbol string `json:"symbol"`
Unpressed float64 `json:"unpressed"`
Base float64 `json:"base"`
SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
// text is "" when its flag is false.
// (the player's recent selling lowers their payout noticeably), pressureText "−4%";
// boosted (a live event, on the quote), boostText "×2"; advised (an advisor sell bonus,
// on the payout), advisorText "+8%". Each text is "" when its flag is false.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
@@ -524,7 +528,6 @@ func (a *APIState) addSection(p *Page) {
// apiMarket maps the market; full adds the chart and the sparklines.
func apiMarket(trade *MarketPage, full bool) *APIMarket {
c := cfg()
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
if trade.Selected != nil {
mk.Selected = trade.Selected.Resource
@@ -537,7 +540,7 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
}
for _, q := range trade.Quotes {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor, Unit: q.Unit(),
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
@@ -550,8 +553,7 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
aq.SellAll = q.SellAll()
if full {
aq.Spark = spark(q.series)
}
+2 -2
View File
@@ -117,7 +117,7 @@ session; the web page also reads it once per page load.
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
@@ -191,7 +191,7 @@ would earn with the player's advisors, `bestRate` the same for their best tool (
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
saved per unit, `sellPrice` what one unit pays the player now (the market quote's `unit`), `why` `busy` (no
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
"+ Slot" is disabled. Refined items sell through `sell` like resources.
+44 -1
View File
@@ -5,6 +5,7 @@ package main
import (
"encoding/json"
"math"
"testing"
)
@@ -38,7 +39,7 @@ func TestAPI(t *testing.T) {
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
if q.SellAll > q.Balance*q.Unpressed+1e-6 || q.Value != q.Balance*q.Price || (q.Balance > 0 && q.SellAll <= 0) {
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
}
}
@@ -302,3 +303,45 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
}
}
}
// TestSharedQuotes: two players with different selling pressure and advisors see the same
// quotes (and 24h history) but get different payouts.
func TestSharedQuotes(t *testing.T) {
c := cfg()
item := sortedKeys(c.ResourcePrices)[0]
var sellAdvisor string
for _, a := range c.Advisors.Items {
if a.Sell > 1 {
sellAdvisor = a.Name
break
}
}
if sellAdvisor == "" {
t.Skip("no advisor with a sell bonus in the config")
}
boosts := PriceBoosts{item: 1.5}
a, b := newPlayerState(), newPlayerState()
b.UserID = testID("u", 9)
b.Now = a.Now
a.PriceBoosts, b.PriceBoosts = boosts, boosts
b.Pressure = map[string]float64{item: c.MarketPressure.Scale}
b.Advisors = []string{sellAdvisor}
rows := []MarketRow{{ResourceBalance: ResourceBalance{Resource: item, Balance: 100}}}
qa, qb := a.marketPage(rows, View{Section: "market"}).Quotes[0], b.marketPage(rows, View{Section: "market"}).Quotes[0]
if qa.Price != qb.Price || qa.Value != qb.Value || qa.Change != qb.Change || qa.High != qb.High || qa.Low != qb.Low {
t.Errorf("quotes differ: %+v vs %+v", qa, qb)
}
if want := marketQuote(item, a.Now, 1.5); qa.Price != want {
t.Errorf("quote %v, want rate × boost %v", qa.Price, want)
}
if qa.Unit() != qa.Price || qa.SellAll() > qa.Balance*qa.Price {
t.Errorf("no modifiers, but unit %v / sellAll %v differ from quote %v", qa.Unit(), qa.SellAll(), qa.Price)
}
if !qb.Advised() || !qb.Pressed() || qb.Unit() == qa.Unit() || qb.SellAll() == qa.SellAll() {
t.Errorf("payouts should differ: %+v vs %+v", qa, qb)
}
want := sellPayout(qb.Balance, qb.Price, b.Bonus().Sell, c.MarketPressure.Average(qb.Pressure, qb.Balance*qb.Base()))
if math.Abs(qb.SellAll()-want) > 1e-9 {
t.Errorf("sellAll %v, want %v", qb.SellAll(), want)
}
}
+3 -2
View File
@@ -198,9 +198,10 @@ func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
}
// sellPrice is what one unit sells for now: market rate × live events × advisors.
// sellPrice is what one unit pays the player now, before selling pressure: the shared
// quote × their advisors.
func (s *State) sellPrice(name string) float64 {
return marketRate(name, s.Now) * s.PriceBoosts.For(name) * s.Bonus().Sell
return marketQuote(name, s.Now, s.PriceBoosts.For(name)) * s.Bonus().Sell
}
// BonusRow is one active bonus, for display.
+1 -1
View File
@@ -1485,7 +1485,7 @@ func sell(ctx context.Context, db *pgxpool.Pool, userID, resource string, amount
if err != nil {
return err
}
earned = amount * price * boost * bonus.Sell * factor
earned = sellPayout(amount, price*boost, bonus.Sell, factor)
_, err = tx.Exec(ctx,
"update resource_account set balance = balance - $2 where id = $1", accountID, amount)
if err != nil {
+38 -16
View File
@@ -3,8 +3,10 @@ package main
// Market prices. A unit sells for its base price (config: resourcePrices, or a refined
// item's price) times a drift that moves every 5 minutes, times any live price events.
// The drift is a deterministic function of the item name and time, so the 24h history
// is recomputed instead of stored. It stays within ±marketVolatility of the base. A
// player's own recent sales of an item lower its price for them a little (selling pressure).
// is recomputed instead of stored. It stays within ±marketVolatility of the base. That
// quote is the same for every player. What a sale pays is personal: the quote × the
// player's sell bonus (advisors) × their selling pressure (their own recent sales of the
// item lower what they get, never the quote): sellPayout.
import (
"context"
@@ -76,13 +78,23 @@ func marketRateAt(name string, tick int64) float64 {
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
}
// marketRate is the market price of a unit at time t, before live events. sell and
// sellRefined use it with time.Now().
// marketRate is the market price of a unit at time t, before live events.
func marketRate(name string, t time.Time) float64 { return marketRateAt(name, marketTick(t)) }
// marketQuote is the shared price of a unit at time t: the market rate × the live price
// events for it (boost). Every player sees the same quote.
func marketQuote(name string, t time.Time, boost float64) float64 { return marketRate(name, t) * boost }
// sellPayout is what a player receives for amount units at a quote: × their sell bonus
// (bonus.go) × the pressure factor averaged over the sale (MarketPressure.Average).
func sellPayout(amount, quote, sellBonus, pressure float64) float64 {
return amount * quote * sellBonus * pressure
}
// ------------------------------------------------------------------ selling pressure
// A player's sales of an item push its price down for them, a little. Each sale adds its
// A player's sales of an item push what they get for it down, a little (slippage on their
// own fill; the shared quote doesn't move). Each sale adds its
// base value (amount × base price) to the player's pressure on the item, which halves every
// halfLifeSecs. The price factor at pressure p is floor + (1−floor)·e^(−p/scale): small sales
// barely move it, heavy selling flattens out towards the floor, never below. A sale is priced
@@ -218,7 +230,7 @@ type MarketPage struct {
Quotes []Quote
Selected *Quote
Chart MarketChart
Total float64 // value of everything held, at current prices
Total float64 // value of everything held, at the quotes
Held int // how many kinds are held
Best *Quote // biggest 24h gain
Worst *Quote // biggest 24h loss
@@ -226,17 +238,17 @@ type MarketPage struct {
type Quote struct {
MarketRow
Price float64 // what one unit sells for now (rate × boost × advisor × factor)
Price float64 // the shared quote: one unit's market price now (rate × boost)
Rate float64 // market rate now, before events
Boost float64
Advisor float64 // the player's advisor sell bonus (bonus.go)
Pressure float64 // the player's selling pressure on it, now
Factor float64 // price factor from that pressure
Advisor float64 // personal: the player's sell bonus (bonus.go), on the payout
Pressure float64 // personal: the player's selling pressure on it, now
Factor float64 // personal: payout factor from that pressure
Open float64 // market rate 24h ago
Change float64 // percent, over 24h, market rate
High float64 // 24h high/low of the market rate
Low float64
Value float64 // Balance × Price
Value float64 // Balance × Price (at the quote)
Spark string // SVG path, viewBox 0 0 100 32
URL string
Selected bool
@@ -247,15 +259,25 @@ func (q Quote) Up() bool { return q.Change >= 0 }
func (q Quote) Boosted() bool { return q.Boost != 1 }
func (q Quote) Advised() bool { return q.Advisor > 1 }
// Pressed is whether recent selling lowers the price noticeably; PressureText is e.g. "−4%".
// Pressed is whether the player's recent selling lowers their payout noticeably;
// PressureText is e.g. "−4%".
func (q Quote) Pressed() bool { return q.Factor < 0.995 }
func (q Quote) PressureText() string {
return "−" + strconv.Itoa(int(math.Round((1-q.Factor)*100))) + "%"
}
// Unpressed is the price of one unit before selling pressure; the sell ticket (market.js)
// applies the pressure curve to its amount.
func (q Quote) Unpressed() float64 { return q.Rate * q.Boost * q.Advisor }
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
// the sell ticket (market.js) applies the pressure curve to its amount.
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
// Unit is what one unit pays the player now (a tiny sale).
func (q Quote) Unit() float64 { return sellPayout(1, q.Price, q.Advisor, q.Factor) }
// SellAll is what selling the whole balance pays the player now.
func (q Quote) SellAll() float64 {
return sellPayout(q.Balance, q.Price, q.Advisor, cfg().MarketPressure.Average(q.Pressure, q.Balance*q.Base()))
}
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
@@ -339,7 +361,7 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
}
q.Pressure = s.Pressure[row.Resource]
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
q.Price = q.Rate * q.Boost * q.Advisor * q.Factor
q.Price = q.Rate * q.Boost
q.Value = q.Balance * q.Price
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
mp.Quotes = append(mp.Quotes, q)
+1 -1
View File
@@ -517,7 +517,7 @@ func sellRefined(ctx context.Context, db *pgxpool.Pool, userID, name string, amo
if err != nil {
return err
}
earned = amount * price * boost * bonus.Sell * factor
earned = sellPayout(amount, price*boost, bonus.Sell, factor)
_, err = tx.Exec(ctx, `
update refined_account set balance = balance - $3 where user_id = $1 and item = $2`,
userID, name, amount)
+1 -1
View File
@@ -64,7 +64,7 @@ func moonOffer(wells []wellSupply, owned int, now time.Time, boost func(string)
r := cfg().MoonResale
o := MoonOffer{Replace: cfg().moonPriceFor(owned - 1)}
for _, w := range wells {
rate := marketRate(w.Resource, now) * boost(w.Resource)
rate := marketQuote(w.Resource, now, boost(w.Resource))
line := ResaleLine{Resource: w.Resource, Supply: w.Supply, Rate: rate, Value: w.Supply * rate}
o.Lines = append(o.Lines, line)
o.Market += line.Value
+1 -1
View File
@@ -33,7 +33,7 @@ export function updateEstimate(form) {
if (note) {
const pct = Math.round((1 - factor) * 100);
note.hidden = pct < 1;
note.textContent = ` · −${pct}% from recent selling`;
note.textContent = `−${pct}% from your recent selling`;
}
}
+7 -5
View File
@@ -410,8 +410,7 @@
<span class="num mk-big">{{cur .Price}}</span>
<span class="num mk-chg-lg {{if .Up}}mk-up{{else}}mk-down{{end}}">{{if .Up}}▲{{else}}▼{{end}} {{.ChangeText}} <span class="muted">24h</span></span>
</div>
{{if .Pressed}}<p class="small mk-down">{{.PressureText}} from recent selling <span class="muted">· wears off over a few minutes</span></p>{{end}}
{{if or .Boosted .Advised .Pressed}}<p class="small muted">Market <span class="num">{{cur .Rate}}</span>{{if .Boosted}} × live event <span class="num">{{.BoostText}}</span>{{end}}{{if .Advised}} × advisors <span class="num">{{.AdvisorText}}</span>{{end}}{{if .Pressed}} × recent selling <span class="num">{{.PressureText}}</span>{{end}}</p>{{end}}
{{if .Boosted}}<p class="small muted">Market <span class="num">{{cur .Rate}}</span> × live event <span class="num">{{.BoostText}}</span></p>{{end}}
<div class="mk-chart {{if .Up}}mk-up{{else}}mk-down{{end}}" data-prices="{{$c.Prices}}" data-start="{{$c.Start}}" data-step="{{$c.Step}}">
<svg viewBox="0 0 600 220" preserveAspectRatio="none" role="img" aria-label="{{.Resource}} market rate, last 24 hours: low {{cur .Low}}, high {{cur .High}}, now {{cur .Rate}}">
@@ -459,9 +458,12 @@
{{/* The payout: what the amount typed (or, with none, Sell all) would earn, live from
market.js; fx.js plays the sale on it. */}}
<div class="mk-payout">
<div class="mk-payout-label"><span class="cap" data-est-label>{{if $empty}}You receive{{else}}Sell all pays{{end}}</span>{{if .Advised}}<span class="mk-boost">Advisors {{.AdvisorText}}</span>{{end}}</div>
<output class="num mk-payout-num" data-est aria-live="polite">{{cur .Value}}</output>
<p class="small mk-payout-sub"><span class="num" data-unit>{{cur .Price}} / unit</span><span class="mk-down" data-press {{if not .Pressed}}hidden{{end}}> · {{.PressureText}} from recent selling</span></p>
<div class="mk-payout-label"><span class="cap" data-est-label>{{if $empty}}You receive{{else}}Sell all pays{{end}}</span></div>
<output class="num mk-payout-num" data-est aria-live="polite">{{cur .SellAll}}</output>
<p class="small mk-payout-sub"><span class="num" data-unit>{{cur .Unit}} / unit</span></p>
{{/* Personal adjustments to the payout (the quote is the same for everyone). */}}
{{if .Advised}}<p class="small mk-up">{{.AdvisorText}} for you <span class="muted">· advisors</span></p>{{end}}
<p class="small mk-down" data-press {{if not .Pressed}}hidden{{end}}>{{.PressureText}} from your recent selling</p>
</div>
<div class="mk-ticket-actions">
<button class="btn btn-primary mk-sell" hx-post="/act/sell" {{if $empty}}disabled{{end}}>Sell {{.Symbol}}</button>