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
8 changes: 2 additions & 6 deletions packages/client/src/arena/layoutFingerprint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fnv1a32 } from '@shobu/core'
import type { GreyboxBlock } from './arenaBlock.ts'

/**
Expand All @@ -16,12 +17,7 @@ import type { GreyboxBlock } from './arenaBlock.ts'
* ```
*/
export function layoutFingerprint(blockout: readonly GreyboxBlock[]): string {
let hash = 0x811c9dc5
for (const character of canonicalText(blockout)) {
hash ^= character.charCodeAt(0)
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash.toString(16).padStart(8, '0')
return fnv1a32(canonicalText(blockout)).toString(16).padStart(8, '0')
}

/**
Expand Down
116 changes: 116 additions & 0 deletions packages/client/src/audio/loopSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { createLoopTrack, type LoopPlayer, switchLoop } from './loopSwitch.ts'

/** Uma fonte do motor falso: o som e a velocidade com que começou, e se parou. */
interface FakeLoopSource {
readonly url: string
readonly rate: number
stopped: boolean
}

/**
* O motor de áudio falso: o download só termina quando o teste manda, que é
* como a ordem de chegada dos downloads fica nas mãos do teste.
*/
class FakeLoopPlayer implements LoopPlayer<string, FakeLoopSource> {
readonly started: FakeLoopSource[] = []
private readonly pending: Array<() => void> = []

bufferOf(url: string): Promise<string> {
return new Promise((resolve) => this.pending.push(() => resolve(url)))
}

start(buffer: string, rate: number): FakeLoopSource {
const source = { url: buffer, rate, stopped: false }
this.started.push(source)
return source
}

stop(source: FakeLoopSource): void {
source.stopped = true
}

/** Termina todo download pendente, na ordem do pedido. */
async finishDownloads(): Promise<void> {
for (const finish of this.pending.splice(0)) finish()
await Promise.resolve()
}

playing(): FakeLoopSource[] {
return this.started.filter((source) => !source.stopped)
}
}

describe('switchLoop', () => {
it('começa o laço pedido quando o download chega', async () => {
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const switching = switchLoop(track, 'passo', 1.2, player)
await player.finishDownloads()
await switching
expect(player.playing()).toEqual([{ url: 'passo', rate: 1.2, stopped: false }])
})

it('trocar para outro som para o laço que tocava', async () => {
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const first = switchLoop(track, 'passo', 1, player)
await player.finishDownloads()
await first
const second = switchLoop(track, 'corrida', 1, player)
await player.finishDownloads()
await second
expect(player.playing().map((source) => source.url)).toEqual(['corrida'])
})

it('a troca pedida durante o download vence a anterior', async () => {
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const switches = [
switchLoop(track, 'passo', 1, player),
switchLoop(track, 'corrida', 1, player),
]
await player.finishDownloads()
await Promise.all(switches)
expect(player.started.map((source) => source.url)).toEqual(['corrida'])
})

it('pedir silêncio para o laço e não começa nada', async () => {
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const first = switchLoop(track, 'passo', 1, player)
await player.finishDownloads()
await first
await switchLoop(track, undefined, 1, player)
expect(player.playing()).toEqual([])
})

it('A→B→A durante o download deixa um laço só tocando', async () => {
// regressão: os dois pedidos de A passavam pela comparação da url, e a
// primeira fonte ficava em laço para sempre (revisão do PR #31).
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const switches = [
switchLoop(track, 'passo', 1, player),
switchLoop(track, 'corrida', 1, player),
switchLoop(track, 'passo', 1, player),
]
await player.finishDownloads()
await Promise.all(switches)
expect(player.started).toHaveLength(1)
expect(track.source).toBe(player.started[0])
})

it('A→silêncio→A durante o download deixa um laço só tocando', async () => {
const player = new FakeLoopPlayer()
const track = createLoopTrack<FakeLoopSource>()
const switches = [
switchLoop(track, 'passo', 1, player),
switchLoop(track, undefined, 1, player),
switchLoop(track, 'passo', 1, player),
]
await player.finishDownloads()
await Promise.all(switches)
expect(player.started).toHaveLength(1)
})
})
56 changes: 56 additions & 0 deletions packages/client/src/audio/loopSwitch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createLatestRequest, type LatestRequest } from './latestRequest.ts'

