Phaser 4로 Pseudo 3D 레이싱 게임 만들기 — Apex Seoul 카메라 구현
Phaser 4와 TypeScript로 Apex Seoul 프로젝트를 세팅하고, horizon, FOV, camera height를 이용해 pseudo 3D 도로 카메라를 구현했습니다.
지난 글에서는 Apex Seoul의 첫 단계로 pseudo 3D camera를 만들었다.
그때 구현한 것은 카메라 앞에 놓인 직선 도로를 화면에 투영하는 정도였다. horizon, FOV, camera height를 이용해 가까운 도로는 크게, 먼 도로는 작게 보이게 만들었다.
이번에는 그 임시 도로 렌더링을 한 단계 정리했다.
목표는 두 가지였다.
RoadSegment 데이터 구조로 분리한다.curve 값을 이용해 커브 도로를 렌더링한다.현재 데모는 아래에서 볼 수 있다.
이번 구현 결과는 이런 모습이다.

아직 차도 없고, 조향도 없고, 드리프트도 없다.
하지만 이제 도로가 단순한 직선이 아니라, 데이터에 따라 좌우로 휘어진다. pseudo 3D 레이싱 게임으로 가기 위한 두 번째 기반이 생긴 셈이다.
지난 구현에서는 main.ts 안에서 바로 도로를 그렸다.
대략 이런 식이었다.
const nearWorldZ = (baseSegment + i) * SEGMENT_LENGTH;
const farWorldZ = nearWorldZ + SEGMENT_LENGTH;
const nearLeft = projectGroundPoint({ x: -ROAD_HALF_WIDTH, z: nearWorldZ }, camera, viewport);
const nearRight = projectGroundPoint({ x: ROAD_HALF_WIDTH, z: nearWorldZ }, camera, viewport);
직선 도로를 검증하기에는 충분했다.
하지만 이 구조로는 곧 막힌다.
커브를 넣으려면 segment마다 도로 중심 x가 달라져야 한다. 언덕을 넣으려면 segment마다 y elevation이 달라져야 한다. 차선 수, 도로 색상, 터널, 배경 오브젝트를 붙이려 해도 결국 “도로 조각마다 다른 데이터”가 필요해진다.
그래서 이번에는 도로를 RoadSegment로 표현했다.
export type RoadSegment = {
curve: number;
index: number;
laneCount: number;
length: number;
};
현재는 필드가 많지 않다.
curve: 이 segment가 도로 중심선을 얼마나 좌우로 밀어내는지index: 트랙 안에서의 segment 번호laneCount: 차선 수length: segment의 z 길이지금은 커브 렌더링이 목적이므로 curve가 가장 중요하다.
전체 도로는 RoadTrack으로 묶었다.
export type RoadTrack = {
length: number;
segmentLength: number;
segments: RoadSegment[];
};
그리고 테스트 트랙은 section 단위로 만든다.
type TrackSection = {
endCurve: number;
startCurve: number;
segments: number;
};
예를 들어 아래 section은 28개의 segment 동안 curve가 0에서 0.48까지 올라가는 구간이다.
{ endCurve: 0.48, segments: 28, startCurve: 0 }
반대로 아래 section은 34개의 segment 동안 왼쪽 커브에서 오른쪽 커브로 넘어가는 구간이다.
{ endCurve: -0.38, segments: 34, startCurve: 0.38 }
현재 테스트 트랙은 이런 흐름으로 구성했다.
const TEST_TRACK_SECTIONS: TrackSection[] = [
{ endCurve: 0, segments: 20, startCurve: 0 },
{ endCurve: 0.48, segments: 28, startCurve: 0 },
{ endCurve: 0.48, segments: 30, startCurve: 0.48 },
{ endCurve: 0, segments: 24, startCurve: 0.48 },
{ endCurve: 0, segments: 14, startCurve: 0 },
{ endCurve: -0.52, segments: 32, startCurve: 0 },
{ endCurve: -0.52, segments: 34, startCurve: -0.52 },
{ endCurve: 0, segments: 28, startCurve: -0.52 },
{ endCurve: 0.38, segments: 22, startCurve: 0 },
{ endCurve: -0.38, segments: 34, startCurve: 0.38 },
{ endCurve: 0, segments: 24, startCurve: -0.38 },
{ endCurve: 0, segments: 32, startCurve: 0 },
];
숫자는 아직 물리적으로 정확한 코스 설계라기보다, 화면에서 커브가 잘 보이게 만든 테스트 값이다.
처음에는 section마다 curve 값을 고정해서 넣었다.
{ curve: 0.18, segments: 34 }
그런데 이렇게 하면 직선에서 커브로 들어가는 순간 값이 갑자기 바뀐다.
도로 중심선도 그 지점에서 갑자기 방향을 바꾸기 때문에, 화면에서 살짝 틱틱 끊기는 느낌이 난다.
그래서 이번에는 startCurve에서 endCurve까지 부드럽게 보간했다.
function ease(start: number, end: number, index: number, length: number) {
if (length <= 1) return end;
const t = index / (length - 1);
const smooth = t * t * (3 - 2 * t);
return start + (end - start) * smooth;
}
여기서 t * t * (3 - 2 * t)는 흔히 smoothstep이라고 부르는 형태다.
선형 보간은 시작부터 끝까지 같은 속도로 값이 바뀐다.
linear: 0.0 0.1 0.2 0.3 0.4 ...
smoothstep: 0.0 0.03 0.10 0.22 0.35 ...
smoothstep은 시작과 끝에서 변화가 느리고, 중간에서 더 빨라진다.
덕분에 직선에서 커브로 들어갈 때 도로가 갑자기 꺾이지 않고, 천천히 말려 들어가는 느낌이 난다.
진짜 3D 엔진이라면 도로 mesh 자체를 휘게 만들 수 있다.
하지만 지금 만드는 것은 pseudo 3D다. 우리는 도로를 사다리꼴 segment로 그린다.
커브를 표현하는 방법은 생각보다 단순하다.
멀리 있는 segment로 갈수록 도로 중심 x를 조금씩 옮긴다.
segment 0 center x = 0
segment 1 center x = 10
segment 2 center x = 25
segment 3 center x = 45
segment 4 center x = 70
이 중심점을 기준으로 도로 왼쪽/오른쪽을 만든다.
left = centerX - roadHalfWidth
right = centerX + roadHalfWidth
그 다음 각 점을 지난 글에서 만든 projectGroundPoint()로 화면에 투영한다.
현재 렌더러에서는 getVisibleBoundaryCenters()가 이 역할을 한다.
function getVisibleBoundaryCenters(
track: RoadTrack,
baseSegment: number,
progress: number,
) {
const centers = [0];
let centerX = 0;
for (let boundary = 1; boundary <= DRAW_SEGMENTS + 1; boundary += 1) {
const previousSegment = getRoadSegment(track, baseSegment + boundary - 1);
const nextSegment = getRoadSegment(track, baseSegment + boundary);
const distanceRatio = boundary === 1 ? 1 - progress : 1;
const averageCurve = (previousSegment.curve + nextSegment.curve) / 2;
centerX += averageCurve * CURVE_STEP * distanceRatio;
centers.push(centerX);
}
return centers;
}
여기서 핵심은 세 가지다.
progress를 반영한다.처음에는 segment마다 near/far center를 바로 계산했다.
그 방식도 동작은 하지만, 카메라가 segment 경계를 넘어갈 때 중심선 계산이 한 프레임 단위로 바뀌며 미세하게 튀는 느낌이 있었다.
boundary center 배열을 먼저 만들면, 현재 카메라가 segment 안에서 이동하는 정도를 반영할 수 있어 조금 더 부드럽게 보인다.
커브가 들어갔다고 해서 투영 방식이 크게 달라지는 것은 아니다.
한 segment는 여전히 네 점으로 그린다.
const nearCenterX = boundaryCenters[i - 1];
const farCenterX = boundaryCenters[i];
가까운 쪽 중심과 먼 쪽 중심이 다를 뿐이다.
const roadNearLeft = projectGroundPoint(
{ x: nearCenterX - ROAD_HALF_WIDTH, z: nearWorldZ },
camera,
viewport,
);
const roadFarLeft = projectGroundPoint(
{ x: farCenterX - ROAD_HALF_WIDTH, z: farWorldZ },
camera,
viewport,
);
이렇게 하면 segment 하나하나는 약간 비틀린 사다리꼴이 된다.
그 사다리꼴을 멀리 있는 것부터 가까운 것 순서로 계속 그리면, 도로가 좌우로 휘어 보인다.
for (let i = projectedSegments.length - 1; i >= 0; i -= 1) {
const projected = projectedSegments[i];
drawRoadBody(graphics, projected.road, projected.absoluteIndex);
drawShoulder(graphics, projected.road, projected.absoluteIndex, -1, camera, viewport);
drawShoulder(graphics, projected.road, projected.absoluteIndex, 1, camera, viewport);
drawLaneMarks(
graphics,
projected.road,
projected.absoluteIndex,
projected.segment.laneCount,
camera,
viewport,
);
}
도로 본체만 커브를 따라가면 어색하다.
빨간색/흰색 rumble strip과 중앙 차선도 같은 center x를 기준으로 그려야 한다.
갓길은 도로 바깥쪽 x 범위를 잡는다.
const innerNearX = road.nearCenterX + side * ROAD_HALF_WIDTH;
const outerNearX = road.nearCenterX + side * (ROAD_HALF_WIDTH + RUMBLE_WIDTH);
const innerFarX = road.farCenterX + side * ROAD_HALF_WIDTH;
const outerFarX = road.farCenterX + side * (ROAD_HALF_WIDTH + RUMBLE_WIDTH);
중앙 차선은 차선 수에 따라 도로 폭 안에서 위치를 나눈다.
const laneCenterRatio = lane / laneCount;
const nearLaneCenterX = Phaser.Math.Linear(
road.nearCenterX - ROAD_HALF_WIDTH,
road.nearCenterX + ROAD_HALF_WIDTH,
laneCenterRatio,
);
그리고 도로 본체와 똑같이 네 점을 투영해 polygon으로 채운다.
결국 pseudo 3D 도로 렌더러는 같은 패턴을 반복한다.
도로 본체, 갓길, 차선은 재료만 다를 뿐이다.
커브를 테스트하다 보니 AWSD/QE로 카메라 값을 바꿀 때도 딱딱하게 느껴졌다.
처음에는 키가 눌린 동안 값을 바로 더했다.
if (this.cursors.left.isDown || this.keys.a.isDown) {
camera.lateralOffset -= 820 * seconds;
}
이 방식은 단순하지만, 키를 누르는 순간 속도가 바로 최대가 되고 키를 떼는 순간 바로 0이 된다.
디버그 입력이라도 화면을 보며 감각을 조정할 때는 이 시작/정지가 거칠게 느껴진다.
그래서 입력을 velocity로 바꾸고, 목표 속도를 부드럽게 따라가게 했다.
const inputBlend = 1 - Math.exp(-CAMERA_INPUT_RESPONSE * seconds);
this.cameraVelocity.lateral = Phaser.Math.Linear(
this.cameraVelocity.lateral,
targetLateralVelocity,
inputBlend,
);
Math.exp()를 이용한 이 방식은 프레임 시간에 비교적 덜 민감하다.
프레임이 조금 길어져도 inputBlend가 그만큼 커지고, 프레임이 짧으면 작아진다. 그래서 60fps 기준으로만 맞춘 고정 보간보다 안정적이다.
이제 카메라 offset, height, pitch가 키 입력을 조금 더 자연스럽게 따라간다.
이번 구현은 “커브 렌더링”까지만 다룬다.
아직 차량이 커브를 따라 움직이지 않는다. 커브에서 차가 바깥쪽으로 밀리는 느낌도 없고, 핸들을 꺾거나 드리프트하는 물리도 없다.
지금 화면에서 도로가 휘는 것은 다음 단계를 위한 기반이다.
하지만 이 기반은 중요하다.
이제 도로를 단순한 그림이 아니라 데이터로 다룰 수 있다.
curve가 있으면 커브를 만들 수 있다.elevation을 추가하면 언덕을 만들 수 있다.laneCount를 이용하면 구간별 차선 수를 바꿀 수 있다.다음에는 여기에 플레이어 차량을 올릴 수 있다.
화면 아래쪽에 차를 하나 두고, 좌우 입력으로 도로 위 위치를 바꾸는 단계다. 그때부터는 정말 레이싱 게임처럼 보이기 시작할 것이다.
아직 차는 없지만 도로가 먼저 달릴 준비를 마쳤다. 이 정도면 엔진의 뼈대가 조금씩 모양을 갖추고 있다.
Phaser 4와 TypeScript로 Apex Seoul 프로젝트를 세팅하고, horizon, FOV, camera height를 이용해 pseudo 3D 도로 카메라를 구현했습니다.
Isometric Minesweeper에 난이도별 최고 기록 저장, Best 표시, New best 상태, 기록 초기화 버튼을 추가하고 이번 지뢰찾기 연재를 마무리했습니다.
블로그 iframe 안에서 Isometric Minesweeper를 모바일로 플레이할 수 있도록 tap reveal, long press flag, landscape 안내, height 기반 board layout을 추가했습니다.
지뢰찾기에 난이도 선택과 타이머를 붙이고, 텍스트로 표시하던 깃발과 지뢰를 Phaser Graphics 기반 아이콘으로 바꿔 게임다운 화면으로 다듬었습니다.
지뢰찾기의 손맛을 만드는 빈 칸 연쇄 오픈을 BFS로 구현하고, 새 게임 버튼과 남은 지뢰 수 UI를 추가했습니다.
ECS scaffold 위에 지뢰찾기 규칙을 얹어 Mine, Flag, AdjacentMineCount 상태를 추가하고, 첫 클릭 이후 지뢰를 배치하는 흐름을 구현했습니다.