Compare commits
25
Commits
8ecfa5753e
...
0bf4779903
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf4779903 | ||
|
|
ba09caf66f | ||
|
|
70ebafa31d | ||
|
|
742e071434 | ||
|
|
11d94d38f0 | ||
|
|
d1d9d51d5d | ||
|
|
ed59c6ae86 | ||
|
|
4c93f0471f | ||
|
|
9c9a2697a8 | ||
|
|
7b6f0b75f9 | ||
|
|
c7839bb2ef | ||
|
|
29c8034bf0 | ||
|
|
b7c6a1c264 | ||
|
|
6fb4392438 | ||
|
|
92c23f574f | ||
|
|
4a4897ffb0 | ||
|
|
1ea59b5d70 | ||
|
|
e805f97a4c | ||
|
|
bdc8d311be | ||
|
|
9d512f5abc | ||
|
|
8293fdca22 | ||
|
|
a21d5a4371 | ||
|
|
b4b6d22113 | ||
|
|
7ec64c39a3 | ||
|
|
9d4b712d8b |
No files matched your search
+54
-3
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
-- Flex: a pure flexbox layout solver (no Defold calls; plain Lua 5.1+/LuaJIT). It only
|
||||
-- computes rects; modules/flex_gui.lua puts persistent nodes there.
|
||||
--
|
||||
-- local flex = require("modules.flex")
|
||||
-- local tree = flex.box({ direction = "column", gap = 8, padding = 12, width = 300 }, {
|
||||
-- flex.box({ height = 22 }), -- fixed
|
||||
-- flex.leaf(function(avail_w) return text_size(avail_w) end), -- measured (wraps)
|
||||
-- flex.box({ direction = "row", justify = "space-between" }, { a, b }),
|
||||
-- })
|
||||
-- flex.solve(tree, 0, 0) -- or flex.solve(tree, x, y, w, h)
|
||||
-- tree.children[2].x, .y, .w, .h -- absolute rect of every box
|
||||
--
|
||||
-- Coordinates: top-left origin, y grows DOWN (like CSS and like this client's ui.* calls,
|
||||
-- which take (x, y) and place nodes at vec(x, -y)). Rects are absolute (relative to the
|
||||
-- origin passed to solve); box.rx/ry are relative to the parent box. flex.gui_pos(box,
|
||||
-- pivot) gives the Defold GUI position (y up) of a node with that pivot in the box.
|
||||
--
|
||||
-- A box: { style = {...}, children = {...}, measure = fn(avail_w) -> w, h, id = any }.
|
||||
-- measure (leaves: text, images) gets the content width it may use (math.huge when
|
||||
-- unbounded) and returns its content size; text should wrap to avail_w. Style keys
|
||||
-- (CSS names, snake_case):
|
||||
-- direction "row" (default) | "column"
|
||||
-- wrap false (default) | true ("wrap")
|
||||
-- gap, row_gap, column_gap px
|
||||
-- padding n | {t, r, b, l} | {v, h} | {t, h, b}; padding_top/_right/_bottom/_left
|
||||
-- justify "start" (default) | "end" | "center" | "space-between" | "space-around"
|
||||
-- | "space-evenly" (justify-content)
|
||||
-- align "stretch" (default) | "start" | "end" | "center" (align-items)
|
||||
-- align_self overrides the parent's align for one child
|
||||
-- grow, shrink (default 0, 1), basis (px | "N%" | "auto")
|
||||
-- width, height px | "N%" (of the parent's content box) | "auto"
|
||||
-- min_width, max_width, min_height, max_height px | "N%"
|
||||
-- display = "none" leaves the box out (no rect: x, y, w, h = nil).
|
||||
-- aspect_ratio w / h: the missing size follows the given one (a width, or a column's
|
||||
-- stretch); in a row it isn't stretched (its height follows its width).
|
||||
-- position = "absolute": out of the flow, placed in its parent's box by left, right,
|
||||
-- top, bottom (px | "N%"), translate = {fx, fy} (fractions of its own size);
|
||||
-- flex.anchor places a box the same way against any solved rect (badges,
|
||||
-- floating numbers, bursts on another tree's box).
|
||||
-- columns = { tracks }: a grid instead of flex (px, "N%", "auto", "Nfr"; cells' span,
|
||||
-- rows, justify_items, justify_self): see grid() below. Tables.
|
||||
-- overflow = "scroll": children don't shrink along the main axis (a scroll area's
|
||||
-- content); flex.content_size(box) gives the scrolled size.
|
||||
--
|
||||
-- Deviations from CSS (on purpose):
|
||||
-- - box-sizing is border-box: width/height include the padding. There are no borders or
|
||||
-- margins (use padding, gap or spacer boxes).
|
||||
-- - min-width/min-height default to 0, not auto (CSS's flex items won't shrink below
|
||||
-- their min-content; here they do, like `min-width: 0`: labels get cut with ... instead
|
||||
-- of overflowing).
|
||||
-- - An auto-sized box is fit-content (max-content, capped by the available width), also
|
||||
-- at the root and for a column's child that isn't stretched; CSS's block-level flex
|
||||
-- container would fill the width. Give the root a width.
|
||||
-- - align-content is always start (lines packed at the top); with wrap and the default
|
||||
-- align = "stretch", each line's items stretch to the line's tallest one, as in CSS
|
||||
-- (equal-height rows of cards). No baseline alignment, no order, no *-reverse, no
|
||||
-- absolute positioning in the flow (position = "absolute" only against the parent).
|
||||
-- - A % size against an auto (indefinite) parent size is treated as auto, like CSS.
|
||||
-- - Sizes are not rounded (rects can be fractional; this client places at fractions).
|
||||
|
||||
local M = {}
|
||||
|
||||
local HUGE = math.huge
|
||||
local EMPTY = {}
|
||||
|
||||
-- Constructors (plain tables work too).
|
||||
function M.box(style, children, id)
|
||||
return { style = style or {}, children = children or {}, id = id }
|
||||
end
|
||||
|
||||
function M.leaf(measure, style, id)
|
||||
return { style = style or {}, measure = measure, id = id }
|
||||
end
|
||||
|
||||
local function len(v, ref)
|
||||
if v == nil or v == "auto" then
|
||||
return nil
|
||||
end
|
||||
if type(v) == "number" then
|
||||
return v
|
||||
end
|
||||
local p = type(v) == "string" and v:match("^([%d%.%-]+)%%$")
|
||||
if p then
|
||||
return ref and ref ~= HUGE and ref * tonumber(p) / 100 or nil
|
||||
end
|
||||
error("flex: bad length " .. tostring(v))
|
||||
end
|
||||
|
||||
local function clamp(v, lo, hi)
|
||||
if hi and v > hi then
|
||||
v = hi
|
||||
end
|
||||
if lo and v < lo then
|
||||
v = lo
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
local function pads(s)
|
||||
local p = s.padding
|
||||
local t, r, b, l = 0, 0, 0, 0
|
||||
if type(p) == "number" then
|
||||
t, r, b, l = p, p, p, p
|
||||
elseif type(p) == "table" then
|
||||
if #p == 1 then
|
||||
t, r, b, l = p[1], p[1], p[1], p[1]
|
||||
elseif #p == 2 then
|
||||
t, r, b, l = p[1], p[2], p[1], p[2]
|
||||
elseif #p == 3 then
|
||||
t, r, b, l = p[1], p[2], p[3], p[2]
|
||||
else
|
||||
t, r, b, l = p[1], p[2], p[3], p[4]
|
||||
end
|
||||
end
|
||||
return s.padding_top or t, s.padding_right or r, s.padding_bottom or b, s.padding_left or l
|
||||
end
|
||||
|
||||
-- Width/height limits of a box: min, max (px or nil) against the parent's content size.
|
||||
local function limits(s, cw, ch)
|
||||
return len(s.min_width, cw) or 0, len(s.max_width, cw), len(s.min_height, ch) or 0, len(s.max_height, ch)
|
||||
end
|
||||
|
||||
local layout, place_abs, grid
|
||||
|
||||
-- Children in the flow, and the absolutely positioned ones (nil when none).
|
||||
local function in_flow(kids)
|
||||
if not kids then
|
||||
return EMPTY, nil
|
||||
end
|
||||
local abs
|
||||
for _, c in ipairs(kids) do
|
||||
if c.style and c.style.position == "absolute" then
|
||||
abs = abs or {}
|
||||
abs[#abs + 1] = c
|
||||
end
|
||||
end
|
||||
if not abs then
|
||||
return kids, nil
|
||||
end
|
||||
local flow = {}
|
||||
for _, c in ipairs(kids) do
|
||||
if not (c.style and c.style.position == "absolute") then
|
||||
flow[#flow + 1] = c
|
||||
end
|
||||
end
|
||||
return flow, abs
|
||||
end
|
||||
|
||||
-- Absolutely positioned children of a box w x h (CSS position: absolute against the box's
|
||||
-- border box): left/right/top/bottom (px or "N%"), width/height (else from both offsets,
|
||||
-- else content), translate = {fx, fy} (fractions of its own size, CSS translate(-50%, ..)).
|
||||
-- No offset on an axis: 0 (the start edge).
|
||||
local function abs_one(c, w, h)
|
||||
local cs = c.style
|
||||
if cs.display == "none" then
|
||||
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
|
||||
return
|
||||
end
|
||||
local l, r, t, bo = len(cs.left, w), len(cs.right, w), len(cs.top, h), len(cs.bottom, h)
|
||||
local cw, ch = len(cs.width, w), len(cs.height, h)
|
||||
if not cw and l and r then
|
||||
cw = math.max(0, w - l - r)
|
||||
end
|
||||
if not ch and t and bo then
|
||||
ch = math.max(0, h - t - bo)
|
||||
end
|
||||
local ww, hh = layout(c, cw, ch, w, h)
|
||||
local x = l or (r and w - r - ww) or 0
|
||||
local y = t or (bo and h - bo - hh) or 0
|
||||
local tr = cs.translate
|
||||
if tr then
|
||||
x, y = x + (tr[1] or 0) * ww, y + (tr[2] or 0) * hh
|
||||
end
|
||||
c.rx, c.ry = x, y
|
||||
end
|
||||
|
||||
function place_abs(abs, w, h)
|
||||
for _, c in ipairs(abs or EMPTY) do
|
||||
abs_one(c, w, h)
|
||||
end
|
||||
end
|
||||
|
||||
-- Grid layout (CSS grid subset): style.columns = list of tracks: px, "N%", "auto"
|
||||
-- (max-content of its cells), "Nfr" (a share of what's left: minmax(0, Nfr); auto when
|
||||
-- the width is indefinite). Cells fill rows left to right (style.span on a cell: columns
|
||||
-- it covers); a row is as tall as its tallest cell (style.rows: fixed heights by row,
|
||||
-- missing = auto). gap/row_gap/column_gap; align (align-items, default stretch) and
|
||||
-- justify_items (default stretch), align_self/justify_self on a cell.
|
||||
function grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
|
||||
local pt, pr, pb, pl = pads(s)
|
||||
local px, py = pl + pr, pt + pb
|
||||
local cols = s.columns
|
||||
local n = #cols
|
||||
local cg = s.column_gap or s.gap or 0
|
||||
local rg = s.row_gap or s.gap or 0
|
||||
local inner_w = w and (w - px) or (avail_w ~= HUGE and avail_w or nil)
|
||||
local cells, r, c = {}, 1, 1
|
||||
for _, k in ipairs(kids) do
|
||||
local ks = k.style or EMPTY
|
||||
if ks.display == "none" then
|
||||
k.x, k.y, k.w, k.h, k.rx, k.ry = nil, nil, nil, nil, nil, nil
|
||||
else
|
||||
local span = math.max(1, math.min(n, ks.span or 1))
|
||||
if c + span - 1 > n then
|
||||
r, c = r + 1, 1
|
||||
end
|
||||
cells[#cells + 1] = { box = k, s = ks, r = r, c = c, span = span }
|
||||
c = c + span
|
||||
if c > n then
|
||||
r, c = r + 1, 1
|
||||
end
|
||||
end
|
||||
end
|
||||
local nrows = #cells > 0 and cells[#cells].r or 0
|
||||
local width, fr, frsum, auto = {}, {}, 0, {}
|
||||
for i, t in ipairs(cols) do
|
||||
local f = type(t) == "string" and t:match("^([%d%.]+)fr$")
|
||||
if f then
|
||||
fr[i] = tonumber(f)
|
||||
frsum = frsum + fr[i]
|
||||
if not inner_w then
|
||||
auto[i] = true
|
||||
end
|
||||
else
|
||||
width[i] = len(t, inner_w)
|
||||
if not width[i] then
|
||||
auto[i] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
local content = {}
|
||||
for _, cl in ipairs(cells) do
|
||||
if cl.span == 1 and auto[cl.c] then
|
||||
local cw = layout(cl.box, len(cl.s.width, inner_w), nil, HUGE, nil)
|
||||
content[cl.c] = math.max(content[cl.c] or 0, cw)
|
||||
end
|
||||
end
|
||||
local used = cg * (n - 1)
|
||||
for i = 1, n do
|
||||
if auto[i] then
|
||||
width[i] = content[i] or 0
|
||||
end
|
||||
if width[i] then
|
||||
used = used + width[i]
|
||||
end
|
||||
end
|
||||
if frsum > 0 and inner_w then
|
||||
local free = math.max(0, inner_w - used) / math.max(frsum, 1)
|
||||
for i = 1, n do
|
||||
if fr[i] then
|
||||
width[i] = free * fr[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
local colx, x = {}, 0
|
||||
for i = 1, n do
|
||||
colx[i] = x
|
||||
x = x + width[i] + cg
|
||||
end
|
||||
w = w or clamp(x - cg + px, minw, maxw)
|
||||
-- Rows: each cell at its width, then the row's height.
|
||||
local rows, rowh = s.rows or EMPTY, {}
|
||||
for _, cl in ipairs(cells) do
|
||||
local cw = colx[cl.c + cl.span - 1] + width[cl.c + cl.span - 1] - colx[cl.c]
|
||||
local iw = len(cl.s.width, cw)
|
||||
local js = cl.s.justify_self or s.justify_items or "stretch"
|
||||
if not iw and js == "stretch" then
|
||||
iw = cw
|
||||
end
|
||||
cl.cw, cl.iw, cl.js = cw, iw, js
|
||||
local _, ih = layout(cl.box, iw, nil, cw, nil)
|
||||
cl.ih = ih
|
||||
local fixed = type(rows[cl.r]) == "number" and rows[cl.r]
|
||||
rowh[cl.r] = fixed or math.max(rowh[cl.r] or 0, ih)
|
||||
end
|
||||
local rowy, y = {}, 0
|
||||
for i = 1, nrows do
|
||||
rowy[i] = y
|
||||
y = y + (rowh[i] or 0) + rg
|
||||
end
|
||||
h = h or clamp((nrows > 0 and y - rg or 0) + py, minh, maxh)
|
||||
for _, cl in ipairs(cells) do
|
||||
local k, ks, rh = cl.box, cl.s, rowh[cl.r]
|
||||
local al = ks.align_self or s.align or "stretch"
|
||||
local auto_h = ks.height == nil or ks.height == "auto"
|
||||
if al == "stretch" and auto_h and not ks.aspect_ratio and cl.ih ~= rh then
|
||||
layout(k, k.w, rh, cl.cw, rh)
|
||||
cl.ih = rh
|
||||
end
|
||||
local ox, oy = 0, 0
|
||||
if cl.js == "end" then
|
||||
ox = cl.cw - k.w
|
||||
elseif cl.js == "center" then
|
||||
ox = (cl.cw - k.w) / 2
|
||||
end
|
||||
if al == "end" then
|
||||
oy = rh - cl.ih
|
||||
elseif al == "center" then
|
||||
oy = (rh - cl.ih) / 2
|
||||
end
|
||||
k.rx, k.ry = pl + colx[cl.c] + ox, pt + rowy[cl.r] + oy
|
||||
end
|
||||
b.w, b.h = w, h
|
||||
return w, h
|
||||
end
|
||||
|
||||
-- Lay out b's content at border-box size w x h (either may be nil: sized by content),
|
||||
-- in a parent content box cw x ch (nil/HUGE: unbounded). Sets b.w, b.h and children's
|
||||
-- rx, ry (relative to b). Returns w, h.
|
||||
function layout(b, w, h, cw, ch)
|
||||
local s = b.style or EMPTY
|
||||
local minw, maxw, minh, maxh = limits(s, cw, ch)
|
||||
w = w or len(s.width, cw)
|
||||
h = h or len(s.height, ch)
|
||||
if w then
|
||||
w = clamp(w, minw, maxw)
|
||||
end
|
||||
if h then
|
||||
h = clamp(h, minh, maxh)
|
||||
end
|
||||
local ar = s.aspect_ratio
|
||||
if ar then
|
||||
if w and not h then
|
||||
h = clamp(w / ar, minh, maxh)
|
||||
elseif h and not w then
|
||||
w = clamp(h * ar, minw, maxw)
|
||||
end
|
||||
end
|
||||
local pt, pr, pb, pl = pads(s)
|
||||
local px, py = pl + pr, pt + pb
|
||||
-- The width content may take when w is auto: the parent's, capped by max-width.
|
||||
local avail_w = w and (w - px) or (math.min(cw or HUGE, maxw or HUGE) - px)
|
||||
local kids, abs = in_flow(b.children)
|
||||
if #kids == 0 then
|
||||
if not (w and h) then
|
||||
local mw, mh = 0, 0
|
||||
if b.measure then
|
||||
mw, mh = b.measure(math.max(0, avail_w))
|
||||
end
|
||||
w = w or clamp(math.min(mw, math.max(0, avail_w)) + px, minw, maxw)
|
||||
h = h or (ar and clamp(w / ar, minh, maxh)) or clamp(mh + py, minh, maxh)
|
||||
end
|
||||
b.w, b.h = w, h
|
||||
place_abs(abs, w, h)
|
||||
return w, h
|
||||
end
|
||||
if s.columns then
|
||||
w, h = grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
|
||||
place_abs(abs, w, h)
|
||||
return w, h
|
||||
end
|
||||
|
||||
local row = (s.direction or "row") == "row"
|
||||
local gap = s.gap or 0
|
||||
local main_gap = row and (s.column_gap or gap) or (s.row_gap or gap)
|
||||
local cross_gap = row and (s.row_gap or gap) or (s.column_gap or gap)
|
||||
local inner_w = w and (w - px) or nil
|
||||
local inner_h = h and (h - py) or nil
|
||||
local inner_main, inner_cross = inner_w, inner_h -- (either may be nil: no and/or here)
|
||||
if not row then
|
||||
inner_main, inner_cross = inner_h, inner_w
|
||||
end
|
||||
-- Where lines break and what text wraps to.
|
||||
local break_main = row and avail_w or (inner_h or HUGE)
|
||||
local align = s.align or "stretch"
|
||||
|
||||
-- Flex base sizes (outer main size; padding counts: border-box).
|
||||
local items = {}
|
||||
for _, c in ipairs(kids) do
|
||||
local cs = c.style or EMPTY
|
||||
if cs.display == "none" then
|
||||
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
|
||||
else
|
||||
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
|
||||
local it = { box = c, s = cs, grow = cs.grow or 0, shrink = s.overflow == "scroll" and 0 or cs.shrink or 1 }
|
||||
if row then
|
||||
it.min, it.max = cminw, cmaxw
|
||||
else
|
||||
it.min, it.max = cminh, cmaxh
|
||||
end
|
||||
local self_align = cs.align_self or align
|
||||
it.stretch = self_align == "stretch" and not (row and cs.aspect_ratio)
|
||||
it.align = self_align
|
||||
local main_style = cs.width
|
||||
if not row then
|
||||
main_style = cs.height
|
||||
end
|
||||
local base = len(cs.basis, inner_main) or len(main_style, inner_main)
|
||||
if not base then
|
||||
if row then
|
||||
base = layout(c, nil, nil, avail_w, inner_h)
|
||||
else
|
||||
-- A column's child: its width first (stretched, set, or content), then
|
||||
-- its height at that width.
|
||||
local cwid = len(cs.width, inner_w)
|
||||
if not cwid and it.stretch and inner_w then
|
||||
cwid = clamp(inner_w, cminw, cmaxw)
|
||||
end
|
||||
local _, hh = layout(c, cwid, nil, avail_w, inner_h)
|
||||
base = hh
|
||||
end
|
||||
end
|
||||
it.base = base
|
||||
it.hyp = clamp(base, it.min, it.max)
|
||||
items[#items + 1] = it
|
||||
end
|
||||
end
|
||||
|
||||
-- Lines.
|
||||
local lines, cur, used = {}, {}, 0
|
||||
for _, it in ipairs(items) do
|
||||
local add = it.hyp + (#cur > 0 and main_gap or 0)
|
||||
if s.wrap and #cur > 0 and used + add > break_main + 1e-6 then
|
||||
lines[#lines + 1] = cur
|
||||
cur, used = {}, 0
|
||||
add = it.hyp
|
||||
end
|
||||
cur[#cur + 1] = it
|
||||
used = used + add
|
||||
end
|
||||
if #cur > 0 then
|
||||
lines[#lines + 1] = cur
|
||||
end
|
||||
|
||||
-- Main sizes per line (CSS "resolve flexible lengths", with freezing at min/max).
|
||||
local content_main = 0
|
||||
for _, ln in ipairs(lines) do
|
||||
local gaps = main_gap * (#ln - 1)
|
||||
local hyp_sum = gaps
|
||||
for _, it in ipairs(ln) do
|
||||
hyp_sum = hyp_sum + it.hyp
|
||||
it.size, it.frozen = it.hyp, false
|
||||
end
|
||||
local space = inner_main
|
||||
if not space and row and hyp_sum > avail_w then
|
||||
space = avail_w -- (an auto-width row is fit-content: it shrinks to what's there)
|
||||
end
|
||||
if space then
|
||||
local growing = hyp_sum < space
|
||||
for _, it in ipairs(ln) do
|
||||
if (growing and it.grow == 0) or (not growing and it.shrink == 0)
|
||||
or (growing and it.base > it.hyp) or (not growing and it.base < it.hyp) then
|
||||
it.frozen = true
|
||||
end
|
||||
end
|
||||
for _ = 1, #ln + 1 do
|
||||
local free, sum = space - gaps, 0
|
||||
local active = false
|
||||
for _, it in ipairs(ln) do
|
||||
if it.frozen then
|
||||
free = free - it.size
|
||||
else
|
||||
free = free - it.base
|
||||
sum = sum + (growing and it.grow or it.shrink * it.base)
|
||||
active = true
|
||||
end
|
||||
end
|
||||
if not active then
|
||||
break
|
||||
end
|
||||
if growing and sum < 1 then
|
||||
free = free * sum -- (CSS: grow factors under 1 take that share)
|
||||
end
|
||||
local total_violation = 0
|
||||
for _, it in ipairs(ln) do
|
||||
if not it.frozen then
|
||||
local share = sum > 0 and (growing and it.grow or it.shrink * it.base) / sum or 0
|
||||
local t = it.base + free * share
|
||||
local c = clamp(t, it.min, it.max)
|
||||
it.size, it.viol = c, c - t
|
||||
total_violation = total_violation + it.viol
|
||||
end
|
||||
end
|
||||
for _, it in ipairs(ln) do
|
||||
if not it.frozen and (total_violation == 0 or (total_violation > 0 and it.viol > 0)
|
||||
or (total_violation < 0 and it.viol < 0)) then
|
||||
it.frozen = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local m = gaps
|
||||
for _, it in ipairs(ln) do
|
||||
m = m + it.size
|
||||
end
|
||||
ln.main = m
|
||||
content_main = math.max(content_main, m)
|
||||
end
|
||||
|
||||
-- Cross sizes: each item at its main size; line cross = its tallest item (a single
|
||||
-- line in a box of definite cross size takes that size).
|
||||
local content_cross = 0
|
||||
for li, ln in ipairs(lines) do
|
||||
local lc = 0
|
||||
for _, it in ipairs(ln) do
|
||||
local c, cs = it.box, it.s
|
||||
local cw2, ch2
|
||||
if row then
|
||||
cw2, ch2 = layout(c, it.size, nil, inner_w, inner_h)
|
||||
it.cross = ch2
|
||||
else
|
||||
local cwid = len(cs.width, inner_w)
|
||||
if not cwid and it.stretch and inner_w then
|
||||
cwid = inner_w
|
||||
end
|
||||
cw2 = layout(c, cwid, it.size, inner_w, inner_h)
|
||||
it.cross = cw2
|
||||
end
|
||||
lc = math.max(lc, it.cross)
|
||||
end
|
||||
if #lines == 1 and inner_cross then
|
||||
lc = inner_cross
|
||||
end
|
||||
ln.cross = lc
|
||||
content_cross = content_cross + lc + (li > 1 and cross_gap or 0)
|
||||
end
|
||||
|
||||
-- The box's own size.
|
||||
if row then
|
||||
w = w or clamp(content_main + px, minw, maxw)
|
||||
h = h or clamp(content_cross + py, minh, maxh)
|
||||
else
|
||||
w = w or clamp(content_cross + px, minw, maxw)
|
||||
h = h or clamp(content_main + py, minh, maxh)
|
||||
end
|
||||
inner_w, inner_h = w - px, h - py
|
||||
inner_main = row and inner_w or inner_h
|
||||
if #lines == 1 then
|
||||
lines[1].cross = row and inner_h or inner_w
|
||||
end
|
||||
|
||||
-- Stretch, then place.
|
||||
local cross_pos = 0
|
||||
for _, ln in ipairs(lines) do
|
||||
local free = inner_main - ln.main
|
||||
local start, between = 0, main_gap
|
||||
local j = s.justify or "start"
|
||||
local n = #ln
|
||||
if j == "end" then
|
||||
start = free
|
||||
elseif j == "center" then
|
||||
start = free / 2
|
||||
elseif j == "space-between" and n > 1 and free > 0 then
|
||||
between = main_gap + free / (n - 1)
|
||||
elseif j == "space-around" and free > 0 then
|
||||
start, between = free / n / 2, main_gap + free / n
|
||||
elseif j == "space-evenly" and free > 0 then
|
||||
start, between = free / (n + 1), main_gap + free / (n + 1)
|
||||
end
|
||||
local pos = start
|
||||
for _, it in ipairs(ln) do
|
||||
local c, cs = it.box, it.s
|
||||
local cross_style
|
||||
if row then
|
||||
cross_style = cs.height
|
||||
else
|
||||
cross_style = cs.width
|
||||
end
|
||||
local auto_cross = cross_style == nil or cross_style == "auto"
|
||||
if it.stretch and auto_cross then
|
||||
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
|
||||
local target = row and clamp(ln.cross, cminh, cmaxh) or clamp(ln.cross, cminw, cmaxw)
|
||||
if target ~= it.cross then
|
||||
if row then
|
||||
layout(c, it.size, target, inner_w, inner_h)
|
||||
else
|
||||
layout(c, target, it.size, inner_w, inner_h)
|
||||
end
|
||||
it.cross = target
|
||||
end
|
||||
end
|
||||
local off = 0
|
||||
if it.align == "end" then
|
||||
off = ln.cross - it.cross
|
||||
elseif it.align == "center" then
|
||||
off = (ln.cross - it.cross) / 2
|
||||
end
|
||||
if row then
|
||||
c.rx, c.ry = pl + pos, pt + cross_pos + off
|
||||
else
|
||||
c.rx, c.ry = pl + cross_pos + off, pt + pos
|
||||
end
|
||||
pos = pos + it.size + between
|
||||
end
|
||||
cross_pos = cross_pos + ln.cross + cross_gap
|
||||
end
|
||||
b.w, b.h = w, h
|
||||
place_abs(abs, w, h)
|
||||
return w, h
|
||||
end
|
||||
|
||||
local function place(b, x, y)
|
||||
b.x, b.y = x, y
|
||||
for _, c in ipairs(b.children or EMPTY) do
|
||||
if c.rx then
|
||||
place(c, x + c.rx, y + c.ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Solve the tree with its top-left at (x, y); w, h force the root's size (otherwise its
|
||||
-- style's, or its content's, capped by avail_w when given). Returns the root.
|
||||
function M.solve(root, x, y, w, h, avail_w)
|
||||
root.rx, root.ry = 0, 0
|
||||
layout(root, w, h, avail_w, nil)
|
||||
place(root, x or 0, y or 0)
|
||||
return root
|
||||
end
|
||||
|
||||
-- Anchor a box to an already solved rect (a box of another tree, a button's rect: x, y,
|
||||
-- w, h, y down) like CSS position: absolute inside it: style left/right/top/bottom,
|
||||
-- width/height, translate (see abs_one). Solves box (and its children) and returns it.
|
||||
-- flex.anchor(button_box, flex.box({ top = -6, right = -6, translate = {0, 0} }, ...))
|
||||
-- flex.anchor(r, flex.leaf(m, { left = "50%", bottom = "100%", translate = { -0.5, 0 } }))
|
||||
function M.anchor(target, box)
|
||||
box.style = box.style or {}
|
||||
abs_one(box, target.w, target.h)
|
||||
place(box, target.x + box.rx, target.y + box.ry)
|
||||
return box
|
||||
end
|
||||
|
||||
-- The scrollable content size of a solved box (CSS scrollWidth/scrollHeight): its size, or
|
||||
-- the extent of its descendants plus its right/bottom padding when they reach further.
|
||||
-- A box whose content should overflow (a scroll area of fixed height) wants style
|
||||
-- overflow = "scroll": its children then don't shrink along its main axis.
|
||||
function M.content_size(b)
|
||||
local mx, my = 0, 0
|
||||
local function walk(p)
|
||||
for _, c in ipairs(p.children or EMPTY) do
|
||||
if c.x then
|
||||
mx = math.max(mx, c.x + c.w - b.x)
|
||||
my = math.max(my, c.y + c.h - b.y)
|
||||
walk(c)
|
||||
end
|
||||
end
|
||||
end
|
||||
walk(b)
|
||||
local _, pr, pb = pads(b.style or EMPTY)
|
||||
return math.max(b.w, mx + pr), math.max(b.h, my + pb)
|
||||
end
|
||||
|
||||
-- Boxes by id (after solve).
|
||||
function M.index(root, out)
|
||||
out = out or {}
|
||||
if root.id ~= nil then
|
||||
out[root.id] = root
|
||||
end
|
||||
for _, c in ipairs(root.children or EMPTY) do
|
||||
M.index(c, out)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Grid columns for a width (CSS grid's repeat(auto-fill, minmax(min, 1fr))): the count and
|
||||
-- the column width, in whole pixels (what's left over stays at the right).
|
||||
function M.columns(w, min, gap)
|
||||
local cols = math.max(1, math.floor((w + gap) / (min + gap)))
|
||||
return cols, math.floor((w - gap * (cols - 1)) / cols)
|
||||
end
|
||||
|
||||
-- A grid as a wrapping row w wide: each child gets the column width (M.columns); with the
|
||||
-- default align = "stretch" a row is as tall as its tallest item. style: more of the
|
||||
-- row's (row_gap, ...).
|
||||
function M.grid(w, min, gap, children, style)
|
||||
local _, cw = M.columns(w, min, gap)
|
||||
for _, c in ipairs(children) do
|
||||
c.style = c.style or {}
|
||||
c.style.width, c.style.shrink = cw, 0
|
||||
end
|
||||
local s = { direction = "row", wrap = true, gap = gap, width = w }
|
||||
for k, v in pairs(style or EMPTY) do
|
||||
s[k] = v
|
||||
end
|
||||
return M.box(s, children)
|
||||
end
|
||||
|
||||
-- flex.apply(tree, nodes, opts): put persistent GUI nodes on a solved tree (Defold only:
|
||||
-- modules/flex_gui.lua, which documents it).
|
||||
local GUI = "modules.flex_gui" -- (by a name bob doesn't follow: flex_gui requires flex)
|
||||
function M.apply(tree, nodes, opts)
|
||||
return require(GUI).apply(tree, nodes, opts)
|
||||
end
|
||||
|
||||
-- Where a node with pivot (0..8 as Defold's gui.PIVOT_*: CENTER, N, NE, E, SE, S, SW, W,
|
||||
-- NW) sits for the rect x, y, w, h (y down): returns its GUI position x, y (y up).
|
||||
local PIVOT = { [0] = { 0.5, 0.5 }, { 0.5, 0 }, { 1, 0 }, { 1, 0.5 }, { 1, 1 }, { 0.5, 1 }, { 0, 1 }, { 0, 0.5 }, { 0, 0 } }
|
||||
function M.gui_pos(r, pivot, ox, oy)
|
||||
local p = PIVOT[pivot or 8] or PIVOT[8]
|
||||
return r.x - (ox or 0) + r.w * p[1], -(r.y - (oy or 0) + r.h * p[2])
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user