Skip to content

Commit 73cc130

Browse files
committed
Add keyboard shortcuts and hits-remaining guidance to attack log
1 parent 58fb6e0 commit 73cc130

4 files changed

Lines changed: 244 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Vitest (unit tests) and Playwright (e2e tests) validate that core sections rende
5656
- **Boss presets, Godhome variants, and custom targets:** Quickly switch between Hallownest encounters, Godhome trials (Attuned, Ascended, Radiant), or specify any target for practice sessions.
5757
- **Data-driven build controls with charm presets:** Choose nail upgrades, toggle influential charms, apply popular charm loadouts with one click, and declare which spell upgrades are available to tune damage presets.
5858
- **Categorized attack logging with undo/redo:** Nail strikes, spell casts, and advanced techniques each expose context-aware damage values that respect build modifiers like Unbreakable Strength or Shaman Stone, while new undo/redo controls make correcting mistakes effortless.
59+
- **Keyboard shortcuts and finishing guidance:** Each attack button surfaces the remaining hits required to reach zero HP if you relied solely on that move, and keyboard shortcuts (number row followed by QWERTY order) allow spectators to log attacks or press <kbd>Esc</kbd> for a quick reset without leaving the action.
5960
- **Live combat analytics:** Remaining HP, DPS, average damage, and actions per minute update instantly as attacks are logged, giving immediate feedback on fight pacing.
6061

6162
Automated workflows in `.github/workflows/` run linting, unit tests, end-to-end tests, and GitHub Pages deployments on every push.

src/features/attack-log/AttackLogPanel.test.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ describe('AttackLogPanel', () => {
1818
);
1919

2020
const nailStrikeButton = screen.getByRole('button', { name: /nail strike/i });
21-
expect(nailStrikeButton).toHaveTextContent(/5/);
21+
const damageDisplay = within(nailStrikeButton).getByLabelText(/damage per hit/i);
22+
expect(damageDisplay).toHaveTextContent('5');
2223

2324
await user.selectOptions(screen.getByLabelText(/nail upgrade/i), 'pure-nail');
24-
expect(nailStrikeButton).toHaveTextContent(/21/);
25+
expect(damageDisplay).toHaveTextContent('21');
2526

2627
await user.click(screen.getByLabelText(/unbreakable strength/i));
27-
expect(nailStrikeButton).toHaveTextContent(/32/);
28+
expect(damageDisplay).toHaveTextContent('32');
2829
});
2930

