Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Turn-based cyberpunk roguelike as a Progressive Web App — tactical grid combat

**Vision and roadmap:** [docs/kernel-panic-v1-blueprint.md](docs/kernel-panic-v1-blueprint.md)
**Short overview:** [docs/game-overview.md](docs/game-overview.md)
**Current phase:** [docs/phase-2.9-plan.md](docs/phase-2.9-plan.md) (`v0.2.9`)
**Current phase:** Post-[docs/phase-3-plan.md](docs/phase-3-plan.md) balance patch (`v0.3.1`)

## Architecture

Expand Down
217 changes: 217 additions & 0 deletions components/CombatInventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* <combat-inventory> — the deployed operator's live inventory overlay.
*
* Three sections:
* 1. **SALVAGE** — the operator's job-scoped pickup wallet (informational).
* 2. **KEY ITEMS** — held keycards, shown as the generic "Access keycard":
* the locked door is right in front of you, so the location is self-evident.
* 3. **CONSUMABLES** — the operator's item list, navigable with ↑/↓ and
* activated with Enter. Emits `use-item` with `{ itemId }` on confirm.
*
* The Hub's read-only campaign stash is a separate element, `<crew-inventory>`.
*/

import { h } from '/src/domUtils.js';
import { emptySalvage, type TypedSalvage } from '/src/game/salvage.js';
import type { Item } from '/src/game/items.js';
import type { KeyItemView } from '/src/shell/domTypes.js';
import { INVENTORY_CSS, InventoryOverlay } from '/components/InventoryOverlay.js';

type CombatInventoryItem = Omit<Item, 'scope' | 'cost' | 'description' | 'needsTarget'> & {
count: number;
};

/** Human-readable labels for item IDs. */
const ITEM_LABELS = {
stim: 'Stim',
'smoke-charge': 'Smoke Charge',
'breaching-charge': 'Breaching Charge',
};

/** Interactive consumables-list styling layered on top of the shared chrome. */
const CONSUMABLE_CSS = `
.rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}

.empty {
text-align: center;
color: var(--inv-dim);
font-size: 0.85rem;
padding: 0.5rem 0;
}

button.row {
appearance: none;
-webkit-appearance: none;
background: transparent;
color: var(--inv-text);
border: 1px solid transparent;
border-radius: 4px;
padding: 0.5rem 0.6rem;
text-align: left;
font: inherit;
cursor: pointer;
min-height: 40px;
display: flex;
gap: 0.5rem;
align-items: center;
}

button.row:not(:disabled):hover {
background: var(--inv-row-hover);
}

button.row:focus-visible,
button.row[aria-current='true'] {
outline: none;
border-color: var(--inv-accent);
background: var(--inv-row-active);
}

.cursor {
color: var(--inv-accent);
font-weight: 700;
visibility: hidden;
}

button.row:focus-visible .cursor,
button.row[aria-current='true'] .cursor {
visibility: visible;
}

.item-name {
color: var(--inv-accent);
font-weight: 700;
letter-spacing: 0.08em;
}

.item-count {
color: var(--inv-dim);
font-size: 0.85rem;
}
`;

