댓글 검색 목록

[javascript] 2KB의 JavaScript로 3D 게임을 만드는 방법

페이지 정보

작성자 운영자 작성일 20-03-19 15:45 조회 952 댓글 0

몇 달 전, 전설적인 JS1k 게임 잼이 계속되지 않을 것이라고 들었을 때, 다른 개발자들과 이야기를 나누고 2kPlus Jam이라는 가려움증에 2k 게임 잼을 호스팅 하기로 결정했습니다. 이 광고의 주요 목표는 2 킬로바이트 zip 파일에 완전히 맞는 게임을 만드는 것이었습니다. 참고로 3.5 플로피 디스크는 700 개가 넘는 게임을 수용 할 수 있습니다.


http://frankforce.com/?p=7427 


Hue Jumper는 80 년대 레이싱 게임 렌더링 기술에 경의를 표합니다. 3D 그래픽 및 물리 엔진은 순수한 JavaScript로 처음부터 구현되었습니다. 또한 게임 플레이와 영상을 조정하는 데 경건한 시간을 보냈습니다.


게임 잼의 테마는“Shift”로, 플레이어가 체크 포인트를 넘을 때 월드 색상의 색조를 이동 시켜 통합했습니다. 나는 체크 포인트를 가로 지르는 것이 허구 적으로 다른 색조로 새로운 차원으로 이동하거나 점프하는 것과 같다고 생각했다.


이 게시물은 게임의 JavaScript 코드가 완전히 포함되어 있기 때문에 조금 길어질 것입니다. 코드는 이미 잘 주석 처리되어 있으므로 모든 코드를 설명하지는 않으며 지금 모든 코드를 읽지 않아도 됩니다. 대신 내 목표는 작동 방식, 내가 왜 이런 식으로 만든지, 전체 구조를 안내하는 것입니다. 이 코드는 CodePen에 있으며 라이브 게임을 즐길 수 있습니다. 그러니 계속 읽고, 펴고, 엉덩이를 붙잡으십시오!

XBQ8FZj.gif 


영감 


나의 주된 영감은 Out Run과 같은 고전적인 80 년대 스타일 레이싱 게임의 향수에서 비롯됩니다. 비슷한 기술을 사용하여 초기 하드웨어에서 실시간 3D 그래픽을 푸시할 수 있었습니다. 

또한 최근에는 비주얼 디자인과 느낌을 알리는 데 도움이 되는 Distance and Lonely Mountains : Downhill과 같은 현대적인 레이싱 게임을 하고 있습니다.


JavaScript로 의사 3D 레이서를 만드는 Jake Gordon의 프로젝트는 큰 도움이 되었습니다. 그는 어떻게 작동하는지 설명하는 환상적인 멀티 포스트 블로그 시리즈를 썼습니다. 처음부터 시작했지만 그의 코드를 살펴보면 몇 가지 수학 및 기타 문제를 해결할 수 있었습니다.


또한 Chris Glover의 Mo11kross라는 JS1k 게임을 보았습니다. 이 작은 1 킬로바이트 레이싱 게임은 가능한 것에 대한 참고 자료를 제공하는 데 도움이 되었으며, 여분의 킬로바이트 공간을 확보 할 수 있었기 때문에 이를 능가해야 했습니다.


높은 수준의 전략 


엄격한 크기 제한으로 인해 프로그램 구성 방식에 매우주의해야했습니다. 나의 일반적인 전략은 외형과 느낌이 강한 게임을 만드는 궁극적 인 목표를 달성 할 때 모든 것을 가능한 한 단순하게 유지하는 것이었습니다.


코드를 압축하기 위해 모든 공백을 제거하고 변수의 이름을 1 자로 바꾸고 가벼운 최적화를 수행하는 Google Closure Compiler를 통해 코드를 실행했습니다. 이 사이트를 사용하여 Closure Compiler Service 온라인을 통해 코드를 실행할 수 있습니다. 불행히도 Closure는 템플릿 문자열, 기본 매개 변수 및 공간 절약에 도움이 되는 기타 ES6 기능을 교체하는 등 도움이 되지 않는 다른 작업을 수행합니다. 그래서 그 중 일부를 수동으로 실행 취소하고 마지막 바이트를 짜기 위해 몇 가지 '위험한'축소 기술을 수행해야 했습니다. 그러나 큰 비용 절감은 코드 자체의 구조에서 비롯됩니다.


2 킬로바이트에 맞게 코드를 압축해야 합니다. 이것이 옵션이 아닌 경우, RegPack이라는 비슷하지만 덜 강력한 도구가 있습니다. 어느 쪽이든 전략은 동일합니다. 가능하면 코드를 반복하고 컴프레서가 수축 시킵니다. 예를 들어 압축률이 큰 특정 문자열이 자주 나타납니다. 가장 좋은 예는 c.width, c.height 및 Math이지만, 더 작은 다른 것들도 많이 있습니다. 따라서 이 코드를 읽을 때 압축을 이용하기 위해 의도적으로 반복되는 것을 볼 수 있습니다.


코드 펜 


CodePen에서 실시간으로 실행되는 게임은 다음과 같습니다. 실제로 iframe에서 재생할 수 있지만 최상의 결과를 얻으려면 코드를 편집하거나 포크 할 수 있는 새 탭에서 여는 것이 좋습니다.


https://codepen.io/KilledByAPixel/pen/poJdLwB 


HTML 


대부분 JavaScript이므로 내 게임에서 사용하는 HTML이 거의 없습니다. 이것은 나중에 캔버스 크기를 창 내부 크기로 설정하는 코드와 결합하여 전체 화면 캔버스를 만드는 가장 작은 방법입니다. CodePen에서 왜 overflow : hidden을 본문에 추가해야 했는지 잘 모르겠지만 직접 열면 제대로 작동합니다.


최종 축소 버전은 자바 스크립트를 onload 호출로 래핑하여 더 작은 설정을 사용합니다… 코드는 문자열에 저장되므로 편집기는 구문을 올바르게 강조 표시 할 수 없습니다.

<body style=margin:0>
<canvas id=c>
<script>

Constants 


게임의 여러 측면을 제어하는 ​​많은 상수가 있습니다. 코드가 Google Closure와 같은 도구로 축소되면 이러한 상수는 모두 C ++의 #define처럼 대체됩니다. 그것들을 먼저 넣으면 게임 플레이를 더 빠르게 조정할 수 있습니다.

// draw settings
const context = c.getContext`2d`; // canvas context
const drawDistance = 800;         // how far ahead to draw
const cameraDepth = 1;            // FOV of camera
const segmentLength = 100;        // length of each road segment
const roadWidth = 500;            // how wide is road
const curbWidth = 150;            // with of warning track
const dashLineWidth = 9;          // width of the dashed line
const maxPlayerX = 2e3;           // limit player offset
const mountainCount = 30;         // how many mountains are there
const timeDelta = 1/60;           // inverse frame rate
const PI = Math.PI;               // shorthand for Math.PI