3031
it('surfaces spell upgrades in the advanced group when unlocked', async () => {
@@ -65,6 +66,20 @@ describe('AttackLogPanel', () => {
6566
expect(within(actionsRow as HTMLElement).getByText('1')).toBeInTheDocument();
6667
});
6768

69+
it('shows hits remaining for each attack and updates after logging damage', async () => {
70+
const user = userEvent.setup();
71+
72+
renderWithFightProvider(<AttackLogPanel />);
73+
74+
const nailStrikeButton = screen.getByRole('button', { name: /nail strike/i });
75+
const hitsDisplay = within(nailStrikeButton).getByLabelText(/hits to finish/i);
76+
expect(hitsDisplay).toHaveTextContent(/hits to finish: 71/i);
77+
78+
await user.click(nailStrikeButton);
79+
80+
expect(hitsDisplay).toHaveTextContent(/hits to finish: 70/i);
81+
});
82+
6883
it('supports undo, redo, and quick reset controls', async () => {
6984
const user = userEvent.setup();
7085

@@ -109,4 +124,25 @@ describe('AttackLogPanel', () => {
109124
expect(redoButton).toBeDisabled();
110125
expect(resetButton).toBeDisabled();
111126
});
127+
128+
it('supports keyboard shortcuts for logging attacks and resetting', async () => {
129+
const user = userEvent.setup();
130+
131+
renderWithFightProvider(
132+
<>
133+
<AttackLogPanel />
134+
<CombatStatsPanel />
135+
</>,
136+
);
137+
138+
await user.keyboard('1');
139+
140+
let damageRow = screen.getByText('Damage Logged').closest('.data-list__item');
141+
expect(within(damageRow as HTMLElement).getByText('5')).toBeInTheDocument();
142+
143+
await user.keyboard('{Escape}');
144+
145+
damageRow = screen.getByText('Damage Logged').closest('.data-list__item');
146+
expect(within(damageRow as HTMLElement).getByText('0')).toBeInTheDocument();
147+
});
112148
});

src/features/attack-log/AttackLogPanel.tsx

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FC } from 'react';
2-
import { useMemo } from 'react';
2+
import { useEffect, useMemo } from 'react';
33

44
import {
55
hasStrengthCharm,
@@ -23,6 +23,62 @@ type AttackGroup = {
2323
attacks: AttackDefinition[];
2424
};
2525

26+
type AttackWithMetadata = AttackDefinition & {
27+
hotkey?: string;
28+
hitsRemaining: number | null;
29+
};
30+
31+
type AttackGroupWithMetadata = {
32+
id: string;
33+
label: string;
34+
attacks: AttackWithMetadata[];
35+
};
36+
37+
const KEY_SEQUENCE = [
38+
'1',
39+
'2',
40+
'3',
41+
'4',
42+
'5',
43+
'6',
44+
'7',
45+
'8',
46+
'9',
47+
'0',
48+
'q',
49+
'w',
50+
'e',
51+
'r',
52+
't',
53+
'y',
54+
'u',
55+
'i',
56+
'o',
57+
'p',
58+
'a',
59+
's',
60+
'd',
61+
'f',
62+
'g',
63+
'h',
64+
'j',
65+
'k',
66+
'l',
67+
';',
68+
'z',
69+
'x',
70+
'c',
71+
'v',
72+
'b',
73+
'n',
74+
'm',
75+
',',
76+
'.',
77+
'/',
78+
];
79+
80+
const RESET_SHORTCUT_KEY = 'Escape';
81+
2682
const NAIL_ART_MULTIPLIERS: Record<string, number> = {
2783
'great-slash': 2.5,
2884
'dash-slash': 2,
@@ -132,11 +188,93 @@ const buildAttackGroups = (
132188

133189
export const AttackLogPanel: FC = () => {
134190
const fight = useFightState();
135-
const { actions, state } = fight;
191+
const { actions, state, derived } = fight;
136192
const { damageLog, redoStack } = state;
137193

138194
const attackGroups = useMemo(() => buildAttackGroups(state), [state]);
139195

196+
const { groupsWithMetadata, shortcutMap } = useMemo(() => {
197+
const map = new Map<string, AttackDefinition>();
198+
let hotkeyIndex = 0;
199+
200+
const groups: AttackGroupWithMetadata[] = attackGroups.map((group) => ({
201+
...group,
202+
attacks: group.attacks.map((attack) => {
203+
const hotkey = KEY_SEQUENCE[hotkeyIndex];
204+
hotkeyIndex += 1;
205+
206+
if (hotkey) {
207+
map.set(hotkey, attack);
208+
}
209+
210+
const hitsRemaining =
211+
attack.damage > 0
212+
? Math.ceil(Math.max(0, derived.remainingHp) / attack.damage)
213+
: null;
214+
215+
return {
216+
...attack,
217+
hotkey,
218+
hitsRemaining,
219+
} satisfies AttackWithMetadata;
220+
}),
221+
}));
222+
223+
return { groupsWithMetadata: groups, shortcutMap: map };
224+
}, [attackGroups, derived.remainingHp]);
225+
226+
useEffect(() => {
227+
const handleKeyDown = (event: KeyboardEvent) => {
228+
if (event.defaultPrevented) {
229+
return;
230+
}
231+
232+
if (event.altKey || event.ctrlKey || event.metaKey) {
233+
return;
234+
}
235+
236+
const target = event.target as HTMLElement | null;
237+
if (target) {
238+
const interactiveElement = target.closest(
239+
'input, textarea, select, [contenteditable="true"]',
240+
);
241+
if (interactiveElement) {
242+
return;
243+
}
244+
}
245+
246+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
247+
248+
if (key === RESET_SHORTCUT_KEY) {
249+
if (state.damageLog.length === 0 && state.redoStack.length === 0) {
250+
return;
251+
}
252+
event.preventDefault();
253+
actions.resetLog();
254+
return;
255+
}
256+
257+
if (key.length === 1) {
258+
const attack = shortcutMap.get(key);
259+
if (!attack) {
260+
return;
261+
}
262+
263+
event.preventDefault();
264+
actions.logAttack({
265+
id: attack.id,
266+
label: attack.label,
267+
damage: attack.damage,
268+
category: attack.category,
269+
soulCost: attack.soulCost,
270+
});
271+
}
272+
};
273+
274+
window.addEventListener('keydown', handleKeyDown);
275+
return () => window.removeEventListener('keydown', handleKeyDown);
276+
}, [actions, shortcutMap, state.damageLog.length, state.redoStack.length]);
277+
140278
return (
141279
<div>
142280
<p className="section__description">
@@ -165,13 +303,14 @@ export const AttackLogPanel: FC = () => {
165303
type="button"
166304
className="quick-actions__button"
167305
onClick={actions.resetLog}
306+
aria-keyshortcuts="Esc"
168307
disabled={damageLog.length === 0 && redoStack.length === 0}
169308
>
170-
Quick reset
309+
Quick reset (Esc)
171310
</button>
172311
</div>
173312
<div className="attack-groups">
174-
{attackGroups.map((group) => (
313+
{groupsWithMetadata.map((group) => (
175314
<section key={group.id} className="attack-group">
176315
<h3 className="attack-group__title">{group.label}</h3>
177316
<div className="button-grid" role="group" aria-label={group.label}>
@@ -180,6 +319,7 @@ export const AttackLogPanel: FC = () => {
180319
key={attack.id}
181320
type="button"
182321
className="button-grid__button"
322+
aria-keyshortcuts={attack.hotkey?.toUpperCase()}
183323
onClick={() =>
184324
actions.logAttack({
185325
id: attack.id,
@@ -190,16 +330,36 @@ export const AttackLogPanel: FC = () => {
190330
})
191331
}
192332
>
193-
<span className="button-grid__label">{attack.label}</span>
333+
<div className="button-grid__header">
334+
<span className="button-grid__label">{attack.label}</span>
335+
{attack.hotkey ? (
336+
<span className="button-grid__hotkey" aria-hidden="true">
337+
{attack.hotkey.toUpperCase()}
338+
</span>
339+
) : null}
340+
</div>
341+
{attack.hotkey ? (
342+
<span className="visually-hidden">
343+
Shortcut key {attack.hotkey.toUpperCase()}.
344+
</span>
345+
) : null}
194346
<span className="button-grid__meta">
195-
<span className="button-grid__damage" aria-hidden="true">
347+
<span className="button-grid__damage" aria-label="Damage per hit">
196348
{attack.damage}
197349
</span>
198350
{typeof attack.soulCost === 'number' ? (
199351
<span className="button-grid__soul" aria-label="Soul cost">
200352
{attack.soulCost} SOUL
201353
</span>
202354
) : null}
355+
{typeof attack.hitsRemaining === 'number' ? (
356+
<span
357+
className="button-grid__hits"
358+
aria-label="Hits to finish with this attack"
359+
>
360+
Hits to finish: {attack.hitsRemaining}
361+
</span>
362+
) : null}
203363
</span>
204364
{attack.description ? (
205365
<span className="button-grid__description">{attack.description}</span>

src/styles/global.css

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ body {
139139
gap: 0.5rem;
140140
}
141141

142+
.button-grid__header {
143+
width: 100%;
144+
display: flex;
145+
justify-content: space-between;
146+
align-items: baseline;
147+
gap: 0.5rem;
148+
}
149+
142150
.button-grid__button:hover,
143151
.button-grid__button:focus-visible {
144152
outline: none;
@@ -151,9 +159,21 @@ body {
151159
font-size: 1rem;
152160
}
153161

162+
.button-grid__hotkey {
163+
font-size: 0.7rem;
164+
letter-spacing: 0.08em;
165+
text-transform: uppercase;
166+
padding: 0.1rem 0.35rem;
167+
border-radius: 0.4rem;
168+
border: 1px solid rgba(255 255 255 / 18%);
169+
color: var(--color-muted);
170+
background: rgba(255 255 255 / 8%);
171+
}
172+
154173
.button-grid__meta {
155174
display: flex;
156175
flex-wrap: wrap;
176+
align-items: center;
157177
gap: 0.4rem;
158178
font-size: 0.85rem;
159179
color: var(--color-muted);
@@ -171,6 +191,12 @@ body {
171191
text-transform: uppercase;
172192
}
173193

194+
.button-grid__hits {
195+
font-size: 0.75rem;
196+
letter-spacing: 0.04em;
197+
text-transform: uppercase;
198+
}
199+
174200
.button-grid__description {
175201
font-size: 0.75rem;
176202
color: var(--color-muted);
@@ -195,6 +221,18 @@ body {
195221
color: var(--color-muted);
196222
}
197223

224+
.visually-hidden {
225+
position: absolute;
226+
width: 1px;
227+
height: 1px;
228+
padding: 0;
229+
margin: -1px;
230+
overflow: hidden;
231+
clip: rect(0, 0, 0, 0);
232+
white-space: nowrap;
233+
border: 0;
234+
}
235+
198236
.data-list {
199237
display: flex;
200238
flex-direction: column;

0 commit comments

Comments
 (0)