refactor: Enhance character sheet and roster management

This commit is contained in:
Matthew Mone 2026-01-14 11:11:51 -08:00
parent b363d0850a
commit dbfa9929dd
3 changed files with 354 additions and 147 deletions

View file

@ -16,6 +16,8 @@ export class RosterManager {
this.roster = []; // List of active Explorer objects (Data only)
/** @type {ExplorerData[]} */
this.graveyard = []; // List of dead units
/** @type {ExplorerData[]} */
this.candidates = []; // Pool of recruitable units
/** @type {number} */
this.rosterLimit = 12;
}
@ -27,6 +29,20 @@ export class RosterManager {
load(saveData) {
this.roster = saveData.roster || [];
this.graveyard = saveData.graveyard || [];
this.candidates = saveData.candidates || [];
// DATA MIGRATION: Fix legacy portrait paths
const fixPortrait = (unit) => {
if (unit.portrait && unit.portrait.includes("class_")) {
// Replace "class_" with "" in the filename part
// e.g. assets/images/portraits/class_weaver.png -> assets/images/portraits/weaver.png
// Use regex to be safe: /class_([a-z]+)\.png/
unit.portrait = unit.portrait.replace(/class_/g, "");
}
};
this.roster.forEach(fixPortrait);
this.candidates.forEach(fixPortrait);
}
/**
@ -37,6 +53,7 @@ export class RosterManager {
return {
roster: this.roster,
graveyard: this.graveyard,
candidates: this.candidates,
};
}
@ -98,7 +115,80 @@ export class RosterManager {
* Clears the roster and graveyard. Used when starting a new game.
*/
clear() {
this.roster = [];
this.graveyard = [];
this.candidates = [];
}
/**
* Generates a new set of candidates for recruitment.
* @param {number} count - Number of candidates to generate
*/
async generateCandidates(count = 3) {
this.candidates = [];
// Lazy import name generator
const { generateCharacterName } = await import("../utils/nameGenerator.js");
// Available classes (basic pool)
const classes = [
"CLASS_VANGUARD",
"CLASS_WEAVER",
"CLASS_SCAVENGER",
"CLASS_TINKER",
];
for (let i = 0; i < count; i++) {
const randomClass = classes[Math.floor(Math.random() * classes.length)];
const name = generateCharacterName();
const candidate = {
id: `CANDIDATE_${Date.now()}_${i}`,
name: name,
className: randomClass, // ID used for lookup
activeClassId: randomClass,
level: 1,
status: "READY",
hiringCost: 100, // Fixed cost for now
portrait: `assets/images/portraits/${randomClass
.replace("CLASS_", "")
.toLowerCase()}.png`,
};
this.candidates.push(candidate);
}
}
/**
* Hires a candidate, moving them to the roster.
* @param {string} candidateId
* @param {Object} wallet - Reference to wallet to deduct funds (if handled here, but manager acts on data)
* @returns {Promise<boolean>} success
*/
async hireCandidate(candidateId) {
if (this.roster.length >= this.rosterLimit) {
console.warn("Roster full.");
return false;
}
const index = this.candidates.findIndex((c) => c.id === candidateId);
if (index === -1) return false;
const candidate = this.candidates[index];
// Recruit logic specific to candidate -> unit conversion
// (We can reused recruitUnit but we need to ensure ID is unique/updated if needed, though candidate ID is fine usually)
// Actually, recruitUnit generates a new ID. Let's use that to be safe and consistent.
await this.recruitUnit({
name: candidate.name,
className: candidate.className,
activeClassId: candidate.activeClassId,
portrait: candidate.portrait,
});
// Remove from candidates
this.candidates.splice(index, 1);
return true;
}
}

View file