// player settings
const height = 150;               // high of player above ground
const maxSpeed = 300;             // limit max player speed
const playerAccel = 1;            // player forward acceleration
const playerBrake = -3;           // player breaking acceleration
const turnControl = .2;           // player turning rate
const jumpAccel = 25;             // z speed added for jump
const springConstant = .01;       // spring players pitch
const collisionSlow = .1;         // slow down from collisions
const pitchLerp = .1;             // rate camera pitch changes
const pitchSpringDamp = .9;       // dampen the pitch spring
const elasticity = 1.2;           // bounce elasticity
const centrifugal = .002;         // how much turns pull player
const forwardDamp = .999;         // dampen player z speed
const lateralDamp = .7;           // dampen player x speed
const offRoadDamp = .98;          // more damping when off road
const gravity = -1;               // gravity to apply in y axis
const cameraTurnScale = 2;        // how much to rotate camera
const worldRotateScale = .00005;  // how much to rotate world
    
// level settings
const maxTime = 20;               // time to start
const checkPointTime = 10;        // add time at checkpoints
const checkPointDistance = 1e5;   // how far between checkpoints
const maxDifficultySegment = 9e3; // how far until max difficulty
const roadEnd = 1e4;              // how far until end of road

Mouse Control 


입력 시스템은 마우스 만 사용합니다. 이 코드를 사용하면 마우스 클릭과 가로 커서 위치를 -1과 1 사이의 값으로 표시 할 수 있습니다. 더블 클릭은 mouseUpFrames를 통해 구현됩니다. mousePressed 변수는 플레이어가 처음 클릭 할 때 게임을 시작하기 위해 한 번만 사용됩니다.


mouseDown     =
mousePressed  =
mouseUpFrames =
mouseX        = 0;
    
onmouseup   =e=> mouseDown = 0;
onmousedown =e=> mousePressed ? mouseDown = 1 : mousePressed = 1;
onmousemove =e=> mouseX = e.x/window.innerWidth*2 - 1;

Math Functions 


이 게임에서 코드를 단순화하고 반복을 줄이기 위해 사용하는 몇 가지 기능이 있습니다. 일부 표준 수학 함수는 클램프 및 Lerp 값입니다. ClampAngle은 많은 게임에 필요한 -PI와 PI 사이의 각도를 감싸기 때문에 유용합니다.


R 함수는 시드 난수를 생성하므로 거의 마술처럼 작동합니다. 이것은 현재의 랜덤 시드의 사인을 취하여 높은 수를 곱한 다음 분수 부분을 보면서 수행됩니다. 여러 가지 방법이 있지만 가장 작은 방법 중 하나입니다. 도박 소프트웨어에는 이것을 사용하지 않는 것이 좋지만 우리의 목적에는 충분합니다. 이 랜덤 생성기를 사용하여 데이터를 저장할 필요 없이 절차 적으로 다양성을 생성 할 것입니다. 예를 들어 산, 바위 및 나무의 변형은 메모리의 어느 곳에도 저장되지 않습니다. 이 경우 목표는 메모리를 줄이는 것이 아니라 해당 데이터를 저장하고 검색하는 데 필요한 코드를 제거하는 것입니다.


이 게임은 "True 3D"게임이므로 코드를 더 작게 만드는 3D 벡터 클래스를 갖는 데 크게 도움이 됩니다. 이 클래스에는 이 게임에 필요한 기본 요소 만 포함되어 있으며 add 및 multiply 함수를 가진 생성자는 스칼라 또는 벡터 매개 변수를 사용할 수 있습니다. 스칼라가 전달되는지 확인하기 위해 스칼라가 큰 수보다 작은 지 확인합니다. 더 정확한 방법은 isNan을 사용하거나 유형이 Vec3인지 확인하는 것이지만 더 많은 공간이 필요합니다.

Clamp     =(v, a, b)  => Math.min(Math.max(v, a), b);
ClampAngle=(a)        => (a+PI) % (2*PI) + (a+PI<0? PI : -PI);
Lerp      =(p, a, b)  => a + Clamp(p, 0, 1) * (b-a);
R         =(a=1, b=0) => Lerp((Math.sin(++randSeed)+1)*1e5%1,a,b);
   
class Vec3 // 3d vector class
{
  constructor(x=0, y=0, z=0) {this.x = x; this.y = y; this.z = z;}
  
  Add=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x + v.x, this.y + v.y, this.z + v.z ));
    
  Multiply=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x * v.x, this.y * v.y, this.z * v.z ));
}

Render Functions 


LSHA는 템플릿 문자열을 사용하여 표준 HSLA (hue, saturation, luminosity, alpha) 색상을 생성하며, 재정렬 되어 더 자주 사용되는 구성 요소가 우선합니다. 검사 점에서 발생하는 전역 색조 이동도 여기에 적용됩니다.


DrawPoly는 사다리꼴 모양을 그리고 장면에서 모든 것을 절대적으로 렌더링 하는 데 사용됩니다. Y 구성 요소는 도로 폴리가 완전히 연결되도록 | 0을 사용하여 정수로 변환됩니다. 이것이 없으면 도로 구간 사이에 가는 선이 생길 것입니다. 같은 이유로 이 렌더링 기술은 대각선에서 그래픽 아티팩트를 발생 시키지 않고 카메라에 롤을 적용하는 것을 처리 할 수 ​​없습니다.


DrawText는 시간, 거리 및 게임 제목을 표시하는 데 사용되는 윤곽선 텍스트 만 렌더링 합니다.


LSHA=(l,s=0,h=0,a=1)=>`hsl(${h+hueShift},${s}%,${l}%,${a})`;

// draw a trapazoid shaped poly
DrawPoly=(x1, y1, w1, x2, y2, w2, fillStyle)=>
{
    context.beginPath(context.fillStyle = fillStyle);
    context.lineTo(x1-w1, y1|0);
    context.lineTo(x1+w1, y1|0);
    context.lineTo(x2+w2, y2|0);
    context.lineTo(x2-w2, y2|0);
    context.fill();
}

// draw outlined hud text
DrawText=(text, posX)=>
{
    context.font = '9em impact';         // set font size
    context.fillStyle = LSHA(99,0,0,.5); // set font color
    context.fillText(text, posX, 129);   // fill text
    context.lineWidth = 3;               // line width
    context.strokeText(text, posX, 129); // outline text
}

Build Track with Procedural Generation 


게임을 시작하기 전에 먼저 전체 트랙을 생성해야 합니다. 이 트랙은 모든 플레이 스루마다 다릅니다. 이를 위해 트랙의 각 지점에서 도로의 위치와 너비를 저장하는 도로 세그먼트 목록을 작성합니다.


트랙 제너레이터는 매우 기본적이며, 주파수, 진폭 및 폭이 다양한 섹션 사이에서 테이퍼링 됩니다. 트랙을 따라 거리는 그 섹션이 얼마나 어려운 지를 결정합니다.


여기서 도로 피치 각도는 물리 및 조명에 사용되는 atan2 기능을 사용하여 계산됩니다.


track.png 


roadGenLengthMax =                     // end of section
roadGenLength =                        // distance left
roadGenTaper =                         // length of taper
roadGenFreqX =                         // X wave frequency 
roadGenFreqY =                         // Y wave frequency
roadGenScaleX =                        // X wave amplitude
roadGenScaleY = 0;                     // Y wave amplitude
roadGenWidth = roadWidth;              // starting road width
startRandSeed = randSeed = Date.now(); // set random seed
road = [];                             // clear road