class CombatInventory extends InventoryOverlay {
#items: CombatInventoryItem[] = [];
#buttons: HTMLButtonElement[] = [];
#selectedIndex = 0;

setContents({
salvage = emptySalvage(),
consumables = [] as Item[],
keyItems = [] as KeyItemView[],
}: {
salvage?: TypedSalvage;
consumables?: Item[];
keyItems?: KeyItemView[];
} = {}) {
this.salvage = salvage;
this.keyItems = keyItems;
// Aggregate by id so duplicates show as "Stim x2".
const counts = new Map<string, number>();
for (const c of consumables) {
counts.set(c.id, (counts.get(c.id) ?? 0) + 1);
}
this.#items = [];
for (const [id, count] of counts) {
this.#items.push({ id, label: ITEM_LABELS[id as keyof typeof ITEM_LABELS] ?? id, count });
}
this.#selectedIndex = 0;
if (this.ready) this.render();
}

protected override styleText(): string {
return INVENTORY_CSS + CONSUMABLE_CSS;
}

protected panelTitle(): string {
return '── INVENTORY ──';
}

protected renderBody(): void {
this.#buttons = [];
this.renderSalvageSection();
this.renderKeycardSection('KEY ITEMS', ki => ki.label);

this.bodyEl.appendChild(h('p', { className: 'section-label', textContent: 'CONSUMABLES' }));
if (this.#items.length === 0) {
this.bodyEl.appendChild(h('p', { className: 'empty', textContent: 'No consumables.' }));
return;
}
const itemRows = h('div', { className: 'rows' });
for (let i = 0; i < this.#items.length; i++) {
const item = this.#items[i];
const btn = h('button', {
type: 'button',
className: 'row',
ariaCurrent: i === this.#selectedIndex ? 'true' : 'false',
}) as HTMLButtonElement;
btn.dataset.index = String(i);
btn.addEventListener('click', () => this.#activate(i));
btn.append(
h('span', { className: 'cursor', textContent: '>' }),
h('span', { className: 'item-name', textContent: item.label }),
h('span', { className: 'item-count', textContent: item.count > 1 ? `x${item.count}` : '' })
);
itemRows.appendChild(btn);
this.#buttons.push(btn);
}
this.bodyEl.appendChild(itemRows);
}

protected hintText(): string {
// Only mention ENTER when there's something to use. Empty wallets + empty
// consumables still get an Esc hint (the salvage view is useful on its own).
if (this.#items.length > 0) return '[ ENTER use · Esc close ]';
if (this.salvageIsEmpty && this.keyItems.length === 0) return 'Nothing carried. [ Esc close ]';
return '[ Esc close ]';
}

protected override onShown(): void {
const btn = this.#buttons[this.#selectedIndex];
if (btn) btn.focus();
else this.focus();
}

protected override handleExtraKey(evt: KeyboardEvent): boolean {
if (evt.key === 'ArrowDown' || evt.key === 's') {
evt.preventDefault();
this.#move(1);
return true;
}
if (evt.key === 'ArrowUp' || evt.key === 'w') {
evt.preventDefault();
this.#move(-1);
return true;
}
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
this.#activate(this.#selectedIndex);
return true;
}
return false;
}

#move(delta: number) {
if (this.#items.length === 0) return;
this.#selectedIndex = (this.#selectedIndex + delta + this.#items.length) % this.#items.length;
for (let i = 0; i < this.#buttons.length; i++) {
this.#buttons[i].setAttribute('aria-current', i === this.#selectedIndex ? 'true' : 'false');
}
const btn = this.#buttons[this.#selectedIndex];
if (btn) btn.focus();
}

#activate(index: number) {
const item = this.#items[index];
if (!item) return;
this.emit('use-item', { itemId: item.id });
}
}

customElements.define('combat-inventory', CombatInventory);

export default CombatInventory;
47 changes: 43 additions & 4 deletions components/ContractSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { h } from '/src/domUtils.js';
import { encounterHostileCount } from '/src/game/encounters.js';
import { contractLocationBadges } from '/src/game/hub/arcSurface.js';
import { cloneObjective } from '/src/game/hub/Curator.js';
import { cloneObjective, contractRequiresCyberspace } from '/src/game/hub/Curator.js';
import type { Contract } from '/src/game/hub/Curator.js';

