Skip to content

Commit b3c9aa0

Browse files
committed
add fire and hit sound effects via web audio
- procedural sounds (no asset files): laser pew on fire, noise burst on rock hit - unlock AudioContext on first user gesture for autoplay policy - add src/audio.ts leaf module; wire playFire in ship, playHit in main ########## added by prepare-commit-msg ########## # COMMIT_MSG_FILE: .git/COMMIT_EDITMSG # DATE: 2026-06-09 09:22:42
1 parent 41cd025 commit b3c9aa0

4 files changed

Lines changed: 91 additions & 4 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// 효과음 — Web Audio API 로 절차적으로 생성한다(에셋 파일 불필요).
2+
// 리프 유틸 모듈: 게임 로직에 의존하지 않으므로 entities/game/main 어디서든 import 가능.
3+
// 브라우저 자동재생 정책상 첫 사용자 제스처에서 unlockAudio() 로 컨텍스트를 깨워야 한다.
4+
5+
let ctx: AudioContext | null = null;
6+
7+
/** AudioContext 를 지연 생성한다. 미지원 환경(SSR 등)에서는 null. */
8+
function getCtx(): AudioContext | null {
9+
if (typeof window === "undefined") return null;
10+
if (!ctx) {
11+
const AC =
12+
window.AudioContext ??
13+
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
14+
if (!AC) return null;
15+
ctx = new AC();
16+
}
17+
return ctx;
18+
}
19+
20+
/**
21+
* 첫 사용자 제스처(키다운/포인터/터치) 핸들러에서 호출한다.
22+
* suspended 상태의 컨텍스트를 resume 해 이후 효과음 재생을 가능하게 한다.
23+
*/
24+
export function unlockAudio(): void {
25+
const c = getCtx();
26+
if (c && c.state === "suspended") {
27+
void c.resume();
28+
}
29+
}
30+
31+
/** 발사음 — 짧게 떨어지는 사각파 "퓨". */
32+
export function playFire(): void {
33+
const c = getCtx();
34+
if (c?.state !== "running") return;
35+
const t = c.currentTime;
36+
const osc = c.createOscillator();
37+
const gain = c.createGain();
38+
osc.type = "square";
39+
osc.frequency.setValueAtTime(880, t);
40+
osc.frequency.exponentialRampToValueAtTime(180, t + 0.12);
41+
gain.gain.setValueAtTime(0.1, t);
42+
gain.gain.exponentialRampToValueAtTime(0.0008, t + 0.14);
43+
osc.connect(gain).connect(c.destination);
44+
osc.start(t);
45+
osc.stop(t + 0.15);
46+
}
47+
48+
/** 명중/파괴음 — 저역으로 떨어지는 노이즈 버스트 "펑". */
49+
export function playHit(): void {
50+
const c = getCtx();
51+
if (c?.state !== "running") return;
52+
const t = c.currentTime;
53+
const dur = 0.22;
54+
55+
const buffer = c.createBuffer(1, Math.max(1, Math.floor(c.sampleRate * dur)), c.sampleRate);
56+
const data = buffer.getChannelData(0);
57+
for (let i = 0; i < data.length; i++) {
58+
// 시간이 지날수록 감쇠하는 화이트 노이즈.
59+
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
60+
}
61+
62+
const noise = c.createBufferSource();
63+
noise.buffer = buffer;
64+
65+
const filter = c.createBiquadFilter();
66+
filter.type = "lowpass";
67+
filter.frequency.setValueAtTime(1400, t);
68+
filter.frequency.exponentialRampToValueAtTime(180, t + dur);
69+
70+
const gain = c.createGain();
71+
gain.gain.setValueAtTime(0.22, t);
72+
gain.gain.exponentialRampToValueAtTime(0.0008, t + dur);
73+
74+
noise.connect(filter).connect(gain).connect(c.destination);
75+
noise.start(t);
76+
noise.stop(t + dur);
77+
}

test_web_page/particle/src/entities/ship.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// 플레이어 비행체 — ctx.input 의 키보드 입력으로 조종되는 시안색 삼각형.
22
import { Bodies, Body } from "matter-js";
33
import { Container, Graphics } from "pixi.js";
4+
import { playFire } from "../audio";
45
import { BULLET, COLLISION, COLORS, SHIP } from "../constants";
56
import type { Entity, GameContext } from "../types";
67
import { Bullet } from "./bullet";
@@ -121,6 +122,7 @@ export class Ship implements Entity {
121122
body.velocity.y,
122123
),
123124
);
125+
playFire();
124126
}
125127

126128
if (this.invulnerableLeft > 0) {

test_web_page/particle/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// 통합 엔트리포인트 — 코어 엔진, 엔티티, 게임플레이 모듈을 와이어링한다.
22
import { Body } from "matter-js";
33
import "./style.css";
4+
import { playHit, unlockAudio } from "./audio";
45
import { COLORS } from "./constants";
56
import { Game } from "./core/game";
67
import { createRenderer } from "./core/renderer";
@@ -26,6 +27,12 @@ async function bootstrap(): Promise<void> {
2627
// 터치 기기에서는 화면 위 가상 버튼 오버레이를 추가한다 (키보드와 동일한 InputState 공유).
2728
// 데스크탑에서는 null 을 반환하고 아무 DOM 도 만들지 않는다.
2829
createTouchControls(input);
30+
31+
// 브라우저 자동재생 정책: 첫 사용자 제스처에서 오디오 컨텍스트를 깨운다.
32+
const unlock = (): void => unlockAudio();
33+
for (const ev of ["keydown", "pointerdown", "touchstart"] as const) {
34+
window.addEventListener(ev, unlock, { once: true });
35+
}
2936
const game = new Game(app, input);
3037

3138
const hud = new Hud(app.screen.width, app.screen.height, INITIAL_LIVES);
@@ -59,6 +66,7 @@ async function bootstrap(): Promise<void> {
5966
for (const fragment of rock.split(hitX, hitY)) {
6067
ctx.spawn(fragment);
6168
}
69+
playHit();
6270
hud.rockDestroyed(rock.sizeLevel);
6371

6472
// 살아있는 바위가 하나도 없으면 웨이브 클리어 (분열 조각도 바위로 집계).

test_web_page/particle/src/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// 공유 계약(인터페이스) 정의.
2-
// 모듈 의존 규칙:
3-
// core/** import 가능: types, constants, matter-js, pixi.js (entities/, game/ 금지)
4-
// entities/** import 가능: types, constants, matter-js, pixi.js (core/, game/ 금지)
5-
// game/** import 가능: types, constants, pixi.js (core/, entities/ 금지)
2+
// 모듈 의존 규칙 (audio 는 게임 로직 없는 리프 유틸이라 어디서든 import 가능):
3+
// core/** import 가능: types, constants, audio, matter-js, pixi.js (entities/, game/ 금지)
4+
// entities/** import 가능: types, constants, audio, matter-js, pixi.js (core/, game/ 금지)
5+
// game/** import 가능: types, constants, audio, pixi.js (core/, entities/ 금지)
66
// main.ts 모든 모듈을 와이어링하는 통합 지점
77

88
import type { Body } from "matter-js";

0 commit comments

Comments
 (0)