Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2451792d46 | ||
|
|
48aa8900a8 | ||
|
|
c1ad202e42 | ||
|
|
25f315e8dc | ||
|
|
571075c5ff | ||
|
|
fbde1b79ae | ||
|
|
4e7e2d010d | ||
|
|
020d1f7197 | ||
|
|
1fd1059aa7 | ||
|
|
a8bf63ffe4 |
No files matched your search
@@ -7,3 +7,4 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
web/static/css/app.css
|
web/static/css/app.css
|
||||||
art
|
art
|
||||||
|
client
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
build/
|
||||||
|
tools/
|
||||||
|
.internal/
|
||||||
|
*.der
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
@@ -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 `<` `&`). `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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 111 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 310 B |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
name: "MMWindow"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||