Remove rank stamp from combat bar seats

- Drop the `rankT` text object, its draw/update logic and data spec (`rankTextPx`) so seats only show icon, veil, sweep and seconds.
- Update `_ensureSkillIcons` fallback wording to reference frame/veil/sweep instead of rank stamp.
- Adjust browser test: stop asserting on rank text, recompute seat clip from the seat box center, and remove the rank-pixel regression check.
- Refresh screenshot fixtures (`dev/shots/*`) to match the new seat layout.
This commit is contained in:
Brian Fertig 2026-09-27 11:52:49 -06:00
parent a17dd7893b
commit 4aa0785b0d
10 changed files with 45 additions and 111 deletions

View File

@ -25,13 +25,13 @@
"seatOutOfRangePolicy": "ignore"
},
"bar": {
"_comment": "How a SEATED skill is DRAWN on the combat bar (js/ui/CombatBar.js): the icon size, the state veils, the recharge sweep and the rank stamp. Behaviour lives in code — js/combat/CombatSystem.js → skillSeats() decides WHICH state a seat is in (ready / recharging / not affordable / inert / empty) and this block says what each one LOOKS like. Sizes: the icon is a FRACTION of the seat (the bar scales its seats down on narrow viewports, so absolute pixels would break at 1024px wide); the two text sizes are px inside a ~44px seat. Colours stay CSS strings — the bar converts them at draw time (js/skills/SkillEffects.js → hexToInt).",
"_comment": "How a SEATED skill is DRAWN on the combat bar (js/ui/CombatBar.js): the icon size, the state veils and the recharge sweep. Behaviour lives in code — js/combat/CombatSystem.js → skillSeats() decides WHICH state a seat is in (ready / recharging / not affordable / inert / empty) and this block says what each one LOOKS like. Sizes: the icon is a FRACTION of the seat (the bar scales its seats down on narrow viewports, so absolute pixels would break at 1024px wide); the text size is px inside a ~44px seat. Colours stay CSS strings — the bar converts them at draw time (js/skills/SkillEffects.js → hexToInt).",
"iconScale": 0.76,
"rankTextPx": 9,
"secondsTextPx": 13,
"assignPulseHz": 1.6,
"states": {
"_comment": "Draw order: the icon, then a veil over it (an unranked skill is buried, a too-expensive one is dimmed blue), then the recharge SWEEP — a dark pie that unwinds clockwise as the skill recharges — with the SECONDS LEFT printed over it, then the rank stamp in the corner. `readyEdge` is the outline a live-and-charged seat wears; `assignEdge` outlines every seat while the tree is holding a skill up waiting to be placed.",
"_comment": "Draw order: the icon, then a veil over it (an unranked skill is buried, a too-expensive one is dimmed blue), then the recharge SWEEP — a dark pie that unwinds clockwise as the skill recharges — with the SECONDS LEFT printed over it. `readyEdge` is the outline a live-and-charged seat wears; `assignEdge` outlines every seat while the tree is holding a skill up waiting to be placed.",
"readyEdge": "#7ce8ff",
"inertVeil": "#050a13",
"inertVeilAlpha": 0.74,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 KiB

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 522 KiB

View File

@ -229,56 +229,48 @@
"state": "ready",
"icon": true,
"frame": "0",
"rank": "5/5",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
@ -290,8 +282,8 @@
"dashing": false,
"down": false,
"ship": {
"x": 3810.5218340666174,
"y": 4088.361289263925
"x": 5909.759469383378,
"y": -988.8723592216014
},
"ledgerSeats": [
"ramming_charge",
@ -337,8 +329,8 @@
"maxPoints": 5,
"energy": 60,
"cooldown": 6,
"cooldownLeft": 5.722706666666667,
"cooldownFrac": 0.046215555555555454,
"cooldownLeft": 5.733383333333334,
"cooldownFrac": 0.04443611111111101,
"affordable": false,
"ready": false,
"inert": false
@ -489,60 +481,52 @@
"state": "cooldown",
"icon": true,
"frame": "0",
"rank": "5/5",
"secs": "6"
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
"energy": 41.66376000000001,
"energy": 41.59970000000001,
"maxEnergy": 100,
"hull": 6000,
"maxHull": 6000,
@ -550,8 +534,8 @@
"dashing": true,
"down": false,
"ship": {
"x": 3870.629477981919,
"y": 4033.226599552123
"x": 5972.397742948351,
"y": -675.1068097485484
},
"ledgerSeats": [
"ramming_charge",
@ -749,69 +733,61 @@
"state": "noEnergy",
"icon": true,
"frame": "0",
"rank": "5/5",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
"energy": 59.384,
"maxEnergy": 100,
"hull": 129,
"hull": 101.896,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 3893.832344512312,
"y": 4012.409104914982
"x": 5967.269013350007,
"y": -697.4064976194823
},
"ledgerSeats": [
"ramming_charge",
@ -1009,69 +985,61 @@
"state": "empty",
"icon": false,
"frame": "0",
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "ready",
"icon": true,
"frame": "0",
"rank": "5/5",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
"energy": 68.21600000000001,
"maxEnergy": 100,
"hull": 121,
"hull": 71.896,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 3893.832344512312,
"y": 4012.409104914982
"x": 5967.269013350007,
"y": -697.4064976194823
},
"ledgerSeats": [
null,
@ -1269,69 +1237,61 @@
"state": "empty",
"icon": false,
"frame": "0",
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": "0",
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
"energy": 71.28800000000001,
"maxEnergy": 100,
"hull": 81,
"hull": 61.896,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 3893.832344512312,
"y": 4012.409104914982
"x": 5967.269013350007,
"y": -697.4064976194823
},
"ledgerSeats": [
null,
@ -1529,69 +1489,61 @@
"state": "ready",
"icon": true,
"frame": "0",
"rank": "5/5",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": "0",
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"rank": "",
"secs": ""
}
],
"energy": 73.97600000000001,
"maxEnergy": 100,
"hull": 41,
"hull": 61.896,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 3893.832344512312,
"y": 4012.409104914982
"x": 5967.269013350007,
"y": -697.4064976194823
},
"ledgerSeats": [
"ramming_charge",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -11,8 +11,8 @@
* is the face and the hands):
* · a `?skill=max&combat` run boots with the class filed, both skills 5/5 and
* the seat Ramming Charge was GIVEN by data filled (nothing to grind);
* · the bound seat draws its ICON off the sheet, wears its RANK stamp and reads
* ready; an unbound seat keeps its reserved empty look;
* · the bound seat draws its ICON off the sheet and reads ready; an unbound
* seat keeps its reserved empty look;
* · pressing 1 dashes: the orb drops by the skill's cost, the seat goes to its
* recharge face with the SECONDS printed on it and the sweep unwinds toward
* full charge (evidence in dev/shots/);
@ -81,7 +81,6 @@ const snap = () =>
state: x.state,
icon: !!x.icon && x.icon.visible,
frame: x.icon ? String(x.icon.frame?.name ?? '') : null,
rank: x.rankT.visible ? x.rankT.text : '',
secs: x.secs.visible ? x.secs.text : '',
})),
energy: g.combat.energy,
@ -152,11 +151,12 @@ const seatClip = () =>
const r = document.querySelector('canvas').getBoundingClientRect();
const sx = r.width / g.scale.width;
const sy = r.height / g.scale.height;
const m = art.rankT.getWorldTransformMatrix();
const cx = art.lx + bar.seatBox.size / 2;
const cy = art.ly + bar.seatBox.size / 2;
const S = (bar.seatBox?.size ?? 44) * 1.2;
return {
x: Math.max(0, Math.round(r.left + (m.tx - S / 2) * sx)),
y: Math.max(0, Math.round(r.top + (m.ty - S / 2) * sy)),
x: Math.max(0, Math.round(r.left + (cx - S / 2) * sx)),
y: Math.max(0, Math.round(r.top + (cy - S / 2) * sy)),
width: Math.max(8, Math.round(S * sx)),
height: Math.max(8, Math.round(S * sy)),
};
@ -247,17 +247,14 @@ const ready = await page
const s0 = ready.seats[0];
check(s0.id === RAM.id && s0.rank === RAM.maxPoints, `seat 1 holds ${RAM.label} ${s0.rank}/${s0.maxPoints} — the DEFAULT binding came from data/skills.json`);
check(ready.art[0].icon === true && ready.art[0].frame === String(RAM.frame), `the seat draws icon frame ${RAM.frame} off the sheet (got "${ready.art[0].frame}")`);
check(ready.art[0].rank === `${RAM.maxPoints}/${RAM.maxPoints}`, `the seat wears its rank stamp "${ready.art[0].rank}"`);
// RENDERED PROOF: the rank stamp and the icon each contribute pixels ON TOP of
// each other (an icon added lazily over the state layers buries them while every
// property above still reads correct — that regression is what this catches).
// RENDERED PROOF: the icon contributes pixels ON TOP of the state layers (an
// icon added lazily over the state layers buries them while every property
// above still reads correct — that regression is what this catches).
await clearScreen();
const seatBase = await shootSeat([]);
const noRank = await shootSeat(['rankT']);
const noIcon = await shootSeat(['icon']);
check(Buffer.compare(seatBase, noRank) !== 0, 'the rank stamp actually reaches the pixels (hiding it changes the seat render, so it is not buried under the icon)');
check(Buffer.compare(seatBase, noIcon) !== 0, 'the icon reaches the pixels too (so the two differ because of stacking, not an empty seat)');
check(Buffer.compare(seatBase, noIcon) !== 0, 'the icon reaches the pixels too (so it is not buried under the state layers)');
fs.writeFileSync(new URL('./shots/skills-seat.png', import.meta.url), seatBase);
await page.screenshot({ path: `${SHOTS}skills-bar-ready.png`, timeout: 60000 });
check(ready.art[0].state === 'ready', `a live, charged, affordable seat reads ready (got ${ready.art[0].state})`);