// generate the road
for( i = 0; i < roadEnd*2; ++i )          // build road past end
{
  if (roadGenLength++ > roadGenLengthMax) // is end of section?
  {
    // calculate difficulty percent
    d = Math.min(1, i/maxDifficultySegment);
  
    // randomize road settings
    roadGenWidth = roadWidth*R(1-d*.7,3-2*d);        // road width
    roadGenFreqX = R(Lerp(d,.01,.02));               // X curves
    roadGenFreqY = R(Lerp(d,.01,.03));               // Y bumps
    roadGenScaleX = i>roadEnd ? 0 : R(Lerp(d,.2,.6));// X scale
    roadGenScaleY = R(Lerp(d,1e3,2e3));              // Y scale
  
    // apply taper and move back
    roadGenTaper = R(99, 1e3)|0;                 // random taper
    roadGenLengthMax = roadGenTaper + R(99,1e3); // random length
    roadGenLength = 0;                           // reset length
    i -= roadGenTaper;                           // subtract taper
  }
  
  // make a wavy road
  x = Math.sin(i*roadGenFreqX) * roadGenScaleX;
  y = Math.sin(i*roadGenFreqY) * roadGenScaleY;
  road[i] = road[i]? road[i] : {x:x, y:y, w:roadGenWidth};
  
  // apply taper from last section and lerp values
  p = Clamp(roadGenLength / roadGenTaper, 0, 1);
  road[i].x = Lerp(p, road[i].x, x);
  road[i].y = Lerp(p, road[i].y, y);
  road[i].w = i > roadEnd ? 0 : Lerp(p, road[i].w, roadGenWidth);
    
  // calculate road pitch angle
  road[i].a = road[i-1] ? 
    Math.atan2(road[i-1].y-road[i].y, segmentLength) : 0;
}

Startup the Game 


이제 트랙이 존재합니다. 게임을 시작하려면 몇 가지 변수 만 초기화하면 됩니다.

// reset everything
velocity = new Vec3
  ( pitchSpring =  pitchSpringSpeed =  pitchRoad = hueShift = 0 );
  
position = new Vec3(0, height);      // set player start pos
nextCheckPoint = checkPointDistance; // init next checkpoint
time = maxTime;                      // set the start time
heading = randSeed;                  // random world heading

Update Player 


게임의 모든 것을 업데이트하고 렌더링 하는 주요 업데이트 기능입니다! 일반적으로 코드에 거대한 함수를 사용하는 것은 좋지 않으며 하위 함수로 나뉩니다. 설명을 돕기 위해 아래의 여러 부분으로 나뉩니다.


먼저 플레이어의 위치에서 도로에 대한 정보를 얻어야 합니다. 물리와 렌더링이 부드럽게 느껴지도록 현재와 다음 도로 구간 사이에 값이 보간 됩니다.


플레이어의 위치와 속도는 3D 벡터이며 운동학으로 업데이트 되어 중력, 감쇠 및 기타 요소를 적용합니다. 플레이어가 도로 아래에 있으면 위치가 지면에 고정되고 속도가 법선에 반영됩니다. 또한 지면에 있을 때 가속이 적용되고 도로에서 벗어날 때 카메라가 흔들립니다. 플레이 테스트 후 나는 비행 중에 플레이어가 여전히 튜닝을 하도록 결정했습니다.


가속, 제동, 점프 및 회전을 제어하기 위해 여기에서 입력을 처리합니다. 두 번 클릭이 감지되면 mouseUpFrames를 통해 이루어집니다. 또한 플레이어가 여전히 점프 할 수 있는 짧은 유예 기간을 허용하기 위해 플레이어가 공중에 얼마나 많은 프레임을 사용했는지 추적하는 코드도 있습니다.


카메라의 피치 각도는 스프링 시스템을 사용하여 플레이어가 가속, 브레이크 및 점프 할 때 역동적 인 느낌을 줍니다. 또한 플레이어가 언덕 위로 운전하고 점프 할 때 도로 각도에 맞게 카메라가 기울어집니다.

Update=()=>
{

// get player road segment
s = position.z / segmentLength | 0; // current road segment
p = position.z / segmentLength % 1; // percent along segment

// get lerped values between last and current road segment
roadX = Lerp(p, road[s].x, road[s+1].x);
roadY = Lerp(p, road[s].y, road[s+1].y) + height;
roadA = Lerp(p, road[s].a, road[s+1].a);

// update player velocity
lastVelocity = velocity.Add(0);
velocity.y += gravity;
velocity.x *= lateralDamp;
velocity.z = Math.max(0, time?forwardDamp*velocity.z:0);

// add velocity to position
position = position.Add(velocity);
  
// limit player x position (how far off road)
position.x = Clamp(position.x, -maxPlayerX, maxPlayerX); 

// check if on ground
if (position.y < roadY)
{
  position.y = roadY; // match y to ground plane
  airFrame = 0;       // reset air frames
  
  // get the dot product of the ground normal and the velocity
  dp = Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z;
  
  // bounce velocity against ground normal
  velocity = new Vec3(0, Math.cos(roadA), Math.sin(roadA))
    .Multiply(-elasticity * dp).Add(velocity);
    
  // apply player brake and accel
  velocity.z += 
    mouseDown? playerBrake :
    Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0);
  
  // check if off road
  if (Math.abs(position.x) > road[s].w)
  {
    velocity.z *= offRoadDamp;                    // slow down
    pitchSpring += Math.sin(position.z/99)**4/99; // rumble
  }
}

// update player turning and apply centrifugal force
turn = Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0);
velocity.x +=
  velocity.z * turn -
  velocity.z ** 2 * centrifugal * roadX;

// update jump
if (airFrame++<6 && time 
  && mouseDown && mouseUpFrames && mouseUpFrames<9)
{
  velocity.y += jumpAccel; // apply jump velocity
  airFrame = 9;            // prevent jumping again
}
mouseUpFrames = mouseDown? 0 : mouseUpFrames+1;

// pitch down with vertical velocity when in air
airPercent = (position.y-roadY) / 99;
pitchSpringSpeed += Lerp(airPercent, 0, velocity.y/4e4);

// update player pitch spring
pitchSpringSpeed += (velocity.z - lastVelocity.z)/2e3;
pitchSpringSpeed -= pitchSpring * springConstant;
pitchSpringSpeed *= pitchSpringDamp;
pitchSpring += pitchSpringSpeed; 
pitchRoad = Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA,0));
playerPitch = pitchSpring + pitchRoad;

// update heading
heading = ClampAngle(heading + velocity.z*roadX*worldRotateScale);
cameraHeading = turn * cameraTurnScale;

// was checkpoint crossed?
if (position.z > nextCheckPoint)
{
  time += checkPointTime;               // add more time
  nextCheckPoint += checkPointDistance; // set next checkpoint
  hueShift += 36;                       // shift hue
}

Pre-Render 


렌더링 하기 전에 캔버스는 너비와 높이를 설정하여 지 웁니다. 이것은 또한 창을 채우기 위해 캔버스에 맞습니다.