/**
* Um laço que troca de som: o que ele toca agora, e qual dos pedidos de troca
* ainda vale. Mora fora de `webAudioSpatial.ts` para a regra da troca ser
* testada sem navegador — o adapter só sabe criar e parar a fonte.
*/
export interface LoopTrack<Source> {
url: string | undefined
rate: number
source: Source | undefined
/** A troca pedida por último: só ela começa uma fonte depois do download. */
readonly latest: LatestRequest
}

/** O que a troca precisa do motor de áudio: baixar, começar e parar. */
export interface LoopPlayer<Buffer, Source> {
bufferOf(url: string): Promise<Buffer>
start(buffer: Buffer, rate: number): Source
stop(source: Source): void
}

export function createLoopTrack<Source>(): LoopTrack<Source> {
return { url: undefined, rate: 1, source: undefined, latest: createLatestRequest() }
}

/**
* Troca o laço (quem chama já sabe que o pedido mudou: o mesmo pedido de
* sempre não mexe no laço, e é o que permite chamar por quadro sem picotar a
* passada). A marca e o número do pedido entram antes do `await`.
*
* Comparar só a url depois do `await` não bastava: A→B→A, ou A→nada→A, com o
* primeiro download ainda correndo, deixava os dois pedidos de A passarem, e a
* primeira fonte ficava em laço para sempre — o passo que nunca parava
* (revisão do PR #31). Com o número, só o último pedido começa uma fonte.
*
* ```ts
* void switchLoop(track, footstepUrl, 1.2, player)
* ```
*/
export async function switchLoop<Buffer, Source>(
track: LoopTrack<Source>,
url: string | undefined,
rate: number,
player: LoopPlayer<Buffer, Source>,
): Promise<void> {
if (track.source !== undefined) player.stop(track.source)
track.url = url
track.rate = rate
track.source = undefined
const ticket = track.latest.stamp()
if (!url) return
const buffer = await player.bufferOf(url)
if (!track.latest.isLatest(ticket)) return
track.source = player.start(buffer, rate)
}
66 changes: 24 additions & 42 deletions packages/client/src/audio/webAudioSpatial.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Vector3 } from '@shobu/core'
import { toWebAudioSpace } from './listenerSpace.ts'
import { createLoopTrack, type LoopPlayer, type LoopTrack, switchLoop } from './loopSwitch.ts'
import { busyUntil, pickVoice, type VoiceSlot } from './voicePool.ts'

/**
Expand Down Expand Up @@ -55,10 +56,9 @@ interface OneShotVoice extends Chain, VoiceSlot {
}

interface LoopVoice extends Chain {
url: string | undefined
rate: number
source: AudioBufferSourceNode | undefined
started: boolean
readonly track: LoopTrack<AudioBufferSourceNode>
/** Como esta voz começa e para a fonte: nasce com a voz, e não por troca. */
readonly player: LoopPlayer<AudioBuffer, AudioBufferSourceNode>
}

