Compare commits

..
10 Commits
Author SHA1 Message Date
JosephFerano 2451792d46 Client: Claim, Redrill and Collect act on the first click; presses survive rebuilds
- Claim prediction counts a drill as ready once its end passed on our clock (the button
  turns on at zero, before the refetch), so a click there predicts instead of a spinner;
  claim-well leaves multi-well drills alone like the server; a plain claim raises the
  well's Redrill count, so Redrill shows at the click. Collect uses the same ready test.
- A press that a rebuild interrupts carries over to the button with the same test id
  under the pointer (every Druid button), so the release still clicks; navmemory and
  inputlock no longer retry clicks.
- A Claim popping in takes clicks on its full size; the well panel's Claim and redeploy
  shows at the zero with Claim; waiting Claim buttons have test id wait:<key>.
- clickfast scenario: real clicks 0-500 ms after each button turns on, and across a
  forced rebuild.
2026-10-20 09:19:03 +07:00
JosephFerano 48aa8900a8 AGENTS.md: the repo is game/ in the workspace; the Defold client is client/ 2026-10-20 09:04:51 +07:00
JosephFerano c1ad202e42 Add the Defold desktop client under client/ (moved from its own repo, history squashed) 2026-10-20 08:43:12 +07:00
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
278 changed files with 25937 additions and 730 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ node_modules
.DS_Store .DS_Store
web/static/css/app.css web/static/css/app.css
art art
client
+11 -9
View File
@@ -25,15 +25,18 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
- Postgres 16, stock image. No functions or extensions in the database. - Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Layout
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it - The repo is `game/` in the `MoonMiners/` workspace; agent worktrees go next to it
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`. (`git worktree add ../wt-<task> -b <task>`). The Defold desktop client is `client/` (its own
AGENTS.md).
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env` - `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis). loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it - `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells `GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as - `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers. `/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body - `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
@@ -96,8 +99,10 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
and unequip any time, they count from the next stake), store cards and the drill panel and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment` (`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped). (`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy - `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`. seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding` - `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`, holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying `all` for players from before it). New players see only the first moon purchase; deploying
@@ -139,11 +144,8 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live - `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`). via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `scripts/tool-art.mjs`: draws the tool images (`web/static/assets/items`, one per tool and
tier: `<image>-tN`, `StoreItem.ImageAt`); rerun it after adding a tool or tier (needs
rsvg-convert and ImageMagick). A new tool needs a drawing there.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`. - `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015): - `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent. changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
+278 -26
View File
@@ -1,9 +1,15 @@
package main package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill // Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments). Each // speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like // Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats. // refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
import ( import (
"context" "context"
@@ -64,39 +70,199 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
return seats, err return seats, err
} }
// hireAdvisor hires an advisor into a free seat. // AdvisorHire is a hired advisor and their seat (0: not seated).
type AdvisorHire struct {
Name string
Seat int
}
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return 0, nil, err
}
rows, err := tx.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire
where user_id = $1 order by hired_at, advisor for update`, userID)
if err != nil {
return 0, nil, err
}
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
return seats, hires, err
}
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
for i := range hires {
if hires[i].Name == name {
return &hires[i]
}
}
return nil
}
// freeSeat is the lowest empty seat, 0 if every seat is taken.
func freeSeat(seats int, hires []AdvisorHire) int {
for n := 1; n <= seats; n++ {
taken := false
for _, h := range hires {
taken = taken || h.Seat == n
}
if !taken {
return n
}
}
return 0
}
// hiredOne finds a hired advisor by name, or the error for one who isn't.
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
if h := findHire(hires, name); h != nil {
return h, nil
}
if cfg().advisor(name) == nil {
return nil, gameErr("Unknown advisor")
}
return nil, gameErr("%s doesn't work for you", name)
}
// setSeat moves a hired advisor to a seat (0: unseated).
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
userID, name, seat)
return err
}
// hireAdvisor hires an advisor into the lowest free seat.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error { func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name) a := cfg().advisor(name)
if a == nil { if a == nil {
return gameErr("Unknown advisor") return gameErr("Unknown advisor")
} }
return inTx(ctx, db, func(tx pgx.Tx) error { return inTx(ctx, db, func(tx pgx.Tx) error {
seats, err := lockAdvisors(ctx, tx, userID) seats, hires, err := lockHires(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
var hired int if findHire(hires, a.Name) != nil {
var already bool
err = tx.QueryRow(ctx, `
select count(*), coalesce(bool_or(advisor = $2), false) from advisor_hire where user_id = $1`,
userID, a.Name).Scan(&hired, &already)
if err != nil {
return err
}
if already {
return gameErr("%s already works for you", a.Name) return gameErr("%s already works for you", a.Name)
} }
if hired >= seats { seat := freeSeat(seats, hires)
if seat == 0 {
return gameErr("All advisor seats are taken. Buy another seat first") return gameErr("All advisor seats are taken. Buy another seat first")
} }
if err := debit(ctx, tx, userID, a.Price); err != nil { if err := debit(ctx, tx, userID, a.Price); err != nil {
return err return err
} }
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name) _, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
if err != nil { if err != nil {
return err return err
} }
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price}) return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
})
}
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if seat < 1 || seat > seats {
return gameErr("No such seat")
}
if h.Seat == seat {
return gameErr("%s already sits there", name)
}
for _, o := range hires {
if o.Seat == seat {
return gameErr("Seat %d is taken by %s", seat, o.Name)
}
}
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
return err
}
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
})
}
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if h.Seat == 0 {
return gameErr("%s isn't seated", name)
}
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
return err
}
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
// other then gives up their seat to them).
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
a, err := hiredOne(hires, name)
if err != nil {
return err
}
b, err := hiredOne(hires, other)
if err != nil {
return err
}
if a.Name == b.Name {
return gameErr("Pick two different advisors")
}
if a.Seat == 0 && b.Seat == 0 {
return gameErr("Neither of them is seated")
}
// One at a time: the seats are unique.
for _, step := range []struct {
name string
seat int
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
return err
}
}
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
"seat": b.Seat, "withSeat": a.Seat})
})
}
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
return err
}
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
}) })
} }
@@ -130,42 +296,120 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct { type AdvisorsPage struct {
Cards []AdvisorCard Cards []AdvisorCard
Seats, Hired int Seats int
Hired int // seats filled
Benched int // hired, not seated
SeatList []SeatSlot
Bench []AdvisorCard // hired, not seated
FreeSeat int // the lowest empty seat, 0 if none
MaxSeats int MaxSeats int
NextSeat int // number of the next seat to buy NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap NextSeatPrice float64 // 0 at the cap
CanBuySeat bool CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped Bonuses []BonusRow // the combined advisor bonus, capped
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
}
// SeatSlot is a seat and who sits in it (nil: empty).
type SeatSlot struct {
N int
Advisor *AdvisorCard
} }
type AdvisorCard struct { type AdvisorCard struct {
*Advisor *Advisor
Hired bool Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool CanAfford bool
} }
func (s *State) advisorsPage() *AdvisorsPage { // AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
// dismissing them.
type AdvisorPanel struct {
AdvisorCard
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
Dismiss string // the dismiss dialog's question
}
func (s *State) advisorsPage(v View) *AdvisorsPage {
c := cfg() c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices), p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1, NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()} Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) { if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n] p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice p.CanBuySeat = s.Balance >= p.NextSeatPrice
} }
for i := range c.Advisors.Items { card := func(a *Advisor) AdvisorCard {
a := &c.Advisors.Items[i]
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price} card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors { for _, h := range s.Hires {
card.Hired = card.Hired || h == a.Name if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
} }
p.Cards = append(p.Cards, card) }
return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
}
// Hired advisors, also those since removed from the config (no bonus).
hired := func(name string) AdvisorCard {
if a := c.advisor(name); a != nil {
return card(a)
}
return card(&Advisor{Name: name, Title: "Advisor"})
}
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
for i := range p.SeatList {
p.SeatList[i].N = i + 1
}
for _, h := range s.Hires {
hc := hired(h.Name)
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
p.SeatList[h.Seat-1].Advisor = &hc
p.Hired++
} else {
p.Bench = append(p.Bench, hc)
p.Benched++
}
}
for _, slot := range p.SeatList {
if slot.Advisor == nil {
p.FreeSeat = slot.N
break
}
}
if v.Adv != "" && s.hire(v.Adv) != nil {
pc := hired(v.Adv)
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
for _, slot := range p.SeatList {
if slot.N != pc.Seat {
panel.Moves = append(panel.Moves, slot)
}
}
if pc.Seat > 0 {
panel.Swaps = p.Bench
}
p.Panel = panel
} }
return p return p
} }
// hire finds a hired advisor by name.
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
// dismissConfirm is the question before dismissing an advisor.
func dismissConfirm(c AdvisorCard) string {
if c.Price > 0 {
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
c.Name, formatCredits(c.Price))
}
return fmt.Sprintf("Dismiss %s?", c.Name)
}
// SeatFree reports whether the player has an empty advisor seat. // SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats } func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
// SeatConfirm is the question before buying the next seat. // SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string { func (p *AdvisorsPage) SeatConfirm() string {
@@ -184,3 +428,11 @@ func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
} }
return "" return ""
} }
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
func (p *AdvisorsPage) SeatWhy() string {
if p.SeatFree() {
return ""
}
return "All seats taken. Unseat someone or buy another seat"
}
+57 -10
View File
@@ -40,7 +40,8 @@ type APIState struct {
Idle int `json:"idle"` // tools not mining Idle int `json:"idle"` // tools not mining
NextEnd *int64 `json:"nextEnd"` NextEnd *int64 `json:"nextEnd"`
Moons []APIMoonListItem `json:"moons"` // active moons Moons []APIMoonListItem `json:"moons"` // active moons
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
PastPage APIPastPage `json:"pastPage"`
Tools []APITool `json:"tools"` Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon Moon *APIMoon `json:"moon"` // s=moons with a moon
@@ -75,6 +76,17 @@ type APIView struct {
Past bool `json:"past"` Past bool `json:"past"`
Pick string `json:"pick"` Pick string `json:"pick"`
Drill string `json:"drill"` Drill string `json:"drill"`
Page int `json:"page"` // the page of past moons (from 1)
Adv string `json:"adv"` // the selected hired advisor (advisors section)
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
}
// APIPastPage is the page of past moons in past: request others with page=<n>.
type APIPastPage struct {
Page int `json:"page"` // from 1, most recently finished first
Pages int `json:"pages"` // at least 1
Total int `json:"total"` // past moons in all
PerPage int `json:"perPage"`
} }
type APIAmount struct { type APIAmount struct {
@@ -143,6 +155,8 @@ type APIStake struct {
Secs int `json:"durationSecs"` Secs int `json:"durationSecs"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
ReachText string `json:"reachText"`
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
// Moon detail only (projection, claims oldest first): what it gets in all and from // Moon detail only (projection, claims oldest first): what it gets in all and from
// this well, and whether that is its full amount (ok, short, none). // this well, and whether that is its full amount (ok, short, none).
@@ -186,6 +200,14 @@ type APIWellRow struct {
Remain float64 `json:"remain"` Remain float64 `json:"remain"`
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none) DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
DryAt *int64 `json:"dryAt"` DryAt *int64 `json:"dryAt"`
// Shared supply, as the web's well note: over (drills want more than it holds), full
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
} }
type APIWell struct { type APIWell struct {
@@ -251,6 +273,18 @@ type APIQuote struct {
Base float64 `json:"base"` Base float64 `json:"base"`
SellAll float64 `json:"sellAll"` SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now 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.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
PressureText string `json:"pressureText"`
Boosted bool `json:"boosted"`
BoostText string `json:"boostText"`
Advised bool `json:"advised"`
AdvisorText string `json:"advisorText"`
} }
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id). // APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
@@ -297,7 +331,7 @@ func apiGear(gear []*Attachment) []string {
func apiStake(st StakeRow, projected bool) APIStake { func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource, a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(), Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers} Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
if projected { if projected {
gets, here := st.Gets, st.Here gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate() a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
@@ -336,7 +370,9 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
func apiState(p *Page, extra ...*Page) *APIState { func apiState(p *Page, extra ...*Page) *APIState {
s, v := p.S, p.V s, v := p.S, p.V
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(), a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill}, View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(), Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd), Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{}, Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
@@ -365,7 +401,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
continue continue
} }
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name), tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades), Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))} Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "") tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil { if st := s.StakeForTool(t.ID); st != nil {
@@ -387,7 +423,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
for _, w := range d.Wells { for _, w := range d.Wells {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply}, row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill, APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt)} Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
for _, st := range w.Wide { for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID) row.Wide = append(row.Wide, st.ID)
} }
@@ -502,7 +539,17 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance, 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,
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value, Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base()} Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
if aq.Pressed {
aq.PressureText = q.PressureText()
}
if aq.Boosted {
aq.BoostText = q.BoostText()
}
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go). // 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.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
if full { if full {
@@ -600,11 +647,11 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
// apiPage loads the player's state and builds the state for a view. get reads the // apiPage loads the player's state and builds the state for a view. get reads the
// request's include= and catalog= (see apiStateWith). // request's include= and catalog= (see apiStateWith).
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) { func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
state, err := loadState(ctx, s.db, userID) state, v, err := loadView(ctx, s.db, userID, v)
if err != nil { if err != nil {
return nil, err return nil, err
} }
page, err := s.apiPageFor(ctx, state, v.resolve(state)) page, err := s.apiPageFor(ctx, state, v)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -736,13 +783,13 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
created, res.View, res.Message = true, View{}, "New game started" created, res.View, res.Message = true, View{}, "New game started"
} }
state, err := loadState(ctx, s.db, userID) state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil { if err != nil {
apiServerError(w, err) apiServerError(w, err)
return return
} }
toast := res.toast(state) toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, res.View.resolve(state)) page, err := s.apiPageFor(ctx, state, v)
if err != nil { if err != nil {
apiServerError(w, err) apiServerError(w, err)
return return
+61 -29
View File
@@ -27,7 +27,9 @@ Banned players get `403`.
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`, Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`, `market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
`well`, `list=past` (show a mined-out moon), `pick` (market: selected quote), `drill`. `well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
`include` (comma-separated sections, or `all`): section blocks to add besides the one for `include` (comma-separated sections, or `all`): section blocks to add besides the one for
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back `s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
like the web (`state.view` is the resolved view). Opening a revealed section with `s=` like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
@@ -70,8 +72,12 @@ The other sections' actions:
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) | | `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | | | `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot | | `buy-slot` | – | refinery slot |
| `hire` | `advisor` (name) | needs a free seat | | `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
| `buy-seat` | – | advisor seat | | `buy-seat` | – | advisor seat |
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon | | `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state | | `new-game` | – | creates a new player and returns its id and state |
@@ -98,26 +104,27 @@ session; the web page also reads it once per page load.
| key | | | key | |
|---|---| |---|---|
| `userId`, `now` | | | `userId`, `now` | |
| `view` | `{section, moon, well, past, pick, drill}`, resolved | | `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `credits` | balance | | `credits` | balance |
| `resources`, `refined` | `[{name, amount}]` | | `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon | | `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false | | `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet | | `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end | | `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `moons`, `past` | `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") | | `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) | | `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` | | `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `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"); `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` | | `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}?}` | | `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?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `chart` and `spark` only for `s=market` (or `include=market`) | | `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`) |
| `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 `""`) | | `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` | | `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) | | `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) | | `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") | | `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` | | `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art from that tier on); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) | | `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` | | `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
@@ -138,7 +145,6 @@ short code, listed with each field, and the web's text next to it.
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle` - Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill. of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `image`: the tool's art at its tier, like `state.tools`.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}], - `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts` canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's (`""` when it can be bought), `short` the credits missing; `blockedText` the button's
@@ -193,14 +199,31 @@ inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText`
### advisors (`s=advisors`) ### advisors (`s=advisors`)
``` ```
{seats, hired, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText, {seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford, bench, bonuses, buySeat, buySeatWhyText, selected,
canHire, why, whyText}]} advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
``` ```
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is `buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why`: `hired` ("On your board"), `seats` ("All seats taken. Buy disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
another seat first"), `credits` ("Need ₥X more") or `""`. taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
dismissed).
The web's panel for a hired advisor (any client may do the same): for every other seat,
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
### leaderboard (`s=leaderboard`) ### leaderboard (`s=leaderboard`)
@@ -222,12 +245,15 @@ balance after buying, `need` the credits missing, `whyText` why the button is di
``` ```
{moonId, name, kind, description, rating, headline, sub, {moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}]} worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
``` ```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market `headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`. `fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
### buyModal (in the `buy-tool` response) ### buyModal (in the `buy-tool` response)
@@ -258,7 +284,7 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
## Texts ## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `newGame`): `{title, text, ok, danger}`, Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g. the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its `{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
@@ -267,11 +293,8 @@ a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.no
Not sent (the client words them from the numbers, like the web's templates): Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than - moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`). supply" (`over`).
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after - deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`). current drills" (`well.idle[].short`, `here`, `free`).
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools."). - upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats: The web's formats:
@@ -318,7 +341,7 @@ Content-Type: application/json
"state": { "state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9", "userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265, "now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "pick": "", "drill": ""}, "view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
"credits": 1644.63, "credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}], "resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [], "refined": [],
@@ -332,6 +355,7 @@ Content-Type: application/json
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240, "drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
"finished": false, "finishedAt": null}], "finished": false, "finishedAt": null}],
"past": [], "past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5, "image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}], "reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
@@ -354,7 +378,7 @@ Content-Type: application/json
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [], "showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"}, "rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]}, "catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "7d983412e77a3dde" "catalogVersion": "9adb4eabab929f5d"
} }
} }
``` ```
@@ -367,7 +391,7 @@ From a live server (long ids and arrays shortened).
```json ```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", {"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1, "image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true, "cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0}, "stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1, "preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
@@ -409,12 +433,19 @@ From a live server (long ids and arrays shortened).
`s=advisors`, `state.advisors`: `s=advisors`, `state.advisors`:
```json ```json
{"seats": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true, {"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
"bonuses": [], "seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
"buySeatWhyText": "",
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png", "advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000, "description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "hired": false, "canAfford": true, "effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
"canHire": true}, "…"]} "hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
"canSeat": false, "seatWhy": "", "seatWhyText": "",
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
"ok": "Dismiss", "danger": true}, "removed": false},
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
``` ```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}` `s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
@@ -433,7 +464,8 @@ From a live server (long ids and arrays shortened).
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88}, "rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79, "headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
"traits": [], "maxSupply": 90, "fullAt": 84, "traits": [], "maxSupply": 90, "fullAt": 84,
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"]} "wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
``` ```
`POST /api/act/buy-tool`, `buyModal`: `POST /api/act/buy-tool`, `buyModal`:
+86 -14
View File
@@ -63,7 +63,6 @@ type APITierUp struct {
ClaimBoost float64 `json:"claimBoost"` ClaimBoost float64 `json:"claimBoost"`
Reach int `json:"reach"` // wells per cycle from this tier on Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
} }
type APIEffect struct { type APIEffect struct {
@@ -148,8 +147,7 @@ func apiCatalog(s *State) *APICatalog {
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}} Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades { for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost, t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)), Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
Image: itemImages + item.ImageAt(u.Tier)})
} }
cat.Tools = append(cat.Tools, t) cat.Tools = append(cat.Tools, t)
} }
@@ -454,7 +452,7 @@ func apiEquipment(p *Page) *APIEquipment {
for _, row := range p.Equipment { for _, row := range p.Equipment {
b := s.toolBonus(row.ID) b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name), t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle, Image: itemImages + row.Item.Image, Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear), Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected, Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row), Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
@@ -728,16 +726,27 @@ func apiRefinery(p *Page) *APIRefinery {
type APIAdvisors struct { type APIAdvisors struct {
Seats int `json:"seats"` Seats int `json:"seats"`
Hired int `json:"hired"` Hired int `json:"hired"` // seats filled
Benched int `json:"benched"` // hired, not seated
MaxSeats int `json:"maxSeats"` MaxSeats int `json:"maxSeats"`
NextSeat int `json:"nextSeat"` NextSeat int `json:"nextSeat"`
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
CanBuySeat bool `json:"canBuySeat"` CanBuySeat bool `json:"canBuySeat"`
SeatFree bool `json:"seatFree"` SeatFree bool `json:"seatFree"`
FreeSeat int `json:"freeSeat"` // the lowest empty seat, 0 if none
SeatList []APISeat `json:"seatList"` // every seat, from 1
Bench []string `json:"bench"` // hired, not seated, in the web's order (hire order)
Bonuses []APIBonusRow `json:"bonuses"` Bonuses []APIBonusRow `json:"bonuses"`
Advisors []APIAdvisorUp `json:"advisors"` Advisors []APIAdvisorUp `json:"advisors"`
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
Selected string `json:"selected"` // the advisor of the web's panel (view.adv), "" if none
}
// APISeat is a seat and who sits in it ("" empty).
type APISeat struct {
N int `json:"n"`
Advisor string `json:"advisor"`
} }
type APIAdvisorUp struct { type APIAdvisorUp struct {
@@ -748,26 +757,48 @@ type APIAdvisorUp struct {
Price float64 `json:"price"` Price float64 `json:"price"`
Effect APIEffect `json:"effect"` Effect APIEffect `json:"effect"`
Hired bool `json:"hired"` Hired bool `json:"hired"`
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
CanAfford bool `json:"canAfford"` CanAfford bool `json:"canAfford"`
CanHire bool `json:"canHire"` // not hired, a seat free, affordable CanHire bool `json:"canHire"` // not hired, a seat free, affordable
EffectText string `json:"effectText"` EffectText string `json:"effectText"`
Why string `json:"why"` // hired, seats (none free), credits; "" can hire Why string `json:"why"` // hired, seats (none free), credits; "" can hire
WhyText string `json:"whyText"` WhyText string `json:"whyText"`
// Hired and not seated: whether seat-advisor can seat them now (in freeSeat);
// seatWhy "seats" (none free) or "".
CanSeat bool `json:"canSeat"`
SeatWhy string `json:"seatWhy"`
SeatWhyText string `json:"seatWhyText"`
Dismiss *APIConfirm `json:"dismiss"` // hired: the dismiss-advisor dialog; null otherwise
Removed bool `json:"removed"` // hired but no longer in the config: no card on the web
} }
func apiAdvisors(p *Page) *APIAdvisors { func apiAdvisors(p *Page) *APIAdvisors {
ap := p.Advisors ap := p.Advisors
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat, a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}} SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
for _, slot := range ap.SeatList {
seat := APISeat{N: slot.N}
if slot.Advisor != nil {
seat.Advisor = slot.Advisor.Name
}
a.SeatList = append(a.SeatList, seat)
}
for _, c := range ap.Bench {
a.Bench = append(a.Bench, c.Name)
}
if ap.NextSeatPrice > 0 { if ap.NextSeatPrice > 0 {
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"} a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice) a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
} }
for _, c := range ap.Cards { up := func(c AdvisorCard) APIAdvisorUp {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image, up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)), Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()} Hired: c.Hired, Seat: c.Seat, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(),
EffectText: c.Advisor.Effect()}
if c.Image != "" {
up.Image = advisorImages + c.Image
}
switch { switch {
case c.Hired: case c.Hired:
up.Why, up.WhyText = "hired", "On your board" up.Why, up.WhyText = "hired", "On your board"
@@ -776,11 +807,42 @@ func apiAdvisors(p *Page) *APIAdvisors {
case !c.CanAfford: case !c.CanAfford:
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance) up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
} }
a.Advisors = append(a.Advisors, up) if c.Hired {
up.Dismiss = &APIConfirm{Title: "Dismiss advisor", Text: dismissConfirm(c), OK: "Dismiss", Danger: true}
if c.Seat == 0 {
up.CanSeat = ap.SeatFree()
if !up.CanSeat {
up.SeatWhy, up.SeatWhyText = "seats", ap.SeatWhy()
}
}
}
return up
}
for _, c := range ap.Cards {
a.Advisors = append(a.Advisors, up(c))
}
// Hired advisors no longer in the config (no bonus) come last.
for _, c := range append(seated(ap.SeatList), ap.Bench...) {
if cfg().advisor(c.Name) == nil {
r := up(c)
r.Removed = true
a.Advisors = append(a.Advisors, r)
}
} }
return a return a
} }
// seated lists the advisors in the seats.
func seated(seats []SeatSlot) []AdvisorCard {
var out []AdvisorCard
for _, slot := range seats {
if slot.Advisor != nil {
out = append(out, *slot.Advisor)
}
}
return out
}
// ------------------------------------------------------------------ leaderboard, help, new moon // ------------------------------------------------------------------ leaderboard, help, new moon
type APILeaderboard struct { type APILeaderboard struct {
@@ -880,6 +942,15 @@ type APIReveal struct {
MaxSupply float64 `json:"maxSupply"` // a full bar MaxSupply float64 `json:"maxSupply"` // a full bar
FullAt float64 `json:"fullAt"` // wells from here count as full FullAt float64 `json:"fullAt"` // wells from here count as full
Wells []APIRevealWell `json:"wells"` Wells []APIRevealWell `json:"wells"`
Next APINextMoon `json:"next"` // "Buy another": buy-moon again
}
// APINextMoon is the reveal card's "Buy another" button.
type APINextMoon struct {
Price float64 `json:"price"` // the next moon's price
CanBuy bool `json:"canBuy"` // affordable and available
Why string `json:"why"` // locked (hide the button), credits; "" can buy
WhyText string `json:"whyText"`
} }
type APIRevealWell struct { type APIRevealWell struct {
@@ -896,7 +967,8 @@ func apiReveal(r *MoonReveal) *APIReveal {
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description, a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
Rating: *apiRating(&r.Rating), Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply, Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{}} FullAt: r.FullAt, Wells: []APIRevealWell{},
Next: APINextMoon{Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
a.Traits = append(a.Traits, r.Traits...) a.Traits = append(a.Traits, r.Traits...)
for _, w := range r.Wells { for _, w := range r.Wells {
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt}) a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
+41 -14
View File
@@ -5,24 +5,16 @@ package main
import ( import (
"encoding/json" "encoding/json"
"io/fs"
"testing" "testing"
) )
func TestAPI(t *testing.T) { func TestAPI(t *testing.T) {
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
for _, item := range game.StoreItems {
for tier := 0; tier <= len(item.Upgrades); tier++ {
name := item.ImageAt(tier)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
}
}
}
unlocked := newPlayerState() unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"} unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} { asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) { for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s)) p := s.page(v.resolve(s))
testRanking(p) testRanking(p)
a := apiState(p) a := apiState(p)
@@ -62,7 +54,8 @@ func TestAPI(t *testing.T) {
} }
if p.Moon != nil { if p.Moon != nil {
// The reveal card and the buy modal, as an action response maps them. // The reveal card and the buy modal, as an action response maps them.
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" { if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
t.Errorf("view %+v: reveal %+v", v, r) t.Errorf("view %+v: reveal %+v", v, r)
} }
} }
@@ -90,6 +83,13 @@ func TestAPI(t *testing.T) {
if _, err := json.Marshal(all); err != nil { if _, err := json.Marshal(all); err != nil {
t.Fatalf("marshal %+v with every section: %v", v, err) t.Fatalf("marshal %+v with every section: %v", v, err)
} }
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
p.Advisors != nil && p.Advisors.Panel != nil
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
}
asides[a.View.Aside] = true
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well { if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
t.Errorf("view %+v: state has %+v", p.V, a.View) t.Errorf("view %+v: state has %+v", p.V, a.View)
} }
@@ -104,11 +104,18 @@ func TestAPI(t *testing.T) {
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) { if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
t.Errorf("view %+v: moon or well missing", v) t.Errorf("view %+v: moon or well missing", v)
} }
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
}
if (a.Market != nil) != s.Unlocked("market") { if (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market")) t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
} }
} }
} }
if !asides[true] || !asides[false] {
t.Errorf("views with and without the aside: %v", asides)
}
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState()))) a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" { if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section) t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
@@ -150,8 +157,11 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if len(w.Drills) > 0 && w.Label == "" { if len(w.Drills) > 0 && w.Label == "" {
fail("well label", w) fail("well label", w)
} }
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
fail("well note", w)
}
for _, d := range w.Drills { for _, d := range w.Drills {
if d.Fate == "" { if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
fail("drill fate", d) fail("drill fate", d)
} }
} }
@@ -160,6 +170,13 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
fail("moon sale", sale) fail("moon sale", sale)
} }
} }
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
fail("quote texts", q)
}
}
}
for _, b := range a.Bonuses { for _, b := range a.Bonuses {
if b.Label == "" || b.Text == "" { if b.Label == "" || b.Text == "" {
fail("bonus", b) fail("bonus", b)
@@ -217,6 +234,10 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire { if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
fail("advisor texts", up) fail("advisor texts", up)
} }
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
fail("advisor seat texts", up)
}
} }
} }
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm { if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
@@ -254,9 +275,15 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: refinery %+v", v, a.Refinery) t.Errorf("view %+v: refinery %+v", v, a.Refinery)
} }
case "advisors": case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) { if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
t.Errorf("view %+v: advisors %+v", v, a.Advisors) t.Errorf("view %+v: advisors %+v", v, a.Advisors)
} }
for _, h := range s.Hires {
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
}
}
case "leaderboard": case "leaderboard":
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 { if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard) t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
+13 -5
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction // ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and // loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are // combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action. // share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) { func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID) select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil { if err != nil {
return noBonus, err return noBonus, err
} }
@@ -143,20 +143,28 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ state // ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors and advisor seats into the state. // loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error { func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats) err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil { if err != nil {
return err return err
} }
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID) select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
if err != nil { if err != nil {
return err return err
} }
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil { if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
return err return err
} }
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, ` rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID) where user_id = $1 order by item`, s.UserID)
+7
View File
@@ -0,0 +1,7 @@
build/
tools/
.internal/
*.der
.DS_Store
dist/
__pycache__/
+506
View File
@@ -0,0 +1,506 @@
# AGENTS.md — Moon Miners client (Defold)
## What this is
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
server (the repo root `..`, see `../AGENTS.md`). Same layout and HUD look as the web build
(header with sections, resources and credits; moon list on the left; the current section
in the middle; aside with the moon viewport and the selected well), made game-native.
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later.
The server is the authority. The client shows the state the server sends and sends
actions. It may show an action's result before the answer (prediction, see Conventions),
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
`rules`; `../api.md` "Prediction"); the next response replaces it. No other game
rules in Lua. Countdowns may tick locally as display only.
## Stack
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
native extension, so a build sends the engine to Defold's build server once (needs
network; later builds reuse it until the dependencies change). Dependencies are fetched
only when `game.project` changes (`make resolve` forces it).
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
`../druid-notes.md`. Druid source: `~/Development/Refold/druid`, Defold source:
`~/Development/Refold/Defold` (read-only).
## Game screen: core, regions, sections
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
state: every new state (a response, a prediction) or a window/UI-scale change sets
`g.dirty`, and the next update drops the Druid instance and every layout node and builds
them again. `g` is the gui script's `self`.
Core (`screens/game/core/`):
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
the build orchestration (banner, header, moon list, main panel, aside; widths from
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
The aside shows only when the view selects something for it (`M.aside_shown`, the
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
the web's boot and suspended pages) shows until the first state.
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
words ("Something went wrong", "Can't reach the server") with the error sound; a
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
suspended screen.
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
(eased number); keyed so values carry over a rebuild.
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
`on_submit` and the refinery's quantity; reset every build), back / forward
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
scroll area under the mouse (else the focused one's, else the biggest;
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
Ctrl +/-/0 the UI scale.
`session.lua` keeps the view history (`g.history`, like the web's pushState).
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
clicks while it pops, and the claim predictor counts a drill whose end has passed on
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
Regions (one module each, so agents can work on them separately): `banner.lua`,
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
in the server's words, counted from its `end`),
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
fullscreen),
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
`advisors.lua`, the new-moon teaser in `reveal.lua`.
**Module contract** (regions and sections):
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
center their own capped content. Regions get their box; the header and banner return
their height. `H` = `g.h`.
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
for content under the viewport.
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before every rebuild
and when the screen goes away (cancel timers, drop references to nodes).
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
again every build), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
builds.
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
when the well or drill changes, both on the first state and after the reveal closes
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
staggered. Not on other rebuilds.
## Components (`components/`)
Build new UI from these instead of coordinate math. Each file's header documents its API.
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
or link button has hover and a test id. After a rebuild the button under the mouse
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
button with a test id: `button.carry`, the one with the same id under the pointer is
pressed in the new build), so the release still clicks it.
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
instance: survives rebuilds, runs actions in the game script, drops page hovers
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
only way to show a dialog (the Monarch popup screen is gone). Input invariant
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
a log with `ERROR:MM`).
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column on Druid Layout (measured text, `title`,
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
advisor). Store, Equipment and Advisors.
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
Header and moon list.
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
history).
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
`bar.ready_label` (gold "Ready" with a pulsing glow).
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
and time on g so it picks up on the rebuilt row).
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
window, hidden by a click or a dialog; on its own layer. Give information-only text a
tooltip like the web does.
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
card once complete). A long list (components/list with `focus_id`) is walked item by
item, scrolling, since its rows out of view don't exist. So anything interactive must register
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
widths yourself); refresh innermost layouts first, synchronously, after building
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
so make a clickable container before the buttons on it.
## Animation (`modules/anim.lua`)
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
`scale_about` (scale a pivot-NW node about its middle, like CSS),
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
content fades in from under a veil and rises, stagger), `group` (invisible container),
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
(scan line), `count`.
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
translucent fills), so content fades in from under a veil (a box in the background
color, `theme.color.veil` for panels) instead of fading a subtree.
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
saved in the settings file unless save is false): every helper skips movement and keeps
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
ticks); the reveal skips the charge and shows the card complete; the ticker stands
still. New animations must honor it.
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
quick repeats of the same gain merge into one number; others stack past it; wallet
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
error shakes the button it came from, found after the rebuild by its test id
(`fx:button`, registered by `H.action`)).
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
## Text and markup
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `&lt;` `&amp;`). `ui.style_text` (so
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
`resource.get_text_metrics`) understands markup.
## Layout
- `game.project`: settings; `[mm]` holds the client's own config (see below).
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
components.
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
Changing it rebuilds the engine on the build server.
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
`chart.lua`
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
unseat, dismiss; an unhired one's details on a card click),
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
whenever the shown state changes: at the click for a predicted action, in the request
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
moon sale register with `fx:box(node)` while built; give a new card-like container one.
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
moon). View links carry the current moon like the web's (`View.ToSection`): without
one the server picks the first moon.
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
file),
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
(hot reload).
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
clones them (`ui.clone`).
- `widgets/moon_row.lua`: the moon list row (Druid widget).
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
fade, ring, spark, speaker, sun and moon icons; tinted per node.
- The moon viewport: `modules/moon/terrain.lua` ports the server's `terrain.js`
(`scripts/moon-check.mjs` compares them), `modules/moon/moon.lua` is the API
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`),
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
HDR target with bloom; the viewport node shows it through the `moon` gui material.
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
`zzfx-render.mjs`.
## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
bob's output; `build/x86_64-linux/dmengine` is the engine with our extension).
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
- `make check`: checks (below).
- Before/after screenshots of every section (works on any build, also one from before a
change): `scripts/fixture.py <api> > fx.json`, then
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
after each other (the fixture's drills and market move on).
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
`mm.fake_reject`, shots via `SHOTS_DIR`).
- Never kill processes by name (scripts stop the game by its pid).
## Test driver (`modules/test.lua`, `scripts/drive.py`)
The engine's debug HTTP service (debug builds; port from the log line `Engine service
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
runs in the same Lua state as the gui script but outside it, so it only pushes commands
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
`$MM_DRIVE_DATA`), never the player's own settings.
- Elements: every interactive component registers a stable id while it's built:
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
over three frames through Druid: real input, hover included), `hover(id)`,
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
`restart()` (quit and start the game again, same options and saved files).
Those go through the test hook, which calls the screen's input function directly, so
they work even when the engine sends the game no input. Real input through the X server
(xdotool on the driver's Xvfb; key and button held 80 ms, since the engine polls once a
frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
page at once; the invariant's repair; args `reduced`, and run it with `--config
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
moon; the Moons tab returns to the last moon after other sections, back / forward, a
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
folder (`<out>/data`), never the real settings file. Run with `--config
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
grants through `/utils/players`); prints its ids as JSON.
## Fast iteration (hot reload)
`make dev` builds once and starts the game from the build folder (engine
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
registered first (a `run_script` listing them), then `.luac` files, then the game's
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
to those, to collections, game objects, `main.script`, materials, shaders or the render
script need a restart: `make dev-stop dev` (reload says so).
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
(core/main.lua) or lazily.
- `make reload` fails loudly on a compile error (and keeps the old code running).
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
## Checks (`make check`, scripts/check.py)
Fails on: 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
a fixture player in every section: symbols in sym, the rest in the text fonts); 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
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
- `api_base`: server URL (default `http://localhost:3000`). The player id is saved per
server.
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
- `view`: the view to open, `key=value&...` of `state.view` keys
(`section=equipment&drill=<id>`).
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
- `theme`: `light` or `dark` (default: the saved setting, else dark).
- `test`: `0` turns the test hook off in a debug build (it's never in release).
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
scale; not saved). Default: the saved setting (the display dialog), else 1.
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
--config mm.display_scale=2` looks like 1280x800).
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
the others on the command line.
## Conventions
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
not screens.
- Every gui_script uses one Druid instance (`druid.new(self)` and the
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
interaction (buttons, hover, scroll, input, layout, data list) through the components.
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
colors come from `theme.lua`.
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
1280x800 every cap is above what fits; keep it that way (compare shots).
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
is a token: no hex or vector colors in code (template node colors via `theme.template`,
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
tells you which).
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
`max_characters` raised in `game.project`. Text past the font batch limit silently
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
dialog) and images split font batches.
- Outline textures use slice9 2 (not 1).
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
status)`. The player id travels in `X-MM-User`. API: `../api.md`.
- The server is the only source of truth: every response's `state` replaces ours and
`state.view` becomes our view; actions send the current view plus their params and
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
when `nextEnd` is due.
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
refine, collect. Others (and predictors that return false) show a busy button.
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
counts through fx.
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
it. Run `make check` before calling it done.
- Keep changes small; don't touch the server (`..`) from here.
+77
View File
@@ -0,0 +1,77 @@
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
# python3 and luajit for shot, play, drive, dev and check.
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/bob.jar
PLATFORM := x86_64-linux
VARIANT ?= debug
BUNDLE := dist/$(PLATFORM)/MoonMiners
BIN := $(BUNDLE)/MoonMiners.x86_64
SHOT ?= dist/shot.png
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?=
.PHONY: build run shot play clean resolve dev reload dev-stop drive check
$(BOB):
mkdir -p tools
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
touch $@
build: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
--archive --bundle-output dist/$(PLATFORM) build bundle
# Hot reload loop: `make dev` starts the game from build/default (once), `make reload`
# rebuilds (no bundle) and posts what changed to the running engine. HEADLESS=1 make dev
# runs it under Xvfb. See AGENTS.md "Fast iteration".
dev: build
scripts/dev.py start $(ARGS)
reload: $(BOB)
@mkdir -p dist/dev
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
scripts/dev.py reload
dev-stop:
scripts/dev.py stop
# A scenario through the test hook (scripts/drive.py, AGENTS.md "Test driver") on a fresh
# build/default, e.g. make drive SCENARIO=smoke MM_API=http://localhost:3118 DRIVE=--fixture
SCENARIO ?= smoke
DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
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.
check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py
run: build
$(BIN) $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
play: build
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
clean:
rm -rf build dist .internal/cache
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Bold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Inter-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: "\342\211\210 !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\202\245\342\230\205\342\230\206\342\227\206\342\226\262\342\226\274\342\200\272\342\227\217\342\227\257\316\221\316\222\316\223\316\224\316\225\316\226\316\227\316\230\316\231\316\232\316\233\316\234\316\235\316\236\316\237\316\240\316\241\316\243\316\244\316\245\316\246\316\247\316\250\316\251\316\261\316\262\316\263\316\264\316\265\316\266\316\267\316\270\316\271\316\272\316\273\316\274\316\275\316\276\316\277\317\200\317\201\317\202\317\203\317\204\317\205\317\206\317\207\317\210\317\211"
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

+33
View File
@@ -0,0 +1,33 @@
Moon Miners sound effects (Defold client)
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
(credit not required, given anyway).
nav.wav Interface Sounds switch_007
toggle.wav Interface Sounds switch_005
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
buy.ogg Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
+ synth beep
upgrade.ogg Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
+ synth sweep
moon.ogg Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
deploy.ogg Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
refine.ogg Sci-Fi Sounds forceField_001 + computerNoise_000
collect.ogg Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
modal.wav, confirm.ogg, error.wav and ready.wav are synthesized with SoX (no samples).
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
https://kenney.nl/assets/impact-sounds
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js).
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Frank Force
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
images {
image: "/assets/images/white.png"
}
images {
image: "/assets/images/frame.png"
}
images {
image: "/assets/images/outline.png"
}
images {
image: "/assets/images/sphere.png"
}
images {
image: "/assets/images/glow.png"
}
images {
image: "/assets/images/fade.png"
}
images {
image: "/assets/images/ring.png"
}
images {
image: "/assets/images/speaker.png"
}
images {
image: "/assets/images/speaker_off.png"
}
images {
image: "/assets/images/spark.png"
}
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/moon.png"
}
margin: 2
extrude_borders: 2
inner_padding: 0
+85
View File
@@ -0,0 +1,85 @@
-- Progress and timer bars (the web's .drill-bar / .bar): a track and a fill. A timer bar
-- fills from start to end (ms, server clock) as display only, easing corrections (core
-- ticks, keyed); once full it takes the ready look: a soft glow in its color that breathes
-- (app.css .drill-bar.is-ready). A static bar shows a fraction.
--
-- bar.timer(g, parent, x, y, w, h, { start, ends, ready, color, none, key })
-- bar.value(parent, x, y, w, h, fraction, { color, track })
-- bar.ready_label(parent, text, x, y, pivot) -- "READY" in gold, its glow pulsing
-- -- (app.css .ready, 1.8 s)
local ui = require("modules.ui")
local anim = require("modules.anim")
local ticks = require("screens.game.core.ticks")
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The ready look on a fill: full, in its own color (cyan, amber when short), with a glow
-- under it that breathes (app.css .drill-bar.is-ready). A drill that gets nothing has no
-- glow (.is-none.is-ready).
local function ready_look(parent, fill, x, y, w, h, color, none)
if none then
return
end
local glow = ui.rect(parent, x - 4, y - 4, w + 8, h + 8, color, 0.25)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, fill)
anim.breathe(glow, "color.w", 0.35, 0.12, 1.8)
end
-- A timer bar. opts: start, ends (ms), ready (already done: full, glowing), color (default
-- "cyan"), track (default "line"), none (the drill gets nothing: an amber dashed track, a
-- muted fill, no glow; app.css .drill-bar.is-none), key (eases corrections; core ticks).
-- Returns the fill node.
function M.timer(g, parent, x, y, w, h, opts)
local color = opts.color or "cyan"
if opts.none then
color = "muted"
for dx = 0, w - 1, 4 do
ui.rect(parent, x + dx, y, math.min(2, w - dx), h, "ready", 0.45)
end
else
ui.rect(parent, x, y, w, h, opts.track or color, opts.track and 1 or 0.16)
end
local fill = ui.rect(parent, x, y, w, h, color, opts.none and 0.7 or 1)
if opts.ready then
ticks.tick_bar(g, fill, 0, 0, w, opts.key)
ready_look(parent, fill, x, y, w, h, color, opts.none)
else
ticks.tick_bar(g, fill, opts.start, opts.ends, w, opts.key, function()
-- Full at the local zero (display only; the server says when it's claimable).
ready_look(parent, fill, x, y, w, h, color, opts.none)
end)
end
return fill
end
-- A static bar showing `fraction` (0..1). opts: color, track, min (smallest fill width).
function M.value(parent, x, y, w, h, fraction, opts)
opts = opts or {}
ui.rect(parent, x, y, w, h, opts.track or "line")
local fw = math.floor(w * math.max(0, math.min(1, fraction)))
if fw > 0 then
return ui.rect(parent, x, y, math.max(opts.min or 0, fw), h, opts.color or "cyan")
end
end
-- "Ready" in gold whose glow pulses (a soft glow sprite behind it, breathing like the
-- web's text-shadow). x, y, pivot like ui.text.
function M.ready_label(parent, text, x, y, pivot, px)
local style = { font = "sans_semi", px = px or 13, color = "ready", tracking = 0.1, upper = true }
local t = ui.text(parent, text or "Ready", style, x, y, pivot)
local w = ui.text_width(t)
local left = (pivot == gui.PIVOT_E and x - w) or (pivot == gui.PIVOT_CENTER and x - w / 2) or x
local glow = ui.rect(parent, left - 12, y - 11, w + 24, 22, "ready", 0)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, t)
anim.breathe(glow, "color.w", 0, 0.3, 1.8)
return t
end
return M
+209
View File
@@ -0,0 +1,209 @@
-- Button (the web's .btn): a filled box with an edge and an uppercase label, optional
-- price ("· ₥1,234"). Variants: "secondary" (default, .btn), "primary" (.btn-primary),
-- "redrill" (.btn-redrill, violet), "danger" (.btn-danger), "ghost" (.btn-dashed: faint,
-- for "+ New moon"; opts.on lights it). States, eased like a CSS transition (120 ms):
-- hover (mouse over), press (held down), disabled (dimmed, no clicks), busy (the web's
-- aria-busy: dimmed, label hidden, a spinner; set by core/hooks while an unpredicted
-- action waits for the server).
--
-- local b = button.new(g, parent, "Sell", x, y, { variant = "danger", small = true,
-- on_click = fn, id = "sell-moon" }) -- draw + behavior; b.root, b.w, b.h
-- button.draw(parent, label, x, y, opts) -- just the nodes (root, nodes, w)
-- button.bind(g, root, nodes, opts) -- behavior on drawn nodes (what
-- -- H.action and H.link use)
--
-- opts (draw): variant, small, w, h, price, accent (the price's color), disabled, on;
-- fill / edge / text ({ color, alpha } or a color name) override the variant's colors
-- (hover then brightens the given fill).
-- opts (bind): on_click, id (test id, modules/test), label (for the test hook; default
-- the label text), zone (stencil node clipping it, when not under a scroll's view), druid
-- (another Druid instance than g.druid: the dialog's).
-- After a rebuild the button under the mouse shows its hover at once (core/main replays
-- the last pointer position; M.instant makes that skip the easing). A press that a rebuild
-- interrupts carries over, for every Druid button with a test id: the one with the same
-- id under the pointer is pressed in the new build, so the release still clicks it
-- (M.carry). opts.hit: a node to take the clicks instead of root (a Claim button popping
-- in is scaled down: core/hooks).
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local helper = require("druid.helper")
local druid_button = require("druid.base.button")
local S = theme.size
local M = {}
local FADE = 0.12
-- What ui.button drew, by root node (variant, disabled, parts).
local looks = ui.button_looks
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local col = ui.color_spec
-- Ease (or set, when M.instant or reduced motion) a node's color.
local function tint(node, c, instant)
gui.cancel_animations(node, "color")
if instant or M.instant or anim.reduced then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, FADE)
end
end
-- Draw a button (ui.button). Returns root, nodes, width.
M.draw = ui.button
-- The state look: "normal", "hover", "press".
local function show(root, state, instant)
local look = looks[root]
if not look or look.disabled or look.busy then
return
end
local function c_of(x)
return type(x) == "userdata" and x or col(x)
end
local c = state == "press" and c_of(look.v.press) or (state == "hover" and c_of(look.v.hover) or look.fill)
tint(root, c, instant)
end
-- Behavior on a drawn button (or any node: then only clicks, no looks). Returns the Druid
-- button.
function M.bind(g, root, nodes, opts)
opts = opts or {}
local look = looks[root]
local b = (opts.druid or g.druid):new_button(opts.hit or root, function()
if opts.on_click and not (look and look.busy) then
opts.on_click()
end
end):set_animations_disabled()
if opts.hit then
test.name(opts.hit, opts.id)
end
if opts.zone then
b:set_click_zone(opts.zone)
end
if look and not look.disabled then
b.hover.on_mouse_hover:subscribe(function(_, on)
look.hovered = on
show(root, on and "hover" or "normal")
end)
b.hover.on_hover:subscribe(function(_, down)
show(root, down and "press" or (look.hovered and "hover" or "normal"), down)
end)
end
local label = opts.label
if not label and look then
label = gui.get_text(look.text)
end
test.register(opts.id, root, opts.kind or (look and "button" or "link"), label, opts.zone)
return b
end
---------------------------------------------------------------- a press across a rebuild
-- Every Druid button (these, tabs, rows, the moon list's widgets, raw ones), wherever it
-- is made: the ones made since the build began, and the one the mouse went down on, by
-- its test id (modules/test id_of). A rebuild drops the pressed Druid button, so the
-- release would click nothing (the web's element survives an htmx morph); M.carry
-- presses its successor instead.
local carry = { made = {}, pressed = nil }
druid_button.mm_hook = function(b)
carry.made[#carry.made + 1] = b
b.on_pressed:subscribe(function()
carry.pressed = { node = b.node, t = socket.gettime() }
end)
end
if not druid_button.mm_init then -- (once: the class outlives a hot reload of this file)
druid_button.mm_init = druid_button.init
druid_button.init = function(self, ...)
druid_button.mm_init(self, ...)
if druid_button.mm_hook then
druid_button.mm_hook(self)
end
end
end
-- A build begins (core/main): buttons made from now on are its.
function M.begin_build()
if carry.pressed and not carry.pressed.id then
carry.pressed.id = test.id_of(carry.pressed.node) -- (before the old nodes go)
end
carry.made = {}
end
-- The mouse went down (before Druid sees it) or up: no press to carry.
function M.release()
carry.pressed = nil
end
-- After a rebuild, with the mouse still down at (x, y) (screen) on a button of the build
-- before: press the new button with its id under the pointer, so the release clicks it.
function M.carry(x, y)
local p = carry.pressed
if not p or not p.id then
return
end
for _, b in ipairs(carry.made) do
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true)
and helper.pick_node(b.node, x, y, b.click_zone) then
b.can_action = true
b.last_pressed_time = p.t
b.hover:set_hover(true)
carry.pressed = { node = b.node, id = p.id, t = p.t }
return true
end
end
end
-- Draw and bind. Returns { root, nodes, w, h, button }.
function M.new(g, parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = M.draw(parent, label, x, y, opts)
local b = { root = root, nodes = nodes, w = w, h = gui.get_size(root).y }
if not opts.disabled then
b.button = M.bind(g, root, nodes, opts)
end
return b
end
-- Busy (the web's aria-busy + spinner): dimmed, label hidden, a rotating square.
function M.set_busy(root)
local look = looks[root]
local size = gui.get_size(root)
if look then
look.busy = true
for _, p in ipairs(look.parts) do
local c = gui.get_color(p[1])
gui.set_color(p[1], vmath.vector4(c.x, c.y, c.z, 0.2))
end
local f = gui.get_color(root)
gui.set_color(root, vmath.vector4(f.x, f.y, f.z, f.w * 0.6))
local e = gui.get_color(look.edge)
gui.set_color(look.edge, vmath.vector4(e.x, e.y, e.z, e.w * 0.6))
end
local spin = look and look.variant == "primary" and "on_cyan" or "cyan_hi"
local sp = ui.outline(root, size.x / 2 - 7, size.y / 2 - 7, 14, 14, spin)
gui.set_pivot(sp, gui.PIVOT_CENTER)
gui.set_position(sp, vec(size.x / 2, -size.y / 2))
gui.animate(sp, "euler.z", -360, gui.EASING_LINEAR, 0.9, 0, nil, gui.PLAYBACK_LOOP_FORWARD)
end
-- Whether draw() made this node (a button with looks).
function M.is_button(root)
return looks[root] ~= nil
end
-- Error feedback on a button (the web's fx-shake: 0.4 s).
function M.shake(root)
anim.shake(root, 6, 0.4)
end
return M
+246
View File
@@ -0,0 +1,246 @@
-- 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
-- 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
-- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
-- Advisors use it.
--
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
-- c:image(item.image)
-- c:title(item.name)
-- c:para(item.description)
-- c:text("+3 per cycle", "small")
-- c:footer(52, function(f, w) ... build into f (0..w wide, 52 tall) ... end)
-- c.click = fn -- (optional) a click anywhere on the card; c.id its test id
-- c.selected = true -- (optional) cyan edge and inner line
-- c.edge_color = { "ready", 0.45 } -- (optional) another edge (a hired advisor), and
-- 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).
local theme = require("modules.theme")
local ui = require("modules.ui")
local images = require("modules.images")
local anim = require("modules.anim")
local button = require("components.button")
local panel = require("components.panel")
local S = theme.size
local M = {}
M.__index = M
local PAD = 14
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- Columns for a width, like the web's grid (minmax(250px, 1fr)): count and card width.
-- Cards at least card_min_w wide keep the image column on the left; narrower ones put the
-- image on top (the web's .card: a 200 px media strip, then the body).
function M.columns(inner)
local cols = math.max(1, math.floor((inner + S.gap) / (S.card_col_min + S.gap)))
return cols, math.floor((inner - (cols - 1) * S.gap) / cols)
end
local STACK_MEDIA = 200 -- the image strip's height on a stacked card (app.css .card-media)
-- Image column width for a card width.
function M.media_size(cw)
return math.max(120, math.min(156, math.floor(cw * 0.3)))
end
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none).
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 }, M)
c.root = ui.rect(parent, x, y, w, 1, "panel")
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
if stacked then
c.bw = w - PAD * 2
c.body = anim.group(c.root, PAD, m + 14, c.bw, 1)
else
c.bw = w - m - PAD * 2
c.body = anim.group(c.root, m + PAD, 12, c.bw, 1)
end
c.layout = g.druid:new_layout(c.body, "vertical"):set_padding(0, 0, 0, 0):set_margin(0, 0):set_hug_content(false, true)
return c
end
-- The image (a catalog URL) in the image column, square at the top (stacked: square in
-- the middle of the strip).
function M:image(url)
local node = ui.rect(self.media, self.stacked and math.floor((self.w - self.m) / 2) or 0, 0, self.m, self.m, "none")
images.set(node, url)
self.image_node = node
end
local function add(self, node, gap)
if gap and gap > 0 then
self:gap(gap)
end
self.layout:add(node)
return node
end
-- Vertical space.
function M:gap(px)
local n = anim.group(self.body, 0, 0, 1, px)
self.layout:add(n)
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.
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)
return t
end
-- The card's title (uppercase, semibold; a 22 px slot).
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).
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)
end
-- Any node (its size is what the layout counts; pivot NW or W).
function M:node(n, gap)
gui.set_parent(n, self.body, false)
return add(self, n, gap)
end
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
-- or c:block(fn, gap) where fn returns the height it used.
function M:block(h, fn, gap)
if type(h) == "function" then
h, fn, gap = nil, h, fn
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)
end
-- Something clickable inside the card's text (a link): fn runs once the card's own click
-- is bound, so it gets its clicks first.
function M:after_bind(fn)
self.late = self.late or {}
self.late[#self.late + 1] = fn
end
-- The bottom block (buttons): h tall, built by fn(parent, w) once the row's height is
-- known; it sits at the bottom of the card.
function M:footer(h, fn)
self.footer_h, self.footer_fn = h, fn
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
else
self.natural = math.max(self.m, 12 + body_h + self.footer_h + 14)
end
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))
gui.set_size(self.edge, vec(self.w, h))
if self.stacked then
ui.rect(self.root, 0, self.m, self.w, 1, "line")
else
gui.set_size(self.media, vec(self.m, h))
ui.rect(self.root, self.m, 0, 1, h, "line")
end
if self.selected then
gui.set_color(self.edge, ui.color("cyan"))
ui.outline(self.root, 1, 1, self.w - 2, h - 2, "cyan", 0.6)
elseif self.edge_color then
gui.set_color(self.edge, ui.color_spec(self.edge_color))
end
-- Hover: the edge brightens (app.css .card:hover, 0.15 s). Bound before the footer's
-- buttons: Druid gives the last made input first, so they get their clicks.
local edge = self.edge
local base = gui.get_color(edge)
local b = button.bind(self.g, self.root, nil, { id = self.id, on_click = self.click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(edge, "color")
local lit = self.selected and ui.color("cyan") or (self.edge_color and ui.color_spec(self.edge_hover or self.edge_color)) or ui.color("line_hi")
gui.animate(edge, "color", on and lit or base, gui.EASING_OUTQUAD, 0.15)
end)
for _, fn in ipairs(self.late or {}) do
fn()
end
if self.footer_fn then
local f = anim.group(self.root, self.stacked and PAD or (self.m + PAD), h - 14 - self.footer_h, self.bw, self.footer_h)
self.footer_fn(f, self.bw)
end
end
-- A grid of cards from y; returns the y under it. make(c, item) fills one card.
function M.grid(g, parent, x, y, inner, items, make)
local cols, cw = M.columns(inner)
local i = 1
while i <= #items do
local row = {}
for col = 1, cols do
local item = items[i]
if not item then
break
end
local c = M.new(g, parent, x + (col - 1) * (cw + S.gap), y, cw)
make(c, item)
c:refresh()
row[#row + 1] = c
i = i + 1
end
local rh = 0
for _, c in ipairs(row) do
rh = math.max(rh, c.natural)
end
for _, c in ipairs(row) do
c:set_height(rh)
if g.frames then
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
end
if g.fx then
g.fx:box(c.root) -- (an action's sweep covers the card)
end
end
y = y + rh + S.gap
end
return y
end
return M
+36
View File
@@ -0,0 +1,36 @@
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
-- a short label, optionally filled. Returns the box node and its width.
--
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
--
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
-- pad (6 each side), min_w, right (x is the right edge).
local ui = require("modules.ui")
local M = {}
function M.new(parent, label, x, y, opts)
opts = opts or {}
local color = opts.color or "line_hi"
local style = { font = opts.font or "sans_semi", px = opts.px or 11, color = opts.text or color,
tracking = opts.tracking or (opts.font == "mono" and 0 or 0.12), upper = opts.upper }
local h = opts.h or 18
local pad = opts.pad or 6
local probe = ui.text(parent, label, style, 0, 0)
local w = math.max(opts.min_w or 0, math.floor(ui.text_width(probe) + pad * 2))
gui.delete_node(probe)
local left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
gui.move_below(f, box)
end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w
end
return M
+325
View File
@@ -0,0 +1,325 @@
-- Dialog (the web's .dialog): a modal panel over the game screen, in the same gui (on its
-- own layer, with its own Druid instance), so a confirmed action runs in the game
-- script and a page rebuild doesn't touch it. One at a time: g.dialog (core/main.lua).
--
-- g.dialog:open({ title, message, w, h,
-- ok = { label, price, danger, act = "<action>", params = {...} } or { label, on_ok = fn },
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
-- In build, d.reply(action, params) closes and runs an action (effects start from the
-- clicked button); d:button(parent, label, x, y, opts) makes a button (components/button
-- opts plus on_click / id). The message wraps (ui.wrap, may hold ₥); the panel grows to
-- fit it. Test ids: "dialog:ok", "dialog:cancel".
--
-- Motion (app.css dialog-in/-out, ui.js): the backdrop fades in (0.28 s); the panel
-- scales up from .94, rises 8 px and unclips from its middle (0.28 s overshoot); a cyan
-- scan line sweeps down it; title, message and buttons rise in, 60 ms apart. Closing:
-- fade, scale .97, clip to the middle (0.18 s). Sounds: open (a confirm) or modal (custom
-- content, after the action's own), confirm on OK, close otherwise. While open the page
-- gets no input and its hovers are dropped (druid:reset_hovers); they come back on close.
local druid = require("druid.druid")
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local sfx = require("modules.sfx")
local button = require("components.button")
local test = require("modules.test")
local S = theme.size
local M = {}
M.__index = M
local W0, H0 = 460, 230
local SCRIM = theme.color.scrim
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- g: the game screen; layer: a node over the page (top-left = page top-left).
function M.new(g, layer)
return setmetatable({ g = g, layer = layer }, M)
end
function M:is_open()
return self.root ~= nil and not self.closing
end
-- A button inside the dialog (its own Druid instance). opts: components/button draw
-- options plus on_click, id.
function M:button(parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = ui.button(parent, label, x, y, opts)
local d = self
if not opts.disabled then
button.bind(self.g, root, nodes, { id = opts.id, druid = self.druid, on_click = function()
d.last_button = root
if opts.on_click then
opts.on_click()
end
end })
end
return root, nodes, w
end
-- Close and run an action on the game screen (its effects start at `from`, a node, or
-- at the page button that opened the dialog: ok.from, a rect from fx:rect_of with its
-- test id). quiet: close without the confirm sound (the buy modal's buttons, main.js).
function M:act(name, params, from, quiet)
local g = self.g
local opener = self.opts and self.opts.ok and self.opts.ok.from
if opener then
g.fx.last_origin = { x = opener.x, y = opener.y, w = opener.w, h = opener.h, id = opener.id }
else
g.fx:origin(from)
end
self:close(quiet and "quiet" or "ok")
require("screens.game.core.session").act(g, name, params or {})
end
function M:open(opts)
if self.root then
self:remove()
end
local g = self.g
opts = opts or {}
self.opts = opts
self.closing = false
self.druid = druid.new(g)
test.begin_build("dialog")
test.set_scope("dialog")
g.druid:reset_hovers()
local W, H = g.size[1], g.size[2]
local w = opts.w or W0
local d = self
self.reply = function(name, params)
d:act(name, params, d.last_button, true)
end
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
gui.set_position(clip, vec(0, 0))
gui.set_pivot(clip, gui.PIVOT_CENTER)
gui.set_clipping_mode(clip, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(clip, false)
self.clip, self.w, self.h = clip, w, h
local box = ui.panel(clip, -w / 2, -h / 2, w, h)
gui.set_color(box, ui.color("field", 0.96))
self.box = box
-- Input: Druid gives the last created component input first, so: the backdrop (a
-- click on it cancels), then a blocker over the panel, then the panel's buttons.
self.druid:new_button(scrim, function()
d:close("cancel")
end):set_animations_disabled()
self.druid:new_blocker(clip)
-- (Druid's back handler takes key_back only: Esc is a hotkey.)
self.druid:new_back_handler(function()
d:close("cancel")
end)
self.druid:new_hotkey("key_esc", function()
d:close("cancel")
end)
-- Title, message (or custom content), buttons: three groups that rise in turn.
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)
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)
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, {
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, {
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,
accent = danger and "danger" or nil,
on_click = function()
if ok.on_ok then
d:close("ok")
ok.on_ok()
elseif ok.act then
d:act(ok.act, ok.params, ob)
else
d:close("ok")
end
end,
})
gui.set_position(ob, vec(w - 24 - cw - 10 - ow, -by))
end
self.by = by
test.set_scope("page")
self:animate_in(g_title, g_body, g_actions)
if opts.build then
sfx.play("modal", { delay = 150 })
else
sfx.play("open")
end
end
function M:animate_in(...)
local groups = { ... }
local holder, clip, scrim = self.holder, self.clip, self.scrim
gui.set_color(scrim, vmath.vector4(SCRIM.x, SCRIM.y, SCRIM.z, 0))
gui.animate(scrim, "color.w", SCRIM.w, anim.ease.ease_out, 0.28)
-- Content fades in from under veils in the panel's color (anim.veil): title, message,
-- buttons in turn.
local field = ui.color("field")
local w, h, by = self.w, self.h, self.by
local regions = { { 2, 48 }, { 50, by - 60 }, { by - 10, h - by + 8 } } -- (the edge stays)
for i, r in ipairs(regions) do
anim.veil(self.box, 2, r[1], w - 4, r[2], field, (i - 1) * 0.06, anim.reduced and 0.12 or 0.35)
end
if anim.reduced then
return
end
local p = gui.get_position(holder)
gui.set_scale(holder, vmath.vector3(0.94, 0.94, 1))
gui.animate(holder, "scale", vmath.vector3(1, 1, 1), anim.ease.overshoot, 0.28)
gui.set_position(holder, vec(p.x, p.y - 8))
gui.animate(holder, "position.y", p.y, anim.ease.overshoot, 0.28)
local size = gui.get_size(clip)
gui.set_size(clip, vec(size.x, size.y * 0.04))
gui.animate(clip, "size", size, anim.ease.overshoot, 0.28)
anim.sweep(self.box, self.w, self.h, ui.color("cyan"), 0.7, 0.05)
for i, grp in ipairs(groups) do
anim.rise(grp, (i - 1) * 0.06, 6, 0.35) -- rise-in: 0.35 s, 60 ms apart
end
end
-- value: "ok" (the confirm sound), "quiet" (none: the action makes its own) or "cancel".
function M:close(value)
if not self.root or self.closing then
return
end
self.closing = true
self.closing_at = socket.gettime()
if value ~= "quiet" then
sfx.play(value == "ok" and "confirm" or "close")
end
local d = self
local function done()
d:remove()
end
local dur = anim.reduced and 0.12 or 0.18
gui.animate(self.scrim, "color.w", 0, anim.ease.ease_in, dur, 0, done)
anim.veil(self.box, 2, 2, self.w - 4, self.h - 4, ui.color("field"), 0, dur, true)
if not anim.reduced then
gui.animate(self.holder, "scale", vmath.vector3(0.97, 0.97, 1), anim.ease.ease_in, 0.18)
local size = gui.get_size(self.clip)
gui.animate(self.clip, "size", vec(size.x, size.y * 0.04), anim.ease.ease_in, 0.18)
end
self.g.rehover = true -- the page's hover comes back under the pointer
end
-- Gone at once (a new dialog, the screen going away).
function M:remove()
if self.druid then
-- A dialog gone in the frame it opened (another one replacing it): Druid's late
-- init (a timer.delay(0)) would still run on its deleted nodes.
if self.druid._late_init_timer_id then
timer.cancel(self.druid._late_init_timer_id)
self.druid._late_init_timer_id = nil
end
self.druid:final()
self.druid = nil
end
if self.root then
gui.delete_node(self.root)
end
test.begin_build("dialog")
self.root, self.closing, self.opts, self.closing_at = nil, false, nil, nil
end
-- How long a close may take before its end is forced (the close animation is 0.18 s; its
-- callback is what removes the backdrop, and while the backdrop exists the page gets no
-- input).
local CLOSE_LIMIT = 1
-- The input invariants (checked every frame by core/main.lua): what blocks the page's
-- input exists only while a dialog is open or closing, and a close finishes. Returns a
-- description of what was wrong (and is now repaired), or nil.
function M:check()
if not self.root then
if self.druid then
self.druid:final()
self.druid = nil
return "a dialog's Druid instance outlived it"
end
return nil
end
if not self.druid then
self:remove()
return "a dialog backdrop without its input"
end
if self.closing and socket.gettime() - (self.closing_at or 0) > CLOSE_LIMIT then
self:remove()
return "a dialog close never finished"
end
return nil
end
-- The page was resized: keep the backdrop full and the panel centered.
function M:layout(W, H)
if not self.root then
return
end
gui.set_size(self.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y")
gui.set_position(self.holder, vec(W / 2, -H / 2))
end
function M:update(dt)
if self.druid then
self.druid:update(dt)
end
end
-- Returns true while open (the page gets no input then).
function M:on_input(action_id, action)
if not self.root then
return false
end
if self.druid and not self.closing then
self.druid:on_input(action_id, action)
end
return true
end
function M:on_message(message_id, message, sender)
if self.druid then
self.druid:on_message(message_id, message, sender)
end
end
return M
+96
View File
@@ -0,0 +1,96 @@
-- A long list (Druid Data List over Scroll + Static Grid, druid-notes.md 2.3): only the
-- rows in view exist, made by create() as they scroll in and deleted as they leave, so
-- hundreds of moons cost a screenful of nodes. Fixed row height; the scroll offset is kept
-- per key on g (g.scroll_pos) so it survives rebuilds; wheel and drag scroll.
--
-- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
-- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus)
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
--
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
-- places it. A Druid component returned with it (a widget) is removed with the row.
local ui = require("modules.ui")
local M = {}
M.__index = M
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
function M.new(g, parent, x, y, w, h, opts)
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = opts.create,
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
g.scroll_pos = g.scroll_pos or {}
l.view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(l.view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(l.view, false)
l.content = ui.rect(l.view, 0, 0, w, h, "none")
-- The grid's prefab: a node of the row's slot size (never shown).
l.prefab = ui.rect(l.view, 0, 0, w, opts.row_h, "none")
gui.set_enabled(l.prefab, false)
l.scroll = g.druid:new_scroll(l.view, l.content)
l.scroll:set_horizontal_scroll(false)
l.scroll:set_extra_stretch_size(0)
l.scroll.style.WHEEL_SCROLL_SPEED = 48
l.grid = g.druid:new_grid(l.content, l.prefab, 1)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index)
end)
l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset)
end
g.scroll_pos[l.key] = pos.y
l:place_thumb(pos.y)
end)
g.scroll_areas = g.scroll_areas or {}
g.scroll_areas[#g.scroll_areas + 1] = { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll }
return l
end
function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
self.items = items
self.data:set_data(items)
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
-- scroll event).
local s = self.scroll
s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then
a.max = max
end
end
-- A thin bar at the right edge (like components/scroll) when it overflows.
if self.track then
gui.delete_node(self.track)
gui.delete_node(self.thumb)
self.track, self.thumb = nil, nil
end
self.max = max
if max > 0 then
local track_h = self.h - 8
self.thumb_h = math.max(28, math.floor(track_h * self.h / (self.h + max)))
self.track = ui.rect(self.parent, self.x + self.w + 4, self.y + 4, 1, track_h, "line")
self.thumb = ui.rect(self.parent, self.x + self.w + 3, self.y + 4, 3, self.thumb_h, "cyan", 0.55)
end
self:place_thumb(self.scroll.position.y)
end
function M:place_thumb(pos_y)
if self.thumb then
local f = math.max(0, math.min(1, pos_y / self.max))
gui.set_position(self.thumb, vec(self.x + self.w + 3, -(self.y + 4 + (self.h - 8 - self.thumb_h) * f)))
end
end
return M
+47
View File
@@ -0,0 +1,47 @@
-- Panel (the web's .frame): a translucent box with a thin edge and cyan corner brackets
-- (gui/templates/panel.gui), and a body group the content goes in. enter() plays the
-- web's entrance (enter.js): the brackets spread from the middle, the content fades in
-- from under a veil and rises 6 px; frames nested in it (cards) follow, staggered.
--
-- local p = panel.new(parent, x, y, w, h) -- p.root (the fill), p.frame, p.body
-- ... build into p.body (top-left based, like any parent) ...
-- p:enter({ delay = 0, nested = { frames } }) -- once it's built (core/main.lua calls
-- -- it when the view changes)
-- panel.frame(node, edge, w, h) -- a nested frame (a card) for `nested`
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local M = {}
M.__index = M
function M.new(parent, x, y, w, h)
local root, nodes = ui.panel(parent, x, y, w, h)
local p = setmetatable({ root = root, frame = ui.part(nodes, "panel", "frame"), w = w, h = h }, M)
p.body = anim.group(root, 0, 0, w, h)
return p
end
-- A nested frame: a card (fill node holding its content, pivot NW) and its edge (an
-- outline over it). For enter's `nested` list.
function M.frame(node, edge, w, h)
return { node = node, edge = edge, w = w, h = h }
end
-- The entrance. opts: delay, clip (false: the box doesn't unclip; the aside, which holds
-- the 3D viewport), nested (frames inside, staggered after this one).
function M:enter(opts)
opts = opts or {}
local delay = opts.delay or 0
local veil = theme.color.veil
anim.enter_frame({ frame = self.frame, body = self.body, veil = { parent = self.root, color = veil },
x = 1, y = 1, w = self.w - 2, h = self.h - 2 }, { delay = delay, clip = false })
local frames = {}
for _, f in ipairs(opts.nested or {}) do
frames[#frames + 1] = { frame = f.edge, veil = { parent = f.node, color = veil }, x = 0, y = 0, w = f.w, h = f.h }
end
anim.enter(frames, { nested = true, delay = delay })
end
return M
+151
View File
@@ -0,0 +1,151 @@
-- List row (wells table rows, the moon list's rows): a full-width row that lights up
-- under the mouse (cyan wash, eased), shows its selection (wash and a 2 px cyan bar on
-- the left) and runs on_click. Buttons on the row made after it take their clicks first.
--
-- row.bind(g, node, w, h, { selected = true, on_click = fn, id = "well:<id>", label })
--
-- A new row's entrance (app.css fx-new: a moon just bought, a drill just deployed): it
-- slides in from 28 px right and fades in over a cyan wash (.35, .2 at 40%, then the
-- row's own fill), with a 3 px cyan bar on its left until halfway (0.9 s). Rows are
-- rebuilt with every state, so the entrance is kept by id and time on g and picks up
-- where it was on the rebuilt row.
--
-- local new = row.fresh(g, "moons", scope, ids) -- ids not there last build (same scope)
-- row.mark(g, id) -- start an entrance for id now
-- row.enter(g, id, node, w, h, { base = fill, slide = node, fade = fn, veil = color })
local ui = require("modules.ui")
local anim = require("modules.anim")
local button = require("components.button")
local M = {}
M.HOVER, M.SELECTED = 0.05, 0.08
function M.bind(g, node, w, h, opts)
opts = opts or {}
local base = ui.color("cyan", opts.selected and M.SELECTED or 0)
gui.set_color(node, base)
if g.fx then
g.fx:box(node) -- (an action's sweep covers the row its button is on)
end
if opts.selected then
ui.rect(node, 0, 0, 2, h - 1, "cyan")
end
local b = button.bind(g, node, nil, { id = opts.id, label = opts.label, on_click = opts.on_click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(node, "color")
local c = on and ui.color("cyan", opts.selected and M.SELECTED or M.HOVER) or base
if button.instant then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
return b
end
---------------------------------------------------------------- new rows
M.ENTER = 0.9
local function clock()
return socket.gettime()
end
-- Which of ids weren't there the last time fresh() ran for key (as a set), when scope
-- (e.g. the moon shown) is the same; nothing the first time or after scope changes.
function M.fresh(g, key, scope, ids)
g.row_known = g.row_known or {}
local was = g.row_known[key]
local now_set = {}
for _, id in ipairs(ids) do
now_set[id] = true
end
g.row_known[key] = { scope = scope, ids = now_set }
local new = {}
if was and was.scope == scope then
for _, id in ipairs(ids) do
if not was.ids[id] then
new[id] = true
end
end
end
return new, was and was.scope == scope and was.ids or nil
end
-- Start an entrance for id (at = when; default now).
function M.mark(g, id, at)
g.row_new = g.row_new or {}
g.row_new[id] = at or clock()
end
-- Play (or pick up) id's entrance on node (a row, w x h, pivot NW). opts: base (the
-- row's resting fill color, vector4; default transparent), slide (the node that slides;
-- default node), fade (fade(e): shows the content at e, 0..1, e.g. through the row's own veil), veil (without
-- fade: the background color a veil over the row fades from; default the panel's), bar
-- (false: no left bar).
function M.enter(g, id, node, w, h, opts)
local t0 = g.row_new and g.row_new[id]
if not t0 then
return
end
local age = clock() - t0
if age >= M.ENTER then
g.row_new[id] = nil
return
end
opts = opts or {}
local base = opts.base or ui.color("cyan", 0)
local cyan = ui.color("cyan")
local slide = opts.slide or node
local p0 = gui.get_position(slide)
local veil
if not opts.fade then
veil = ui.rect(node, 0, 0, w, h, opts.veil or "veil")
end
local bar = opts.bar ~= false and ui.rect(node, 0, 0, 3, h, "cyan") or nil
local ease = anim.fn.out
local function step(k)
local t = k * M.ENTER
local a = math.min(1, t / (M.ENTER * 0.4)) -- 0..40%: in
local e = ease(a)
-- The wash: .35 -> .2 (40%) -> the row's fill.
local wash
if a < 1 then
wash = vmath.lerp(e, ui.color("cyan", 0.35), ui.color("cyan", 0.2))
else
wash = vmath.lerp(ease((t - M.ENTER * 0.4) / (M.ENTER * 0.6)), ui.color("cyan", 0.2), base)
end
gui.set_color(node, wash)
if not anim.reduced then
gui.set_position(slide, vmath.vector3(p0.x + 28 * (1 - e), p0.y, p0.z))
end
if opts.fade then
opts.fade(e)
elseif veil then
anim.set(veil, "color.w", 1 - e)
end
if bar then
local b = k < 0.5 and 1 or 1 - ease((k - 0.5) / 0.5)
gui.set_color(bar, vmath.vector4(cyan.x, cyan.y, cyan.z, b))
gui.set_size(bar, vmath.vector3(3 * b, h, 0))
end
end
step(age / M.ENTER)
anim.tween(node, M.ENTER, step, -age, function()
pcall(gui.set_color, node, base)
pcall(gui.set_position, slide, p0)
if veil then
pcall(gui.delete_node, veil)
end
if bar then
pcall(gui.delete_node, bar)
end
if opts.fade then
pcall(opts.fade, 1)
end
end)
end
return M
+94
View File
@@ -0,0 +1,94 @@
-- A vertically scrolling area (Druid scroll in a stencil-clipped view) for content taller
-- than its panel: cards in the Store, Equipment and Advisors, the drill panel.
--
-- local area = scroll.area(self, parent, x, y, w, h, "store")
-- ... build into area.content (top-left based, like any parent) ...
-- area:finish(content_height)
--
-- Screens rebuild their layout on every new state, so the offset is kept per key in
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
-- thumb lights up under the mouse (the web's scrollbar hover). Areas register on
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
local ui = require("modules.ui")
local M = {}
local WHEEL = 48 -- pixels per wheel step
local BAR_W = 3
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Area = {}
Area.__index = Area
function M.area(self, parent, x, y, w, h, key)
local view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(view, false)
local content = ui.rect(view, 0, 0, w, h, "none")
self.scroll_pos = self.scroll_pos or {}
return setmetatable({ self = self, parent = parent, view = view, content = content,
x = x, y = y, w = w, h = h, key = key }, Area)
end
-- Call once the content is built: content_h is its height. Returns the Druid scroll (nil
-- when everything fits).
function Area:finish(content_h)
local self_, w, h = self.self, self.w, self.h
content_h = math.max(h, math.ceil(content_h))
gui.set_size(self.content, vec(w, content_h))
local max = content_h - h
if max <= 0 then
self_.scroll_pos[self.key] = 0
return nil
end
-- An eased key scroll that a rebuild cut short (core/keys) lands where it was going.
local goal = self_.scroll_goal
if goal and goal.key == self.key and socket.gettime() - goal.t < 1 then
self_.scroll_pos[self.key] = goal.y
self_.scroll_goal = nil
end
local y0 = math.max(0, math.min(self_.scroll_pos[self.key] or 0, max))
gui.set_position(self.content, vec(0, y0))
-- The bar sits outside the clipped view (on the parent), at the view's right edge.
local track_h = h - 8
local thumb_h = math.max(28, math.floor(track_h * h / content_h))
ui.rect(self.parent, self.x + w - BAR_W, self.y + 4, 1, track_h, "line")
local thumb = ui.rect(self.parent, self.x + w - BAR_W - 1, self.y + 4, BAR_W, thumb_h, "cyan", 0.55)
local function place(pos_y)
local f = math.max(0, math.min(1, pos_y / max))
gui.set_position(thumb, vec(self.x + w - BAR_W - 1, -(self.y + 4 + (track_h - thumb_h) * f)))
end
place(y0)
-- Hover: a wider invisible strip over the bar lights the thumb.
local hot = ui.rect(self.parent, self.x + w - 10, self.y + 4, 10, track_h, "none")
local hover = self_.druid:new_hover(hot)
hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(thumb, "color")
gui.animate(thumb, "color", ui.color("cyan", on and 1 or 0.55), gui.EASING_OUTQUAD, 0.12)
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
end)
local s = self_.druid:new_scroll(self.view, self.content)
self_.scroll_areas = self_.scroll_areas or {}
self_.scroll_areas[#self_.scroll_areas + 1] = { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s }
s:set_horizontal_scroll(false)
s:set_extra_stretch_size(0)
s.style.WHEEL_SCROLL_SPEED = WHEEL
s.on_scroll:subscribe(function(_, pos)
if self_.tearing_down then
return -- (a rebuild dropping the old scroll: keep the offset)
end
self_.scroll_pos[self.key] = pos.y
place(pos.y)
end)
return s
end
return M
+31
View File
@@ -0,0 +1,31 @@
-- Stat lines: a cap label with its value.
--
-- stat.inline(parent, "Yield", "+3", x, y, color) "YIELD +3" (y the middle); returns
-- its width (cards.stat)
-- stat.row(parent, "Supply left", "75", x, y, w, opts) label left, value right across
-- w (y the top); returns the y under
-- it (H.fact). opts: credits (₥ before
-- the value), suffix, color
local ui = require("modules.ui")
local M = {}
function M.inline(parent, label, value, x, y, color)
local l = ui.text(parent, label, "cap", x, y)
local lw = ui.text_width(l)
local v = ui.text(parent, value, { font = "mono", px = 14, color = color or "text" }, x + lw + 6, y)
return lw + 6 + ui.text_width(v)
end
function M.row(parent, label, value, x, y, w, opts)
ui.text(parent, label, "cap", x, y + 8)
if opts and opts.credits then
ui.credits(parent, value, x + w, y + 8, { pivot = gui.PIVOT_E, suffix = opts.suffix })
else
ui.text(parent, value, { font = "mono", px = 13, color = opts and opts.color or "text" }, x + w, y + 8, gui.PIVOT_E)
end
return y + 20
end
return M
+193
View File
@@ -0,0 +1,193 @@
-- Tabs: the web's nav tabs and their sliding ink (tabs.js, app.css "tab ink"). A strip's
-- ink is a highlight (transparent to cyan .1, top to bottom) with a glowing 2 px
-- underline under the active tab; when the active tab changes it slides from the old
-- one (220 ms, a slight overshoot). Its spot is kept per strip key on g (g.inks), so it
-- slides across the rebuild a navigation causes; a layout resize makes it jump.
--
-- local ink = tabs.ink(g, parent, "nav") -- before the tabs (it draws under them)
-- ... place the tabs ...
-- tabs.tab(g, root, text_node, { active = true, on_click = fn, id = "tab:market" })
-- ink:to(x, y, w, h) -- the active tab's box in parent
-- ink:none() -- no active tab (the ink goes)
--
-- tabs.tab: hover lights the label (nav -> text, eased), a click runs on_click; the label
-- keeps opts.color (default "nav", active "cyan").
--
-- A tab revealed after the page loaded (onboarding) enters once (app.css tab-reveal, 0.7 s
-- overshoot): it fades in, drops 8 px into place and grows from .9 about its middle, out
-- of a soft glow (the web's blur); kept by name and time on g across rebuilds.
-- tabs.revealed(g, "nav", names) -- once per build: which names are new
-- tabs.reveal(g, name, root, text, w, h) -- play / pick up its entrance
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local M = {}
local SLIDE = 0.22
local REVEAL = 0.7
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Ink = {}
Ink.__index = Ink
function M.ink(g, parent, key)
g.inks = g.inks or {}
local root = anim.group(parent, 0, 0)
-- Highlight: the fade texture flipped (transparent at the top).
local hl = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(hl, root, false)
gui.set_texture(hl, "ui")
gui.play_flipbook(hl, "fade")
gui.set_pivot(hl, gui.PIVOT_CENTER)
gui.set_rotation(hl, vmath.vector3(0, 0, 180))
gui.set_color(hl, ui.color("cyan", 0.1))
local glow = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_pivot(glow, gui.PIVOT_CENTER)
gui.set_color(glow, ui.color("cyan", 0.45))
local line = ui.rect(root, 0, 0, 1, 2, "cyan")
gui.set_enabled(root, false)
return setmetatable({ g = g, key = key, root = root, hl = hl, glow = glow, line = line }, Ink)
end
local function place(self, r)
gui.set_position(self.root, vec(r.x, -r.y))
gui.set_size(self.hl, vec(r.w, r.h))
gui.set_position(self.hl, vec(r.w / 2, -r.h / 2))
gui.set_size(self.glow, vec(r.w + 16, 18))
gui.set_position(self.glow, vec(r.w / 2, -(r.h - 1)))
gui.set_size(self.line, vec(r.w, 2))
gui.set_position(self.line, vec(0, -(r.h - 2)))
end
-- Show the ink on the active tab's box (x, y, w, h in the strip's parent).
function Ink:to(x, y, w, h)
local g = self.g
local to = { x = x, y = y, w = w, h = h, layout = g.size and (g.size[1] .. "x" .. g.size[2]) }
local from = g.inks[self.key]
g.inks[self.key] = to
gui.set_enabled(self.root, true)
if not from or from.layout ~= to.layout or anim.reduced
or (from.x == x and from.y == y and from.w == w and from.h == h) then
place(self, to)
return
end
-- Slide: from the old spot to the new one, a frame-driven tween (box sizes and
-- positions together).
place(self, from)
local ease = anim.fn.ink
anim.tween(self.root, SLIDE, function(k)
local e = ease(k)
place(self, { x = from.x + (x - from.x) * e, y = from.y + (y - from.y) * e,
w = from.w + (w - from.w) * e, h = from.h + (h - from.h) * e })
end)
end
-- No active tab: the ink goes (and comes back without a slide).
function Ink:none()
self.g.inks[self.key] = nil
gui.set_enabled(self.root, false)
end
-- Names not shown in the last build of this strip start their entrance now (none the
-- first time: what's there on load doesn't enter).
function M.revealed(g, key, names)
g.tabs_known = g.tabs_known or {}
g.tab_reveal = g.tab_reveal or {}
local known = g.tabs_known[key]
local now = socket.gettime()
local set = {}
for _, n in ipairs(names) do
set[n] = true
if known and not known[n] then
g.tab_reveal[key .. ":" .. n] = now
end
end
g.tabs_known[key] = set
end
-- The entrance of a revealed tab (root: pivot NW, w x h; text: its label), if it is
-- still playing. extra: more nodes that fade in with the label (a button's edge, price).
function M.reveal(g, key, name, root, text, w, h, extra)
local t0 = g.tab_reveal and g.tab_reveal[key .. ":" .. name]
if not t0 then
return
end
local age = socket.gettime() - t0
if age >= REVEAL then
g.tab_reveal[key .. ":" .. name] = nil
return
end
local p, s = gui.get_position(root), gui.get_scale(root)
local fill, tc = gui.get_color(root), gui.get_color(text)
local glow = gui.new_box_node(vec(w / 2, -h / 2), vec(w + 20, h))
local more = {}
for _, n in ipairs(extra or {}) do
more[#more + 1] = { n, gui.get_color(n) }
end
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.move_below(glow, text)
local function step(k)
local a = anim.fn.out(math.min(1, k / 0.5)) -- (opacity: most of it early)
for _, m in ipairs(more) do
local c = m[2]
gui.set_color(m[1], vmath.vector4(c.x, c.y, c.z, c.w * a))
end
if anim.reduced then
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
return
end
local e = anim.fn.overshoot(k)
anim.scale_about(root, vmath.vector3(p.x, p.y + 8 * (1 - e), p.z), s, w / 2, -h / 2, 0.9 + 0.1 * e)
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
gui.set_color(glow, ui.color("cyan_hi", 0.35 * a * (1 - k)))
end
step(age / REVEAL)
anim.tween(root, REVEAL, step, -age, function()
pcall(gui.set_position, root, p)
pcall(gui.set_scale, root, s)
pcall(gui.set_color, root, fill)
pcall(anim.set, text, "color.w", tc.w)
for _, m in ipairs(more) do
pcall(gui.set_color, m[1], m[2])
end
pcall(gui.delete_node, glow)
end)
end
-- A tab's behavior: hover lights its label, a click runs opts.on_click. opts: active,
-- color (the label's color when not hovered), on_click, id (test id), label.
function M.tab(g, root, text, opts)
opts = opts or {}
local base = ui.color(opts.color or (opts.active and "cyan" or "nav"))
local lit = ui.color(opts.active and "cyan" or "text")
local b = g.druid:new_button(root, function()
if opts.on_click then
opts.on_click()
end
end):set_animations_disabled()
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(text, "color")
local c = on and lit or base
if anim.reduced or require("components.button").instant then
gui.set_color(text, c)
else
gui.animate(text, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
test.register(opts.id, root, "tab", opts.label or gui.get_text(text))
return b
end
return M
+58
View File
@@ -0,0 +1,58 @@
[project]
title = MoonMiners
version = 0.1.0
dependencies#0 = https://github.com/Insality/defold-event/archive/refs/tags/21.zip
dependencies#1 = https://github.com/Insality/druid/archive/refs/tags/1.4.1.zip
dependencies#2 = https://github.com/britzl/monarch/archive/refs/tags/6.0.3.zip
[bootstrap]
main_collection = /main/main.collectionc
render = /render/mm.renderc
[display]
width = 1280
height = 800
high_dpi = 1
update_frequency = 60
vsync = 1
[render]
clear_color_red = 0.0157
clear_color_green = 0.0392
clear_color_blue = 0.0863
clear_color_alpha = 1
[input]
game_binding = /builtins/input/all.input_bindingc
[script]
shared_state = 1
[graphics]
max_font_batches = 1024
default_texture_min_filter = linear
default_texture_mag_filter = linear
max_characters = 32768
[gui]
max_count = 2048
[event]
use_xpcall = 1
[druid]
no_auto_input = 1
[mm]
api_base = http://localhost:3000
quit_after = 0
popup = 0
window =
ui_scale =
moon_id =
moon_spin = 1
player =
view =
[sound]
max_component_count = 64
+79
View File
@@ -0,0 +1,79 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.1
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 80.0000
y: -22.0000
}
size {
x: 150.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Button"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 80.0000
y: 20.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -6.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 14.0000
y: -10.0000
}
size {
x: 70.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "text"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+32
View File
@@ -0,0 +1,32 @@
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Label"
font: "sans"
id: "root"
pivot: PIVOT_W
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+180
View File
@@ -0,0 +1,180 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
alpha: 0.0
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 10.0000
y: -11.0000
}
size {
x: 36.0000
y: 36.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/sphere"
id: "planet"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 58.0000
y: -20.0000
}
size {
x: 150.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Moon"
font: "sans_semi"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 58.0000
y: -39.0000
}
size {
x: 150.0000
y: 18.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Idle"
font: "sans"
id: "sub"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 232.0000
y: -29.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_BOX
texture: "ui/outline"
id: "badge"
pivot: PIVOT_E
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: -11.0000
y: 0.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_TEXT
text: "1"
font: "mono"
id: "badge_text"
pivot: PIVOT_CENTER
parent: "badge"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+52
View File
@@ -0,0 +1,52 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.0549
y: 0.1255
z: 0.2275
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.55
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/frame"
id: "frame"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 14.0000
y: 14.0000
z: 14.0000
w: 14.0000
}
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+25
View File
@@ -0,0 +1,25 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 100.0000
y: 100.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 110.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 55.0000
y: -22.0000
}
size {
x: 110.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Tab"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -42.0000
}
size {
x: 110.0000
y: 2.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "underline"
pivot: PIVOT_NW
parent: "root"
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+208
View File
@@ -0,0 +1,208 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 520.0000
y: 52.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -51.0000
}
size {
x: 520.0000
y: 1.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "line"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 10.0000
y: -22.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 26.0000
y: -26.0000
}
size {
x: 120.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Well"
font: "sans"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 140.0000
y: 6.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar_bg"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 70.0000
y: 6.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 150.0000
y: -38.0000
}
size {
x: 140.0000
y: 16.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "supply"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 310.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Empty"
font: "sans"
id: "drill"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 510.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: ""
font: "mono"
id: "status"
pivot: PIVOT_E
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+143
View File
@@ -0,0 +1,143 @@
name: "main"
scale_along_z: 0
embedded_instances {
id: "main"
data: "components {\n"
" id: \"main\"\n"
" component: \"/main/main.script\"\n"
"}\n"
""
}
embedded_instances {
id: "game"
data: "components {\n"
" id: \"screen_proxy\"\n"
" component: \"/monarch/screen_proxy.script\"\n"
" properties {\n"
" id: \"screen_id\"\n"
" value: \"game\"\n"
" type: PROPERTY_TYPE_HASH\n"
" }\n"
"}\n"
"embedded_components {\n"
" id: \"collectionproxy\"\n"
" type: \"collectionproxy\"\n"
" data: \"collection: \\\"/screens/game/game.collection\\\"\\n"
"\"\n"
"}\n"
""
}
embedded_instances {
id: "sfx"
data: "embedded_components {\n"
" id: \"arrive\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/arrive.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"burst\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/burst.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"buy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/buy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"charge\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"coin\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/coin.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"collect\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/collect.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"confirm\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/confirm.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"deploy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/deploy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"error\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/error.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"harvest\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/harvest.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"modal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/modal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"moon\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/moon.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"nav\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/nav.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"open\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/open.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"powerdown\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/powerdown.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"ready\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/ready.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"refine\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/refine.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"shimmer\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/shimmer.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"tick\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/tick.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"toggle\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/toggle.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"upgrade\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/upgrade.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
""
}
+48
View File
@@ -0,0 +1,48 @@
-- Bootstrap: shows the game screen once the Monarch screen proxies have registered.
--
-- Debug flags (game.project [mm], or --config=mm.<key>=<value> at launch):
-- mm.quit_after seconds after which the game exits (used by `make shot`; 0: never)
-- mm.popup 1: open a dialog on start (to check it in a shot; core/main.lua)
-- mm.window WxH: resize the window on start (to check scaling; display.width and
-- display.height are the minimum layout size and stay 1280x800)
-- mm.ui_scale the UI scale, forced (not saved; modules/screen.lua)
-- mm.display_scale pretend the display has this scale (HiDPI checks; modules/screen.lua)
--
-- The window listener: sound goes silent while the window is unfocused or minimized
-- (modules/sfx set_hidden, like the web tab going hidden).
local monarch = require("monarch.monarch")
local api = require("modules.api")
local sfx = require("modules.sfx")
local screen = require("modules.screen")
local function on_window(self, event, data)
if event == window.WINDOW_EVENT_FOCUS_LOST or event == window.WINDOW_EVENT_ICONIFIED then
sfx.set_hidden(true)
elseif event == window.WINDOW_EVENT_FOCUS_GAINED or event == window.WINDOW_EVENT_DEICONIFIED then
sfx.set_hidden(false)
end
end
function init(self)
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
if w then
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
end
window.set_listener(on_window)
screen.apply_saved()
msg.post("#", "start")
local quit_after = sys.get_config_number("mm.quit_after", 0)
if quit_after > 0 then
timer.delay(quit_after, false, function()
sys.exit(0)
end)
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("start") then
monarch.show(hash("game"))
end
end
+1
View File
@@ -0,0 +1 @@
name: "MMWindow"
+136
View File
@@ -0,0 +1,136 @@
// mmwindow: a fullscreen toggle at runtime (Defold's window module has none; the engine
// reads display.fullscreen once at start). Desktop only: the engine's GLFW window (the
// current GL context's) goes to its monitor at the monitor's own video mode ("windowed
// fullscreen": no mode change) and back to the size and position it had.
//
// mmwindow.set_fullscreen(true | false)
// mmwindow.is_fullscreen() -> bool
//
// The GLFW functions are the engine's (statically linked); declared here, no header.
#define EXTENSION_NAME MMWindow
#define LIB_NAME "MMWindow"
#define MODULE_NAME "mmwindow"
#include <dmsdk/sdk.h>
#if defined(DM_PLATFORM_LINUX) || defined(DM_PLATFORM_WINDOWS) || defined(DM_PLATFORM_OSX)
#define MM_GLFW 1
extern "C" {
typedef struct GLFWwindow GLFWwindow;
typedef struct GLFWmonitor GLFWmonitor;
typedef struct GLFWvidmode {
int width, height, redBits, greenBits, blueBits, refreshRate;
} GLFWvidmode;
GLFWwindow* glfwGetCurrentContext(void);
GLFWmonitor* glfwGetPrimaryMonitor(void);
GLFWmonitor** glfwGetMonitors(int* count);
GLFWmonitor* glfwGetWindowMonitor(GLFWwindow* window);
const GLFWvidmode* glfwGetVideoMode(GLFWmonitor* monitor);
void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
}
static int g_X, g_Y, g_W, g_H;
static bool g_Saved = false;
// The monitor the window's middle is on (else the primary one).
static GLFWmonitor* MonitorOf(GLFWwindow* win)
{
int wx, wy, ww, wh;
glfwGetWindowPos(win, &wx, &wy);
glfwGetWindowSize(win, &ww, &wh);
int cx = wx + ww / 2, cy = wy + wh / 2;
int count = 0;
GLFWmonitor** list = glfwGetMonitors(&count);
for (int i = 0; i < count; ++i)
{
const GLFWvidmode* mode = glfwGetVideoMode(list[i]);
if (!mode)
continue;
int mx, my;
glfwGetMonitorPos(list[i], &mx, &my);
if (cx >= mx && cx < mx + mode->width && cy >= my && cy < my + mode->height)
return list[i];
}
return glfwGetPrimaryMonitor();
}
#endif
static int IsFullscreen(lua_State* L)
{
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
lua_pushboolean(L, win && glfwGetWindowMonitor(win) != 0);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static int SetFullscreen(lua_State* L)
{
bool on = lua_toboolean(L, 1);
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
if (!win)
{
lua_pushboolean(L, 0);
return 1;
}
bool is = glfwGetWindowMonitor(win) != 0;
if (on && !is)
{
GLFWmonitor* monitor = MonitorOf(win);
const GLFWvidmode* mode = monitor ? glfwGetVideoMode(monitor) : 0;
if (!mode)
{
lua_pushboolean(L, 0);
return 1;
}
glfwGetWindowPos(win, &g_X, &g_Y);
glfwGetWindowSize(win, &g_W, &g_H);
g_Saved = true;
glfwSetWindowMonitor(win, monitor, 0, 0, mode->width, mode->height, mode->refreshRate);
}
else if (!on && is)
{
if (!g_Saved)
{
// Started fullscreen: a window of 1280x800 in the monitor's corner.
g_X = 80; g_Y = 80; g_W = 1280; g_H = 800;
}
glfwSetWindowMonitor(win, 0, g_X, g_Y, g_W, g_H, 0);
}
lua_pushboolean(L, 1);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static const luaL_reg Module_methods[] = {
{"set_fullscreen", SetFullscreen},
{"is_fullscreen", IsFullscreen},
{0, 0}
};
static dmExtension::Result Initialize(dmExtension::Params* params)
{
lua_State* L = params->m_L;
int top = lua_gettop(L);
luaL_register(L, MODULE_NAME, Module_methods);
lua_pop(L, 1);
assert(top == lua_gettop(L));
return dmExtension::RESULT_OK;
}
static dmExtension::Result Finalize(dmExtension::Params* params)
{
return dmExtension::RESULT_OK;
}
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, 0, 0, Finalize)
+448
View File
@@ -0,0 +1,448 @@
-- Animation helpers: the web build's motion (enter.js, app.css keyframes, fx.js) as GUI
-- tweens. Every helper honors the reduced-motion option (M.reduced: no movement, quick
-- fades only), like the web honors prefers-reduced-motion.
--
-- anim.ease.out / .overshoot / .ink / .back / .linear ... easing curves (gui.animate)
-- anim.to(node, prop, value, dur, ease, delay, done) one tween (instant if dur 0)
-- anim.enter_frame(f, opts) a frame grows: brackets spread from the center, the
-- fill unclips, the content fades and rises (enter.js)
-- anim.enter(frames, opts) several, staggered (30 ms, capped at 60)
-- anim.group(parent, x, y) an invisible container (content that moves together)
-- anim.veil(parent, x, y, w, h, color, delay, dur) content under it fades in
-- anim.rise(node, delay, by) a group rises into place
-- anim.breathe(node, prop, a, b, period) pingpong forever (glows, ready labels)
-- anim.pulse(node, scale, dur) one swell and back
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
-- decaying noise shake (sell power-down, reveal burst)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
-- anim.update(dt) steps the frame-driven ones (call every frame)
local M = {}
---------------------------------------------------------------- reduced motion
local settings = require("modules.settings")
M.reduced = sys.get_config_int("mm.reduced_motion", -1) == 1
or (sys.get_config_int("mm.reduced_motion", -1) == -1 and settings.load().reduced_motion == true)
-- The option (the header's toggle), saved with the other settings (modules/settings)
-- unless --config=mm.reduced_motion forces it (tests, shots) or save is false (the test
-- hook).
local forced = sys.get_config_int("mm.reduced_motion", -1) ~= -1
function M.set_reduced_motion(on, save)
M.reduced = on and true or false
if not forced and save ~= false then
settings.save({ reduced_motion = M.reduced })
end
end
---------------------------------------------------------------- easing
-- cubic-bezier(x1, y1, x2, y2) sampled for gui.animate (a vector of values over 0..1).
local function bezier(x1, y1, x2, y2, n)
n = n or 64
local function bx(t) return 3 * x1 * t * (1 - t) ^ 2 + 3 * x2 * t ^ 2 * (1 - t) + t ^ 3 end
local function by(t) return 3 * y1 * t * (1 - t) ^ 2 + 3 * y2 * t ^ 2 * (1 - t) + t ^ 3 end
local samples = {}
for i = 0, n - 1 do
local x = i / (n - 1)
local lo, hi = 0, 1
for _ = 1, 30 do -- bisection: bx is monotonic for x1, x2 in 0..1
local mid = (lo + hi) / 2
if bx(mid) < x then lo = mid else hi = mid end
end
samples[#samples + 1] = by((lo + hi) / 2)
end
local v = vmath.vector(samples)
-- The same curve as a Lua function (frame-driven tweens).
local fn = function(k)
k = math.max(0, math.min(1, k))
local f = k * (n - 1)
local i = math.floor(f)
local a, b = samples[i + 1], samples[math.min(n, i + 2)]
return a + (b - a) * (f - i)
end
return v, fn
end
M.ease, M.fn = {}, {}
local function curve(name, ...)
M.ease[name], M.fn[name] = bezier(...)
end
curve("out", 0.2, 0.8, 0.2, 1) -- enter.js EASE (frames, content)
curve("overshoot", 0.2, 0.9, 0.3, 1.2) -- dialog-in (app.css)
curve("ink", 0.3, 1.25, 0.5, 1) -- the sliding tab ink (220 ms)
curve("toast", 0.2, 1.4, 0.4, 1) -- toast-in squash and overshoot
curve("pop", 0.3, 1.6, 0.5, 1) -- ready pop .6 -> 1.12 -> 1
curve("toast_in", 0.2, 0.9, 0.3, 1.15) -- fx-toast-in, per keyframe segment
curve("ready_pop", 0.2, 0.9, 0.3, 1.4) -- fx-ready-pop, per keyframe segment
curve("gain", 0.2, 0.9, 0.3, 1.3) -- fx-gain (wallet), per keyframe segment
curve("ease_in", 0.42, 0, 1, 1)
curve("ease_out", 0, 0, 0.58, 1)
curve("ease", 0.25, 0.1, 0.25, 1)
M.ease.linear, M.fn.linear = gui.EASING_LINEAR, function(k) return k end
M.ease.back, M.fn.back = gui.EASING_OUTBACK, function(k)
local c1, c3 = 1.70158, 2.70158
return 1 + c3 * (k - 1) ^ 3 + c1 * (k - 1) ^ 2
end
M.ease.sine, M.fn.sine = gui.EASING_INOUTSINE, function(k) return -(math.cos(math.pi * k) - 1) / 2 end
---------------------------------------------------------------- basics
local function v3(x, y, z)
return vmath.vector3(x, y, z or 0)
end
-- Set a property, "color" or one component like "color.w" (what gui.animate takes).
local GETTERS = { position = gui.get_position, scale = gui.get_scale, color = gui.get_color, size = gui.get_size,
euler = gui.get_euler, outline = gui.get_outline, shadow = gui.get_shadow }
local SETTERS = { position = gui.set_position, scale = gui.set_scale, color = gui.set_color, size = gui.set_size,
euler = gui.set_euler, outline = gui.set_outline, shadow = gui.set_shadow }
function M.set(node, prop, value)
local base, comp = prop:match("^(%a+)%.(%a)$")
if base and GETTERS[base] then
local v = GETTERS[base](node)
v[comp] = value
SETTERS[base](node, v)
elseif SETTERS[prop] then
SETTERS[prop](node, value)
else
gui.set(node, prop, value)
end
end
-- One tween. dur 0 (or reduced motion for a movement, opts.move) sets the value at once.
function M.to(node, prop, value, dur, ease, delay, done)
if dur <= 0 then
M.set(node, prop, value)
if done then
done()
end
return
end
gui.animate(node, prop, value, ease or M.ease.out, dur, delay or 0, done and function() done() end or nil)
end
-- A CSS-like keyframe track at progress k (0..1): frames = { {t, v}, ... } sorted by t
-- (first at 0, last at 1), each segment eased by ease_fn (a M.fn curve; default linear),
-- like animation-timing-function applies per keyframe interval.
function M.keys(frames, k, ease_fn)
if k <= frames[1][1] then
return frames[1][2]
end
for i = 2, #frames do
local a, b = frames[i - 1], frames[i]
if k <= b[1] then
local u = (k - a[1]) / math.max(1e-6, b[1] - a[1])
u = ease_fn and ease_fn(u) or u
return a[2] + (b[2] - a[2]) * u
end
end
return frames[#frames][2]
end
-- Scale a node about a point: p = its resting position, base = its resting scale, (dx,
-- dy) = from p to the point that stays put (a pivot NW box of w x h: w/2, -h/2 for its
-- middle), sx/sy the factors (sy defaults to sx). Template roots are pivot NW, so scaling
-- them alone grows from the corner; CSS scales about the middle.
function M.scale_about(node, p, base, dx, dy, sx, sy)
sy = sy or sx
gui.set_scale(node, vmath.vector3(base.x * sx, base.y * sy, base.z))
gui.set_position(node, vmath.vector3(p.x + dx * (1 - sx), p.y + dy * (1 - sy), p.z))
end
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {}
local function now()
return socket.gettime()
end
function M.tween(node, dur, step, delay, done)
local t = { node = node, t0 = now() + (delay or 0), dur = dur, step = step, done = done }
running[#running + 1] = t
return t
end
function M.cancel(t)
if t then
t.dead = true
end
end
function M.update()
if #running == 0 then
return
end
local t1 = now()
local keep = {}
for _, t in ipairs(running) do
if not t.dead then
local k = (t1 - t.t0) / t.dur
if k >= 0 then
local ok = pcall(t.step, math.min(1, k), t.node)
if not ok then
t.dead = true -- node deleted (a rebuild)
elseif k >= 1 then
t.dead = true
if t.done then
pcall(t.done)
end
end
end
if not t.dead then
keep[#keep + 1] = t
end
end
end
running = keep
end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
end
---------------------------------------------------------------- fading content
-- GUI nodes don't pass their alpha down (inherit_alpha is off, and most containers here
-- are translucent fills: panels, cards, buttons), so content doesn't fade as a subtree.
-- Content fades in from under a veil instead: a box in the background's color over it
-- that fades out (M.veil), while a group (M.group) holding the content rises.
-- An invisible container (not drawn; alpha 1): content built into it moves with it.
function M.group(parent, x, y, w, h)
local n = gui.new_box_node(v3(x or 0, -(y or 0)), v3(w or 1, h or 1))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_color(n, vmath.vector4(1, 1, 1, 1))
gui.set_visible(n, false)
return n
end
-- A veil over (x, y, w, h) of parent (top-left based) in `color` (the background behind
-- the content) that fades out after delay over dur and goes away: the content under it
-- fades in. Make it after the content, so it draws over it. out = true: it fades in
-- instead (the content fading out) and stays. Returns the veil node.
function M.veil(parent, x, y, w, h, color, delay, dur, out)
local n = gui.new_box_node(v3(x, -y), v3(w, h))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_texture(n, "ui")
gui.play_flipbook(n, "white")
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, out and 0 or 1))
gui.animate(n, "color.w", out and 1 or 0, M.ease.out, dur or 0.14, delay or 0, (not out) and function()
gui.delete_node(n)
end or nil)
return n
end
---------------------------------------------------------------- entrances (enter.js)
local GROW, FADE, NESTED, STAGGER, MAX_STAGGER = 0.18, 0.14, 0.06, 0.03, 0.06
-- Rise: a group starts `by` px lower and moves up (0.14 s), after delay.
function M.rise(node, delay, by, dur)
if M.reduced then
return
end
local p = gui.get_position(node)
gui.set_position(node, v3(p.x, p.y - (by or 6), p.z))
gui.animate(node, "position.y", p.y, M.ease.out, dur or FADE, delay or 0)
end
-- A frame grows (enter.js grow): brackets spread from the center, the fill unclips, the
-- content fades and rises. f = { frame = the bracket node (slice9 corners, pivot NW),
-- fill = the frame's fill node (pivot NW, or nil), body = a group holding the content
-- (rises), veil = { parent, color } for the content's fade (it covers the box: x, y in
-- veil.parent, w x h). opts: delay, clip (false: the fill doesn't unclip, the aside).
function M.enter_frame(f, opts)
opts = opts or {}
local delay = opts.delay or 0
local w, h = f.w, f.h
local function fade(at)
if f.veil then
local v = M.veil(f.veil.parent, f.x or 0, f.y or 0, w, h, f.veil.color, at, M.reduced and 0.12 or FADE)
if f.frame and gui.get_parent(f.frame) == f.veil.parent then
gui.move_above(f.frame, v) -- the brackets spread over the hidden content
end
end
end
if M.reduced then
fade(delay)
return
end
local x, y = math.max(0, w / 2 - 16), math.max(0, h / 2 - 16)
local function grow(node)
local size = gui.get_size(node)
local pos = gui.get_position(node)
gui.set_size(node, v3(size.x - 2 * x, size.y - 2 * y))
gui.set_position(node, v3(pos.x + x, pos.y - y))
gui.animate(node, "size", size, M.ease.out, GROW, delay)
gui.animate(node, "position", pos, M.ease.out, GROW, delay)
end
if f.frame then
grow(f.frame) -- corner brackets (slice9 corners) start near the center and spread
end
if f.fill and opts.clip ~= false then
grow(f.fill) -- the box unclips from its middle
end
fade(delay + GROW / 2)
if f.body then
M.rise(f.body, delay + GROW / 2, 6)
end
end
-- Several frames, staggered like enter.js (nested ones start NESTED later).
function M.enter(frames, opts)
opts = opts or {}
for i, f in ipairs(frames) do
M.enter_frame(f, { delay = (opts.nested and NESTED or 0) + math.min((i - 1) * STAGGER, MAX_STAGGER) + (opts.delay or 0),
clip = f.clip })
end
end
---------------------------------------------------------------- loops and one-shots
-- Pingpong a property between a and b forever (a glow that breathes). Reduced motion:
-- holds a.
function M.breathe(node, prop, a, b, period)
gui.cancel_animations(node, prop)
M.set(node, prop, a)
if M.reduced then
return
end
gui.animate(node, prop, b, gui.EASING_INOUTSINE, (period or 1.8) / 2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end
-- Swell to `scale` and back once (pivot center nodes scale about their middle).
function M.pulse(node, scale, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.animate(node, "scale", s * (scale or 1.08), gui.EASING_OUTSINE, (dur or 0.3) / 2, 0, nil, gui.PLAYBACK_ONCE_PINGPONG)
end
-- Scale in from `from` with an overshoot (the web's ready pop: .6 -> 1.12 -> 1).
function M.pop(node, from, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.set_scale(node, s * (from or 0.6))
gui.animate(node, "scale", s, M.ease.pop, dur or 0.6)
end
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
function M.shake(node, amp, dur, delay)
if M.reduced then
return
end
amp, dur = amp or 6, dur or 0.4
local p
return M.tween(node, dur, function(k, n)
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function()
if p then
pcall(gui.set_position, node, p)
end
end)
end
-- 1D value noise (smooth), for shakes.
local function noise(x, seed)
local function h(i)
local s = math.sin((i + seed) * 127.1) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local i = math.floor(x)
local f = x - i
local u = f * f * (3 - 2 * f)
return h(i) * (1 - u) + h(i + 1) * u
end
-- A decaying noise shake in x and y (fx.js sell power-down, the reveal burst). freq:
-- noise steps per second.
function M.noise_shake(node, amp, dur, freq)
if M.reduced then
return
end
amp, dur, freq = amp or 4, dur or 0.34, freq or 30
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + noise(t, seed) * a, p.y + noise(t, seed + 50) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur)
local c = gui.get_color(node)
if M.reduced then
return
end
gui.set_color(node, color)
gui.animate(node, "color", c, M.ease.ease_out, dur or 0.5)
end
-- A scan line sweeping down a box (w x h at the parent's top-left): a bright line with a
-- glow that fades as it goes (app.css scan, 0.7-0.9 s). Returns the line node.
function M.sweep(parent, w, h, color, dur, delay)
if M.reduced then
return
end
color = color or require("modules.theme").color.cyan
local line = gui.new_box_node(v3(0, 0), v3(w, 2))
gui.set_pivot(line, gui.PIVOT_NW)
gui.set_parent(line, parent, false)
gui.set_texture(line, "ui")
gui.play_flipbook(line, "white")
gui.set_color(line, vmath.vector4(color.x, color.y, color.z, 0.9))
local glow = gui.new_box_node(v3(0, 8), v3(w, 18))
gui.set_pivot(glow, gui.PIVOT_W)
gui.set_parent(glow, line, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_color(glow, vmath.vector4(color.x, color.y, color.z, 0.35))
gui.set_inherit_alpha(glow, true)
M.tween(line, dur or 0.8, function(k, n)
local e = M.fn.ease_out(k)
gui.set_position(n, v3(0, -h * e))
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, 0.9 * (1 - k)))
end, delay or 0, function()
pcall(gui.delete_node, line)
end)
return line
end
-- A number counting from `from` to `to` (fmt formats it).
function M.count(node, from, to, dur, fmt, done)
fmt = fmt or tostring
if M.reduced or (dur or 0) <= 0 then
gui.set_text(node, fmt(to))
return
end
return M.tween(node, dur, function(k, n)
local v = from + (to - from) * M.fn.ease_out(k)
if to == math.floor(to) then
v = math.floor(v + 0.5)
end
gui.set_text(n, fmt(v))
end, 0, done)
end
return M
+190
View File
@@ -0,0 +1,190 @@
-- The game server's JSON API. The server is the authority: the client sends actions and
-- shows the state it gets back, it never computes game results itself.
--
-- GET /api/state the player's state (params: the view, e.g. moon, well)
-- POST /api/act/<action> run an action, returns the new state (or an error)
--
-- The player id travels in the X-MM-User header. The server returns one on the first
-- visit; it is kept with sys.save so the same player comes back.
--
-- Base URL: game.project [mm] api_base (default http://localhost:3000), overridable at
-- launch with --config=mm.api_base=http://host:port.
local M = {}
M.base = sys.get_config_string("mm.api_base", "http://localhost:3000"):gsub("/+$", "")
M.timeout = 10
-- Debug: mm.fake_latency_ms delays every request by half of it and its response by the
-- other half (prediction checks). mm.fake_reject=<action>[:n] makes the n-th call of that
-- action (every one without :n) fail on the server with a 422: it is sent as a claim of
-- a well that doesn't exist.
M.fake_latency = sys.get_config_int("mm.fake_latency_ms", 0) / 1000
local reject_name, reject_n = sys.get_config_string("mm.fake_reject", ""):match("^([^:]*):?(%d*)$")
reject_n = tonumber(reject_n)
local reject_count = 0
-- One saved player per server, so runs against a local test server don't replace the
-- player of another one.
local SAVE_FILE = sys.get_save_file("moonminers", "player-" .. M.base:gsub("^%a+://", ""):gsub("[^%w]", "_"))
local HEADER = "X-MM-User"
local player_id = nil
local function load_player()
local ok, data = pcall(sys.load, SAVE_FILE)
if ok and type(data) == "table" and type(data.id) == "string" and data.id ~= "" then
return data.id
end
return nil
end
local function store_player(id)
if not id or id == "" or id == player_id then
return
end
player_id = id
if not sys.save(SAVE_FILE, { id = id }) then
print("api: could not save the player id")
end
end
player_id = load_player()
-- mm.new_player=1: start as a new player (screenshots of the first launch).
if sys.get_config_int("mm.new_player", 0) == 1 then
player_id = nil
sys.save(SAVE_FILE, {})
end
-- mm.player=<id>: play as this player (test setups prepared through the admin pages).
local forced = sys.get_config_string("mm.player", "")
if forced ~= "" then
player_id = forced
end
function M.player_id()
return player_id
end
-- Forget the stored player (the next request starts a new one).
function M.reset_player()
player_id = nil
sys.save(SAVE_FILE, {})
end
local function urlencode(s)
return (tostring(s):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
local function query(params)
if not params then
return ""
end
local keys = {}
for k in pairs(params) do
keys[#keys + 1] = k
end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(params[k])
end
return table.concat(parts, "&")
end
-- Header names arrive lowercased in http.request responses; look both up to be safe.
local function header(headers, name)
if not headers then
return nil
end
return headers[name:lower()] or headers[name]
end
-- cb(err, data, status, recv): err is nil on a 2xx with valid JSON; otherwise a string. On
-- an error response with a JSON body (e.g. {"error": "..."}), data is still the decoded
-- body. recv: socket.gettime() when the response arrived (before any fake latency).
local function request(method, path, params, cb)
local url = M.base .. path
local headers = { ["Accept"] = "application/json" }
if player_id then
headers[HEADER] = player_id
end
local body = nil
if method == "GET" then
local q = query(params)
if q ~= "" then
url = url .. "?" .. q
end
else
headers["Content-Type"] = "application/json"
-- An empty Lua table could encode as [] (array); the server wants an object.
body = (params and next(params) ~= nil) and json.encode(params) or "{}"
end
local half = M.fake_latency / 2
local function done(...)
if not cb then
return
end
local args = { ... }
if half > 0 then
timer.delay(half, false, function() cb(unpack(args, 1, 4)) end)
else
cb(...)
end
end
local function send()
http.request(url, method, function(_, _, response)
local recv = socket.gettime()
store_player(header(response.headers, HEADER))
local data = nil
if response.response and response.response ~= "" then
local ok, decoded = pcall(json.decode, response.response)
if ok then
data = decoded
end
end
local status = response.status or 0
local err = nil
if status == 0 then
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
err = "bad JSON"
end
done(err, data, status, recv)
end, headers, body, { timeout = M.timeout })
end
if half > 0 then
timer.delay(half, false, send)
else
send()
end
end
-- The player's state for a view (params: e.g. { s = "moons", moon = 12, well = 34 }).
function M.get_state(params, cb)
request("GET", "/api/state", params, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
reject_count = reject_count + 1
if not reject_n or reject_count == reject_n then
local p = {}
for k, v in pairs(params or {}) do
p[k] = v
end
p["claim-well"] = "00000000-0000-0000-0000-000000000000"
print("api: fake reject of " .. name)
name = "claim"
params = p
end
end
request("POST", "/api/act/" .. urlencode(name), params, cb)
end
return M
+89
View File
@@ -0,0 +1,89 @@
-- A text field (Druid input on a cloned rect) that keeps its text and focus across
-- layout rebuilds: the screen rebuilds on every new state, so the value and whether it
-- had focus live in g.fields[key], and the new field takes them over (focus included,
-- so typing goes on through a refresh).
local ui = require("modules.ui")
local theme = require("modules.theme")
local test = require("modules.test")
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The stored state of field `key` on screen g: { text, focus }.
function M.get(g, key)
g.fields = g.fields or {}
local f = g.fields[key]
if not f then
f = { text = "", focus = false }
g.fields[key] = f
end
return f
end
-- A field at (x, y), w x h, under parent. opts: hint (placeholder), font, px, allowed
-- (Lua pattern of allowed characters), max_len, keyboard (gui.KEYBOARD_TYPE_*), disabled,
-- unit (text at the right end), on_change(text), on_submit() (Enter while editing, like a
-- web form; screens/game/core/keys), min / max (Up / Down step a number field within them,
-- core/keys), esc_clears (Esc while editing empties it, like type=search). Returns the
-- Druid input (nil when disabled) and the box node.
function M.new(g, parent, key, x, y, w, h, opts)
opts = opts or {}
local f = M.get(g, key)
if opts.disabled then
f.focus = false
end
local px = opts.px or 15
local box = ui.rect(parent, x, y, w, h, "field")
local edge = ui.outline(box, 0, 0, w, h, f.focus and "cyan" or "line_hi")
local right = 12
if opts.unit then
local u = ui.text(box, opts.unit, { font = "mono", px = 12, color = "muted", tracking = 0.08 }, w - 12, h / 2, gui.PIVOT_E)
right = right + ui.text_width(u) + 8
end
local hint = ui.text(box, opts.hint or "", { font = opts.hint_font or opts.font or "sans", px = px, color = "faint" }, 12, h / 2)
local txt = ui.text(box, f.text, { font = opts.font or "sans", px = px, color = "text" }, 12, h / 2)
gui.set_size(txt, vec((w - 12 - right) / gui.get_scale(txt).x, theme.font_px * 1.4))
gui.set_enabled(hint, f.text == "")
if opts.disabled then
return nil, box
end
local input = g.druid:new_input(box, txt, opts.keyboard or gui.KEYBOARD_TYPE_DEFAULT)
test.register("field:" .. key, box, "field", opts.hint)
g.inputs = g.inputs or {} -- (core/keys: Enter submits; other keys skip while editing)
g.inputs[#g.inputs + 1] = { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
esc_clears = opts.esc_clears }
-- (no "<" or "&": the text node would read them as markup)
input:set_allowed_characters(opts.allowed or "[^<&]")
if opts.max_len then
input:set_max_length(opts.max_len)
end
input.style.on_select = function()
gui.set_color(edge, ui.color("cyan"))
end
input.style.on_unselect = function()
gui.set_color(edge, ui.color("line_hi"))
end
input.on_input_select:subscribe(function()
f.focus = true
end)
input.on_input_unselect:subscribe(function()
f.focus = false
end)
input.on_input_text:subscribe(function(_, text)
f.text = text
gui.set_enabled(hint, text == "")
if opts.on_change then
opts.on_change(text)
end
end)
if f.focus then
input:select()
end
return input, box
end
return M
+406
View File
@@ -0,0 +1,406 @@
-- Keyboard focus (the web's Tab focus and :focus-visible ring). Every interactive element
-- the components register for the test hook (modules/test: buttons, links, tabs, rows,
-- fields, dialog buttons) is also registered here, in build order (= the page's reading
-- order: banner, header, moon list, main panel, aside), so Tab / Shift+Tab walk them and
-- Enter or Space presses the focused one: a real press and release through Druid at its
-- center (the same path a mouse click takes, so busy states, sounds and confirms all
-- apply). A field takes the press as a click and starts editing.
--
-- The focused element is kept by its id across rebuilds (every new state rebuilds the
-- page); when it goes away, Tab goes on from where it was. While a dialog is open only its
-- buttons take focus, and while a new moon's reveal card is up only the card's (scope
-- "reveal"); closing it goes back to the page's. An element in a scroll area is
-- scrolled into view when it takes focus. The ring (2 px, 2 px out, like the web's
-- outline) shows only after a key moved the focus; a mouse click hides it.
--
-- Not Druid's navigation_handler (Widget Store): that one is directional (arrow keys),
-- needs Druid button instances (ours are made again every build), and has no scopes.
local ui = require("modules.ui")
local screen = require("modules.screen")
local tooltip = require("modules.tooltip")
local M = {}
local entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page"
local seen = {} -- key -> count in this build (duplicate ids get "#n")
local function alive(node)
return pcall(gui.get_id, node)
end
-- The stencil node clipping a node (a scroll view, a list), if any.
local function zone_of(node)
local n = gui.get_parent(node)
while n do
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE then
return n
end
n = gui.get_parent(n)
end
end
-- Start of a build of `which` ("page" or "dialog"): its old elements go.
function M.begin_build(which)
which = which or "page"
local keep = {}
for _, e in ipairs(entries) do
if e.scope ~= which then
keep[#keep + 1] = e
end
end
entries = keep
seen = {}
for _, e in ipairs(entries) do
seen[e.base] = (seen[e.base] or 0) + 1
end
end
function M.set_scope(which)
scope = which or "page"
end
-- Register an element (modules/test forwards every registration).
function M.add(id, node, kind, label, zone)
if not node then
return
end
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
seen[base] = (seen[base] or 0) + 1
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end
-- The keys in the focus order (scope:id), for the test driver.
function M.keys()
local out = {}
for _, e in ipairs(entries) do
out[#out + 1] = e.key
end
return out
end
---------------------------------------------------------------- geometry
local PIVOT = { -- (fraction of the size left of / above the pivot point)
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
local function screen_at(node, lx, ly)
local probe = gui.new_box_node(vmath.vector3(lx, ly, 0), vmath.vector3(1, 1, 0))
gui.set_parent(probe, node, false)
local p = gui.get_screen_position(probe)
gui.delete_node(probe)
return p
end
-- A node's rectangle: in layout units (x, y top-left, y down; w, h) and its center in
-- action coordinates (ax, ay) and window pixels (sx, sy).
function M.rect(node)
local size = gui.get_size(node)
local k = PIVOT[gui.get_pivot(node)] or { 0.5, 0.5 }
local a = screen_at(node, -k[1] * size.x, k[2] * size.y)
local b = screen_at(node, (1 - k[1]) * size.x, -(1 - k[2]) * size.y)
local W, H = screen.layout()
local ww, wh = window.get_size()
ww, wh = math.max(1, ww), math.max(1, wh)
local sx, sy = (a.x + b.x) / 2, (a.y + b.y) / 2
local ax, ay = sx, sy
if not gui.pick_node(node, ax, ay) then
-- action coordinates span the display size (game.project), not the window
ax = sx * sys.get_config_int("display.width", 1280) / ww
ay = sy * sys.get_config_int("display.height", 800) / wh
end
return {
x = math.min(a.x, b.x) * W / ww, y = (wh - math.max(a.y, b.y)) * H / wh,
w = math.abs(b.x - a.x) * W / ww, h = math.abs(b.y - a.y) * H / wh,
ax = ax, ay = ay, sx = sx, sy = sy,
}
end
---------------------------------------------------------------- state
local function state(g)
g.focus = g.focus or { key = nil, index = 0, visible = false, page_key = nil }
return g.focus
end
local function visible(node)
return alive(node) and gui.is_enabled(node, true)
end
-- The scope taking focus now: "dialog", "reveal" or "page".
local function current_scope(g)
if g.dialog and g.dialog:is_open() then
return "dialog"
elseif g.reveal and g.reveal:card_done() then
return "reveal"
end
return "page"
end
-- The elements that can take focus now, in order.
local function list(g)
local want = current_scope(g)
local out = {}
for _, e in ipairs(entries) do
if e.scope == want and visible(e.node) then
local z = e.zone or zone_of(e.node)
-- In a clipped view: only when it shows, or its view scrolls (components/scroll).
local ok = true
if z and alive(z) then
local r = M.rect(e.node)
ok = gui.pick_node(z, r.ax, r.ay)
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z and not a.list then
ok = true
end
end
end
if ok then
out[#out + 1] = e
end
end
end
return out, want
end
local function find(g, key)
if not key then
return nil
end
for _, e in ipairs(entries) do
if e.key == key and visible(e.node) then
return e
end
end
end
-- The focused element (nil when none, or it went away).
function M.current(g)
return find(g, state(g).key)
end
-- Scroll a scroll area so the element shows (the layout rebuilds).
local function into_view(g, e)
local z = e.zone or zone_of(e.node)
if not z then
return
end
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z then
local r, v = M.rect(e.node), M.rect(z)
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
local to = pos
if r.y < v.y + 4 then
to = pos - (v.y + 4 - r.y) - 8
elseif r.y + r.h > v.y + v.h - 4 then
to = pos + (r.y + r.h - (v.y + v.h - 4)) + 8
end
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty = true
end
return
end
end
end
-- The long list (components/list with focus_id) whose view clips an element, if any.
local function list_of(g, e)
local z = e and (e.zone or zone_of(e.node))
if not z then
return nil
end
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z and a.list and a.list.focus_id then
return a
end
end
end
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- the item before / after the focused row (or the first / last when coming from outside),
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
-- when it moved the focus.
local function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur)
local items, i
if a then
items = a.list.items
local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do
if a.list.focus_id(item) == id then
i = n + dir
end
end
if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page)
end
else
a = list_of(g, e)
if not a then
return false
end
items = a.list.items
i = dir > 0 and 1 or #items
end
if not items[i] then
return false
end
local key = scope_ .. ":" .. a.list.focus_id(items[i])
f.key, f.visible = key, true
local slot = a.list.row_h or 1
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
local top = (i - 1) * slot
local to = pos
if top < pos then
to = top
elseif top + slot > pos + a.h then
to = top + slot - a.h
end
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty = true
end
local now = find(g, key)
f.tip = now and tooltip.show_for(now.node, M.rect(now.node)) or false
return true
end
-- Tab (dir 1) / Shift+Tab (dir -1).
function M.move(g, dir)
local f = state(g)
local l = list(g)
if #l == 0 then
return
end
local i
for n, e in ipairs(l) do
if e.key == f.key then
i = n
end
end
if not i then
-- Nothing focused (or it went away): from where it was, else the first / last.
i = f.key and math.max(0, math.min(#l, f.index - (dir > 0 and 1 or 0))) or (dir > 0 and 0 or #l + 1)
end
local cur = find(g, f.key)
i = (i + dir - 1) % #l + 1
local e = l[i]
if list_step(g, f, cur, e, dir, current_scope(g)) then
f.index = i
return
end
f.key, f.index, f.visible = e.key, i, true
into_view(g, e)
f.tip = tooltip.show_for(e.node, M.rect(e.node))
end
-- Enter / Space: press the focused element. input(action_id, action) is the screen's
-- input. Returns true when something was pressed.
function M.activate(g, input)
local f = state(g)
local e = (f.visible or f.soft) and M.current(g)
if not e then
return false
end
local r = M.rect(e.node)
local function a(t)
t.x, t.y, t.screen_x, t.screen_y = r.ax, r.ay, r.sx, r.sy
t.dx, t.dy, t.screen_dx, t.screen_dy = 0, 0, 0, 0
return t
end
tooltip.hide()
input(hash("touch"), a({ pressed = true }))
input(hash("touch"), a({ released = true }))
return true
end
-- A mouse click: the ring hides (focus stays where it was for the next Tab).
function M.pointer_click(g)
local f = state(g)
f.visible, f.soft = false, false
end
-- Whether a key moved the focus (the ring shows).
function M.showing(g)
local f = state(g)
return (f.visible or f.soft) and M.current(g) ~= nil
end
---------------------------------------------------------------- ring
local RING = 2 -- px
local GAP = 2 -- px outside the element
local COLORS = { danger = "danger", redrill = "violet" }
-- Every frame: draw the ring around the focused element (layer: a node over the page),
-- and switch scope when a dialog opens or closes.
function M.update(g, layer)
local f = state(g)
local want = current_scope(g)
if want ~= (f.scope or "page") then
-- A dialog (or the reveal card) opened: focus its first button (if the keyboard
-- was in use); closed: back to the page's element.
local from = f.scope or "page"
f.scope = want
if want ~= "page" then
if from == "page" then
f.page_key, f.page_index = f.key, f.index
end
f.key, f.index = nil, 0
-- A confirm's OK takes the focus (the web's autofocus): Enter confirms even
-- after a mouse click (no ring then: soft focus).
-- (by key: while the panel unclips, its buttons aren't in list(g) yet)
local ok = find(g, want .. ":dialog:ok")
if ok then
f.key, f.index = ok.key, 1
end
if ok then
f.soft = not f.visible
elseif f.visible then
M.move(g, 1)
end
else
f.key, f.index, f.soft = f.page_key, f.page_index or 0, false
end
end
if not f.ring or not alive(f.ring) then
f.ring = ui.rect(layer, 0, 0, 1, 1, "none")
f.sides = {}
for i = 1, 4 do
f.sides[i] = ui.rect(f.ring, 0, 0, 1, 1, "cyan")
end
end
local e = f.visible and M.current(g)
gui.set_enabled(f.ring, e and true or false)
if not e then
if f.tip then
tooltip.hide() -- (the focused element's tooltip)
f.tip = false
end
return
end
local r = M.rect(e.node)
local x, y, w, h = r.x - GAP - RING, r.y - GAP - RING, r.w + (GAP + RING) * 2, r.h + (GAP + RING) * 2
gui.set_position(f.ring, vmath.vector3(x, -y, 0))
local look = ui.button_looks[e.node]
local c = ui.color(look and COLORS[look.variant] or "cyan")
local s = f.sides
local rects = { { 0, 0, w, RING }, { 0, h - RING, w, RING }, { 0, 0, RING, h }, { w - RING, 0, RING, h } }
for i = 1, 4 do
local q = rects[i]
gui.set_position(s[i], vmath.vector3(q[1], -q[2], 0))
gui.set_size(s[i], vmath.vector3(q[3], q[4], 0))
gui.set_color(s[i], c)
end
end
return M
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
-- Hot reload support (debug builds; `make dev` / `make reload`, AGENTS.md "Fast
-- iteration"). The engine's resource reload (what the editor posts to
-- /post/@resource/reload) re-runs a changed .luac but doesn't replace package.loaded,
-- so modules that hold the old table keep the old functions. scripts/reload.py posts
-- the changed .luac files first (that updates the module sources the engine keeps), then
-- the game's .gui_scriptc: its top-level chunk runs again and calls M.script_loaded()
-- before its requires, which drops our modules from package.loaded so the requires load
-- the new sources. on_reload (core/main.lua) then points the screen's objects (fx,
-- toasts, ...) at the new module tables and rebuilds.
--
-- Modules that hold state shared with other scripts or resources stay loaded (KEEP): the
-- moon (moon.script reads it), the API session, sounds, image textures, screen scale.
local M = {}
local KEEP = {
["modules.moon.moon"] = true, ["modules.moon.terrain"] = true, ["modules.api"] = true,
["modules.sfx"] = true, ["modules.images"] = true, ["modules.screen"] = true,
["modules.hot"] = true,
}
local OURS = { "^modules%.", "^screens%.", "^widgets%.", "^components%." }
local function ours(name)
for _, p in ipairs(OURS) do
if name:match(p) then
return true
end
end
end
-- Called at the top of a gui_script's chunk, before its requires. The first run is the
-- normal load; a later run is a hot reload: our modules load again.
function M.script_loaded(name)
_G.mm_hot_loaded = _G.mm_hot_loaded or {}
if not _G.mm_hot_loaded[name] then
_G.mm_hot_loaded[name] = true
return false
end
local n = 0
for k in pairs(package.loaded) do
if ours(k) and not KEEP[k] then
package.loaded[k] = nil
n = n + 1
end
end
print("mm: hot reload " .. name .. " (" .. n .. " modules)")
return true
end
-- Point an object made by a module (setmetatable(obj, M) with M.__index = M) at the
-- module's new table.
function M.rebind(obj, module_name)
if obj and getmetatable(obj) then
setmetatable(obj, require(module_name))
end
end
return M
+122
View File
@@ -0,0 +1,122 @@
-- Images from the server (catalog image paths like "/static/assets/items/shovel.png"),
-- shown on GUI box nodes: images.set(node, url).
--
-- Each URL is downloaded once (http) and decoded once (image.load, premultiplied like the
-- atlas textures); the decoded image is kept for the session. GUI textures belong to a GUI
-- scene, so each scene that shows a URL gets its own texture (gui.new_texture, created on
-- first use, named after the URL). Until the image is there the node shows a placeholder
-- (a faint fill); a node deleted in the meantime (the layout was rebuilt) is skipped.
-- Call images.set from a gui_script (the download's callback runs in that script).
local api = require("modules.api")
local M = {}
local decoded = {} -- url -> { width, height, type, buffer } | false (failed)
local loading = {} -- scene key .. url -> { nodes }
local made = {} -- scene key -> { url -> true }: textures created in that scene
local TYPES = {
[image.TYPE_RGB] = "rgb",
[image.TYPE_RGBA] = "rgba",
[image.TYPE_LUMINANCE] = "l",
}
local function scene_key()
return tostring(msg.url())
end
local function full_url(url)
if url:match("^%a+://") then
return url
end
return api.base .. (url:sub(1, 1) == "/" and "" or "/") .. url
end
-- The texture for url in the current scene (created from the decoded image if needed).
local function texture(url)
local key = scene_key()
made[key] = made[key] or {}
local name = "img:" .. url
if not made[key][url] then
local img = decoded[url]
local ok, code = gui.new_texture(name, img.width, img.height, TYPES[img.type] or "rgba", img.buffer, false)
if not ok and code ~= gui.RESULT_TEXTURE_ALREADY_EXISTS then
return nil
end
made[key][url] = true
end
return name
end
local function apply(node, url)
local name = texture(url)
if not name then
return
end
-- pcall: the node may have been deleted since (layout rebuilt while downloading).
pcall(function()
gui.set_texture(node, name)
gui.set_color(node, vmath.vector4(1, 1, 1, 1))
end)
end
local function placeholder(node)
gui.set_texture(node, "ui")
gui.play_flipbook(node, "white")
gui.set_color(node, require("modules.theme").color.placeholder) -- (loading, or no image)
end
-- Show the image at url (a server path or a full URL) on box node `node`. The node keeps
-- its size; the image is stretched to it (give it the image's aspect).
function M.set(node, url)
if not url or url == "" then
placeholder(node)
return
end
if decoded[url] then
apply(node, url)
return
end
placeholder(node)
if decoded[url] == false then
return
end
local key = scene_key() .. url
if loading[key] then
table.insert(loading[key], node)
return
end
loading[key] = { node }
http.request(full_url(url), "GET", function(_, _, response)
local nodes = loading[key]
loading[key] = nil
if decoded[url] == nil then
local img = response.status == 200 and response.response
and image.load(response.response, { premultiply_alpha = true }) or nil
decoded[url] = img or false
if not img then
print("images: could not load " .. url .. " (" .. tostring(response.status) .. ")")
end
end
if decoded[url] then
for _, n in ipairs(nodes) do
apply(n, url)
end
end
end)
end
-- Whether url is downloaded and decoded (ready to show without waiting).
function M.ready(url)
return decoded[url] and true or false
end
-- Forget the textures made in the current scene (call from final() of a screen whose
-- GUI scene goes away and may come back, like a popup).
function M.forget_scene()
made[scene_key()] = nil
end
return M
+161
View File
@@ -0,0 +1,161 @@
-- The moon in the aside viewport: the procedural moon of the web build (terrain.lua, a
-- port of the server's terrain.js; look of scene.js), drawn in 3D behind a GUI node.
--
-- Use from a gui_script:
-- moon.show(id, node) show moon `id` (a moon id, any string) in GUI box node `node`;
-- call again after the layout moves the node (it reads the node's
-- screen rect). The node gets the "moon" material (listed in the
-- .gui's materials), which draws the rendered moon.
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
-- moon.hide() no moon (the node keeps its own look).
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
-- appearing in front of the moon shown) charges up, then bursts
-- into moon `id` once its mesh is built. moon.show(id, node) during
-- it only moves the viewport. Progress in moon.revealing (below).
-- moon.skip_reveal() jump ahead: a charge bursts as soon as the moon is ready, a
-- burst ends.
--
-- How it works: moon/moon.script (in moon/moon.collection, a collection instance in the
-- game screen) generates meshes (cached per id, spread over frames), spins and orbits the
-- camera (drag to rotate, wheel to zoom), and fills `moon.frame`; the render script
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
--
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
-- defaults like the web does; until the request answers nothing is generated.
--
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
local terrain = require("modules.moon.terrain")
local M = {}
-- Requested state (GUI side).
M.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
-- Settings: nil until loaded (or failed: then the defaults).
M.settings = nil
-- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false }
-- "mystery" while the shroud stands in for an unknown moon (moon.mystery).
M.mode = nil
-- The reveal in progress (moon.reveal), nil when none: { id, t (seconds since it started,
-- advanced by moon.script; holds at mm.reveal_freeze points), stage ("charge", "burst"),
-- burst (t at the burst), ready (the moon's mesh is on screen), reduced (set by the caller:
-- no charge-up or pop, reduced motion) }. screens/game/reveal.lua
-- reads it to time the card.
M.revealing = nil
local debug_id = sys.get_config_string("mm.moon_id", "")
local function node_rect(node)
local size = gui.get_size(node)
local tl = gui.get_screen_position(node)
-- The node's pivot is NW (ui.rect); its screen size follows the GUI scale: measure it
-- with a probe at the opposite corner.
local probe = gui.new_box_node(vmath.vector3(size.x, -size.y, 0), vmath.vector3(0, 0, 0))
gui.set_parent(probe, node, false)
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe)
gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
end
local function set_node(node)
if node then
local old = M.rect
M.rect, M.unit = node_rect(node)
local r = M.rect
if not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h then
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
end
gui.set_material(node, "moon")
end
end
function M.show(id, node)
if debug_id ~= "" then
id = debug_id
end
id = id ~= nil and tostring(id) or nil
-- Another moon ends a reveal at once; the same one keeps it going.
if M.revealing and M.revealing.id ~= id then
M.revealing = nil
end
M.mode = nil
M.id = id
set_node(node)
end
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
function M.move(node)
if node and (M.id or M.mode) then
set_node(node)
end
end
function M.hide()
M.id = nil
M.rect = nil
M.mode = nil
M.revealing = nil
end
function M.mystery(node)
M.revealing = nil
M.mode = "mystery"
M.id = nil
set_node(node)
end
function M.reveal(id, node)
if debug_id ~= "" then
id = debug_id
end
M.mode = nil
M.id = tostring(id)
M.revealing = { id = M.id, t = 0, stage = "charge", ready = false }
set_node(node)
end
function M.skip_reveal()
local r = M.revealing
if r then
r.skip = true
end
end
---------------------------------------------------------------- settings
local loading = false
-- Fetches the saved settings once (base: the server URL).
function M.load_settings(base)
if M.settings or loading then
return
end
loading = true
http.request(base .. "/api/moon-settings", "GET", function(_, _, res)
local saved = nil
if res.status == 200 and res.response then
local ok, data = pcall(json.decode, res.response)
if ok and type(data) == "table" then
saved = data
end
else
print("moon: no saved settings (status " .. tostring(res.status) .. "), using the defaults")
end
M.settings = terrain.merge_settings(saved)
loading = false
end, nil, nil, { timeout = 8 })
end
return M
+774
View File
@@ -0,0 +1,774 @@
-- Procedural moon terrain: a port of the server's web/static/js/terrain.js (generation,
-- deterministic from the moon id). Same seed and settings give the same moon as the web:
-- the integer hashing and random draws are bit-exact, the floats differ only by the JS
-- Float32Array rounding (~1e-7).
--
-- Pure Lua (LuaJIT `bit`), no engine APIs, so it also runs in plain luajit for checks
-- (scripts/moon-check.mjs). Keep it in sync with terrain.js when that changes.
local bit = require("bit")
local band, bor, bxor, rshift, lshift, tobit = bit.band, bit.bor, bit.bxor, bit.rshift, bit.lshift, bit.tobit
local floor, sqrt, abs, min, max, pow = math.floor, math.sqrt, math.abs, math.min, math.max, math.pow
local cos, sin, pi = math.cos, math.sin, math.pi
local M = {}
---------------------------------------------------------------- settings
-- Every tweakable value: a literal copy of DEFAULTS in terrain.js. Saved settings (from
-- GET /api/moon-settings) are merged over these (merge_settings).
M.DEFAULTS = {
resolution = 128,
technique = "fbm",
shape = { strength = 0.035, frequency = 1.1, octaves = 5, persistence = 0.5, lacunarity = 2.1, minValue = 0.35 },
ridge = { strength = 0.025, frequency = 1.6, octaves = 4, persistence = 0.5, lacunarity = 2.2, sharpness = 2.5, gain = 0.9, minValue = 0.25 },
warp = { strength = 0.55, frequency = 0.9, octaves = 3 },
craters = {
count = 320, minRadius = 0.02, maxRadius = 0.3, sizeBias = 3, depth = 0.42, rimWidth = 0.6,
rimSteepness = 0.35, floorHeight = -0.45, smoothness = 0.3, ejecta = 0.25,
},
shading = {
low = "#38332e", mid = "#76695c", high = "#b5a895", steep = "#4e3e32", steepThreshold = 0.22,
steepBlend = 0.14, patch = "#2a2b30", patchAmount = 0.45, patchFrequency = 1.2, grain = 0.05,
},
variation = { hue = 180, saturation = 0.6, lightness = 0.12, shape = 0.5, frequency = 0.3, craters = 0.6, technique = true },
render = {
bloomStrength = 0.45, bloomRadius = 0.55, bloomThreshold = 0.55, atmosphere = "#5fd4ff",
atmosphereIntensity = 0.9, atmosphereFollowsHue = false, sunIntensity = 2.6, ambient = 0.12,
rotationSpeed = 0.1, exposure = 1, detail = 0.006, stars = true,
},
}
M.TECHNIQUES = { "fbm", "ridged", "warped", "billow", "craterField" }
local function is_finite(v)
return v == v and v ~= math.huge and v ~= -math.huge
end
-- Deep-merges `saved` over the defaults, keeping only known keys of the right type.
function M.merge_settings(saved, base)
base = base or M.DEFAULTS
local out = {}
for key, def in pairs(base) do
local v = nil
if type(saved) == "table" then
v = saved[key] -- may be false
end
if type(def) == "table" then
out[key] = M.merge_settings(v, def)
elseif type(v) == type(def) and (type(v) ~= "number" or is_finite(v)) then
out[key] = v
else
out[key] = def
end
end
return out
end
local function clone(t)
if type(t) ~= "table" then
return t
end
local c = {}
for k, v in pairs(t) do
c[k] = clone(v)
end
return c
end
-- Stable text for a settings table (sorted keys), used in cache keys.
function M.serialize(t)
if type(t) ~= "table" then
return type(t) == "string" and string.format("%q", t) or tostring(t)
end
local keys = {}
for k in pairs(t) do
keys[#keys + 1] = k
end
table.sort(keys, function(a, b)
return tostring(a) < tostring(b)
end)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = tostring(k) .. "=" .. M.serialize(t[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
---------------------------------------------------------------- random
-- Math.imul: 32-bit multiply (split in 16-bit halves so doubles stay exact).
local function imul(a, b)
local ah, al = band(rshift(a, 16), 0xffff), band(a, 0xffff)
local bh, bl = band(rshift(b, 16), 0xffff), band(b, 0xffff)
return tobit(al * bl + lshift(tobit(ah * bl + al * bh), 16))
end
local function u32(x)
x = tobit(x)
return x < 0 and x + 4294967296 or x
end
-- 32-bit FNV-1a hash of a string (moon id or seed text), as an unsigned number.
-- JS hashes UTF-16 code units; ids are ASCII, so bytes are the same.
function M.hash_string(s)
local h = tobit(2166136261)
for i = 1, #s do
h = bxor(h, s:byte(i))
h = imul(h, 16777619)
end
return u32(h)
end
-- mulberry32: small deterministic PRNG, uniform in [0, 1).
function M.rng(seed)
local a = tobit(seed)
return function()
a = tobit(a + 0x6d2b79f5)
local t = imul(bxor(a, rshift(a, 15)), bor(1, a))
t = bxor(tobit(t + imul(bxor(t, rshift(t, 7)), bor(61, t))), t)
return u32(bxor(t, rshift(t, 14))) / 4294967296
end
end
---------------------------------------------------------------- noise
-- 3D simplex noise (Stefan Gustavson's reference implementation), roughly in [-1, 1].
-- 0-based tables (LuaJIT keeps index 0 in the array part). GX/GY/GZ[v] is the gradient of
-- permutation value v (GRAD3 at v % 12, i.e. terrain.js PERM12).
local GRAD3 = {
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
}
local PERM, GX, GY, GZ = {}, {}, {}, {}
do
local p = {}
for i = 0, 255 do
p[i] = i
end
local r = M.rng(1337)
for i = 255, 1, -1 do
local j = floor(r() * (i + 1))
p[i], p[j] = p[j], p[i]
end
for i = 0, 511 do
PERM[i] = p[band(i, 255)]
end
for v = 0, 255 do
local g = (v % 12) * 3
GX[v], GY[v], GZ[v] = GRAD3[g + 1], GRAD3[g + 2], GRAD3[g + 3]
end
end
M.PERM = PERM
local F3 = 1 / 3
local G3 = 1 / 6
-- Branch-free (LuaJIT side traces of the reference's if/else chains abort on register
-- pressure): the simplex corner order comes from 0/1 flags, and corners outside their
-- radius get a zero weight. Bit-identical results: for floats, x >= y iff x - y >= 0, and
-- here |x - y| < 1, so 1 + floor(x - y) is that flag.
local function simplex3(x, y, z)
local P = PERM
local s = (x + y + z) * F3
local i, j, k = floor(x + s), floor(y + s), floor(z + s)
local t = (i + j + k) * G3
local x0, y0, z0 = x - (i - t), y - (j - t), z - (k - t)
local a = 1 + floor(x0 - y0) -- x0 >= y0
local b = 1 + floor(y0 - z0) -- y0 >= z0
local c = 1 + floor(x0 - z0) -- x0 >= z0
local i1 = a * (b + (1 - b) * c)
local j1 = (1 - a) * b
local k1 = 1 - i1 - j1
local i2 = a + (1 - a) * b * c
local j2 = a * b + 1 - a
local k2 = 2 - i2 - j2
local ii, jj, kk = band(i, 255), band(j, 255), band(k, 255)
local tt = max(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0)
local g = P[ii + P[jj + P[kk]]]
tt = tt * tt
local n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0)
local x1, y1, z1 = x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3
tt = max(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1)
g = P[ii + i1 + P[jj + j1 + P[kk + k1]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1)
local x2, y2, z2 = x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3
tt = max(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2)
g = P[ii + i2 + P[jj + j2 + P[kk + k2]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2)
local x3, y3, z3 = x0 - 1 + 3 * G3, y0 - 1 + 3 * G3, z0 - 1 + 3 * G3
tt = max(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3)
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3)
return 32 * n
end
M.simplex3 = simplex3
-- Caps on saved settings, which come from the admin page.
local MAX_OCTAVES = 10
local MAX_CRATERS = 2000
-- Fractal (fBm) noise in [-1, 1].
local function fbm(x, y, z, octaves, frequency, persistence, lacunarity)
local sum, amp, norm, f = 0, 1, 0, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
sum = sum + simplex3(x * f, y * f, z * f) * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Billow noise (fBm of |noise|) in [0, 1].
local function billow(x, y, z, octaves, frequency, persistence, lacunarity)
local sum, amp, norm, f = 0, 1, 0, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
sum = sum + abs(simplex3(x * f, y * f, z * f)) * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Ridged multifractal in [0, 1]: sharp crests; each octave is weighted by the previous.
local function ridged(x, y, z, octaves, frequency, persistence, lacunarity, sharpness, gain)
local sum, amp, norm, weight, f = 0, 1, 0, 1, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
local v = 1 - abs(simplex3(x * f, y * f, z * f))
v = pow(v, sharpness) * weight
weight = min(1, max(0, v * gain))
sum = sum + v * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Polynomial smooth min/max (Inigo Quilez), k = smoothing width.
local function smooth_min(a, b, k)
if k <= 0 then
return min(a, b)
end
local h = min(1, max(0, (b - a + k) / (2 * k)))
return a * h + b * (1 - h) - k * h * (1 - h)
end
local function smooth_max(a, b, k)
return -smooth_min(-a, -b, k)
end
local function smoothstep(e0, e1, x)
local d = e1 - e0
if d == 0 then
d = 1e-6
end
local t = min(1, max(0, (x - e0) / d))
return t * t * (3 - 2 * t)
end
---------------------------------------------------------------- colors
local function round(x)
return floor(x + 0.5)
end
function M.hex_to_rgb(hex)
local n = tonumber((tostring(hex):gsub("#", "")), 16) or 0
return { band(rshift(n, 16), 255) / 255, band(rshift(n, 8), 255) / 255, band(n, 255) / 255 }
end
local function to_hex(rgb)
local s = "#"
for i = 1, 3 do
s = s .. string.format("%02x", round(min(1, max(0, rgb[i])) * 255))
end
return s
end
local function rgb_to_hsl(c)
local r, g, b = c[1], c[2], c[3]
local mx, mn = max(r, g, b), min(r, g, b)
local l = (mx + mn) / 2
if mx == mn then
return 0, 0, l
end
local d = mx - mn
local s = l > 0.5 and d / (2 - mx - mn) or d / (mx + mn)
local h
if mx == r then
h = (g - b) / d + (g < b and 6 or 0)
elseif mx == g then
h = (b - r) / d + 2
else
h = (r - g) / d + 4
end
return h * 60, s, l
end
local function hsl_to_rgb(h, s, l)
h = (((h % 360) + 360) % 360) / 360
if s == 0 then
return { l, l, l }
end
local q = l < 0.5 and l * (1 + s) or l + s - l * s
local p = 2 * l - q
local function hue(t)
t = (t + 1) % 1
if t < 1 / 6 then
return p + (q - p) * 6 * t
end
if t < 1 / 2 then
return q
end
if t < 2 / 3 then
return p + (q - p) * (2 / 3 - t) * 6
end
return p
end
return { hue(h + 1 / 3), hue(h), hue(h - 1 / 3) }
end
-- Shifts a color's hue (degrees), scales its saturation and offsets its lightness.
function M.adjust_color(hex, dh, s_scale, dl)
local h, s, l = rgb_to_hsl(M.hex_to_rgb(hex))
return to_hex(hsl_to_rgb(h + dh, min(1, s * s_scale), min(1, max(0, l + dl))))
end
local function srgb_to_linear(c)
return c <= 0.04045 and c / 12.92 or pow((c + 0.055) / 1.055, 2.4)
end
M.srgb_to_linear = srgb_to_linear
function M.linear_color(hex)
local c = M.hex_to_rgb(hex)
return { srgb_to_linear(c[1]), srgb_to_linear(c[2]), srgb_to_linear(c[3]) }
end
---------------------------------------------------------------- per-moon variation
-- The concrete parameters for one moon: the settings, varied by the seed within the
-- `variation` amounts (resolveMoon in terrain.js; same draw order).
function M.resolve(settings, seed)
local s = clone(settings)
local r = M.rng(M.hash_string(tostring(seed)))
local v = s.variation
local function jitter(amount)
return 1 + (r() * 2 - 1) * amount
end
local offsets = {}
for i = 1, 3 do
offsets[i] = r() * 200 - 100
end
local technique_roll = r()
local dh = (r() * 2 - 1) * v.hue
local s_scale = jitter(v.saturation)
local dl = (r() * 2 - 1) * v.lightness
local shape_j = jitter(v.shape)
local ridge_j = jitter(v.shape)
local freq_j = jitter(v.frequency)
local ridge_freq_j = jitter(v.frequency)
local count_j = jitter(v.craters)
local size_j = jitter(v.craters * 0.5)
local crater_seed = floor(r() * 4294967296)
if v.technique then
s.technique = M.TECHNIQUES[floor(technique_roll * #M.TECHNIQUES) + 1]
end
s.offset = offsets
s.craterSeed = crater_seed
s.shape.strength = s.shape.strength * shape_j
s.shape.frequency = s.shape.frequency * freq_j
s.ridge.strength = s.ridge.strength * ridge_j
s.ridge.frequency = s.ridge.frequency * ridge_freq_j
s.craters.count = max(0, round(s.craters.count * count_j))
s.craters.maxRadius = s.craters.maxRadius * size_j
for _, key in ipairs({ "low", "mid", "high", "steep", "patch" }) do
s.shading[key] = M.adjust_color(s.shading[key], dh, s_scale, dl)
end
if s.render.atmosphereFollowsHue then
s.render.atmosphere = M.adjust_color(s.render.atmosphere, dh, 1, 0)
end
return s
end
-- The resolved values the server's moon description is built from (moonTraits).
function M.traits(settings, seed)
local p = M.resolve(settings, seed)
return {
technique = p.technique,
mid = p.shading.mid,
shapeStrength = p.shape.strength,
ridgeStrength = p.ridge.strength,
craterCount = p.craters.count,
maxRadius = p.craters.maxRadius,
}
end
---------------------------------------------------------------- mesh
-- Cube faces: outward normal and two tangent axes (a x b = up, so triangles face out).
local FACES = {
{ 1, 0, 0, 0, 1, 0, 0, 0, 1 },
{ -1, 0, 0, 0, 0, 1, 0, 1, 0 },
{ 0, 1, 0, 0, 0, 1, 1, 0, 0 },
{ 0, -1, 0, 1, 0, 0, 0, 0, 1 },
{ 0, 0, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 0, -1, 0, 1, 0, 1, 0, 0 },
}
-- Unit-sphere directions of the cube-sphere grid (6 faces of (n+1)^2 vertices, flat xyz,
-- vertex v at [3v+1..3v+3], v 0-based) and the shared key of each seam vertex (-1 inside).
function M.cube_sphere(n)
local row = n + 1
local dirs, keys = {}, {}
local v = 0
local function k(c)
return round(((c + 1) / 2) * n)
end
for f = 1, 6 do
local ux, uy, uz, ax, ay, az, bx, by, bz = unpack(FACES[f])
for j = 0, n do
for i = 0, n do
local s = (i / n) * 2 - 1
local t = (j / n) * 2 - 1
local cx = ux + s * ax + t * bx
local cy = uy + s * ay + t * by
local cz = uz + s * az + t * bz
local x2, y2, z2 = cx * cx, cy * cy, cz * cz
dirs[v * 3 + 1] = cx * sqrt(1 - y2 / 2 - z2 / 2 + (y2 * z2) / 3)
dirs[v * 3 + 2] = cy * sqrt(1 - x2 / 2 - z2 / 2 + (x2 * z2) / 3)
dirs[v * 3 + 3] = cz * sqrt(1 - x2 / 2 - y2 / 2 + (x2 * y2) / 3)
if i == 0 or j == 0 or i == n or j == n then
keys[v + 1] = (k(cx) * row + k(cy)) * row + k(cz)
else
keys[v + 1] = -1
end
v = v + 1
end
end
end
return dirs, keys
end
-- Vertex order (0-based grid indices) of one triangle strip covering the whole cube-sphere.
-- Each row of quads is a strip (top, bottom pairs), so its triangles are exactly
-- terrain.js's (i, i+1, i+n+2) and (i, i+n+2, i+n+1), with the same winding; rows are
-- joined by two repeated vertices (degenerate triangles).
local strip_cache = {}
function M.strip(n)
if strip_cache[n] then
return strip_cache[n]
end
local row = n + 1
local per = row * row
local out = {}
local c = 0
for f = 0, 5 do
local base = f * per
for j = 0, n - 1 do
if c > 0 then
out[c + 1] = out[c] -- repeat the last vertex
out[c + 2] = base + (j + 1) * row -- and the next row's first
c = c + 2
end
for i = 0, n do
out[c + 1] = base + (j + 1) * row + i
out[c + 2] = base + j * row + i
c = c + 2
end
end
end
strip_cache[n] = out
return out
end
-- Craters scattered on the unit sphere, bucketed in a 3D grid for fast lookup.
local function make_craters(c, seed, count_scale)
local r = M.rng(seed)
local count = round(min(c.count, MAX_CRATERS) * count_scale)
local x_max = max(1 + c.rimWidth, sqrt(1 + max(0, c.smoothness)))
local list = {}
for i = 1, count do
local z = r() * 2 - 1
local a = r() * pi * 2
local q = sqrt(1 - z * z)
local radius = c.minRadius + (c.maxRadius - c.minRadius) * pow(r(), c.sizeBias)
list[i] = { x = q * cos(a), y = q * sin(a), z = z, r = radius, reach = radius * x_max }
end
local G = 12
local cell = 2 / G
local grid = {}
for i = 1, G * G * G do
grid[i] = {}
end
local function at(v)
return min(G - 1, max(0, floor((v + 1) / cell)))
end
for _, cr in ipairs(list) do
for x = at(cr.x - cr.reach), at(cr.x + cr.reach) do
for y = at(cr.y - cr.reach), at(cr.y + cr.reach) do
for z = at(cr.z - cr.reach), at(cr.z + cr.reach) do
local g = grid[(x * G + y) * G + z + 1]
g[#g + 1] = cr
end
end
end
end
return {
cell_of = function(x, y, z)
return grid[(at(x) * G + at(y)) * G + at(z) + 1]
end,
x_max = x_max,
}
end
-- Builds the moon for resolved parameters (see M.resolve): flat arrays over the cube-sphere
-- grid (vertex v 0-based at [3v+1..3v+3]): positions (unit radius plus elevation), normals,
-- colors (linear RGB). `checkpoint` (optional) is called every few hundred vertices, e.g.
-- to yield a coroutine and spread the work over frames.
function M.generate(p, checkpoint)
checkpoint = checkpoint or function() end
local n = max(8, min(320, round(p.resolution)))
local row = n + 1
local per = row * row
local count = 6 * per
local dirs, keys = M.cube_sphere(n)
local heights, ejecta = {}, {}
local technique = p.technique
local sh, rd, wp, cr = p.shape, p.ridge, p.warp, p.craters
local off = p.offset or { 0, 0, 0 }
local ox, oy, oz = off[1], off[2], off[3]
local crater_field = technique == "craterField"
local cparams = cr
if crater_field then
cparams = clone(cr)
cparams.minRadius = cr.minRadius * 0.8
cparams.maxRadius = cr.maxRadius * 0.6
end
local craters = make_craters(cparams, p.craterSeed or 1, crater_field and 3 or 1)
local x_max = craters.x_max
local shape_strength = crater_field and sh.strength * 0.4 or sh.strength
local smooth = max(0, cr.smoothness)
local ejecta_reach = 1 + cr.rimWidth * 2.5
local rim_width, rim_steep, floor_h = cr.rimWidth, cr.rimSteepness, cr.floorHeight
local s_oct, s_freq, s_pers, s_lac, s_min = sh.octaves, sh.frequency, sh.persistence, sh.lacunarity, sh.minValue
local r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain, r_min, r_str =
rd.octaves, rd.frequency, rd.persistence, rd.lacunarity, rd.sharpness, rd.gain, rd.minValue, rd.strength
for i = 0, count - 1 do
local x, y, z = dirs[i * 3 + 1], dirs[i * 3 + 2], dirs[i * 3 + 3]
local px, py, pz = x + ox, y + oy, z + oz
-- Primary shape layer.
local value
if technique == "ridged" then
value = ridged(px, py, pz, s_oct, s_freq, s_pers, s_lac, r_sharp, r_gain)
elseif technique == "billow" then
value = billow(px, py, pz, s_oct, s_freq, s_pers, s_lac)
elseif technique == "warped" then
local wf = wp.frequency
local wx = fbm(px + 5.2, py + 1.3, pz, wp.octaves, wf, 0.5, 2)
local wy = fbm(px, py + 9.7, pz + 2.8, wp.octaves, wf, 0.5, 2)
local wz = fbm(px + 3.1, py, pz + 7.4, wp.octaves, wf, 0.5, 2)
local s = wp.strength
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, s_oct, s_freq, s_pers, s_lac) + 1) / 2
else
value = (fbm(px, py, pz, s_oct, s_freq, s_pers, s_lac) + 1) / 2
end
local h = max(0, value - s_min) * shape_strength
-- Ridge layer.
if r_str ~= 0 then
local r = ridged(px - 11.3, py + 4.1, pz - 7.9, r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain)
h = h + max(0, r - r_min) * r_str
end
-- Craters: parabola cavity, flattened floor, smooth rim (smooth min/max blend them).
local crater_h, glow = 0, 0
local list = craters.cell_of(x, y, z)
for c = 1, #list do
local k = list[c]
local dx, dy, dz = x - k.x, y - k.y, z - k.z
local d2 = dx * dx + dy * dy + dz * dz
local reach = max(k.reach, k.r * ejecta_reach)
if d2 <= reach * reach then
local dist = sqrt(d2) / k.r
if dist < x_max then
local cavity = dist * dist - 1
local rim_x = min(dist - 1 - rim_width, 0)
local rim = rim_steep * rim_x * rim_x
local shape = smooth_max(cavity, floor_h, smooth)
shape = smooth_min(shape, rim, smooth)
crater_h = crater_h + shape * k.r
end
if dist > 0.8 and dist < ejecta_reach then
glow = glow + 1 - smoothstep(1, ejecta_reach, dist)
end
end
end
heights[i + 1] = h + crater_h * cr.depth
ejecta[i + 1] = min(1, glow)
if i % 256 == 0 then
checkpoint()
end
end
-- Positions on a unit sphere plus elevation, re-centered so the mean radius stays 1.
local mean = 0
for i = 1, count do
mean = mean + heights[i]
end
mean = mean / count
local positions = {}
local min_h, max_h = math.huge, -math.huge
for i = 0, count - 1 do
local hh = heights[i + 1] - mean
heights[i + 1] = hh
local r = 1 + hh
positions[i * 3 + 1] = dirs[i * 3 + 1] * r
positions[i * 3 + 2] = dirs[i * 3 + 2] * r
positions[i * 3 + 3] = dirs[i * 3 + 3] * r
if hh < min_h then
min_h = hh
end
if hh > max_h then
max_h = hh
end
end
checkpoint()
-- Area-weighted vertex normals (the same two triangles per quad as terrain.js).
local normals = {}
for i = 1, count * 3 do
normals[i] = 0
end
local P = positions
local function tri(a, b, c)
a, b, c = a * 3, b * 3, c * 3
local e1x, e1y, e1z = P[b + 1] - P[a + 1], P[b + 2] - P[a + 2], P[b + 3] - P[a + 3]
local e2x, e2y, e2z = P[c + 1] - P[a + 1], P[c + 2] - P[a + 2], P[c + 3] - P[a + 3]
local nx = e1y * e2z - e1z * e2y
local ny = e1z * e2x - e1x * e2z
local nz = e1x * e2y - e1y * e2x
normals[a + 1] = normals[a + 1] + nx
normals[a + 2] = normals[a + 2] + ny
normals[a + 3] = normals[a + 3] + nz
normals[b + 1] = normals[b + 1] + nx
normals[b + 2] = normals[b + 2] + ny
normals[b + 3] = normals[b + 3] + nz
normals[c + 1] = normals[c + 1] + nx
normals[c + 2] = normals[c + 2] + ny
normals[c + 3] = normals[c + 3] + nz
end
for f = 0, 5 do
local base = f * per
for y = 0, n - 1 do
for x = 0, n - 1 do
local i = base + y * row + x
tri(i, i + 1, i + n + 2)
tri(i, i + n + 2, i + n + 1)
end
checkpoint()
end
end
-- Faces duplicate their edge vertices; sum the copies so there are no lighting seams.
local seams = {}
for i = 0, count - 1 do
local key = keys[i + 1]
if key >= 0 then
local g = seams[key]
if g then
g[#g + 1] = i
else
seams[key] = { i }
end
end
end
for _, group in pairs(seams) do
if #group > 1 then
local sx, sy, sz = 0, 0, 0
for _, i in ipairs(group) do
sx, sy, sz = sx + normals[i * 3 + 1], sy + normals[i * 3 + 2], sz + normals[i * 3 + 3]
end
for _, i in ipairs(group) do
normals[i * 3 + 1], normals[i * 3 + 2], normals[i * 3 + 3] = sx, sy, sz
end
end
end
for i = 0, count - 1 do
local o = i * 3
local nx, ny, nz = normals[o + 1], normals[o + 2], normals[o + 3]
local len = sqrt(nx * nx + ny * ny + nz * nz)
if len == 0 then
len = 1
end
normals[o + 1], normals[o + 2], normals[o + 3] = nx / len, ny / len, nz / len
end
checkpoint()
-- Colors: elevation gradient, dark patches, steep slopes, crater halos, grain.
local sd = p.shading
local low, mid, high = M.linear_color(sd.low), M.linear_color(sd.mid), M.linear_color(sd.high)
local steep, patch = M.linear_color(sd.steep), M.linear_color(sd.patch)
local colors = {}
local range = max_h - min_h
if range == 0 then
range = 1
end
local patch_amount, patch_freq, grain = sd.patchAmount, sd.patchFrequency, sd.grain
local st_lo, st_hi = sd.steepThreshold - sd.steepBlend, sd.steepThreshold + sd.steepBlend
local cr_ejecta = cr.ejecta
for i = 0, count - 1 do
local o = i * 3
local x, y, z = dirs[o + 1], dirs[o + 2], dirs[o + 3]
local e = (heights[i + 1] - min_h) / range
local lo, hi, te
if e < 0.5 then
lo, hi, te = low, mid, e * 2
else
lo, hi, te = mid, high, (e - 0.5) * 2
end
local c1 = lo[1] + (hi[1] - lo[1]) * te
local c2 = lo[2] + (hi[2] - lo[2]) * te
local c3 = lo[3] + (hi[3] - lo[3]) * te
if patch_amount > 0 then
local pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, patch_freq, 0.5, 2)
-- Patches favor low ground, like maria.
local w = smoothstep(-0.05, 0.4, pn) * patch_amount * (1 - e * 0.6)
c1, c2, c3 = c1 + (patch[1] - c1) * w, c2 + (patch[2] - c2) * w, c3 + (patch[3] - c3) * w
end
local g = ejecta[i + 1] * cr_ejecta
c1, c2, c3 = c1 + (high[1] - c1) * g, c2 + (high[2] - c2) * g, c3 + (high[3] - c3) * g
local steepness = 1 - (normals[o + 1] * x + normals[o + 2] * y + normals[o + 3] * z)
local ws = smoothstep(st_lo, st_hi, steepness)
c1, c2, c3 = c1 + (steep[1] - c1) * ws, c2 + (steep[2] - c2) * ws, c3 + (steep[3] - c3) * ws
local gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * grain
colors[o + 1], colors[o + 2], colors[o + 3] = c1 * gr, c2 * gr, c3 * gr
if i % 512 == 0 then
checkpoint()
end
end
return {
n = n,
count = count,
positions = positions,
normals = normals,
colors = colors,
min_h = min_h,
max_h = max_h,
}
end
return M
+645
View File
@@ -0,0 +1,645 @@
-- Client-side prediction: show an action's likely result before the server answers.
--
-- The server still decides. A predictor only reuses numbers the server sent (stake
-- `take`, the tool's `cycleSecs`/`yield`, the market quote and `rules.pressure`, ...,
-- see ../api.md "Prediction"); it never invents game rules. The game screen keeps
-- the last server state as the base and shows base + every pending prediction, re-applied
-- in click order whenever a response brings a new base. A response drops its own
-- prediction (200: the server's state has the real result; 422/network error: rolled back).
--
-- A predictor: fn(st, params, ctx) -> true when it predicted (and changed st), false/nil
-- when it can't (the button then shows the busy spinner until the response).
-- st a copy of the shown state; change it in place
-- params the action's params (view params included, as sent)
-- ctx { now = server ms at the click, seq = the clientSeq, memo = M.memo }
-- It must be deterministic in (st, params, ctx): it runs again on every new base, so
-- read the time from ctx.now, never from the clock. Return false when the state already
-- shows the effect is impossible (nothing ready, tool busy, amount over the balance):
-- the server will answer with the truth. After the change, call M.derive(st) (done for
-- you after every predictor) to refresh the aggregates (ready counts, idle, nextEnd, ...).
--
-- Register one per action name (game.gui_script's action() looks it up), e.g. in a section
-- module: predict.register("refine", function(st, p, ctx) ... return true end)
-- or pass one as action()'s optional 7th argument for a single button.
local M = {}
M.registry = {}
-- Debug (mm.predict_skew): scale predicted gains (claims, sales) so corrections show.
M.skew = tonumber(sys.get_config_string("mm.predict_skew", "1")) or 1
-- What the client learned from server states (not rules): the well each tool last mined.
M.memo = { last_well = {} }
function M.register(name, fn)
M.registry[name] = fn
end
---------------------------------------------------------------- state helpers
local SHARED = { catalog = true, rules = true }
local function copy(v, top)
if type(v) ~= "table" then
return v
end
local out = {}
for k, x in pairs(v) do
out[k] = (top and SHARED[k]) and x or copy(x)
end
return out
end
function M.copy(st)
return copy(st, true)
end
local function find(list, id, key)
key = key or "id"
for i, x in ipairs(list or {}) do
if x[key] == id then
return x, i
end
end
end
M.find = find
-- Every well entry with this id: the moon list's and the selected moon's.
local function wells(st, id)
local out = {}
for _, list in ipairs({ st.moons or {}, st.past or {} }) do
for _, moon in ipairs(list) do
local w = find(moon.wells, id)
if w then
out[#out + 1] = w
end
end
end
if st.moon then
local w = find(st.moon.wells, id)
if w then
out[#out + 1] = w
end
end
return out
end
function M.supply(st, well_id)
local w = wells(st, well_id)[1]
return w and w.supply or 0
end
function M.add_supply(st, well_id, delta)
for _, w in ipairs(wells(st, well_id)) do
w.supply = math.max(0, w.supply + delta)
end
end
-- Market pressure (api.md): the average price factor over d base-price credits of sales
-- on top of pressure p (factor at p when d is 0).
function M.pressure_avg(rules, p, d)
local pr = rules.pressure
if d <= 0 then
return pr.floor + (1 - pr.floor) * math.exp(-p / pr.scale)
end
return pr.floor + (1 - pr.floor) * pr.scale * (math.exp(-p / pr.scale) - math.exp(-(p + d) / pr.scale)) / d
end
-- A quote's pressure at server time t (it halves every halfLifeSecs after st.now).
function M.pressure_at(st, q, t)
local hl = st.rules.pressure.halfLifeSecs
return (q.pressure or 0) * 0.5 ^ (math.max(0, t - st.now) / 1000 / hl)
end
local function refresh_quote(st, q)
q.value = q.balance * q.price
if q.unpressed and q.base then
q.sellAll = q.balance * q.unpressed * M.pressure_avg(st.rules, q.pressure or 0, q.balance * q.base)
end
end
function M.refresh_market(st)
if not st.market then
return
end
local total = 0
for _, q in ipairs(st.market.quotes) do
total = total + (q.value or 0)
end
st.market.total = total
end
-- Add delta of a resource or refined item (balance, market quote, refinery stock).
function M.add_item(st, name, delta)
local e = find(st.resources, name, "name") or find(st.refined, name, "name")
if not e then
e = { name = name, amount = 0 }
st.refined = st.refined or {}
st.refined[#st.refined + 1] = e
end
e.amount = math.max(0, e.amount + delta)
local q = st.market and find(st.market.quotes, name, "item")
if q then
q.balance = e.amount
refresh_quote(st, q)
end
local rf = st.refinery
if rf then
local s = find(rf.stock, name, "name")
if s then
s.amount = e.amount
end
for _, r in ipairs(rf.recipes or {}) do
local i = find(r.inputs, name, "name")
if i then
i.have = e.amount
end
end
end
end
function M.amount(st, name)
local e = find(st.resources, name, "name") or find(st.refined, name, "name")
return e and e.amount or 0
end
---------------------------------------------------------------- tools and stakes
function M.tool(st, id)
return find(st.tools, id)
end
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
function M.idle_copy(st, id)
local t = find(st.tools, id)
if not t then
return nil
end
if not t.stakeId then
return t
end
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
return x
end
end
end
local function yield_boost(st, resource)
local b = st.rules and st.rules.boosts and st.rules.boosts.yield or {}
return b[resource] or 1
end
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
-- group: the well panel's idle group for it (cycle and yield already for this well).
function M.add_stake(st, tool, moon_id, well, ctx, group)
local cycle = group and group.cycleSecs or tool.cycleSecs
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
local committed = 0
for _, s in ipairs(st.stakes) do
if s.wellId == well.id then
committed = committed + (s.take or 0)
end
end
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
local s = {
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
resource = well.resource, amount = amount, reach = tool.reach or 1,
start = ctx.now, ["end"] = ctx.now + cycle * 1000, durationSecs = cycle, ready = false, take = take,
predicted = true,
}
st.stakes[#st.stakes + 1] = s
tool.stakeId = s.id
-- Off the selected well's idle list.
if st.well then
for i, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
g.count = g.count - 1
if g.toolId == tool.id then
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
g.toolId = x.id
break
end
end
end
if g.count <= 0 then
table.remove(st.well.idle, i)
end
break
end
end
end
-- It no longer counts for Redrill on the well it last mined.
local last = M.memo.last_well[tool.id]
if last and st.moon then
local w = find(st.moon.wells, last)
if w and (w.redrill or 0) > 0 then
w.redrill = w.redrill - 1
end
end
return s
end
-- Take a stake off (claimed); its tool becomes idle.
function M.remove_stake(st, s)
local _, i = find(st.stakes, s.id)
if i then
table.remove(st.stakes, i)
end
local tool = find(st.tools, s.toolId)
if not tool then
return
end
tool.stakeId = nil
if st.moon then
local w = find(st.moon.wells, s.wellId)
if w then
w.redrill = (w.redrill or 0) + 1
end
end
if st.well and st.well.idle then
local g = nil
for _, x in ipairs(st.well.idle) do
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
g = x
end
end
if g then
g.count = g.count + 1
else
local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = {
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
short = free < y, attachments = tool.attachments or {},
}
end
end
end
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
-- buttons' amounts, next ends). Wide drills (reach > 1) are left as the server sent them.
function M.derive(st)
local ready, idle, next_end = 0, 0, nil
local per_moon = {}
for _, s in ipairs(st.stakes or {}) do
local m = per_moon[s.moonId] or { drills = 0, ready = 0 }
per_moon[s.moonId] = m
m.drills = m.drills + 1
if s.ready then
ready = ready + 1
m.ready = m.ready + 1
elseif not next_end or s["end"] < next_end then
next_end = s["end"]
end
end
for _, t in ipairs(st.tools or {}) do
if not t.stakeId then
idle = idle + 1
end
end
st.ready, st.idle, st.nextEnd = ready, idle, next_end
for _, moon in ipairs(st.moons or {}) do
local m = per_moon[moon.id] or { drills = 0, ready = 0 }
moon.drills, moon.ready = m.drills, m.ready
end
local moon = st.moon
if not moon then
return
end
local mready, mnext = 0, nil
for _, w in ipairs(moon.wells or {}) do
local old = {}
for _, d in ipairs(w.drills or {}) do
old[d.id] = d
end
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0
for _, s in ipairs(st.stakes or {}) do
if s.wellId == w.id and (s.reach or 1) <= 1 then
local d = old[s.id]
if not d then
d = copy(s)
d.gets, d.here = s.take, s.take
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
end
drills[#drills + 1] = d
demand = demand + (d.here or s.take or 0)
if s.ready then
wready = wready + 1
amount = amount + (s.take or 0)
elseif not wnext or s["end"] < wnext then
wnext = s["end"]
end
end
end
table.sort(drills, function(a, b) return a.start < b.start end)
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
w.demand = demand
w.remain = math.max(0, w.supply - demand)
mready = mready + wready
if wnext and (not mnext or wnext < mnext) then
mnext = wnext
end
end
if moon.wide then
mready = mready + (moon.wide.ready or 0)
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
mnext = moon.wide.nextEnd
end
end
moon.ready, moon.nextEnd = mready, mnext
end
---------------------------------------------------------------- queue
-- Learn from a server state (the well each running or unclaimed drill is on).
function M.learn(st)
for _, s in ipairs(st.stakes or {}) do
M.memo.last_well[s.toolId] = s.wellId
end
end
-- Run one pending entry ({fn, params, at, seq}) on st; true when it predicted.
function M.run(st, e)
local ok, res = pcall(e.fn, st, e.params, { now = e.at, seq = e.seq, memo = M.memo })
if not ok then
print("predict: " .. tostring(e.name) .. " failed: " .. tostring(res))
return false
end
if res then
M.derive(st)
end
return res and true or false
end
-- The state to show: base with every pending prediction applied in order (base itself
-- when nothing is pending).
function M.apply(base, pending)
if #pending == 0 then
return base
end
local st = M.copy(base)
for _, e in ipairs(pending) do
M.run(st, e)
end
return st
end
---------------------------------------------------------------- core-loop predictors
-- Finished: ready in the state, or its end has passed by ctx.now (the Claim button turns
-- on at the countdown's zero, before the refetch says ready: core/ready, hooks claim_wait).
local function done(x, ctx)
return x.ready or (x["end"] ~= nil and x["end"] <= ctx.now)
end
-- claim: claim-well (its own drills only, like the server: claim-wide takes the multi-well
-- ones) / claim-moon / everything ready; redeploy=1 restarts the same tools.
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {}
for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
if wide then
return false -- multi-well drills: the server splits them
end
list[#list + 1] = s
end
end
if #list == 0 then
return false
end
for _, s in ipairs(list) do
local take = (s.take or 0) * M.skew
M.add_item(st, s.resource, take)
M.add_supply(st, s.wellId, -(s.take or 0))
M.remove_stake(st, s)
end
M.refresh_market(st)
if not p.redeploy then
for _, s in ipairs(list) do
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail then
detail.redrill = (detail.redrill or 0) + 1
end
end
else
for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1]
if tool and not tool.stakeId and w and w.supply > 0 then
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
detail.redrill = detail.redrill - 1
end
end
end
end
return true
end)
local function moon_of_well(st, well_id)
for _, moon in ipairs(st.moons or {}) do
local w = find(moon.wells, well_id)
if w then
return moon, w
end
end
end
-- stake (deploy): the deploy card's tool on the target well, timer from now.
M.register("stake", function(st, p, ctx)
local moon, w = moon_of_well(st, p.target)
if not moon or w.supply <= 0 then
return false
end
if st.view and ((p.moon and p.moon ~= st.view.moon) or (p.well and p.well ~= st.view.well)) then
return false -- "Deploy there": the view moves to the other well, wait for it
end
local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then
return false
end
local group = nil
if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
group = g
end
end
end
M.add_stake(st, tool, moon.id, w, ctx, group)
return true
end)
-- redrill: the idle tools that last mined the well (as far as the client saw).
M.register("redrill", function(st, p, ctx)
local id = p["redrill-well"]
local moon, w = moon_of_well(st, id)
if not moon or w.supply <= 0 then
return false
end
local detail = st.moon and find(st.moon.wells, id)
local n = detail and detail.redrill or 0
local tools = {}
for _, t in ipairs(st.tools) do
if #tools < n and not t.stakeId and M.memo.last_well[t.id] == id and (t.reach or 1) <= 1 then
tools[#tools + 1] = t
end
end
if #tools == 0 then
return false
end
for _, t in ipairs(tools) do
local group = nil
if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then
group = g
end
end
end
M.add_stake(st, t, moon.id, w, ctx, group)
end
if detail then
detail.redrill = 0
end
return true
end)
-- sell: amount × unpressed × 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
return false
end
local amount = p.all and q.balance or tonumber(p.amount)
if not amount or amount <= 0 or amount > q.balance + 1e-9 then
return false
end
local rules = st.rules
local pnow = M.pressure_at(st, q, ctx.now)
local d = amount * q.base
local earned = amount * q.unpressed * M.pressure_avg(rules, pnow, d) * M.skew
st.credits = st.credits + earned
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))
q.factor = M.pressure_avg(rules, after, 0)
q.price = q.unpressed * q.factor
M.add_item(st, q.item, -amount)
M.refresh_market(st)
return true
end)
---------------------------------------------------------------- refinery predictors
-- Only when the refinery block is in the state (s=refinery).
local function refresh_refinery(st)
local rf = st.refinery
local free, ready = 0, 0
for _, slot in ipairs(rf.slots or {}) do
if not slot.job then
free = free + 1
elseif slot.job.ready then
ready = ready + 1
end
end
rf.free, rf.ready = free, ready
for _, r in ipairs(rf.recipes or {}) do
local max = rf.maxBatch or (st.rules.refinery and st.rules.refinery.maxBatch) or 1
for _, i in ipairs(r.inputs or {}) do
if i.need > 0 then
max = math.min(max, math.floor(i.have / i.need))
end
end
r.maxQty = math.max(0, max)
if free == 0 then
r.why = "busy"
elseif r.maxQty < 1 then
r.why = "inputs"
else
r.why = ""
end
end
end
-- refine: a batch of qty (or maxQty) in the first free slot.
M.register("refine", function(st, p, ctx)
local rf = st.refinery
if not rf then
return false
end
local r = find(rf.recipes, p.recipe, "item")
if not r then
return false
end
local qty = p.all and r.maxQty or tonumber(p.qty)
if not qty or qty < 1 or qty ~= math.floor(qty) or qty > (r.maxQty or 0) then
return false
end
local slot = nil
for _, s in ipairs(rf.slots or {}) do
if not s.job and not slot then
slot = s
end
end
if not slot then
return false
end
local rr = st.rules.refinery
local secs = math.floor(r.seconds * qty ^ rr.bulkExponent / (rr.speed or 1) + 0.5)
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
slot.job = job
st.jobs = st.jobs or {}
st.jobs[#st.jobs + 1] = copy(job)
for _, i in ipairs(r.inputs or {}) do
M.add_item(st, i.name, -i.need * qty)
end
refresh_refinery(st)
M.refresh_market(st)
return true
end)
-- collect: one finished batch (job) or every finished one.
M.register("collect", function(st, p, ctx)
local rf = st.refinery
if not rf then
return false
end
local any = false
for _, slot in ipairs(rf.slots or {}) do
local j = slot.job
if j and done(j, ctx) and (not p.job or p.job == j.id) then
M.add_item(st, j.item, j.qty)
rf.stock = rf.stock or {}
if not find(rf.stock, j.item, "name") then
rf.stock[#rf.stock + 1] = { name = j.item, amount = M.amount(st, j.item) }
end
slot.job = nil
local _, i = find(st.jobs, j.id)
if i then
table.remove(st.jobs, i)
end
any = true
end
end
if not any then
return false
end
refresh_refinery(st)
M.refresh_market(st)
return true
end)
return M
+137
View File
@@ -0,0 +1,137 @@
-- Screen size, UI scale, display scale and fullscreen. Layout units are window pixels
-- divided by the scale: at scale 1 one layout unit is one window pixel, so a bigger window
-- shows the same-size text and controls with more room, not a scaled-up 1280x800 layout.
-- Windows smaller than 1280x800 get a smaller layout (the regions rearrange, like the
-- web's breakpoints) down to FLOOR_W x FLOOR_H; below that the UI scales down to fit it.
--
-- The scale is the player's UI scale (saved in the settings file, or forced with
-- --config=mm.ui_scale=1.5, then not saved) times the display's scale (2 on a HiDPI / 2x
-- screen, window.get_display_scale(); high_dpi is on, so window sizes are physical pixels;
-- --config=mm.display_scale=2 pretends one). screens pick changes up on their next update.
--
-- Fullscreen: the mmwindow native extension (mmwindow/), saved with the UI scale; F11 and
-- the display settings (screens/game/settings.lua) toggle it.
--
-- A screen's "root" node keeps adjust FIT against 1280x800 (display.width/height); M.fit(root)
-- scales it on top of that adjust so the result is the scale's window pixels per unit.
local M = {}
M.MIN_W, M.MIN_H = 1280, 800 -- display.width/height: the GUI's adjust reference
M.FLOOR_W, M.FLOOR_H = 960, 600 -- the smallest layout; smaller windows scale it down
M.STEPS = { 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2 } -- the settings' UI scale steps
local settings = require("modules.settings")
local function clamp_scale(s)
s = tonumber(s) or 1
return math.max(0.5, math.min(4, s))
end
local load_settings = settings.load
local function save_settings(fn)
local data = load_settings()
fn(data)
if not settings.save(data) then
print("screen: could not save the settings")
end
end
local forced_scale = sys.get_config_string("mm.ui_scale", "") ~= ""
local forced_display = tonumber(sys.get_config_string("mm.display_scale", ""))
do
local saved = load_settings()
M.ui_scale = clamp_scale(forced_scale and sys.get_config_number("mm.ui_scale", 1) or saved.ui_scale or 1)
M.want_fullscreen = saved.fullscreen == true
end
-- Sets the player's UI scale; saved unless --config=mm.ui_scale forces it.
function M.set_ui_scale(s)
M.ui_scale = clamp_scale(s)
if not forced_scale then
save_settings(function(d) d.ui_scale = M.ui_scale end)
end
end
-- The next / previous step from the current scale (dir 1 / -1); 0: back to 1.
function M.step_ui_scale(dir)
if dir == 0 then
return M.set_ui_scale(1)
end
local pick = M.ui_scale
for i = dir > 0 and 1 or #M.STEPS, dir > 0 and #M.STEPS or 1, dir do
local s = M.STEPS[i]
if (dir > 0 and s > M.ui_scale + 0.001) or (dir < 0 and s < M.ui_scale - 0.001) then
pick = s
break
end
end
M.set_ui_scale(pick)
end
function M.display_scale()
if forced_display then
return forced_display
end
local ok, s = pcall(window.get_display_scale)
return (ok and tonumber(s) and s > 0) and s or 1
end
---------------------------------------------------------------- fullscreen
function M.can_fullscreen()
return mmwindow ~= nil
end
function M.fullscreen()
return mmwindow ~= nil and mmwindow.is_fullscreen()
end
-- Fullscreen on or off; saved.
function M.set_fullscreen(on)
if mmwindow then
mmwindow.set_fullscreen(on)
end
M.want_fullscreen = on
save_settings(function(d) d.fullscreen = on end)
end
function M.toggle_fullscreen()
M.set_fullscreen(not M.fullscreen())
end
-- At start (main.script): the saved fullscreen, unless the window size is forced
-- (--config=mm.window, tests and shots).
function M.apply_saved()
if M.want_fullscreen and mmwindow and sys.get_config_string("mm.window", "") == "" then
mmwindow.set_fullscreen(true)
end
end
---------------------------------------------------------------- layout
-- Layout size (W, H, in units), window pixels per unit, and the root's scale relative to
-- its FIT adjust.
function M.layout()
local ww, wh = window.get_size()
if ww == 0 or wh == 0 then
return M.MIN_W, M.MIN_H, 1, 1
end
local fit = math.min(ww / M.FLOOR_W, wh / M.FLOOR_H)
local s = math.min(M.ui_scale * M.display_scale(), fit)
local adjust = math.min(ww / M.MIN_W, wh / M.MIN_H)
return math.floor(ww / s + 0.5), math.floor(wh / s + 0.5), s, s / adjust
end
-- Scale and size a screen's root node (pivot center, adjust FIT) for the window; returns
-- the layout size W, H.
function M.fit(root)
local W, H, _, k = M.layout()
gui.set_scale(root, vmath.vector3(k, k, 1))
gui.set_size(root, vmath.vector3(W, H, 0))
return W, H
end
return M
+47
View File
@@ -0,0 +1,47 @@
-- The player's settings (mute and volume: sfx, theme, reduced motion: anim, UI scale and
-- fullscreen: screen), one file:
-- sys.get_save_file("moonminers", "settings") (~/.local/share/moonminers/settings on
-- Linux). Reduced motion used to go to a "MoonMiners" folder of its own; its value moves
-- over once, when the new file has none.
--
-- settings.load() -- the table (empty when there's no file)
-- settings.save({ theme = "light" }) -- merges these keys in; false when it couldn't
local M = {}
local FILE = sys.get_save_file("moonminers", "settings")
local OLD = (FILE:gsub("moonminers([/\\]settings)$", "MoonMiners%1"))
function M.load()
local ok, t = pcall(sys.load, FILE)
return ok and type(t) == "table" and t or {}
end
function M.save(fields)
local t = M.load()
for k, v in pairs(fields) do
t[k] = v
end
local ok, saved = pcall(sys.save, FILE, t)
return ok and saved
end
-- The old file's reduced motion, once (only read when it exists: get_save_file would make
-- its folder).
local function migrate()
if OLD == FILE or M.load().reduced_motion ~= nil then
return
end
local f = io.open(OLD, "rb")
if not f then
return
end
f:close()
local ok, old = pcall(sys.load, OLD)
if ok and type(old) == "table" and old.reduced_motion ~= nil then
M.save({ reduced_motion = old.reduced_motion == true })
end
end
migrate()
return M
+207
View File
@@ -0,0 +1,207 @@
-- Sound effects (display only, like fx.lua): a port of the web build's sfx.js.
--
-- Clips in assets/sfx (Kenney CC0 packs plus synth layers, see assets/sfx/CREDITS.txt) and
-- the ZzFX synth sounds, pre-rendered to .wav by scripts/zzfx-render.mjs with the web's
-- parameters (their gains baked in). Each is a sound component on main:/sfx (main.collection,
-- always loaded, so the game and popup screens can both play them). The pitch follows the
-- game through the component's playback speed (counter ticks, rising claim streaks).
--
-- Muted when the player mutes (header speaker, saved with sys.save), while the window is
-- unfocused or minimized (set_hidden), or while something calls sfx.mute() (e.g. an ad;
-- mute/unmute nest). Debug: mm.sfx_debug=1 logs every play.
local M = {}
-- name: gain. Clips are loudness-matched (UI about -24 LUFS, rewards about -18); the synth
-- sounds have their gain in the file (1 here).
local GAIN = {
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
}
-- Minimum time between two plays of the same sound, in seconds.
local GAP = { tick = 0.03, arrive = 0.045, coin = 0.045 }
local DEFAULT_GAP = 0.06
local MAX_VOICES = 16
local settings = require("modules.settings")
local GROUP = hash("sfx")
local debug = sys.get_config_int("mm.sfx_debug", 0) == 1
local state = { muted = false, external = 0, volume = 0.7, hidden = false }
local last = {}
local voices = 0
local function load_settings()
local data = settings.load()
state.muted = data.sfx_muted == true
if type(data.sfx_volume) == "number" then
state.volume = math.max(0, math.min(1, data.sfx_volume))
end
end
local function save_settings()
if not settings.save({ sfx_muted = state.muted, sfx_volume = state.volume }) then
print("sfx: could not save the settings")
end
end
local function silent()
return state.muted or state.external > 0 or state.hidden
end
local function apply_gain()
sound.set_group_gain(GROUP, silent() and 0 or state.volume)
end
load_settings()
local applied = false
-- play("buy"), play("tick", { rate = 1.2, gain = 0.5, delay = 100 }). rate is a playback
-- rate (1 = as recorded), delay in ms like the web; each play varies pitch and volume a
-- little.
function M.play(name, opts)
if not applied then
applied = true
apply_gain()
end
local base = GAIN[name]
if not base or silent() then
return
end
opts = opts or {}
local rate, gain, delay, vary = opts.rate or 1, opts.gain or 1, (opts.delay or 0) / 1000, opts.vary or 0.03
local when = socket.gettime() + delay
local gap = GAP[name] or DEFAULT_GAP
if last[name] and math.abs(when - last[name]) < gap then
return
end
if voices >= MAX_VOICES then
return
end
last[name] = when
local speed = rate * (1 + (math.random() * 2 - 1) * vary)
local g = base * gain * (1 - math.random() * 0.12)
voices = voices + 1
local ok, err = pcall(sound.play, "main:/sfx#" .. name, { delay = delay, gain = g, speed = speed }, function()
voices = math.max(0, voices - 1)
end)
if not ok then
voices = math.max(0, voices - 1)
print("sfx: can't play " .. name .. ": " .. tostring(err))
elseif debug then
print(string.format("sfx: %s rate %.3f gain %.2f delay %d", name, speed, g, delay * 1000))
end
end
local function semitones(n)
return 2 ^ (n / 12)
end
M.semitones = semitones
-- Consecutive claims within a few seconds climb in pitch (up to a fifth).
local streak = 0
local streak_at = -math.huge
-- A claim: a rising servo whir as the sparks burst out.
function M.claim()
local now = socket.gettime()
streak = now - streak_at < 8 and math.min(streak + 1, 7) or 0
streak_at = now
M.play("harvest", { rate = semitones(streak) })
end
local STEPS = { 0, 4, 7, 12, 16, 19 }
-- Ticks timed to fx's count (750 ms, ease-out cubic): one each time the number passes
-- another sixth of the way, so they slow down with it.
function M.count_up(ms, steps)
ms, steps = ms or 750, steps or 6
for k = 1, steps do
local t = 1 - (1 - k / steps) ^ (1 / 3)
M.play("tick", { delay = t * ms, rate = semitones(streak + k), gain = 1 - k * 0.08, vary = 0.01 })
end
end
-- Resource number i (0-based) of a claim (or collect) lands in the wallet: a blip that
-- rises with i and with the streak, then counter ticks while the number counts up
-- (still: no ticks, the number doesn't count with reduced motion, like the web).
function M.arrive(i, still)
i = i or 0
M.play("arrive", { rate = semitones(streak + STEPS[math.min(i, 5) + 1]) })
if i == 0 and not still then
M.count_up()
end
end
-- Credits arrive: a transfer blip, plus more for bigger amounts (one per digit, up to 6),
-- spaced like the coins flying in.
function M.cash(amount)
local n = math.max(1, math.min(6, math.floor(math.log10(math.max(1, amount))) + 1))
local loud = math.min(1.15, 0.8 + n * 0.06)
for i = 0, n - 1 do
M.play("coin", { delay = i * 80, rate = semitones(i % 2 == 1 and 2 or 0) * (1 + i * 0.01), gain = loud * (1 - i * 0.1) })
end
end
-- A moon is mined out: a low thud and a falling shimmer, after the claim's own sounds.
function M.mined_out(delay)
delay = delay or 0
M.play("burst", { delay = delay, rate = 0.7, gain = 0.7 })
M.play("shimmer", { delay = delay + 220, rate = 0.6, gain = 0.8 })
end
-- A market sale: a confirm blip as the payout swells, then the power-down sweep.
function M.power_down()
M.play("confirm", { gain = 0.7 })
M.play("powerdown", { delay = 240, vary = 0.02 })
end
---------------------------------------------------------------- settings and outside control
function M.muted()
return state.muted
end
-- The player's mute (the header's speaker); saved.
function M.toggle_mute()
state.muted = not state.muted
save_settings()
apply_gain()
if not state.muted then
M.play("toggle")
end
return state.muted
end
-- For ads and the like: mute() and unmute() nest and leave the player's setting alone.
function M.mute()
state.external = state.external + 1
apply_gain()
end
function M.unmute()
state.external = math.max(0, state.external - 1)
apply_gain()
end
-- The window lost focus or was minimized (main.script's window listener), like the web
-- tab going hidden (sfx.js visibilitychange): silent until it's back. Not saved.
function M.set_hidden(on)
state.hidden = on and true or false
apply_gain()
end
function M.hidden()
return state.hidden
end
function M.set_volume(v)
state.volume = math.max(0, math.min(1, tonumber(v) or 0.7))
save_settings()
apply_gain()
end
return M
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more