또한 월드 포인트를 캔버스 공간으로 변환하는 데 사용되는 프로젝션 스케일을 계산합니다. cameraDepth 값은이 게임에서 90 도인 카메라의 FOV (Field of View)를 나타냅니다. 계산은 1 / Math.tan ((fovRadians / 2)로, 90 도의 FOV에서 정확히 1이 됩니다. 종횡비를 유지하기 위해 투영의 크기는 c.width로 조정됩니다.

// clear the screen and set size
c.width = window.innerWidth, c.height = window.innerHeight;

// calculate projection scale, flip y
projectScale = (new Vec3(1,-1,1)).Multiply(c.width/2/cameraDepth);

Draw Sky, Sun and Moon 


배경 분위기는 태양의 방향에 따라 색이 변하는 전체 화면 선형 그라데이션으로 그려집니다.


공간을 절약하기 위해 태양과 달은 모두 투명하고 전체 화면 방사형 그래디언트를 사용하여 동일한 for 루프로 그려집니다.


선형 및 방사형 그래디언트가 결합되어 장면을 완전히 감싸는 스카이 박스를 만듭니다.


// get horizon, offset, and light amount
horizon = c.height/2 - Math.tan(playerPitch)*projectScale.y;
backgroundOffset = Math.sin(cameraHeading)/2;
light = Math.cos(heading);

// create linear gradient for sky
g = context.createLinearGradient(0,horizon-c.height/2,0,horizon);
g.addColorStop(0,LSHA(39+light*25,49+light*19,230-light*19));
g.addColorStop(1,LSHA(5,79,250-light*9));

// draw sky as full screen poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);

// draw sun and moon (0=sun, 1=moon)
for( i = 2 ; i--; )
{
  // create radial gradient
  g = context.createRadialGradient(
    x = c.width*(.5+Lerp(
      (heading/PI/2+.5+i/2)%1,
      4, -4)-backgroundOffset),
    y = horizon - c.width/5,
    c.width/25,
    x, y, i?c.width/23:c.width);
  g.addColorStop(0, LSHA(i?70:99));
  g.addColorStop(1, LSHA(0,0,0,0));
  
  // draw full screen poly
  DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
}

Draw Mountains and Horizon 


수평선에 50 개의 삼각형을 그려서 산을 절차 적으로 생성합니다. 조명은 그림자가 있어 태양을 향할 때 산이 어두워 지도록 적용됩니다. 또한 더 가까운 산은 안개를 시뮬레이트 하기 위해 더 어둡습니다. 실제 트릭은 크기와 색상에 대한 임의의 값을 조정하여 좋은 결과를 얻었습니다.


배경을 그리는 마지막 부분은 수평선을 그 아래에 그려 캔버스의 바닥을 녹색으로 채 웁니다.

mount2-1024x297.jpg 

// set random seed for mountains
randSeed = startRandSeed;

// draw mountains
for( i = mountainCount; i--; )
{
  angle = ClampAngle(heading+R(19));
  light = Math.cos(angle-heading); 
  DrawPoly(
    x = c.width*(.5+Lerp(angle/PI/2+.5,4,-4)-backgroundOffset),
    y = horizon,
    w = R(.2,.8)**2*c.width/2,
    x + w*R(-.5,.5),
    y - R(.5,.8)*w, 0,
    LSHA(R(15,25)+i/3-light*9, i/2+R(19), R(220,230)));
}

// draw horizon
DrawPoly(
  c.width/2, horizon, c.width/2, c.width/2, c.height, c.width/2,
  LSHA(25, 30, 95));

Project Road Segments to Canvas Space 


도로를 렌더링 하기 전에 먼저 예상 도로 지점을 가져와야 합니다. 도로의 x 값을 월드 스페이스 위치로 변환해야 하기 때문에 첫 번째 부분은 약간 까다롭습니다. 도로가 곡선으로 보이도록 x 값을 2 차 도함수로 적용합니다. 이상한 코드“x + = w + =”가있는 이유입니다. 이것이 작동하는 방식으로 인해 도로 구간에는 영구적 인 월드 공간 위치가 없지만 대신 플레이어의 위치에 따라 모든 프레임이 다시 계산됩니다.


월드 공간 위치를 확보 한 후 도로 위치에서 플레이어 위치를 빼면 로컬 카메라 공간 위치를 얻을 수 있습니다. 코드의 나머지 부분은 먼저 회전 제목, 피치, 투영 변환을 적용하여 더 멀리 있는 것을 더 작게 보이게 하고 최종적으로 캔버스 공간으로 이동 시킵니다.

How I made a 3D game in only 2KB of JavaScript

kyjXrea.gifMonths ago, when I heard that the legendary JS1k game jam would not be continuing, I talked it over with some other devs and decided to help fill the void we would host a 2k game jam on itch called 2kPlus Jam. The primary goal of this comp was to create a game that fits entirely in a 2 kilobyte zip file. That is incredibly small, for point of reference a 3.5 floppy disk could hold over 700 of these games.

My entry, Hue Jumper, is an homage to 80’s racing game rendering technology. The 3D graphics and physics engine was implemented from scratch in pure JavaScript. I also spent ungodly hours tweaking the gameplay and visuals.

The theme for the game jam was “Shift” which I incorporated by shifting the hue for the world’s color when the player crosses a checkpoint. I imagined that crossing a checkpoint was fictionally like shifting or jumping into a new dimension with a different hue, which is how I came up with the name “Hue Jumper”.

This post is going to be a bit long because it contains the JavaScript code for my game in it’s entirety. The code is already well commented so I’m not going to explain every line of it nor are you expected to read through all the code now. Instead my goal is to explain how it works, why I made it this way, and walk you through the overall structure. This same code is on CodePen for you to play around with live. So please continue reading, buckle up, and hold onto your butts!

XBQ8FZj.gif

Inspiration

Out Run by Sega

My primary inspiration comes from nostalgia of classic 80’s style racing games like Out Run. Using a similar technique, they were able to push real time 3D graphics on very early hardware. Also I have recently been playing some modern racing games like Distance and Lonely Mountains: Downhill which helped inform the visual design and feel.

Jake Gordon’s project to create a pseudo 3D racer in JavaScript was a big help. He wrote a fantastic multi post blog series that explains how it works. Though I started from scratch, seeing his code helped me work through some of the math and other problems I encountered.

I also looked at a JS1k game called Moto1kross by Chris Glover. This small one kilobyte racing game helped give me a point of reference for what was possible, and with an extra kilobyte of space available I knew I had to far surpass it.

High Level Strategy

Because of the strict size limitation, I needed to be very careful about how my program is structured. My general strategy was to keep everything as simple as possible in serving the ultimate goal of making a game that looks and feels solid.

To help compress the code, I ran it through Google Closure Compiler which removes all white space, renames variables to 1 letter characters, and performs some light optimization. You can use this site to run your code through Closure Compiler Service online. Unfortunately Closure does some other stuff that doesn’t help, like replacing template strings, default parameters and other ES6 features that help save space. So I needed to manually undo some of that and perform a few more ‘risky’ minification techniques to squeeze out every last byte. It’s not a huge win though, the bulk of the savings comes from the structure of the code itself.

The code needs to be zipped to fit into a 2 kilobytes. If that was not an option there is a similar yet less powerful tool called RegPack which can make self uncompromising JavaScript. Either way the strategy is the same, to repeat code wherever possible and let the compressor deflate it. For example there are certain strings which appear often so their compression ratio is large. Some of the best examples are c.width, c.height, and Math, but there are many other smaller ones that add up. So, when reading through this code, keep in mind that you will often see things purposefully repeated to take advantage of the compression.

CodePen

Here’s the game running live on CodePen. You can actually play it in the iframe, but for best results I recommend opening it in a new tab, where you can edit or fork the code.

HTML

There is very little html used by my game, as it is mostly JavaScript. This is the smallest way to create a full screen canvas, combined with code that later sets the canvas size to the window inner size. I’m not sure why on CodePen it was necessary to add overflow:hidden to the body, but this should work fine when opened directly.

The final minified version uses an even smaller setup by wrapping the JavaScript in an onload call… <body style=margin:0 onload=”code_goes_here”><canvas id=c> However, during development I prefer not to use that condensed setup because the code is stored in a string so editors can’t properly highlight the syntax.

<body style=margin:0>
<canvas id=c>
<script>

Constants

There are many constants that control different aspects of the game. When the code is minified with a tool like Google Closure, these constants will all be replaced much like a #define in C++. Putting them first makes it faster to tweak gameplay.

// draw settings
const context = c.getContext`2d`; // canvas context
const drawDistance = 800;         // how far ahead to draw
const cameraDepth = 1;            // FOV of camera
const segmentLength = 100;        // length of each road segment
const roadWidth = 500;            // how wide is road
const curbWidth = 150;            // with of warning track
const dashLineWidth = 9;          // width of the dashed line
const maxPlayerX = 2e3;           // limit player offset
const mountainCount = 30;         // how many mountains are there
const timeDelta = 1/60;           // inverse frame rate
const PI = Math.PI;               // shorthand for Math.PI

// player settings
const height = 150;               // high of player above ground
const maxSpeed = 300;             // limit max player speed
const playerAccel = 1;            // player forward acceleration
const playerBrake = -3;           // player breaking acceleration
const turnControl = .2;           // player turning rate
const jumpAccel = 25;             // z speed added for jump
const springConstant = .01;       // spring players pitch
const collisionSlow = .1;         // slow down from collisions
const pitchLerp = .1;             // rate camera pitch changes
const pitchSpringDamp = .9;       // dampen the pitch spring
const elasticity = 1.2;           // bounce elasticity
const centrifugal = .002;         // how much turns pull player
const forwardDamp = .999;         // dampen player z speed
const lateralDamp = .7;           // dampen player x speed
const offRoadDamp = .98;          // more damping when off road
const gravity = -1;               // gravity to apply in y axis
const cameraTurnScale = 2;        // how much to rotate camera
const worldRotateScale = .00005;  // how much to rotate world
    
// level settings
const maxTime = 20;               // time to start
const checkPointTime = 10;        // add time at checkpoints
const checkPointDistance = 1e5;   // how far between checkpoints
const maxDifficultySegment = 9e3; // how far until max difficulty
const roadEnd = 1e4;              // how far until end of road

Mouse Control

The input system uses only the mouse. With this bit of code we can track mouse clicks and the horizontal cursor position expressed as a value between -1 and 1. Double clicking is implemented via mouseUpFrames. The mousePressed variable is only used once, to start the game when the player clicks for the first time.

mouseDown     =
mousePressed  =
mouseUpFrames =
mouseX        = 0;
    
onmouseup   =e=> mouseDown = 0;
onmousedown =e=> mousePressed ? mouseDown = 1 : mousePressed = 1;
onmousemove =e=> mouseX = e.x/window.innerWidth*2 - 1;

Math Functions

There are a few functions used by this game to simplify code and reduce repetition. Some standard math functions to Clamp and Lerp values. ClampAngle is useful because it wraps angles between -PI and PI, something many games require.

Random Test Pattern

The R function works almost like magic because it generates seeded random numbers. This is done by taking the sine of the current random seed, multiplying it by a high number, and then looking at the fractional part. There are many ways to do it but this is one of the smallest. I wouldn’t recommend using this for gambling software but it’s random enough for our purposes. We will be using this random generator to create variety procedurally without needing to save any data. For example the variation in mountains, rocks and trees is not stored anywhere in memory. The goal isn’t to reduce memory in this case though, but to eliminate the code that would be needed to store and retrieve that data.

As this is a “True 3D” game it helps greatly to have a 3D vector class which also makes the code smaller. This class contains only the bare essentials necessary for this game, a constructor with add and multiply functions can take either scalar or vector parameter. To determine if a scalar is passed in, we just check if it is less then a large number. A more correct way would be to use isNan or check if it’s type is a Vec3, but that would require more space.

Clamp     =(v, a, b)  => Math.min(Math.max(v, a), b);
ClampAngle=(a)        => (a+PI) % (2*PI) + (a+PI<0? PI : -PI);
Lerp      =(p, a, b)  => a + Clamp(p, 0, 1) * (b-a);
R         =(a=1, b=0) => Lerp((Math.sin(++randSeed)+1)*1e5%1,a,b);
   
class Vec3 // 3d vector class
{
  constructor(x=0, y=0, z=0) {this.x = x; this.y = y; this.z = z;}
  
  Add=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x + v.x, this.y + v.y, this.z + v.z ));
    
  Multiply=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x * v.x, this.y * v.y, this.z * v.z ));
}

