diff --git a/e2e/lobby.spec.ts b/e2e/lobby.spec.ts index 711ffa4..9fa27b8 100644 --- a/e2e/lobby.spec.ts +++ b/e2e/lobby.spec.ts @@ -37,7 +37,7 @@ test.describe('title', () => { await expect(picker.getByRole('radio', { name: 'La VJ' })).toBeChecked(); await picker.getByRole('radio', { name: 'Le roadie' }).click(); await expect(picker.getByRole('radio', { name: 'Le roadie' })).toBeChecked(); - await expect(picker).toContainText('Tank : tient la ligne'); + await expect(picker).toContainText('Tient la ligne'); expect(lines).toContain('[ui] onChooseClass tank'); await expect(picker).toHaveAttribute('aria-current', 'true'); @@ -150,6 +150,26 @@ test.describe('local lobby', () => { expect(lines.filter((line) => line.startsWith('[ui] onSeatClass'))).toHaveLength(2); }); + test('keeps a cursor per seat and rings only the seat in play', async ({ page }) => { + await page.goto(`${LOBBY}local-full`); + + const seats = lobbyOf(page).getByRole('listitem'); + const lit = (index: number) => seats.nth(index).locator('[aria-current]:not([data-quiet])'); + const quiet = (index: number) => seats.nth(index).locator('[aria-current][data-quiet]'); + await expect(seats.locator('[aria-current]')).toHaveCount(4); + await expect(lit(0)).toHaveCount(1); + for (const index of [1, 2, 3]) { + await expect(lit(index)).toHaveCount(0); + await expect(quiet(index)).toHaveCount(1); + } + + await tap(page, 'KeyP'); + await tap(page, 'ArrowDown'); + await expect(lit(1)).toHaveCount(1); + await expect(lit(0)).toHaveCount(0); + await expect(quiet(0)).toHaveCount(1); + }); + test('lets the first player launch from their own seat', async ({ page }) => { const lines = logs(page); await page.goto(`${LOBBY}local-two`); diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index ff4a5d4..5028ccb 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -69,6 +69,25 @@ test.describe('the HUD of a team', () => { await expect(page.locator('.ui-band__weapon:visible')).toHaveCount(0); }); + test('shows each band the prompts of the device of its player', async ({ page }) => { + await page.goto('/dev/ui.html?screen=team&locals=3&pads=2'); + + const keys = page.locator('.ui-band:not(.ui-band--compact)').locator('.ui-skill__key'); + await expect(keys).toHaveText(['E', 'R', 'X', 'Y', 'X', 'Y']); + }); + + test('keeps the traps out of the play area when several players share the screen', async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/dev/ui.html?screen=team&locals=4'); + + const traps = await page.getByRole('region', { name: 'Pièges' }).boundingBox(); + const band = await page.locator('.ui-band').first().boundingBox(); + expect(traps?.y).toBeLessThan(320); + expect(band?.height).toBeLessThan(125); + }); + test('shows four full bands when four players sit at the screen', async ({ page }) => { await page.goto('/dev/ui.html?screen=team&locals=4'); diff --git a/src/data/classes.ts b/src/data/classes.ts index c320f3b..394339e 100644 --- a/src/data/classes.ts +++ b/src/data/classes.ts @@ -5,7 +5,7 @@ export const CLASSES: readonly ClassDefinition[] = [ { id: 'mage', name: 'La VJ', - role: 'Mage : dégâts de zone à distance, fragile.', + role: 'Balaie la foule de loin avec ses lasers. Fragile.', color: '#ff2bd6', maxHp: 80, speed: 7.5, @@ -44,7 +44,7 @@ export const CLASSES: readonly ClassDefinition[] = [ { id: 'tank', name: 'Le roadie', - role: 'Tank : tient la ligne, attire les bad vibes, bouclier, lent.', + role: 'Tient la ligne et attire les bad vibes. Lent.', color: '#ff9a3d', maxHp: 200, speed: 6.8, @@ -81,7 +81,7 @@ export const CLASSES: readonly ClassDefinition[] = [ { id: 'healer', name: 'Le care', - role: 'Healer : soigne, répare la scène, relève ses amis.', + role: 'Soigne, répare la scène et relève ses amis.', color: '#7cf2b0', maxHp: 120, speed: 7, diff --git a/src/ui/dev.ts b/src/ui/dev.ts index 2b71504..a87cb1f 100644 --- a/src/ui/dev.ts +++ b/src/ui/dev.ts @@ -324,6 +324,13 @@ function playersOf(snapshot: InputSnapshot): LocalPlayer[] { })); } +// With `?pads=N`, the last N players of the screen play with a gamepad. +const pads = Number(params.get('pads') ?? 0); + +function padSnapshot(index: number, count: number, snapshot: InputSnapshot): InputSnapshot { + return index >= count - pads ? { ...snapshot, device: 'gamepad' } : snapshot; +} + // With `?devices`, the frame lists every device: the keyboard, a first pad, and a second one that // presses A when J is pressed. let padPressed = false; @@ -374,7 +381,7 @@ window.setInterval(() => { lobby === null ? state.players.slice(0, localCount).map((player, index) => ({ playerId: player.id, - snapshot: index === 1 ? second : snapshot, + snapshot: padSnapshot(index, localCount, index === 1 ? second : snapshot), })) : playersOf(snapshot); ui.update(state, { snapshot, players, ...devicesOf(snapshot) }, UI_FIXTURE_CONTENT); diff --git a/src/ui/lobby-fixtures.ts b/src/ui/lobby-fixtures.ts index 1c7b860..e7e771e 100644 --- a/src/ui/lobby-fixtures.ts +++ b/src/ui/lobby-fixtures.ts @@ -6,19 +6,19 @@ export const LOBBY_CLASSES: readonly ClassInfo[] = [ { id: 'mage', name: 'La VJ', - role: 'Mage : dégâts de zone à distance, fragile.', + role: 'Balaie la foule de loin avec ses lasers. Fragile.', color: '#ff2bd6', }, { id: 'tank', name: 'Le roadie', - role: 'Tank : tient la ligne, attire les bad vibes, lent.', + role: 'Tient la ligne et attire les bad vibes. Lent.', color: '#ff9a3d', }, { id: 'healer', name: 'Le care', - role: 'Healer : soigne, répare la scène, ralentit.', + role: 'Soigne, répare la scène et relève ses amis.', color: '#7cf2b0', }, ]; diff --git a/src/ui/lobby.ts b/src/ui/lobby.ts index 70cb2bc..3346091 100644 --- a/src/ui/lobby.ts +++ b/src/ui/lobby.ts @@ -96,7 +96,7 @@ export function createLobby(actions: LobbyActions, classes: readonly ClassInfo[] const heading = el('h2', 'ui-lobby__title'); const lead = el('p', 'ui-lobby__lead'); const header = el('header', 'ui-lobby__header'); - header.append(el('p', 'ui-kicker', 'Salon'), heading, lead); + header.append(el('p', 'ui-kicker', 'À plusieurs'), heading, lead); const error = el('p', 'ui-lobby__error'); error.setAttribute('role', 'alert'); @@ -291,6 +291,7 @@ export function createLobby(actions: LobbyActions, classes: readonly ClassInfo[] let cursor = 0; let cursorView = lobbyView(model); const seatCursors = new Map(); + let activeSeat: PlayerId | null = null; const seatInputs = new Map(); const deviceInputs = new Map(); @@ -320,6 +321,10 @@ export function createLobby(actions: LobbyActions, classes: readonly ClassInfo[] function paintCursors(): void { const marked: (HTMLElement | null)[] = []; + const typing = model.seats.find( + (seat) => cards[seat.playerId]?.input === document.activeElement, + ); + const lead = typing?.playerId ?? activeSeat ?? model.seats[0]?.playerId; if (model.mode === 'local') { for (const seat of model.seats) { const row = rowsOfSeat(seat)[seatCursors.get(seat.playerId) ?? 0]; @@ -346,6 +351,19 @@ export function createLobby(actions: LobbyActions, classes: readonly ClassInfo[] } else { node.removeAttribute('aria-current'); } + const seat = model.seats.find( + (candidate) => + cards[candidate.playerId]?.field === node || + cards[candidate.playerId]?.stepper.element === node, + ); + setFlag( + node, + 'quiet', + model.mode === 'local' && + model.seats.length > 1 && + seat !== undefined && + seat.playerId !== lead, + ); } } @@ -470,6 +488,9 @@ export function createLobby(actions: LobbyActions, classes: readonly ClassInfo[] const step = stepRow(rows.length, seatCursors.get(seat.playerId) ?? 0, own); const row = rows[step.index]; seatCursors.set(seat.playerId, step.index); + if (Object.values(own).some(Boolean)) { + activeSeat = seat.playerId; + } if (step.side !== 0) { side(seat, rows[step.index], step.side); } diff --git a/src/ui/ui.css b/src/ui/ui.css index 933ff7c..c99a30f 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -769,6 +769,12 @@ box-shadow: 0 0 0 2px var(--turquoise); } +.ui-lobby__field[aria-current][data-quiet], +.ui-stepper[aria-current][data-quiet] { + border-color: color-mix(in srgb, var(--turquoise) 70%, transparent); + box-shadow: none; +} + .ui-lobby__entry .ui-button { justify-content: center; } @@ -2181,6 +2187,88 @@ gap: var(--ui-gap); } +@media (min-width: 721px) { + .ui-hud[data-multi] { + grid-template: + 'core lineup threat' auto + 'volume . traps' auto + 'roster . .' 1fr + 'team team team' auto + / minmax(0, 1fr) auto minmax(0, 1fr); + } + + .ui-hud[data-multi] .ui-hud__traps { + align-self: start; + justify-self: end; + padding: 10px 14px 12px; + } + + .ui-hud[data-multi] .ui-trap { + width: 56px; + height: 56px; + gap: 3px; + border-radius: 18px; + } + + .ui-hud[data-multi] .ui-traps { + gap: 8px; + } + + .ui-hud[data-multi] .ui-trap__icon { + width: 24px; + height: 24px; + } + + .ui-hud[data-multi] .ui-traps__name { + margin-top: 12px; + } + + .ui-hud[data-multi] .ui-band { + align-items: flex-start; + gap: 12px; + padding: 8px 12px 10px 14px; + } + + .ui-hud[data-multi] .ui-band__main { + gap: 5px; + } + + .ui-hud[data-multi] .ui-band__head { + min-height: 20px; + } + + .ui-hud[data-multi] .ui-band__gear { + min-height: 20px; + } + + .ui-hud[data-multi] .ui-band__weapon { + padding: 1px 7px 1px 5px; + } + + .ui-hud[data-multi] .ui-band__skills { + gap: 6px; + } + + .ui-hud[data-multi] .ui-band .ui-skill { + width: 46px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__ring { + width: 38px; + height: 38px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__glyph { + width: 31px; + height: 31px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__icon { + width: 17px; + height: 17px; + } +} + .ui-band { --who: var(--or); @@ -2286,7 +2374,8 @@ min-height: 22px; } -.ui-hud:not([data-multi]) .ui-band__gear { +.ui-hud:not([data-multi]) .ui-band__gear, +.ui-band__gear:empty { display: none; } @@ -2672,6 +2761,99 @@ padding: 10px 12px 12px 14px; } + .ui-hud[data-multi] .ui-band { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px 8px; + padding: 8px 10px 10px 12px; + } + + .ui-hud[data-multi] .ui-band__main { + display: contents; + } + + .ui-hud[data-multi] .ui-band__head { + grid-column: 1; + min-height: 20px; + } + + .ui-hud[data-multi] .ui-band__head .ui-who { + gap: 6px; + } + + .ui-hud[data-multi] .ui-band__head .ui-who__mark { + width: 18px; + height: 18px; + } + + .ui-hud[data-multi] .ui-band__head .ui-who__name { + font-size: 15px; + } + + .ui-hud[data-multi] .ui-band__status { + display: none; + } + + .ui-hud[data-multi] .ui-band__row { + grid-column: 1; + grid-template-columns: minmax(0, 1fr) auto; + } + + .ui-hud[data-multi] .ui-band__row:not(.ui-band__row--vibes) > .ui-band__label, + .ui-hud[data-multi] .ui-band__row--vibes > .ui-band__value { + display: none; + } + + .ui-hud[data-multi] .ui-band__row--vibes { + grid-template-columns: auto minmax(0, 1fr); + } + + .ui-hud[data-multi] .ui-band__gear { + grid-column: 1 / -1; + min-height: 20px; + } + + .ui-hud[data-multi] .ui-band__skills { + grid-column: 2; + grid-row: 1 / span 3; + flex-direction: column; + gap: 6px; + } + + .ui-hud[data-multi] .ui-band .ui-skill { + width: 52px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__ring { + width: 30px; + height: 30px; + } + + .ui-hud[data-multi] .ui-trap { + width: 52px; + height: 52px; + } + + .ui-hud[data-multi] .ui-traps__name { + margin-top: 10px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__glyph { + width: 28px; + height: 28px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__icon { + width: 16px; + height: 16px; + } + + .ui-hud[data-multi] .ui-band .ui-skill__key { + right: 0; + bottom: -4px; + } + .ui-band__skills { flex-direction: row; gap: 14px;