Compare commits

4 Commits
Author SHA1 Message Date
JosephFerano e267992615 Tool art: keep the tier ring and tops inside the deploy card crop 2026-10-19 22:37:26 +07:00
JosephFerano fa5fc46cb8 AGENTS.md: tool art script 2026-10-19 22:34:16 +07:00
JosephFerano 7af1c5a821 Show each tool's tier art in Equipment, deploy cards, drill lines and the API
StoreItem.ImageAt picks <image>-tN, falling back to the base image when a tier has
no file. API: state.tools and equipment rows use it; catalog tiers get an image.
TestAPI checks every tool has an image at every tier.
2026-10-19 22:33:44 +07:00
JosephFerano fcec292660 Tool art: generated drill images per tool and tier
scripts/tool-art.mjs draws each store item as an isometric SVG (own silhouette and
color per tool; tiers grow the rig, add parts, light a ring of tier segments and
strengthen the glow) and rasterizes it to web/static/assets/items. Tier 0 keeps the
existing file names; tier N adds -tN before the extension.
2026-10-19 22:33:44 +07:00
50 changed files with 589 additions and 11 deletions

No files matched your search

+3
View File
@@ -139,6 +139,9 @@ 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–015):
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.
+1 -1
View File
@@ -365,7 +365,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.Image, Tier: t.Tier, MaxTier: len(item.Upgrades), Image: itemImages + item.ImageAt(t.Tier), 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 {
+5 -4
View File
@@ -106,7 +106,7 @@ session; the web page also reads it once per page load.
| `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") | | `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") |
| `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) | | `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) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` | | `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` |
| `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` | | `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` |
| `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}?}` |
@@ -117,7 +117,7 @@ session; the web page also reads it once per page load.
| `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}]}]}], 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) | | `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) |
| `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,6 +138,7 @@ 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
@@ -353,7 +354,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": "9adb4eabab929f5d" "catalogVersion": "7d983412e77a3dde"
} }
} }
``` ```
@@ -366,7 +367,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.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1, "image": "/static/assets/items/shovel-t1.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,
+4 -2
View File
@@ -63,6 +63,7 @@ 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 {
@@ -147,7 +148,8 @@ 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)
} }
@@ -452,7 +454,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.Image, Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle, Image: itemImages + row.Item.ImageAt(row.Tier), 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),
+10
View File
@@ -5,10 +5,20 @@ 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} { for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
+17
View File
@@ -10,10 +10,13 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"maps" "maps"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"path"
"slices" "slices"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -183,6 +186,20 @@ func (item *StoreItem) ToolReach(tier int) int {
return max(1, reach) return max(1, reach)
} }
// ImageAt is the tool's image at a tier: tier N is the image's name with "-tN" before the
// extension (drawn by scripts/tool-art.mjs), else the tier 0 image when that file is missing.
func (item *StoreItem) ImageAt(tier int) string {
if tier <= 0 {
return item.Image
}
ext := path.Ext(item.Image)
name := fmt.Sprintf("%s-t%d%s", strings.TrimSuffix(item.Image, ext), tier, ext)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil {
return item.Image
}
return name
}
func (item *StoreItem) NextUpgrade(tier int) *Upgrade { func (item *StoreItem) NextUpgrade(tier int) *Upgrade {
for i := range item.Upgrades { for i := range item.Upgrades {
if item.Upgrades[i].Tier == tier+1 { if item.Upgrades[i].Tier == tier+1 {
+545
View File
@@ -0,0 +1,545 @@
// Tool art: draws every store item (drill) at every tier as an SVG and rasterizes it into
// web/static/assets/items (rsvg-convert + ImageMagick). Tier 0 keeps the item's `image` name
// from config/game.json; tier N is the same name with "-tN" before the extension (toolImage
// in game.go). Run from the repo root: node scripts/tool-art.mjs [--svg dir]
//
// Each tool has its own silhouette and color family; tiers grow the rig, add parts, light
// up a ring of tier segments around its base and strengthen the glow.
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
const SIZE = 512;
const OUT = "web/static/assets/items";
// ------------------------------------------------------------------ helpers
const C = Math.cos(Math.PI / 6);
const R2 = Math.SQRT2;
const f = (n) => +n.toFixed(1);
// Isometric projection: X runs down-right, Y down-left, Z up.
// While BB is set, every projected point grows it (the rig's bounding box, for fitting).
let BB = null;
function grow(x, y, r = 0) {
if (!BB) return;
BB.x0 = Math.min(BB.x0, x - r); BB.x1 = Math.max(BB.x1, x + r);
BB.y0 = Math.min(BB.y0, y - r); BB.y1 = Math.max(BB.y1, y + r);
}
const P = (x, y, z) => {
const p = [f((x - y) * C), f((x + y) * 0.5 - z)];
grow(...p);
return p;
};
const pts = (list) => list.map((p) => p.join(",")).join(" ");
function hex(c) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
const [x, y] = [hex(a), hex(b)];
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("");
}
const shade = (c, k) => (k >= 0 ? mix(c, "#ffffff", k) : mix(c, "#000000", -k));
// A seeded random, so the rocks stay put between runs.
function rng(seed) {
let s = seed * 2654435761 % 4294967296;
return () => ((s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296);
}
// Materials: hull plating, dark machinery, and the tool's own color.
const HULL = "#b9c8db";
const IRON = "#3a4a63";
class Art {
constructor(color) {
this.color = color;
this.out = [];
}
add(s) {
this.out.push(s);
}
poly(list, fill, extra = "") {
this.add(`<polygon points="${pts(list)}" fill="${fill}" ${extra}/>`);
}
line(a, b, stroke, w = 2, extra = "") {
this.add(`<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${extra}/>`);
}
// Box from its min corner (x, y, z), size w (X), d (Y), h (Z): top, right (+X), left (+Y).
box(x, y, z, w, d, h, col, edge = true) {
const st = edge ? `stroke="${shade(col, -0.55)}" stroke-width="1" stroke-linejoin="round"` : "";
this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, -0.18), st);
this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], shade(col, -0.45), st);
this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, 0.22), st);
}
cbox(cx, cy, z, w, d, h, col, edge) {
this.box(cx - w / 2, cy - d / 2, z, w, d, h, col, edge);
}
// Vertical cylinder centered on (cx, cy), from z up h.
cyl(cx, cy, z, r, h, col) {
const [bx, by] = P(cx, cy, z);
const [, ty] = P(cx, cy, z + h);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
const g = this.grad(col);
this.add(`<path d="M${bx - rx},${ty} L${bx - rx},${by} A${rx},${ry} 0 0 0 ${bx + rx},${by} L${bx + rx},${ty} Z" fill="url(#${g})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
this.add(`<ellipse cx="${bx}" cy="${ty}" rx="${rx}" ry="${ry}" fill="${shade(col, 0.25)}" stroke="${shade(col, -0.5)}" stroke-width="1"/>`);
}
// A flat disc (ellipse) lying at height z.
disc(cx, cy, z, r, fill, extra = "") {
const [x, y] = P(cx, cy, z);
this.add(`<ellipse cx="${x}" cy="${y}" rx="${f(r * C * R2)}" ry="${f(r * R2 * 0.5)}" fill="${fill}" ${extra}/>`);
}
// Cone (drill bit) from a disc of radius r at z1 down to a point at z2.
cone(cx, cy, z1, z2, r, col) {
const [x, y1] = P(cx, cy, z1);
const [, y2] = P(cx, cy, z2);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
this.add(`<path d="M${x - rx},${y1} A${rx},${ry} 0 0 0 ${x + rx},${y1} L${x},${y2} Z" fill="url(#${this.grad(col)})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
}
// Horizontal shading gradient for round parts (lit from the left).
grad(col) {
const id = "g" + col.slice(1);
this.defs ??= new Map();
if (!this.defs.has(id)) {
this.defs.set(id, `<linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="${shade(col, -0.25)}"/><stop offset="0.3" stop-color="${shade(col, 0.3)}"/>
<stop offset="0.65" stop-color="${shade(col, -0.2)}"/><stop offset="1" stop-color="${shade(col, -0.6)}"/></linearGradient>`);
}
return id;
}
// A light: a bright core with a soft halo in the tool's color.
light(p, r, k = 1, col = this.color) {
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${f(r * 2.6)}" fill="${col}" opacity="${f(0.35 * k * 10) / 10}" filter="url(#blur)"/>`);
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${r}" fill="${mix(col, "#ffffff", 0.6)}"/>`);
}
// Lattice between two square frames (a tower section): legs plus X bracing.
lattice(cx, cy, z1, z2, half1, half2, col, w = 3, bays = 4) {
const corner = (h, z) => [[cx - h, cy - h, z], [cx + h, cy - h, z], [cx + h, cy + h, z], [cx - h, cy + h, z]];
const at = (t) => corner(half1 + (half2 - half1) * t, z1 + (z2 - z1) * t);
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
if (i === 0) continue; // the back face would only add clutter
for (let b = 0; b < bays; b++) {
const lo = at(b / bays), hi = at((b + 1) / bays);
this.line(P(...lo[i]), P(...hi[j]), shade(col, -0.25), w * 0.55);
this.line(P(...lo[j]), P(...hi[i]), shade(col, -0.25), w * 0.55);
this.line(P(...hi[i]), P(...hi[j]), shade(col, -0.1), w * 0.6);
}
}
const lo = at(0), hi = at(1);
for (let i = 0; i < 4; i++) this.line(P(...lo[i]), P(...hi[i]), i === 2 ? shade(col, 0.2) : col, w);
}
// Diagonal warning stripes on the +Y face of a box.
stripes(x, y, z, w, d, h, n, col) {
for (let i = 0; i < n; i++) {
const a = x + (w / n) * i, b = a + (w / n) * 0.5;
this.poly([P(a, y + d, z), P(b, y + d, z), P(b + h * 0.4, y + d, z + h), P(a + h * 0.4, y + d, z + h)], col, 'opacity="0.85"');
}
}
}
// ------------------------------------------------------------------ the tools
// Each draw(a, t) adds the rig's shapes around the ground origin (0, 0, 0); t is the tier.
const TOOLS = {
// Prospector Drone PD-1: a hovering quad-rotor that pulls ore up with a tractor beam.
1: {
color: "#7cf06a",
size: 0.8,
draw(a, t) {
const c = a.color, z = 92;
// tractor beam and its pool on the ground
a.disc(0, 0, 0, 34, c, `opacity="${0.18 + t * 0.05}" filter="url(#blur)"`);
const [bx, by] = P(0, 0, z), [, gy] = P(0, 0, 0);
a.add(`<polygon points="${bx - 14},${by} ${bx + 14},${by} ${bx + 34},${gy} ${bx - 34},${gy}" fill="url(#beam)" opacity="${0.45 + t * 0.08}"/>`);
const arms = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
const rotor = (sx, sy) => {
const x = sx * 58, y = sy * 58;
a.cyl(x, y, z + 6, 7, 10, IRON);
a.disc(x, y, z + 17, 30, shade(c, 0.4), `opacity="0.18"`);
a.disc(x, y, z + 17, 30, "none", `stroke="${c}" stroke-width="${t >= 4 ? 3 : 1.5}" opacity="0.9"`);
a.light(P(x, y, z + 17), 2.5, 0.6);
};
// back rotors and arms first
for (const [sx, sy] of arms.slice(0, 1)) rotor(sx, sy);
for (const [sx, sy] of arms) a.line(P(0, 0, z + 10), P(sx * 58, sy * 58, z + 10), shade(HULL, -0.3), 7);
for (const [sx, sy] of arms.slice(1, 3)) rotor(sx, sy);
// body: a squat saucer with a dome
a.cyl(0, 0, z - 6, 30, 16, HULL);
a.cyl(0, 0, z + 10, 22, 6, IRON);
const [dx, dy] = P(0, 0, z + 16);
a.add(`<path d="M${dx - 22},${dy} A22,14 0 0 1 ${dx + 22},${dy} A22,9 0 0 1 ${dx - 22},${dy}" fill="${mix(c, "#0a2020", 0.55)}" stroke="${c}" stroke-width="1.5"/>`);
a.light([dx, dy - 4], 4, 0.8 + t * 0.1);
// side lamps grow in number with the tier
for (let i = 0; i < 2 + Math.min(t, 3); i++) a.light(P(26, -18 + i * 10, z + 2), 2.2, 0.7);
if (t >= 2) {
a.line(P(-8, -8, z + 18), P(-14, -14, z + 50), shade(HULL, -0.2), 2);
a.light(P(-14, -14, z + 50), 2.5, 1);
}
if (t >= 3) {
a.cyl(-6, 34, z - 14, 9, 22, c);
a.cyl(34, -6, z - 14, 9, 22, c);
}
rotor(1, 1);
},
},
// Percussion Drill PX-4: a tracked crawler with a hammer mast at its nose.
2: {
color: "#ff9a3d",
size: 0.86,
draw(a, t) {
const c = a.color;
// shock rings where the hammer bites
for (let i = 0; i < 1 + Math.ceil(t / 2); i++) {
a.disc(70, 0, 0, 22 + i * 14, "none", `stroke="${c}" stroke-width="2" opacity="${0.7 - i * 0.18}"`);
}
// tracks
a.box(-70, -48, 0, 120, 22, 26, IRON);
a.box(-70, 26, 0, 120, 22, 26, IRON);
for (let i = 0; i < 5; i++) {
const [x, y] = P(-60 + i * 25, 48, 13);
a.add(`<ellipse cx="${x}" cy="${y}" rx="8" ry="10" fill="${shade(IRON, -0.4)}" stroke="${shade(IRON, 0.3)}" stroke-width="1.5"/>`);
}
// hull, cab and the stack
a.box(-64, -40, 26, 108, 80, 26, HULL);
a.stripes(-64, -40, 26, 108, 80, 26, 7, c);
a.box(-60, -34, 52, 48, 56, 34 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-12, -26, 60), P(-12, 16, 60), P(-12, 16, 80), P(-12, -26, 80)], mix(c, "#04121a", 0.5));
a.cyl(-50, -24, 86 + (t >= 3 ? 10 : 0), 6, 26, IRON);
if (t >= 2) a.light(P(-50, -24, 118 + (t >= 3 ? 10 : 0)), 3, 0.8);
// the mast: a tall column with a piston block
const top = 150 + t * 10;
a.box(44, -12, 26, 16, 24, top - 26, shade(HULL, -0.1));
a.stripes(44, -12, 30, 16, 24, top - 34, 6, c);
a.cyl(70, 0, 30, 9, top - 40, shade(HULL, 0.1));
a.cbox(70, 0, 46 + (top - 80) * 0.45, 30, 30, 34, c);
a.cbox(70, 0, top - 10, 26, 26, 14, IRON);
a.cone(70, 0, 30, -6, 10, shade(HULL, -0.2));
a.light(P(70, 0, 2), 4, 0.6 + t * 0.1);
if (t >= 4) {
a.cyl(0, -8, 52, 12, 22, c);
a.light(P(0, -8, 74), 3, 1);
}
a.light(P(70, 0, top + 4), 3, 0.9);
},
},
// LunarLite L100: a light tripod with a spiral rotary bit.
3: {
color: "#5fd4ff",
size: 0.84,
draw(a, t) {
const c = a.color, hub = 130;
a.disc(0, 0, 0, 22, c, `opacity="${0.3 + t * 0.07}" filter="url(#blur)"`);
const legs = [[-70, -10], [20, -66], [40, 60]];
a.line(P(...legs[0], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
a.line(P(...legs[1], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
// the bit: a spiral auger into the ground
a.cone(0, 0, 76, -14, 14, shade(HULL, 0.05));
for (let i = 0; i < 6; i++) {
const z = 70 - i * 13, r = 14 * (z + 14) / 90;
const [x, y] = P(0, 0, z);
a.add(`<path d="M${f(x - r * 1.2)},${y} q${f(r * 1.2)},${f(r * 0.7)} ${f(r * 2.4)},-5" fill="none" stroke="${c}" stroke-width="2" opacity="0.9"/>`);
}
// motor housing
a.cyl(0, 0, 76, 16, 14, IRON);
a.cyl(0, 0, 90, 24, 40 + (t >= 3 ? 8 : 0), HULL);
a.disc(0, 0, 108, 24.5, "none", `stroke="${c}" stroke-width="3"`);
if (t >= 1) a.disc(0, 0, 98, 24.5, "none", `stroke="${c}" stroke-width="2" opacity="0.7"`);
a.cyl(0, 0, 130 + (t >= 3 ? 8 : 0), 10, 10, IRON);
a.light(P(0, 0, 146 + (t >= 3 ? 8 : 0)), 3, 0.8 + t * 0.1);
if (t >= 2) {
a.cbox(-34, 18, 0, 22, 22, 26, c);
a.line(P(-34, 18, 26), P(0, 0, 100), IRON, 2);
}
if (t >= 4) a.cyl(30, -30, 0, 9, 34, c);
a.line(P(...legs[2], 0), P(0, 0, hub), shade(HULL, -0.1), 6);
for (const l of legs) a.disc(l[0], l[1], 0, 7, IRON);
},
},
// LunarLite L300: a tall derrick with a laser cutter firing straight down.
4: {
color: "#ff5fd2",
size: 0.94,
draw(a, t) {
const c = a.color, top = 230 + t * 12;
// laser impact
a.disc(0, 0, 0, 30 + t * 4, c, `opacity="0.55" filter="url(#blur)"`);
a.disc(0, 0, 0, 12, "#ffffff", `opacity="0.8" filter="url(#blur)"`);
// power unit at the back
a.box(-96, -70, 0, 40, 36, 34, HULL);
a.stripes(-96, -70, 0, 40, 36, 34, 4, c);
a.box(-46, -50, 0, 92, 100, 10, IRON);
a.lattice(0, 0, 10, top, 42, 13, shade(HULL, -0.15), 4, 5);
// emitter and beam
const ez = 96;
const [bx, by] = P(0, 0, ez), [, gy] = P(0, 0, 0);
a.add(`<rect x="${bx - 8}" y="${by}" width="16" height="${gy - by}" fill="${c}" opacity="0.9" filter="url(#blur)"/>`);
a.add(`<rect x="${bx - 1.8}" y="${by}" width="4" height="${gy - by}" fill="#ffe6f8"/>`);
a.cbox(0, 0, ez, 26, 26, 30, HULL);
a.disc(0, 0, ez + 12, 18.5, "none", `stroke="${c}" stroke-width="3"`);
a.cone(0, 0, ez, ez - 16, 9, IRON);
if (t >= 2) a.cbox(0, 0, ez + 30, 16, 16, 24, c);
// crown block
a.cbox(0, 0, top, 34, 34, 14, c);
a.light(P(0, 0, top + 22), 3.5, 0.9 + t * 0.1);
if (t >= 3) {
a.cyl(56, 30, 0, 12, 44, c);
a.light(P(56, 30, 48), 3, 0.8);
}
if (t >= 4) {
for (const z of [ez + 70, ez + 110]) a.disc(0, 0, z, 30, "none", `stroke="${c}" stroke-width="2.5" opacity="0.85"`);
}
},
},
// Lunar Regolith Extractor LRE-3000: a bucket-wheel excavator on tracks.
5: {
color: "#ffd24a",
size: 0.95,
draw(a, t) {
const c = a.color;
// counterweight boom (behind)
a.line(P(-10, 0, 80), P(-120, 0, 106), shade(HULL, -0.35), 12);
a.line(P(-30, 0, 120), P(-120, 0, 122), shade(HULL, 0.05), 3);
a.cbox(-120, 0, 92, 30, 34, 30, IRON);
// tracks and turntable
a.box(-60, -56, 0, 100, 26, 24, IRON);
a.box(-60, 30, 0, 100, 26, 24, IRON);
a.cyl(-10, 0, 24, 40, 10, IRON);
// superstructure
a.box(-50, -34, 34, 74, 68, 46, HULL);
a.stripes(-50, -34, 34, 74, 68, 16, 6, c);
a.box(-36, -24, 80, 34, 40, 26 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-2, -18, 88), P(-2, 10, 88), P(-2, 10, 100), P(-2, -18, 100)], mix(c, "#141004", 0.4));
// boom out to the wheel
const wx = 130, wz = 74;
a.line(P(10, 0, 70), P(wx, 0, wz + 6), shade(HULL, -0.1), 12);
a.line(P(10, 0, 116), P(wx - 6, 0, wz + 26), shade(HULL, 0.1), 3);
a.line(P(-20, 0, 110), P(10, 0, 116), shade(HULL, 0.1), 3);
// the bucket wheel, facing the viewer
const [x, y] = P(wx, 0, wz);
const R = 52 + t * 3, n = 8 + t * 2;
grow(x, y, R + 9);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${shade(HULL, -0.2)}" stroke-width="9"/>`);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${c}" stroke-width="2"/>`);
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const ex = x + Math.cos(ang) * R, ey = y + Math.sin(ang) * R;
a.line([x, y], [f(ex), f(ey)], shade(HULL, -0.3), 2.5);
a.add(`<rect x="${f(ex - 8)}" y="${f(ey - 7)}" width="16" height="14" rx="3" fill="${c}" stroke="${shade(c, -0.6)}" stroke-width="1" transform="rotate(${f((ang * 180) / Math.PI + 90)} ${f(ex)} ${f(ey)})"/>`);
}
a.add(`<circle cx="${x}" cy="${y}" r="12" fill="${IRON}" stroke="${c}" stroke-width="2"/>`);
a.light([x, y], 4, 0.7 + t * 0.1);
if (t >= 2) a.light(P(-36, -24, 108 + (t >= 3 ? 10 : 0)), 3, 0.9);
if (t >= 4) {
a.cyl(22, 40, 34, 10, 30, c);
a.light(P(22, 40, 66), 2.5, 1);
}
// dust thrown up by the wheel
const r = rng(5);
for (let i = 0; i < 6 + t * 3; i++) {
a.add(`<circle cx="${f(x - 20 + r() * 70)}" cy="${f(y + R * 0.4 + r() * 50)}" r="${f(1 + r() * 2.5)}" fill="${mix(c, "#ffffff", 0.3)}" opacity="${f(0.3 + r() * 0.5)}"/>`);
}
},
},
// Electrostatic Dust Excavator EDE-700: a coil tower of charged plates with arcs.
6: {
color: "#a98bff",
size: 0.97,
draw(a, t) {
const c = a.color, plates = 3 + Math.min(t, 3);
a.disc(0, 0, 0, 80, c, `opacity="${0.15 + t * 0.05}" filter="url(#blur)"`);
// base platform with feet
a.cyl(0, 0, 0, 62, 16, IRON);
for (const [x, y] of [[-60, 0], [0, -60], [60, 0], [0, 60]]) a.cbox(x, y, 0, 18, 18, 22, HULL);
a.cyl(0, 0, 16, 30, 26, HULL);
// the column and its plates, widest at the bottom
const h = 60 + plates * 30;
a.cyl(0, 0, 40, 13, h, shade(HULL, -0.05));
const r = rng(6);
for (let i = 0; i < plates; i++) {
const z = 58 + i * 30, rad = 54 - i * 5;
a.cyl(0, 0, z, rad, 6, HULL);
a.disc(0, 0, z + 6, rad + 0.5, "none", `stroke="${c}" stroke-width="2.5"`);
a.disc(0, 0, z + 6, rad, c, `opacity="0.25" filter="url(#blur)"`);
a.cyl(0, 0, z + 6, 14, 24, shade(HULL, -0.05));
}
// arcs between the plates
for (let i = 0; i < Math.min(plates - 1, 1 + t); i++) {
const z = 64 + i * 30, side = i % 2 ? 1 : -1;
let [x, y] = P(side * 36, side * -10, z);
let d = `M${x},${y}`;
for (let k = 0; k < 4; k++) d += ` L${f(x + side * (4 + r() * 10))},${f(y - 7 - k * 6)}`;
a.add(`<path d="${d}" fill="none" stroke="${c}" stroke-width="5" opacity="0.5" filter="url(#blur)"/>`);
a.add(`<path d="${d}" fill="none" stroke="#f2ecff" stroke-width="1.5"/>`);
}
// the top sphere
const [tx, ty] = P(0, 0, 40 + h + 14);
grow(tx, ty, 16 + t);
a.add(`<circle cx="${tx}" cy="${ty}" r="${16 + t}" fill="url(#orb)" stroke="${c}" stroke-width="1.5"/>`);
a.light([tx, ty], 5, 0.8 + t * 0.12);
// dust rising into the field
for (let i = 0; i < 8 + t * 4; i++) {
const ang = r() * Math.PI * 2, rr = 50 + r() * 50;
const [px, py] = P(Math.cos(ang) * rr, Math.sin(ang) * rr, r() * h);
a.add(`<circle cx="${px}" cy="${py}" r="${f(1 + r() * 2)}" fill="${mix(c, "#ffffff", 0.4)}" opacity="${f(0.3 + r() * 0.6)}"/>`);
}
},
},
// Lunar Core Extractor LCE-360: a heavy rig on legs over a glowing bore, with a tall tower.
7: {
color: "#ff5a3c",
size: 1,
draw(a, t) {
const c = a.color;
// the bore under the rig
a.disc(0, 0, 0, 52, c, `opacity="0.75" filter="url(#blur)"`);
a.disc(0, 0, 0, 30, "#ffd0a0", `opacity="0.85" filter="url(#blur)"`);
a.disc(0, 0, 0, 44, "none", `stroke="${shade(c, 0.2)}" stroke-width="3"`);
// legs
for (const [x, y] of [[-80, -80], [80, -80], [-80, 80]]) a.cbox(x, y, 0, 14, 14, 56, IRON);
// drill string into the bore
a.cyl(0, 0, -4, 8, 60, shade(HULL, -0.2));
// deck
a.box(-92, -92, 56, 184, 184, 16, HULL);
a.stripes(-92, -92, 56, 184, 184, 16, 12, c);
// tanks and a crane
a.cyl(-60, 60, 72, 18, 40, HULL);
a.disc(-60, 60, 96, 18.5, "none", `stroke="${c}" stroke-width="2.5"`);
if (t >= 2) {
a.cyl(60, -60, 72, 16, 34, HULL);
a.disc(60, -60, 92, 16.5, "none", `stroke="${c}" stroke-width="2.5"`);
}
a.box(30, 30, 72, 52, 44, 40 + (t >= 3 ? 14 : 0), HULL);
a.poly([P(82, 38, 92), P(82, 66, 92), P(82, 66, 104), P(82, 38, 104)], mix(c, "#1a0604", 0.4));
const top = 260 + t * 12;
a.lattice(0, 0, 72, top, 36, 12, shade(HULL, -0.1), 4, 6);
a.cbox(0, 0, top, 30, 30, 12, c);
a.light(P(0, 0, top + 20), 4, 0.9 + t * 0.1);
a.line(P(-70, -40, 72), P(-110, 10, 170 + t * 6), shade(HULL, -0.2), 5);
a.line(P(-110, 10, 170 + t * 6), P(-110, 10, 120), IRON, 1.5);
a.cbox(-110, 10, 108, 10, 10, 12, c);
if (t >= 4) for (const z of [140, 200]) a.disc(0, 0, z, 26, "none", `stroke="${c}" stroke-width="2.5" opacity="0.9"`);
// the front leg last
a.cbox(80, 80, 0, 14, 14, 56, IRON);
a.light(P(92, 92, 72), 3, 0.9);
},
},
};
// ------------------------------------------------------------------ scene
function svg(id, tier, maxTier) {
const tool = TOOLS[id];
const c = tool.color;
const gx = 256, gy = 338; // ground origin on the image; deploy cards crop to about y 51..392
// Fit the top tier into the frame (up to y 72, 440 wide); lower tiers are smaller, and
// smaller tools stay smaller (tool.size).
BB = { x0: 0, x1: 0, y0: 0, y1: 0 };
tool.draw(new Art(c), maxTier);
const box = BB;
BB = null;
const fit = Math.min((gy - 72) / -box.y0, 440 / (box.x1 - box.x0), 1.8);
const a = new Art(c);
tool.draw(a, tier);
const k = maxTier ? tier / maxTier : 0;
const scale = fit * tool.size * (0.8 + 0.2 * k);
const shift = f(-((box.x0 + box.x1) / 2) * scale);
// The ground disc, rocks, and a ring of tier segments around the base.
const r = rng(id * 31 + 7);
let rocks = "";
for (let i = 0; i < 26; i++) {
const ang = r() * Math.PI * 2, d = 0.25 + r() * 0.75;
const x = f(Math.cos(ang) * 190 * d), y = f(Math.sin(ang) * 50 * d);
const s = f(2 + r() * 6);
rocks += `<ellipse cx="${x}" cy="${y}" rx="${s}" ry="${f(s * 0.6)}" fill="#2a3446" opacity="${f(0.5 + r() * 0.5)}"/>`;
}
const rx = 168, ry = 44;
const circ = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
const seg = circ / Math.max(maxTier, 1);
const lit = Array.from({ length: maxTier }, (_, i) => (i < tier ? `${f(seg * 0.82)} ${f(seg * 0.18)}` : `0 ${f(seg)}`)).join(" ");
const ring = `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-opacity="0.18" stroke-width="3" stroke-dasharray="${f(seg * 0.82)} ${f(seg * 0.18)}" transform="rotate(180)"/>`
+ (tier ? `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="7" opacity="0.6" filter="url(#blur)" stroke-dasharray="${lit}" transform="rotate(180)"/>
<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${mix(c, "#ffffff", 0.35)}" stroke-width="3" stroke-dasharray="${lit}" transform="rotate(180)"/>` : "");
// Sparkles at the top tier.
let sparkles = "";
if (tier === maxTier && maxTier > 0) {
const s = rng(id * 13);
for (let i = 0; i < 14; i++) {
const x = f(80 + s() * 352), y = f(70 + s() * 240), q = f(2 + s() * 4);
sparkles += `<path d="M${x},${y - q * 2} L${x + q * 0.4},${y} L${x},${y + q * 2} L${x - q * 0.4},${y} Z M${x - q * 2},${y} L${x},${y + q * 0.4} L${x + q * 2},${y} L${x},${y - q * 0.4} Z" fill="${mix(c, "#ffffff", 0.6)}" opacity="${f(0.4 + s() * 0.6)}"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<radialGradient id="bg" cx="0.5" cy="0.45" r="0.75"><stop offset="0" stop-color="#0e2446"/><stop offset="1" stop-color="#040a16"/></radialGradient>
<radialGradient id="aura" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${c}" stop-opacity="${f((0.16 + 0.3 * k) * 100) / 100}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>
<radialGradient id="ground" cx="0.5" cy="0.4" r="0.6"><stop offset="0" stop-color="#1c2638"/><stop offset="0.75" stop-color="#121a28"/><stop offset="1" stop-color="#0a101c" stop-opacity="0"/></radialGradient>
<linearGradient id="beam" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.9"/><stop offset="1" stop-color="${c}" stop-opacity="0.15"/></linearGradient>
<radialGradient id="orb" cx="0.35" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="${mix(c, "#ffffff", 0.4)}"/><stop offset="1" stop-color="${shade(c, -0.5)}"/></radialGradient>
<filter id="blur" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="halo" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="${f(2 + 4 * k)}" result="b"/><feFlood flood-color="${c}" flood-opacity="${f((0.25 + 0.5 * k) * 100) / 100}"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>
${[...(a.defs?.values() ?? [])].join("\n ")}
</defs>
<rect width="${SIZE}" height="${SIZE}" fill="url(#bg)"/>
<g stroke="#5fd4ff" stroke-opacity="0.06" stroke-width="1">${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 36 + 4}" y1="0" x2="${i * 36 + 4}" y2="${SIZE}"/><line x1="0" y1="${i * 36 + 4}" x2="${SIZE}" y2="${i * 36 + 4}"/>`).join("")}</g>
<ellipse cx="${gx}" cy="${gy - 110}" rx="${f(150 + 60 * k)}" ry="${f(150 + 60 * k)}" fill="url(#aura)"/>
<g transform="translate(${gx} ${gy})">
<ellipse rx="230" ry="70" fill="url(#ground)"/>
${rocks}
${ring}
<ellipse rx="${f(110 * scale)}" ry="${f(38 * scale)}" fill="#000" opacity="0.45" filter="url(#blur)"/>
<g transform="translate(${shift} 0) scale(${f(scale * 1000) / 1000})" filter="url(#halo)">
${a.out.join("\n ")}
</g>
</g>
${sparkles}
</svg>`;
}
// ------------------------------------------------------------------ main
export function tierImage(image, tier) {
if (!tier) return image;
const dot = image.lastIndexOf(".");
return `${image.slice(0, dot)}-t${tier}${image.slice(dot)}`;
}
const game = JSON.parse(readFileSync("config/game.json", "utf8"));
const svgDir = process.argv.indexOf("--svg") > 0 ? process.argv[process.argv.indexOf("--svg") + 1] : null;
const tmp = mkdtempSync(join(tmpdir(), "tool-art-"));
if (svgDir) mkdirSync(svgDir, { recursive: true });
try {
for (const item of game.storeItems) {
if (!TOOLS[item.id]) {
console.warn(`no art for store item ${item.id} (${item.name}), skipped`);
continue;
}
const maxTier = item.upgrades?.length ?? 0;
for (let tier = 0; tier <= maxTier; tier++) {
const name = tierImage(item.image, tier);
const src = join(svgDir ?? tmp, name.replace(/\.\w+$/, ".svg"));
writeFileSync(src, svg(item.id, tier, maxTier));
const png = join(tmp, "out.png");
execFileSync("rsvg-convert", ["-w", String(SIZE), "-h", String(SIZE), "-o", png, src]);
const dst = join(OUT, name);
const opts = name.endsWith(".jpg") ? ["-strip", "-quality", "88", "-interlace", "Plane"] : ["-strip", "-colors", "256", "PNG8:" + dst];
execFileSync("magick", [png, ...opts, ...(name.endsWith(".jpg") ? [dst] : [])]);
console.log(dst);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+4 -4
View File
@@ -335,9 +335,9 @@
{{$d := $.V.ToDrill .ID}} {{$d := $.V.ToDrill .ID}}
<article class="card tool-card {{if .Selected}}is-selected{{end}}"> <article class="card tool-card {{if .Selected}}is-selected{{end}}">
{{if $open}} {{if $open}}
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></a> <a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'"></a>
{{else}} {{else}}
<div class="card-media"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></div> <div class="card-media"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{end}} {{end}}
<div class="card-body"> <div class="card-body">
<h2>{{if $open}}<a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{else}}{{.Item.Name}}{{end}}{{if gt .Count 1}} <span class="muted num">×{{.Count}}</span>{{end}} <span class="tier-chip num" title="Tier">T{{.Tier}}/{{.MaxTier}}</span></h2> <h2>{{if $open}}<a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{else}}{{.Item.Name}}{{end}}{{if gt .Count 1}} <span class="muted num">×{{.Count}}</span>{{end}} <span class="tier-chip num" title="Tier">T{{.Tier}}/{{.MaxTier}}</span></h2>
@@ -662,7 +662,7 @@
{{range .Idle}} {{range .Idle}}
<button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}" hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}} T{{.Tier}}{{range .Gear}} · {{.Name}}{{end}}"> <button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}" hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}} T{{.Tier}}{{range .Gear}} · {{.Name}}{{end}}">
<span class="deploy-card-media"> <span class="deploy-card-media">
<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"> <img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">
{{with .Gear}}<span class="deploy-card-pips pips">{{range .}}<i class="pip is-on"></i>{{end}}</span>{{end}} {{with .Gear}}<span class="deploy-card-pips pips">{{range .}}<i class="pip is-on"></i>{{end}}</span>{{end}}
{{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}} {{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}}
</span> </span>
@@ -693,7 +693,7 @@
<h3>Drills{{with .Queue}}{{if gt (len .) 1}} <span class="muted small">· {{len .}} share this well, oldest claims first</span>{{end}}{{end}}</h3> <h3>Drills{{with .Queue}}{{if gt (len .) 1}} <span class="muted small">· {{len .}} share this well, oldest claims first</span>{{end}}{{end}}</h3>
{{range .Queue}} {{range .Queue}}
<div class="line drill-line is-{{.State}}"> <div class="line drill-line is-{{.State}}">
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span> <span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
<span class="drill-main"> <span class="drill-main">
<span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}} <span class="muted">T{{.Tier}}</span>{{template "gear" .Gear}}</span> <span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}} <span class="muted">T{{.Tier}}</span>{{template "gear" .Gear}}</span>
<span class="drill-meta small"><span class="muted"><span class="num">+{{money .Amount}}</span>/cycle{{with .ReachText}} · {{.}}{{end}}</span> <span class="drill-meta small"><span class="muted"><span class="num">+{{money .Amount}}</span>/cycle{{with .ReachText}} · {{.}}{{end}}</span>