Render Functions

LSHA generates a standard HSLA (hue, saturation, luminosity, alpha) color using a template string, and has just been reordered so more often used components come first. The global hue shift that occurs at checkpoints is also applied here.

DrawPoly draws a trapezoid shape and is used to render absolutely everything in the scene. The Y component is converted to an integer using |0 to ensure that the road polys are fully connected. Without this, there would be a thin line between road segments. For this same reason this rendering tech can’t handle applying roll to the camera without causing graphical artifacts from diagonal lines.

DrawText just renders outlined text used to display time, distance, and the game’s title.

LSHA=(l,s=0,h=0,a=1)=>`hsl(${h+hueShift},${s}%,${l}%,${a})`;

// draw a trapazoid shaped poly
DrawPoly=(x1, y1, w1, x2, y2, w2, fillStyle)=>
{
    context.beginPath(context.fillStyle = fillStyle);
    context.lineTo(x1-w1, y1|0);
    context.lineTo(x1+w1, y1|0);
    context.lineTo(x2+w2, y2|0);
    context.lineTo(x2-w2, y2|0);
    context.fill();
}

// draw outlined hud text
DrawText=(text, posX)=>
{
    context.font = '9em impact';         // set font size
    context.fillStyle = LSHA(99,0,0,.5); // set font color
    context.fillText(text, posX, 129);   // fill text
    context.lineWidth = 3;               // line width
    context.strokeText(text, posX, 129); // outline text
}

Build Track with Procedural Generation

Before the game starts we must first generate the entire track which is will be different for every play through. To do this we build a list of road segments that store the position and width of the road at each point along the track.