View File

@ -8431,14 +8431,14 @@ export class GameScene extends Phaser.Scene {
/**
* The skill icons the bar and the console draw (loaded once, on demand, the way
* every other UI sheet in the game is). A missing file is not an error: the seats
* keep their frame, veil, sweep and rank stamp and simply draw no icon.
* keep their frame, veil and sweep and simply draw no icon.
*/
_ensureSkillIcons() {
if (this._skillIconsTried) return;
const sheet = skillIconSheet();
// The path is the data's (data/skills.json → icons.texture). No path, or a
// sheet that never loads, is not an error — the seats keep their frame, veil,
// sweep and rank stamp and simply draw no icon.
// sweep and seconds and simply draw no icon.
if (!sheet.texture) {
this._skillIconsTried = true;
return;
@ -8451,7 +8451,7 @@ export class GameScene extends Phaser.Scene {
// issued while another load runs (the deferred comms/media pass, the scene
// restart's own queue) is dropped — so this waits for the idle beat instead of
// losing the sheet. A handful of beats, then it gives up and the bar draws
// seats without icons (frame, veil, sweep and rank stamp still tell the story).
// seats without icons (frame, veil and sweep still tell the story).
if (this.load.isLoading?.() === true) {
this._skillIconBeats = (this._skillIconBeats ?? 0) + 1;
if (this._skillIconBeats <= 40) this.time.delayedCall(250, () => this._ensureSkillIcons());

View File

@ -71,9 +71,9 @@ function orbDotKey(scene) {
* the run's bindings live in the skill ledger (js/skills/SkillState.js),
* a press lands on o.onCast(index) → GameScene.castSkillSeat → the
* fight's cast(), and the fight answers every press with a reason. A
* seat draws its skill's icon (assets/images/skills.png), its rank
* stamp, a recharge sweep with the seconds left, a dimmed "cannot
* afford it" veil and an inert veil for a skill nobody has ranked;
* seat draws its skill's icon (assets/images/skills.png), a recharge
* sweep with the seconds left, a dimmed "cannot afford it" veil and
* an inert veil for a skill nobody has ranked;
* unbound seats keep their reserved look. RIGHT-pressing a seat clears
* it (making room), and while the skill tree is holding a skill up the
* seats breathe and a press PLACES the skill instead of casting.
@ -274,8 +274,8 @@ export class CombatBar extends Phaser.GameObjects.Container {
// layer only draws what each one looks like, and every colour, size and pulse
// rate is data-owned (data/skills.json → bar). Draw order per seat: the icon,
// a VEIL over it (an unranked skill is buried, a too-expensive one dimmed),
// the recharge SWEEP with the SECONDS left printed over that, and the RANK
// stamp in the corner. An empty seat draws nothing here — its reserved frame
// the recharge SWEEP with the SECONDS left printed over that. An empty seat
// draws nothing here — its reserved frame
// (above) with its key number IS the empty look.
this.seatArt = [];
for (let i = 0; i < n; i++) {
@ -300,26 +300,12 @@ export class CombatBar extends Phaser.GameObjects.Container {
.setScrollFactor(0)
.setDepth(DEPTH + 1)
.setVisible(false);
const rankT = scene.add
.text(lx + S - 3, ly + S - 3, '', {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${this._barSpec.rankTextPx}px`,
fontStyle: 'bold',
color: this._barSpec.states.rankColor,
stroke: '#02070d',
strokeThickness: 2.5,
})
.setOrigin(1, 1)
.setScrollFactor(0)
.setDepth(DEPTH + 1)
.setVisible(false);
this.add(veil);
this.add(sweep);
this.add(edge);
this.add(secs);
this.add(rankT);
this.seatArt.push({
lx, ly, veil, sweep, edge, secs, rankT,
lx, ly, veil, sweep, edge, secs,
state: 'empty', // what the seat is drawing (see _drawSeat)
icon: null, // created on the first seat that carries a skill
sig: '', // last draw signature — unchanged seats are skipped
@ -622,9 +608,8 @@ export class CombatBar extends Phaser.GameObjects.Container {
edge.strokeRoundedRect(...boxIn, rad);
}
// 5 — the printed seconds + the rank stamp.
// 5 — the printed seconds.
art.secs.setVisible(cooling).setText(secs);
art.rankT.setVisible(bound).setText(`${row.rank}/${row.maxPoints}`);
}
/** The seat's icon image, made on first use (the sheet is loaded once). */
@ -646,10 +631,10 @@ export class CombatBar extends Phaser.GameObjects.Container {
this.add(art.icon);
// The icon is made LAZILY (mid-frame, on the first seat that needs it), so it
// lands on top of the seat's state layers and an opaque sheet would bury the
// veil, the sweep, the seconds and the rank stamp. Put those back above it —
// veil → sweep → edge → seconds → rank, the order the data describes
// veil, the sweep and the seconds. Put those back above it —
// veil → sweep → edge → seconds, the order the data describes
// (data/skills.json → bar._comment). The icon never moves again afterwards.
for (const go of [art.veil, art.sweep, art.edge, art.secs, art.rankT]) {
for (const go of [art.veil, art.sweep, art.edge, art.secs]) {
if (!go) continue;
if (typeof this.bringToTop === 'function') this.bringToTop(go);
else {
@ -801,7 +786,7 @@ export class CombatBar extends Phaser.GameObjects.Container {
this.onSeatUnbind = null;
for (const art of this.seatArt ?? []) {
art.icon = null;
art.veil = art.sweep = art.edge = art.secs = art.rankT = null;
art.veil = art.sweep = art.edge = art.secs = null;
}
this.seatArt = null;
this.xpLabel = null;