const DIFFICULTY_LABEL: Record<string, string> = Object.freeze({
Expand Down Expand Up @@ -145,6 +145,14 @@ const CSS = `
color: #020403;
}

/* Cyberspace job marker — magenta from the cyber palette (see CYBER_ACCENT in
src/render/pip.ts and the simstim FLIP fab) so a dual-deploy reads as "the
cyber layer" wherever it surfaces. */
.badge.cyber {
background: #ff8ad8;
color: #020403;
}

.known {
color: var(--board-dim);
border: 1px solid rgba(106, 232, 200, 0.45);
Expand All @@ -167,6 +175,11 @@ const CSS = `
border-color: #9be7ff;
}

.known.keycard {
color: var(--board-accent, #6ae8c8);
border-color: rgba(106, 232, 200, 0.7);
}

.meta {
color: var(--board-dim);
font-size: 0.84rem;
Expand Down Expand Up @@ -205,6 +218,7 @@ class ContractSelect extends HTMLElement {
#contracts: Contract[] = [];
#scoreTargetSiteId: string | null = null;
#scorePrincipalId: string | null = null;
#heldKeycardPrincipalIds: ReadonlySet<string> = new Set();
#selectedIndex = 0;
#ready = false;
#listEl: HTMLElement | null = null;
Expand Down Expand Up @@ -271,6 +285,14 @@ class ContractSelect extends HTMLElement {
if (this.#ready) this.#render();
}

setHeldKeycardPrincipalIds(principalIds: string[]) {
if (!Array.isArray(principalIds)) {
throw new TypeError('<contract-select>.setHeldKeycardPrincipalIds requires an array');
}
this.#heldKeycardPrincipalIds = new Set(principalIds);
if (this.#ready) this.#render();
}

show() {
this.setAttribute('open', '');
queueMicrotask(() => this.#focusSelected());
Expand Down Expand Up @@ -307,12 +329,23 @@ class ContractSelect extends HTMLElement {
className: `badge ${isScoreContract(contract) ? 'score' : contract.difficulty}`,
textContent: difficultyLabel(contract),
}),
// Cyber jobs send a Decker into Cyberspace (with a meat partner
// when the crew can spare one) — flag them on the board so the
// player knows a Decker is in play before they open the briefing.
...(contractRequiresCyberspace(contract)
? [h('span', { className: 'badge cyber', textContent: 'CYBER' })]
: []),
h('span', { className: 'target', textContent: jobTitleCopy(contract) }),
]),
h(
'div',
{ className: 'location' },
locationLine(contract, this.#scoreTargetSiteId, this.#scorePrincipalId)
locationLine(
contract,
this.#scoreTargetSiteId,
this.#scorePrincipalId,
this.#heldKeycardPrincipalIds
)
),
h('div', { className: 'meta', textContent: rewardCopy(contract) }),
]),
Expand Down Expand Up @@ -404,13 +437,19 @@ function jobTitleCopy(contract: Contract): string {
function locationLine(
contract: Contract,
scoreTargetSiteId: string | null,
scorePrincipalId: string | null
scorePrincipalId: string | null,
heldKeycardPrincipalIds: ReadonlySet<string>
): HTMLElement[] {
const { principal, site, siteState } = contract.context;
const place = site ? `${principal.label} ${site.label}` : principal.label;
const state = siteState ? ` [${siteState.label}]` : '';
const nodes: HTMLElement[] = [h('span', { textContent: `Location: ${place}${state}` })];
for (const badge of contractLocationBadges(contract, scoreTargetSiteId, scorePrincipalId)) {
for (const badge of contractLocationBadges(
contract,
scoreTargetSiteId,
scorePrincipalId,
heldKeycardPrincipalIds
)) {
const className = badge.variant === 'revisit' ? 'known' : `known ${badge.variant}`;
nodes.push(h('span', { className, textContent: badge.text }));
}
Expand Down
48 changes: 48 additions & 0 deletions components/CrewInventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* <crew-inventory> — the Hub's read-only campaign stash overlay.
*
* Two sections:
* 1. **SALVAGE** — the campaign-wide accumulated wallet (so the player can
* audit typed totals without opening the shop).
* 2. **STOLEN KEYCARDS** — the persistent keycard inventory, each labelled by
* the location it was lifted from (e.g. "Redline server farm").
*
* No consumables (those are operator-held, not a shared crew pool) and nothing
* to activate — the interactive kit lives on `<combat-inventory>`.
*/

import { emptySalvage, type TypedSalvage } from '/src/game/salvage.js';
import type { KeyItemView } from '/src/shell/domTypes.js';
import { InventoryOverlay } from '/components/InventoryOverlay.js';

class CrewInventory extends InventoryOverlay {
setContents({
salvage = emptySalvage(),
keyItems = [] as KeyItemView[],
}: {
salvage?: TypedSalvage;
keyItems?: KeyItemView[];
} = {}) {
this.salvage = salvage;
this.keyItems = keyItems;
if (this.ready) this.render();
}

protected panelTitle(): string {
return '── INVENTORY ──';
}

protected renderBody(): void {
this.renderSalvageSection();
this.renderKeycardSection('STOLEN KEYCARDS', ki => ki.locationName ?? ki.label);
}

protected hintText(): string {
if (this.salvageIsEmpty && this.keyItems.length === 0) return 'Nothing stashed. [ Esc close ]';
return '[ Esc close ]';
}
}

customElements.define('crew-inventory', CrewInventory);

export default CrewInventory;
Loading
Loading