The track generator is pretty basic, it just tapers between sections of varying frequency, amplitude, and width. The distance along the track determines how difficult that section can be.

The road pitch angle is calculated here using the atan2 function to be used for physics and lighting.

Example result of the procedural track generator.

roadGenLengthMax =                     // end of section
roadGenLength =                        // distance left
roadGenTaper =                         // length of taper
roadGenFreqX =                         // X wave frequency 
roadGenFreqY =                         // Y wave frequency
roadGenScaleX =                        // X wave amplitude
roadGenScaleY = 0;                     // Y wave amplitude
roadGenWidth = roadWidth;              // starting road width
startRandSeed = randSeed = Date.now(); // set random seed
road = [];                             // clear road

// generate the road
for( i = 0; i < roadEnd*2; ++i )          // build road past end
{
  if (roadGenLength++ > roadGenLengthMax) // is end of section?
  {
    // calculate difficulty percent
    d = Math.min(1, i/maxDifficultySegment);
  
    // randomize road settings
    roadGenWidth = roadWidth*R(1-d*.7,3-2*d);        // road width
    roadGenFreqX = R(Lerp(d,.01,.02));               // X curves
    roadGenFreqY = R(Lerp(d,.01,.03));               // Y bumps
    roadGenScaleX = i>roadEnd ? 0 : R(Lerp(d,.2,.6));// X scale
    roadGenScaleY = R(Lerp(d,1e3,2e3));              // Y scale
  
    // apply taper and move back
    roadGenTaper = R(99, 1e3)|0;                 // random taper
    roadGenLengthMax = roadGenTaper + R(99,1e3); // random length
    roadGenLength = 0;                           // reset length
    i -= roadGenTaper;                           // subtract taper
  }
  
  // make a wavy road
  x = Math.sin(i*roadGenFreqX) * roadGenScaleX;
  y = Math.sin(i*roadGenFreqY) * roadGenScaleY;
  road[i] = road[i]? road[i] : {x:x, y:y, w:roadGenWidth};
  
  // apply taper from last section and lerp values
  p = Clamp(roadGenLength / roadGenTaper, 0, 1);
  road[i].x = Lerp(p, road[i].x, x);
  road[i].y = Lerp(p, road[i].y, y);
  road[i].w = i > roadEnd ? 0 : Lerp(p, road[i].w, roadGenWidth);
    
  // calculate road pitch angle
  road[i].a = road[i-1] ? 
    Math.atan2(road[i-1].y-road[i].y, segmentLength) : 0;
}

Startup the Game

Now the the track exists, we need only initialize a few variables to start the game.

// reset everything
velocity = new Vec3
  ( pitchSpring =  pitchSpringSpeed =  pitchRoad = hueShift = 0 );
  
position = new Vec3(0, height);      // set player start pos
nextCheckPoint = checkPointDistance; // init next checkpoint
time = maxTime;                      // set the start time
heading = randSeed;                  // random world heading

Update Player

This is the main update function that handles updating and rendering everything in the game! Normally it is not good practice to have a giant function in your code and this would be split up into sub functions. So to help explain things, it is broken up into several parts below.

First we need to get some info about the road at the player’s location. To make the physics  and rendering feel smooth, the values are interpolated between current and next road segments.

The player’s position and velocity are 3D vectors, and are updated with kinematics to apply gravity, dampening and other factors. If the player is below the road, the position is clamped to the ground plane and velocity is reflected against the normal. Also, while on ground the acceleration is applied and the camera will shake when off road. After play testing I decided to allow the player to still tun while airborne.

Input is handled here to control acceleration, braking, jumping, and turning. Double clicks are detected is via mouseUpFrames. There is also some code to track how many frames the player has been in the air to allow a short grace period when the player can still jump.

The camera’s pitch angle uses a spring system to give a dynamic feel as the player accelerates, brakes, and jumps. Also the camera tilts to match the road angle as the player drives over hills and jumps.

Update=()=>
{

// get player road segment
s = position.z / segmentLength | 0; // current road segment
p = position.z / segmentLength % 1; // percent along segment

// get lerped values between last and current road segment
roadX = Lerp(p, road[s].x, road[s+1].x);
roadY = Lerp(p, road[s].y, road[s+1].y) + height;
roadA = Lerp(p, road[s].a, road[s+1].a);

// update player velocity
lastVelocity = velocity.Add(0);
velocity.y += gravity;
velocity.x *= lateralDamp;
velocity.z = Math.max(0, time?forwardDamp*velocity.z:0);

// add velocity to position
position = position.Add(velocity);
  
// limit player x position (how far off road)
position.x = Clamp(position.x, -maxPlayerX, maxPlayerX); 

// check if on ground
if (position.y < roadY)
{
  position.y = roadY; // match y to ground plane
  airFrame = 0;       // reset air frames
  
  // get the dot product of the ground normal and the velocity
  dp = Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z;
  
  // bounce velocity against ground normal
  velocity = new Vec3(0, Math.cos(roadA), Math.sin(roadA))
    .Multiply(-elasticity * dp).Add(velocity);
    
  // apply player brake and accel
  velocity.z += 
    mouseDown? playerBrake :
    Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0);
  
  // check if off road
  if (Math.abs(position.x) > road[s].w)
  {
    velocity.z *= offRoadDamp;                    // slow down
    pitchSpring += Math.sin(position.z/99)**4/99; // rumble
  }
}

// update player turning and apply centrifugal force
turn = Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0);
velocity.x +=
  velocity.z * turn -
  velocity.z ** 2 * centrifugal * roadX;

// update jump
if (airFrame++<6 && time 
  && mouseDown && mouseUpFrames && mouseUpFrames<9)
{
  velocity.y += jumpAccel; // apply jump velocity
  airFrame = 9;            // prevent jumping again
}
mouseUpFrames = mouseDown? 0 : mouseUpFrames+1;

// pitch down with vertical velocity when in air
airPercent = (position.y-roadY) / 99;
pitchSpringSpeed += Lerp(airPercent, 0, velocity.y/4e4);

// update player pitch spring
pitchSpringSpeed += (velocity.z - lastVelocity.z)/2e3;
pitchSpringSpeed -= pitchSpring * springConstant;
pitchSpringSpeed *= pitchSpringDamp;
pitchSpring += pitchSpringSpeed; 
pitchRoad = Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA,0));
playerPitch = pitchSpring + pitchRoad;

// update heading
heading = ClampAngle(heading + velocity.z*roadX*worldRotateScale);
cameraHeading = turn * cameraTurnScale;

// was checkpoint crossed?
if (position.z > nextCheckPoint)
{
  time += checkPointTime;               // add more time
  nextCheckPoint += checkPointDistance; // set next checkpoint
  hueShift += 36;                       // shift hue
}

Pre-Render

Before rendering, the canvas is cleared by setting it’s width and height. This also fits the canvas to fill the window.

