Split the bottom HUD into tile and unit panels

This commit is contained in:
2026-09-19 19:45:49 +02:00
parent b514a19ecc
commit 45dabed2c7
5 changed files with 107 additions and 37 deletions
+26 -10
View File
@@ -505,12 +505,12 @@ input:focus, select:focus { border-color: var(--accent); }
text-shadow: 0 1px 2px #000;
}
#bottom-panel {
left: 50%;
/* The inspected tile / city panel is anchored to the bottom right. */
#tile-panel {
right: 14px;
bottom: 14px;
transform: translateX(-50%);
min-width: 420px;
max-width: 720px;
min-width: 320px;
max-width: 560px;
padding: 12px 18px;
}
.info-section.hidden { display: none !important; }
@@ -522,7 +522,7 @@ input:focus, select:focus { border-color: var(--accent); }
.tile-gdp-caret { margin-left: 6px; color: var(--muted); font-size: 11px; }
.tile-gdp-mods { margin-top: 2px; }
.tile-gdp-mod { font-size: 12px; color: var(--muted); padding-left: 12px; font-variant-numeric: tabular-nums; }
#bottom-panel .row { justify-content: flex-start; margin-top: 10px; }
#tile-panel .row, #unit-panel .row { justify-content: flex-start; margin-top: 10px; }
.health-row { display: flex; align-items: center; gap: 8px; }
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; }
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
@@ -537,9 +537,9 @@ input:focus, select:focus { border-color: var(--accent); }
}
.unit-stat:hover { color: var(--text); }
/* Chat-like feed of short-lived news in the lower-left corner, with the News
button anchored beneath it. */
.game-corner {
/* Lower-left dock: the news feed sits above the selected-unit panel, so both
stay clear of the bottom-right tile panel. */
#left-dock {
position: absolute;
left: 14px;
bottom: 14px;
@@ -547,10 +547,26 @@ input:focus, select:focus { border-color: var(--accent); }
max-width: calc(50vw - 30px);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
pointer-events: none;
z-index: 40;
}
#unit-panel {
position: relative;
width: 100%;
padding: 12px 18px;
pointer-events: auto;
}
/* Chat-like feed of short-lived news, with the News button beneath it. */
.game-corner {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
pointer-events: none;
z-index: 40;
}
.game-feed {
width: 100%;
+15 -11
View File
@@ -133,7 +133,7 @@
</div>
<div id="fps-label">60 FPS</div>
<div id="bottom-panel" class="panel hidden">
<div id="tile-panel" class="panel hidden">
<div id="tile-info" class="info-section hidden">
<div id="tile-desc" class="info-title"></div>
<div id="tile-coords" class="tile-coords"></div>
@@ -147,6 +147,20 @@
<button id="city-manage" class="aero">City</button>
</div>
</div>
</div>
<div id="stat-tooltip" class="tooltip hidden"></div>
</div>
<!-- Right-click menu listing the units sharing a tile -->
<div id="stack-menu" class="stack-menu hidden"></div>
<!-- Lower-left dock: the news feed sits above the selected-unit panel. -->
<div id="left-dock">
<div class="game-corner">
<div id="game-feed" class="game-feed"></div>
<button id="news-button" class="aero news-button" title="News">News</button>
</div>
<div id="unit-panel" class="panel hidden">
<div id="unit-info" class="info-section hidden">
<div id="unit-name" class="info-title"></div>
<div class="unit-stats">
@@ -162,16 +176,6 @@
</div>
</div>
</div>
<div id="stat-tooltip" class="tooltip hidden"></div>
</div>
<!-- Right-click menu listing the units sharing a tile -->
<div id="stack-menu" class="stack-menu hidden"></div>
<!-- Chat-like feed of short-lived news, pinned to the lower left -->
<div class="game-corner">
<div id="game-feed" class="game-feed"></div>
<button id="news-button" class="aero news-button" title="News">News</button>
</div>
<!-- City modal: train, buildings and the regional budget tabs -->
+6 -1
View File
@@ -336,12 +336,17 @@ export class GameScreen {
this.map.camera.zoom = Math.max(0.2, this.map.camera.zoom / 1.1);
this.map._applyCamera();
break;
case "Escape":
case "Escape": {
// ESC closes an open modal first; over the map it clears the selection
// and the inspected tile, dismissing both bottom panels.
const modalOpen = $(".modal").not(".hidden").length > 0;
this.cityModal.close();
this.nationModal.close();
this.confirmModal.close();
this.newsModal.close();
if (!modalOpen) this.map.clearSelection();
break;
}
default:
return;
}
+22 -15
View File
@@ -118,7 +118,13 @@ export const panelMethods = {
}
this.selectedUnitIds = units.map((unit) => unit.id);
this.selectedCityId = 0;
if (units.length > 0) this._inspectedTile = null;
if (units.length === 0) {
// Clearing the selection also drops the inspected tile, so the tile panel
// closes with it. Selecting units leaves the inspected tile alone, which
// is what lets the unit panel and the tile panel show together.
this._inspectedTile = null;
$("#tile-info, #city-info").addClass("hidden");
}
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
@@ -479,7 +485,7 @@ export const panelMethods = {
if (units.length === 0) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
this._refreshPanelVisibility();
return;
}
if (units.length === 1) {
@@ -493,16 +499,15 @@ export const panelMethods = {
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
this._updatePillageButton(units);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
this._refreshPanelVisibility();
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
this._refreshPanelVisibility();
return;
}
const proto = this.protoUnits[unit.proto];
@@ -513,9 +518,8 @@ export const panelMethods = {
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
this._refreshPanelVisibility();
},
// The unit's name with its current air activity, so the panel explains why a
@@ -573,7 +577,7 @@ export const panelMethods = {
if (!city) {
$("#city-info").addClass("hidden");
$("#tile-info").addClass("hidden");
this._refreshBottomVisibility();
this._refreshPanelVisibility();
return;
}
this._inspectedTile = null;
@@ -581,7 +585,7 @@ export const panelMethods = {
$("#city-name").text(city.name);
$("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshBottomVisibility();
this._refreshPanelVisibility();
this._updateTileHighlight();
},
@@ -596,7 +600,7 @@ export const panelMethods = {
this._renderTileInfo(coords);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
this._refreshPanelVisibility();
this._updateTileHighlight();
},
@@ -809,12 +813,15 @@ export const panelMethods = {
}
},
_refreshBottomVisibility() {
const visible =
// The tile/city panel and the unit panel are independent docks, so either
// can be open without the other.
_refreshPanelVisibility() {
const tileVisible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
!$("#city-info").hasClass("hidden");
const unitVisible = !$("#unit-info").hasClass("hidden");
$("#tile-panel").toggleClass("hidden", !tileVisible);
$("#unit-panel").toggleClass("hidden", !unitVisible);
},
_unit(id) {
+38
View File
@@ -592,6 +592,44 @@ export class GameScreenTest extends TestCase {
}
}
async test_tile_and_unit_panels_dock_independently() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
// Inspecting a tile opens the bottom-right tile panel on its own.
screen.map.onTileRequested(capital.coords);
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens");
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the unit panel stays shut");
// Selecting a unit opens the bottom-left unit panel and leaves the tile
// panel showing the inspected tile.
const unit = state.units.find((u) => u.civ === 0);
screen.map.onUnitSelected(unit.id);
this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens");
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel stays open");
// Clearing the selection (an empty left-click) dismisses both.
screen.map.clearSelection();
this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "the click clears the tile panel");
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the click clears the unit panel");
// ESC over the map does the same.
screen.map.onTileRequested(capital.coords);
screen.map.onUnitSelected(unit.id);
env.$(env.window).trigger(env.$.Event("keydown", { key: "Escape" }));
this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "ESC clears the tile panel");
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "ESC clears the unit panel");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_keyboard_and_render_loop() {
const env = await setupDom();
try {