@ -433,8 +433,8 @@ export class CharacterSheet extends LitElement {
}
.equipment-slot {
width: clamp(50px, 7cqw, 70px);
height: clamp(50px, 7cqw, 70px);
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
border: 2px solid #555;
display: flex;
@ -443,6 +443,7 @@ export class CharacterSheet extends LitElement {
cursor: pointer;
transition: all 0.2s;
position: relative;
padding: 0;
}
.equipment-slot:hover {
@ -508,7 +509,6 @@ export class CharacterSheet extends LitElement {
width: 100%;
height: 100%;
object-fit: contain;
padding: 5px;
}
.slot-label {
@ -591,6 +591,7 @@ export class CharacterSheet extends LitElement {
cursor: pointer;
transition: all 0.2s;
position: relative;
padding: 0;
}
.item-card:hover {
@ -630,7 +631,6 @@ export class CharacterSheet extends LitElement {
width: 100%;
height: 100%;
object-fit: contain;
padding: 5px;
}
.skills-container {

View file

@ -33,11 +33,12 @@ export class BarracksScreen extends LitElement {
font-family: var(--font-family);
display: grid;
grid-template-columns: 60% 40%;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
"header header"
"roster detail";
gap: var(--spacing-lg);
"tabs tabs"
"content content";
gap: var(--spacing-md);
}
.header {
@ -118,9 +119,8 @@ export class BarracksScreen extends LitElement {
color: var(--color-text-primary);
}
/* Roster List */
.roster-list {
grid-area: roster;
/* grid-area: roster; - Removed to allow nesting */
display: flex;
flex-direction: column;
gap: var(--spacing-md);
@ -244,7 +244,7 @@ export class BarracksScreen extends LitElement {
/* Detail Sidebar */
.detail-sidebar {
grid-area: detail;
/* grid-area: detail; - Removed to allow nesting */
background: var(--color-bg-panel);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-lg);
@ -339,7 +339,9 @@ export class BarracksScreen extends LitElement {
filter: { type: String },
sort: { type: String },
healingCostPerHp: { type: Number },
wallet: { type: Object },
viewMode: { type: String }, // "ROSTER" | "RECRUIT"
};
}
@ -351,12 +353,23 @@ export class BarracksScreen extends LitElement {
this.sort = "LEVEL_DESC";
this.healingCostPerHp = 0.5; // 1 HP = 0.5 Shards
this.wallet = { aetherShards: 0, ancientCores: 0 };
this.viewMode = "ROSTER";
}
connectedCallback() {
super.connectedCallback();
this._loadRoster();
this._loadWallet();
// Auto-generate candidates if none exist
if (
!gameStateManager.rosterManager.candidates ||
gameStateManager.rosterManager.candidates.length === 0
) {
gameStateManager.rosterManager
.generateCandidates(4)
.then(() => this.requestUpdate());
}
}
_loadRoster() {
@ -455,7 +468,9 @@ export class BarracksScreen extends LitElement {
let portrait = unitData.portrait || unitData.image;
if (!portrait) {
// Default portrait path based on class
portrait = `assets/images/portraits/${activeClassId.toLowerCase()}.png`;
portrait = `assets/images/portraits/${activeClassId
.replace("CLASS_", "")
.toLowerCase()}.png`;
}
return {
@ -688,7 +703,9 @@ export class BarracksScreen extends LitElement {
explorer.portrait = rosterData.portrait || rosterData.image;
} else {
// Default portrait path
explorer.portrait = `assets/images/portraits/${activeClassId.toLowerCase()}.png`;
explorer.portrait = `assets/images/portraits/${activeClassId
.replace("CLASS_", "")
.toLowerCase()}.png`;
}
// Generate skill tree if gameLoop isn't running
@ -844,129 +861,118 @@ export class BarracksScreen extends LitElement {
`;
}
_renderDetailSidebar() {
const unit = this._getSelectedUnit();
if (!unit) {
return html`
<div class="detail-sidebar">
<div class="empty-state">
<p>Select a unit to view details</p>
</div>
</div>
`;
_renderRecruitView() {
const candidates = gameStateManager.rosterManager.candidates || [];
if (candidates.length === 0) {
return html`<div class="empty-state">
No recruits available at this time.
</div>`;
}
const healCost = this._calculateHealCost(unit);
const canHeal =
unit.currentHp < unit.maxHp && this.wallet.aetherShards >= healCost;
const isInjured = unit.currentHp < unit.maxHp;
const hpPercent = (unit.currentHp / unit.maxHp) * 100;
return html`
<div class="roster-list">
${candidates.map(
(candidate) => html`
<div class="unit-card ready">
<div class="unit-portrait">
<img
src="${candidate.portrait ||
"assets/images/portraits/default.png"}"
alt="Portrait"
/>
</div>
<div class="unit-info">
<div class="unit-name">${candidate.name}</div>
<div class="unit-meta">
<span class="unit-class"
>${candidate.className.replace("CLASS_", "")}</span
>
<span class="unit-level">Lvl 1</span>
</div>
</div>
<div class="unit-status">
<button
class="btn btn-primary"
?disabled=${this.wallet.aetherShards < candidate.hiringCost}
@click=${() => this._onHireClick(candidate)}
>
Hire (${candidate.hiringCost} 💎)
</button>
</div>
</div>
`
)}
</div>
<div class="detail-sidebar">
<div class="detail-header">
<div class="detail-preview">
${unit.portrait
? html`<img
src="${unit.portrait}"
alt="${unit.name}"
style="width: 100%; height: 100%; object-fit: cover;"
@error=${(e) => {
e.target.style.display = "none";
}}
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
/>
<span
style="display: none; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 2em;"
>
${unit.classId
? unit.classId.replace("CLASS_", "")[0]
: "?"}
</span>`
: html`<span
style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 2em;"
>
${unit.classId ? unit.classId.replace("CLASS_", "")[0] : "?"}
</span>`}
</div>
<div>
<h3 style="margin: 0; color: var(--color-accent-cyan);">
${unit.name}
</h3>
<p
style="margin: var(--spacing-xs) 0; color: var(--color-text-secondary);"
>
${unit.classId?.replace("CLASS_", "") || "Unknown"} Level
${unit.level}
<h3>Recruitment Office</h3>
<p style="color: var(--color-text-secondary);">
New candidates arrive periodically. Hire them to expand your squad.
</p>
</div>
</div>
<div class="detail-stats">
<div class="stat-row">
<span class="stat-label">Health</span>
<span class="stat-value">${unit.currentHp} / ${unit.maxHp}</span>
</div>
<div class="progress-bar-container">
<div
class="progress-bar-fill hp"
style="width: ${hpPercent}%"
></div>
<div class="progress-bar-label">${Math.round(hpPercent)}%</div>
</div>
<div class="stat-row">
<span class="stat-label">Status</span>
<span class="stat-value">${unit.status}</span>
</div>
</div>
<div class="detail-actions">
<button class="btn action-button" @click=${this._handleInspect}>
Inspect / Equip
</button>
${isInjured
? html`
<button
class="btn btn-primary action-button"
?disabled=${!canHeal}
@click=${this._handleHeal}
>
Treat Wounds (${healCost} 💎)
</button>
${!canHeal && healCost > 0
? html`
<div class="heal-cost">
Insufficient funds. Need ${healCost} Shards.
</div>
`
: ""}
`
: html`
<button class="btn action-button" disabled>Full Health</button>
`}
<button
class="btn btn-danger action-button"
@click=${this._handleDismiss}
>
Dismiss
</button>
</div>
</div>
`;
}
render() {
async _onHireClick(candidate) {
if (this.wallet.aetherShards < candidate.hiringCost) {
alert("Insufficient funds.");
return;
}
// Deduct Funds
if (gameStateManager.hubStash && gameStateManager.hubStash.currency) {
gameStateManager.hubStash.currency.aetherShards -= candidate.hiringCost;
this.wallet.aetherShards =
gameStateManager.hubStash.currency.aetherShards;
// Calls RosterManager to move candidate to roster
const success = await gameStateManager.rosterManager.hireCandidate(
candidate.id
);
if (success) {
// Save everything
await gameStateManager.persistence.saveRoster(
gameStateManager.rosterManager.save()
);
await gameStateManager.persistence.saveHubStash(
gameStateManager.hubStash
);
// Update UI
this._loadRoster();
this.requestUpdate();
window.dispatchEvent(
new CustomEvent("wallet-updated", {
detail: { wallet: { ...this.wallet } },
bubbles: true,
composed: true,
})
);
} else {
alert("Roster is full!");
// Refund if failed (simple rollback)
gameStateManager.hubStash.currency.aetherShards += candidate.hiringCost;
this.wallet.aetherShards =
gameStateManager.hubStash.currency.aetherShards;
}
}
}
_renderRosterView() {
const filteredUnits = this._getFilteredUnits();
const rosterCount = this.units.length;
const rosterLimit = gameStateManager.rosterManager.rosterLimit || 12;
const selectedUnit = this._getSelectedUnit();
return html`
<div class="header">
<div>
<h2>The Squad Quarters</h2>
<div class="roster-info">
<span class="roster-count"
>Roster: ${rosterCount}/${rosterLimit}</span
<div
style="grid-column: 1 / -1; display: grid; grid-template-columns: 60% 40%; gap: var(--spacing-lg); height: 100%;"
>
<div
class="roster-list-container"
style="display: flex; flex-direction: column; gap: 10px; overflow: hidden;"
>
<div class="filter-bar">
<button
@ -982,55 +988,166 @@ export class BarracksScreen extends LitElement {
Ready
</button>
<button
class="filter-button ${this.filter === "INJURED"
? "active"
: ""}"
class="filter-button ${this.filter === "INJURED" ? "active" : ""}"
@click=${() => this._onFilterClick("INJURED")}
>
Injured
</button>
</div>
<div class="sort-bar">
<div class="roster-list" style="flex: 1; overflow-y: auto;">
${filteredUnits.length === 0
? html`<div class="empty-state">
No units found matching filter.
</div>`
: filteredUnits.map((unit) => this._renderUnitCard(unit))}
</div>
</div>
<!-- Detail Sidebar -->
${selectedUnit
? this._renderDetailContent(selectedUnit)
: html`
<div class="detail-sidebar">
<div class="empty-state">Select a unit to view details</div>
</div>
`}
</div>
`;
}
_renderDetailContent(selectedUnit) {
const healCost = this._calculateHealCost(selectedUnit);
const canHeal =
selectedUnit.currentHp < selectedUnit.maxHp &&
this.wallet.aetherShards >= healCost;
return html`
<div class="detail-sidebar">
<div class="detail-header">
<div class="detail-preview">
${selectedUnit.portrait
? html`<img
src="${selectedUnit.portrait}"
alt="${selectedUnit.name}"
/>`
: html`👤`}
</div>
<div class="unit-name">${selectedUnit.name}</div>
<div class="unit-class">
${selectedUnit.classId.replace("CLASS_", "")}
<span class="unit-level">Lv ${selectedUnit.level}</span>
</div>
</div>
<div class="detail-stats">
<div class="stat-row">
<span class="stat-label">Health</span>
<span class="stat-value"
>${selectedUnit.currentHp}/${selectedUnit.maxHp}</span
>
</div>
<div class="unit-hp-bar">
<div class="progress-bar-container">
<div
class="progress-bar-fill ${selectedUnit.currentHp <
selectedUnit.maxHp
? "injured"
: "ready"}"
style="width: ${(selectedUnit.currentHp / selectedUnit.maxHp) *
100}%"
></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">Status</span>
<span
style="color: var(--color-text-secondary); font-size: var(--font-size-xs);"
class="stat-value status-badge ${selectedUnit.status.toLowerCase()}"
>${selectedUnit.status}</span
>
Sort:
</span>
<button
class="sort-button"
@click=${() => this._onSortClick("LEVEL_DESC")}
>
Level
</div>
</div>
<div class="detail-actions">
<button class="btn" @click=${this._handleInspect}>
Inspect / Equip
</button>
${selectedUnit.currentHp < selectedUnit.maxHp
? html`
<button
class="sort-button"
@click=${() => this._onSortClick("NAME_ASC")}
class="btn btn-primary"
?disabled=${!canHeal}
@click=${this._handleHeal}
>
Name
Heal (${healCost} 💎)
</button>
`
: ""}
<button
class="sort-button"
@click=${() => this._onSortClick("HP_ASC")}
class="btn btn-danger"
@click=${this._handleDismiss}
style="margin-top: auto;"
>
HP
Dismiss
</button>
</div>
</div>
`;
}
render() {
const isRecruit = this.viewMode === "RECRUIT";
return html`
<div class="header">
<div class="roster-info">
<h2>Squad Quarters</h2>
<div class="roster-count">
${gameStateManager.rosterManager.roster.length} /
${gameStateManager.rosterManager.rosterLimit} Units
</div>
</div>
<div
class="wallet-display"
style="display: flex; gap: 20px; align-items: center;"
>
<div
class="wallet-item"
style="color: var(--color-accent-gold); font-weight: bold;"
>
💎 ${this.wallet.aetherShards}
</div>
<button class="btn btn-close" @click=${this._handleClose}></button>
</div>
<div class="roster-list">
${filteredUnits.length === 0
? html`
<div class="empty-state">
<p>No units found matching the current filter.</p>
</div>
`
: filteredUnits.map((unit) => this._renderUnitCard(unit))}
</div>
${this._renderDetailSidebar()}
<div
style="grid-area: tabs; display: flex; gap: 10px; border-bottom: 1px solid var(--color-border-default); padding-bottom: 10px;"
>
<button
class="filter-button ${!isRecruit ? "active" : ""}"
@click=${() => {
this.viewMode = "ROSTER";
this.requestUpdate();
}}
>
Active Roster
</button>
<button
class="filter-button ${isRecruit ? "active" : ""}"
@click=${() => {
this.viewMode = "RECRUIT";
this.requestUpdate();
}}
>
Recruit
</button>
</div>
<div style="grid-area: content; height: 100%; overflow: hidden;">
${isRecruit ? this._renderRecruitView() : this._renderRosterView()}
</div>
`;
}
}