We also calculate the projection scale used to transform world points to canvas space. The cameraDepth value represents the field of view (FOV) of the camera which is to 90 degrees for this game. The calculation is 1/Math.tan((fovRadians/2), which works out to be exactly 1 for an FOV of 90 degrees. To preserve aspect ratio, the projection is scaled by c.width.

// clear the screen and set size
c.width = window.innerWidth, c.height = window.innerHeight;

// calculate projection scale, flip y
projectScale = (new Vec3(1,-1,1)).Multiply(c.width/2/cameraDepth);

Draw Sky, Sun and Moon

The background atmosphere is drawn with a full screen linear gradient that changes color based on the sun’s direction.

To conserve space, both the sun and moon are drawn in the same for loop using a full screen radial gradient with transparency.

The linear and radial gradients combine to make a sky box that fully wraps around the scene.

// get horizon, offset, and light amount
horizon = c.height/2 - Math.tan(playerPitch)*projectScale.y;
backgroundOffset = Math.sin(cameraHeading)/2;
light = Math.cos(heading);

// create linear gradient for sky
g = context.createLinearGradient(0,horizon-c.height/2,0,horizon);
g.addColorStop(0,LSHA(39+light*25,49+light*19,230-light*19));
g.addColorStop(1,LSHA(5,79,250-light*9));

// draw sky as full screen poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);

// draw sun and moon (0=sun, 1=moon)
for( i = 2 ; i--; )
{
  // create radial gradient
  g = context.createRadialGradient(
    x = c.width*(.5+Lerp(
      (heading/PI/2+.5+i/2)%1,
      4, -4)-backgroundOffset),
    y = horizon - c.width/5,
    c.width/25,
    x, y, i?c.width/23:c.width);
  g.addColorStop(0, LSHA(i?70:99));
  g.addColorStop(1, LSHA(0,0,0,0));
  
  // draw full screen poly
  DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
}

Draw Mountains and Horizon

The mountains are procedurally generated, by drawing 50 triangles on the horizon. Lighting is applied so that mountains are darker when facing towards the sun because they are in shadow. Also, closer mountains are darker to simulate fog. The real trick here was tweaking the random values for the size and color to give good results.

The final part of drawing the background is to draw the horizon line and below to fill the bottom of the canvas with solid green.

// set random seed for mountains
randSeed = startRandSeed;

// draw mountains
for( i = mountainCount; i--; )
{
  angle = ClampAngle(heading+R(19));
  light = Math.cos(angle-heading); 
  DrawPoly(
    x = c.width*(.5+Lerp(angle/PI/2+.5,4,-4)-backgroundOffset),
    y = horizon,
    w = R(.2,.8)**2*c.width/2,
    x + w*R(-.5,.5),
    y - R(.5,.8)*w, 0,
    LSHA(R(15,25)+i/3-light*9, i/2+R(19), R(220,230)));
}

// draw horizon
DrawPoly(
  c.width/2, horizon, c.width/2, c.width/2, c.height, c.width/2,
  LSHA(25, 30, 95));

Project Road Segments to Canvas Space

Before the road can be rendered, we must first get the projected road points. The first part of this is a bit tricky because our road’s x value needs to be converted to a world space position. To make the roads appear to curve, we will apply the x value as a second order derivative. This is why there is the strange bit of code “x+=w+=”. Because of the way this works, the road segments don’t have persistent world space positions, but instead are recomputed every frame based on the player’s location.

Once we have the world space position, we can subtract the player position from the road position to get the local camera space position. The rest of the code applies a transform by first rotating heading, pitch, then the projection transform to make farther things appear smaller, and finally moving it to canvas space.

for( x = w = i = 0; i < drawDistance+1; )
{
  p = new Vec3(x+=w+=road[s+i].x,     // sum local road offsets
    road[s+i].y, (s+i)*segmentLength) // road y and z pos
      .Add(position.Multiply(-1));    // get local camera space

  // apply camera heading
  p.x = p.x*Math.cos(cameraHeading) - p.z*Math.sin(cameraHeading);
  
  // tilt camera pitch and invert z
  z = 1/(p.z*Math.cos(playerPitch) - p.y*Math.sin(playerPitch));
  p.y = p.y*Math.cos(playerPitch) - p.z*Math.sin(playerPitch);
  p.z = z;
  
  // project road segment to canvas space
  road[s+i++].p =                         // projected road point
    p.Multiply(new Vec3(z, z, 1))         // projection
    .Multiply(projectScale)               // scale
    .Add(new Vec3(c.width/2,c.height/2)); // center on canvas
}

Draw Road Segments 


이제 각 도로 구간에 대한 캔버스 공간 포인트가 확보되었으므로 렌더링은 매우 간단합니다. 우리는 각 도로 세그먼트를 뒤에서 앞으로 또는 더 구체적으로 도로 세그먼트를 연결하는 사다리꼴 모양의 폴리를 그릴 필요가 있습니다.


도로를 만들려면 지면, 줄무늬 연석, 도로 자체 및 점선 흰색 선 등 4 개의 레이어가 서로 위에 렌더링 됩니다. 각 레이어의 모양에 따라 추가 로직이 포함 된 도로 세그먼트의 피치 및 방향을 기준으로 각각 음영 처리됩니다.


이상한 렌더링 아티팩트를 방지하기 위해 세그먼트가 근거리 / 원거리 클립 범위에 있는지 확인해야 합니다. 또한 도로의 해상도가 매우 얇아 질 때 거리를 기준으로 축소 할 수있는 최적화가 있습니다. 이렇게 하면 성능이 크게 떨어지지 않고 품질이 눈에 띄게 떨어지지 않고 드로우 카운트가 절반 이상 줄어 듭니다.


road_outline.png 


let segment2 = road[s+drawDistance]; // store the last segment
for( i = drawDistance; i--; )        // iterate in reverse
{
  // get projected road points
  segment1 = road[s+i];
  p1 = segment1.p;
  p2 = segment2.p;
  
  // random seed and lighting
  randSeed = startRandSeed + s + i;
  light = Math.sin(segment1.a) * Math.cos(heading) * 99;
  
  // check near and far clip
  if (p1.z < 1e5 && p1.z > 0)
  {
    // fade in road resolution over distance
    if (i % (Lerp(i/drawDistance,1,9)|0) == 0)
    {
      // ground
      DrawPoly(c.width/2, p1.y, c.width/2,
        c.width/2, p2.y, c.width/2,
        LSHA(25 + light, 30, 95));

      // curb if wide enough
      if (segment1.w > 400)
        DrawPoly(p1.x, p1.y, p1.z*(segment1.w+curbWidth),
          p2.x, p2.y, p2.z*(segment2.w+curbWidth),
          LSHA(((s+i)%19<9? 50: 20) + light));
      
      // road and checkpoint marker
      DrawPoly(p1.x, p1.y, p1.z*segment1.w,
        p2.x, p2.y, p2.z*segment2.w,
        LSHA(((s+i)*segmentLength%checkPointDistance < 300 ?
          70 : 7) + light));
        
      // dashed lines if wide and close enough
      if ((segment1.w > 300) && (s+i)%9==0 && i < drawDistance/3)
          DrawPoly(p1.x, p1.y, p1.z*dashLineWidth,
          p2.x, p2.y, p2.z*dashLineWidth,
          LSHA(70 + light));

      // save this segment
      segment2 = segment1;
    }

Draw Road Trees and Rocks 


이 게임에는 나무와 바위라는 두 가지 다른 유형의 오브젝트가 있습니다. 먼저 R () 함수를 사용하여 객체가 있는지 확인합니다. 이것은 시드 난수의 마법이 빛나는 곳 중 하나입니다. 또한 R ()을 사용하여 객체에 임의의 모양과 색상 변형을 추가합니다.


원래는 다른 차량을 갖고 싶었지만 크게 자르지 않고는 크기 제한에 맞지 않으므로 풍경을 장애물로 사용하여 구성했습니다. 위치는 도로에 더 가깝도록 무작위화되고 편향되어 있습니다. 공간을 절약하기 위해 객체 높이에 따라 객체 유형이 결정됩니다.

3D 공간에서의 위치를 ​​비교하여 플레이어와 객체 간의 충돌을 확인하는 곳입니다. 물체에 부딪치면 플레이어 속도가 느려지고 해당 물체가 히트로 표시되어 안전하게 통과 할 수 있습니다.


수평선에 물체가 튀는 것을 방지하기 위해 투명도가 점점 멀어집니다. 객체의 모양과 색상은 앞서 언급 한 마법의 시드 임의 함수 덕분에 변형 된 사다리꼴 그리기 기능을 사용합니다.

objs.png 


if (R()<.2 && s+i>29)                  // is there an object?
    {
      // player object collision check
      x = 2*roadWidth * R(10,-10) * R(9);  // choose object pos
      const objectHeight = (R(2)|0) * 400; // choose tree or rock
      if (!segment1.h                      // dont hit same object
        && Math.abs(position.x-x)<200                      // X
        && Math.abs(position.z-(s+i)*segmentLength)<200    // Z
        && position.y-height<segment1.y+objectHeight+200)  // Y
      {
        // slow player and mark object as hit
        velocity = velocity.Multiply(segment1.h = collisionSlow);
      }

      // draw road object
      const alpha = Lerp(i/drawDistance, 4, 0);  // fade in object
      if (objectHeight) 
      {
        // tree trunk
        DrawPoly(x = p1.x+p1.z * x, p1.y, p1.z*29,
          x, p1.y-99*p1.z, p1.z*29,
          LSHA(5+R(9), 50+R(9), 29+R(9), alpha));
          
        // tree leaves
        DrawPoly(x, p1.y-R(50,99)*p1.z, p1.z*R(199,250),
          x, p1.y-R(600,800)*p1.z, 0,
          LSHA(25+R(9), 80+R(9), 9+R(29), alpha));
      }
      else
      {
        // rock
        DrawPoly(x = p1.x+p1.z*x, p1.y, p1.z*R(200,250),
          x+p1.z*(R(99,-99)), p1.y-R(200,250)*p1.z, p1.z*R(99),
          LSHA(50+R(19), 25+R(19), 209+R(9), alpha));
      }
    }
  }
}

Draw HUD, Update Time, Request Next Update 


게임 제목, 시간 및 거리는 앞에서 설정 한 DrawText 기능을 사용하여 매우 기본적인 글꼴 렌더링 시스템으로 표시됩니다. 플레이어가 마우스를 클릭하기 전에 화면 중앙에 제목이 표시됩니다. 나는 게임 타이틀을 보여주고 대담한 윤곽의 영향 글꼴을 사용하는 고급스러움을 자랑스럽게 생각합니다. 내가 우주에서 더 빡빡했다면, 그 중 일부는 가장 먼저 갔을 것입니다.


마우스를 누르면 게임이 시작되고 HUD에 남은 시간과 현재 거리가 표시됩니다. 레이스가 시작된 후에야 시간이 줄어들 기 때문에 이 조건부 블록에서도 시간이 업데이트 됩니다.


이 방대한 Update 함수의 끝에서 requestAnimationFrame(Update)을 호출하여 다음 업데이트를 트리거 합니다.


hud2.png 

if (mousePressed)
{
  time = Clamp(time - timeDelta, 0, maxTime); // update time
  DrawText(Math.ceil(time), 9);               // show time
  context.textAlign = 'right';                // right alignment
  DrawText(0|position.z/1e3, c.width-9);      // show distance
}
else
{
  context.textAlign = 'center';      // center alignment
  DrawText('HUE JUMPER', c.width/2); // draw title text
}

requestAnimationFrame(Update); // kick off next frame

} // end of update function