export function createSpatialVoices(
Expand Down Expand Up @@ -127,12 +127,12 @@ function loopAtPoint(
gain: number,
rate: number,
): void {
const voice = pool.loops.get(name) ?? openLoop(pool.context, pool.destination, pool.loops, name)
const voice = pool.loops.get(name) ?? openLoop(pool, name)
aimChain(pool, voice, positionM, gain)
// o caso de todo quadro é o laço de sempre, só andando: a guarda vem antes
// da função assíncrona, que criaria duas promessas por bot por quadro.
if (voice.url === url && voice.rate === rate) return
void switchLoop(voice, url, rate, pool.bufferOf).catch(() => {})
if (voice.track.url === url && voice.track.rate === rate) return
void switchLoop(voice.track, url, rate, voice.player).catch(() => {})
}

/** Põe a corrente no ponto, com o ganho da distância. */
Expand Down Expand Up @@ -225,49 +225,31 @@ function startOneShot(
voice.busyUntilS = busyUntil(nowS, delayS, buffer.duration, offsetS, 1)
}

function openLoop(
context: AudioContext,
destination: AudioNode,
loops: Map<string, LoopVoice>,
name: string,
): LoopVoice {
function openLoop(pool: SpatialPool, name: string): LoopVoice {
const chain = createChain(pool.context, pool.destination)
const voice: LoopVoice = {
...createChain(context, destination),
url: undefined,
rate: 1,
source: undefined,
started: false,
...chain,
track: createLoopTrack(),
player: {
bufferOf: pool.bufferOf,
start: (buffer, rate) => startLoopSource(chain.panner, buffer, rate),
stop: (source) => source.stop(),
},
}
loops.set(name, voice)
pool.loops.set(name, voice)
return voice
}

/**
* Troca o laço (quem chama já sabe que o pedido mudou: o mesmo pedido de
* sempre não mexe no laço, e é o que permite chamar por quadro sem picotar a
* passada). A marca entra antes do `await`: duas trocas no mesmo quadro não
* deixam dois laços tocando.
*/
async function switchLoop(
voice: LoopVoice,
url: string | undefined,
function startLoopSource(
panner: PannerNode,
buffer: AudioBuffer,
rate: number,
bufferOf: (url: string) => Promise<AudioBuffer>,
): Promise<void> {
if (voice.started) voice.source?.stop()
voice.url = url
voice.rate = rate
voice.started = false
voice.source = undefined
if (!url) return
const buffer = await bufferOf(url)
if (voice.url !== url || voice.rate !== rate) return
const source = voice.panner.context.createBufferSource()
): AudioBufferSourceNode {
const source = panner.context.createBufferSource()
source.buffer = buffer
source.loop = true
source.playbackRate.value = rate
source.connect(voice.panner)
source.connect(panner)
source.start()
voice.source = source
voice.started = true
return source
}
7 changes: 6 additions & 1 deletion packages/client/src/controller/arenaCombat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ function stepBot(combat: TickCombat, bot: BotPlayer, botIndex: number): void {
}
const fighterIndex = botIndex + 1
if (aimAndTrigger(bot, fighterIndex, parts.botTick)) fireBot(combat, bot, botIndex)
if (bot.melee.struckThisTick) strikeFrom(combat, fighterIndex, bot.eyeM, bot.aimM, 'knife')
if (bot.melee.struckThisTick) {
strikeFrom(combat, fighterIndex, bot.eyeM, bot.aimM, bot.melee.weapon)
}
moveBot(bot, parts.botTick)
}

Expand Down Expand Up @@ -236,11 +238,14 @@ function scoreKill(
*
* A luneta fecha **aqui**, e não no respawn: o tick do jogador morto não anda
* a arma, e quem morria mirando via a tela de morte inteira pela luneta (11/10).
* O aviso de alcance do gancho apaga pelo mesmo motivo: o tick morto não o
* recalcula.
*/
function freezeLocalDeath(combat: TickCombat, killerId: string): void {
const { session, options, parts } = combat
options.character.holdStill()
closeScope(parts.weapon)
session.grappleInReach = false
session.killedBy = killerId
parts.boundary.outsideS = 0
session.boundaryRemainingS = null
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/controller/arenaMelee.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import {
type BotPlayer,
boxFromCenterSize,
createNavGraph,
killsWithin,
Expand Down Expand Up @@ -125,6 +126,8 @@ describe('corpo a corpo na sessão', () => {
/** O relato de 11/10: morrer de luneta aberta deixava a luneta aberta até o respawn. */
it('morrer de luneta aberta fecha a luneta já na tela de morte', () => {
const rig = mountMelee({ hostileBots: true })
// a partida nova pede um aperto novo (`rearmWeapon`): o quadro solto vem antes.
rig.frames()
rig.weaponInput.scope = true
rig.frames()
rig.weaponInput.scope = false
Expand All @@ -134,6 +137,16 @@ describe('corpo a corpo na sessão', () => {
expect(rig.session.weapon.scoped).toBe(false)
})

it('o golpe do bot sai com a lâmina dele, e não sempre com a faca', () => {
// regressão: o golpe do bot era resolvido com 'knife' cravado (revisão do PR #31).
const rig = mountMelee({ hostileBots: true })
const bot = rig.session.bots[0] as BotPlayer
bot.melee.weapon = 'katana'
for (let frame = 0; frame < 3 / FRAME_S && rig.session.local.alive; frame += 1) rig.frames()
expect(rig.session.local.alive).toBe(false)
expect(rig.session.deathCause.weapon).toBe('katana')
})

it('renascer volta à sniper', () => {
const rig = mountMelee({ hostileBots: true })
rig.session.restartMatch({
Expand Down
Loading
Loading