Final Bit of Code 


업데이트 루프를 작동 시키려면 위의 거대한 업데이트 기능을 한 번 호출해야 합니다.


또한 HTML은 모든 코드가 실제로 실행되도록 닫기 스크립트 태그가 필요합니다.


Update(); // kick off update loop
</script>

Minification 


이것이 전체 게임입니다! 다음은 여러 부분을 보여주기 위해 색상 코딩으로 축소 한 최종 결과를 보여줍니다. 모든 작업을 마친 후에는 작은 코드 조각으로 내 게임 전체가 얼마나 만족스러운지 상상할 수 있습니다. 이것은 또한 반복적인 코드를 제거하여 크기를 거의 절반으로 줄인 지퍼 앞입니다.

  • HTML – Red
  • Functions – Orange
  • Setup – Yellow
  • Player Update – Green
  • Background Render – Cyan
  • Road Render – Purple
  • Object Render – Pink
  • HUD Render – Brown

code_breakdown4.png 


Caveats 


성능과 시각적 이점을 모두 제공하는 3D 렌더링을 달성하는 다른 방법이 있습니다. 사용 가능한 공간이 더 많으면 작년에 만든 약간 비슷한 게임인 Bogus Roads에 사용한 three.js와 같은 WebGL API를 사용하는 것이 좋습니다. 또한 requestAnimationFrame을 사용하기 때문에 프레임 속도가 60fps로 제한되도록 추가 코드가 실제로 필요합니다.이 코드는 향상된 버전에 추가되었습니다. vIntersync로 인해 렌더링이 더 매끄럽기 때문에 setInterval보다 requestAnimationFrame을 사용하는 것이 좋습니다. 이 코드의 주요 이점 중 하나는 호환성이 뛰어나고 모든 기기에서 작동하지만 노화 된 iPhone에서는 약간 느리다는 것입니다.


Wrapping It Up 


이 모든 것을 읽거나 적어도 아래로 스크롤 해 주셔서 감사합니다! 새로운 것을 배웠으면 좋겠습니다. 이 게시물이 마음에 들면 Twitter에서 나를 팔로우 하여 더 많은 것들을 얻으십시오. 또한 2kPlus Jam을 위해 더 많은 게임을 즐길 수 있습니다. 가려움증을 모두 확인하십시오!


이 게임의 코드는 GPL-3.0의 GitHub에서 오픈 소스이므로 자신의 프로젝트에서 자유롭게 사용할 수 있습니다. 이 저장소에는 게시 시점의 2031 바이트에 불과한 2k 버전도 포함되어 있습니다! 음악 및 음향 효과와 같은 일부 보너스 기능을 사용하여 "플러스"버전을 재생할 수도 있습니다.




댓글목록 0

등록된 댓글이 없습니다.

웹학교 로고

온라인 코딩학교

코리아뉴스 2001 - , All right reserved.