init
This commit is contained in:
24
vue/.gitignore
vendored
Normal file
24
vue/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
vue/.vscode/extensions.json
vendored
Normal file
3
vue/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
11
vue/README.md
Normal file
11
vue/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `public/`: static source assets (includes `race` and `t_race` game files)
|
||||
- `src/`: Vue app source code
|
||||
- `dist/`: build output directory generated by `npm run build` (not source, can be deleted anytime)
|
||||
13
vue/index.html
Normal file
13
vue/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>test1</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1488
vue/package-lock.json
generated
Normal file
1488
vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
vue/package.json
Normal file
30
vue/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "test1",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.",
|
||||
"keywords": [
|
||||
"123456"
|
||||
],
|
||||
"license": "ISC",
|
||||
"author": "yoyuzh",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
178
vue/public/race/audio.js
Normal file
178
vue/public/race/audio.js
Normal file
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Audio settings
|
||||
|
||||
let soundEnable = 1;
|
||||
let soundVolume = .3;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class Sound
|
||||
{
|
||||
constructor(zzfxSound)
|
||||
{
|
||||
if (!soundEnable) return;
|
||||
|
||||
// generate zzfx sound now for fast playback
|
||||
this.randomness = zzfxSound[1] || 0;
|
||||
this.samples = zzfxG(...zzfxSound);
|
||||
}
|
||||
|
||||
play(volume=1, pitch=1)
|
||||
{
|
||||
if (!soundEnable) return;
|
||||
|
||||
// play the sound
|
||||
const playbackRate = pitch + this.randomness*rand(-pitch,pitch);
|
||||
return playSamples(this.samples, volume, playbackRate);
|
||||
}
|
||||
|
||||
playNote(semitoneOffset, pos, volume)
|
||||
{ return this.play(pos, volume, 2**(semitoneOffset/12), 0); }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let audioContext;
|
||||
|
||||
function playSamples(samples, volume, rate)
|
||||
{
|
||||
const sampleRate=zzfxR;
|
||||
|
||||
if (!soundEnable || isTouchDevice && !audioContext)
|
||||
return;
|
||||
|
||||
if (!audioContext)
|
||||
audioContext = new AudioContext; // create audio context
|
||||
|
||||
// prevent sounds from building up if they can't be played
|
||||
if (audioContext.state != 'running')
|
||||
{
|
||||
// fix stalled audio
|
||||
audioContext.resume();
|
||||
return; // prevent suspended sounds from building up
|
||||
}
|
||||
|
||||
// create buffer and source
|
||||
const buffer = audioContext.createBuffer(1, samples.length, sampleRate),
|
||||
source = audioContext.createBufferSource();
|
||||
|
||||
// copy samples to buffer and setup source
|
||||
buffer.getChannelData(0).set(samples);
|
||||
source.buffer = buffer;
|
||||
source.playbackRate.value = rate;
|
||||
|
||||
// create and connect gain node (createGain is more widely spported then GainNode construtor)
|
||||
const gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = soundVolume*volume;
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
// connect source to stereo panner and gain
|
||||
//source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})).connect(gainNode);
|
||||
source.connect(gainNode);
|
||||
|
||||
// play and return sound
|
||||
source.start();
|
||||
return source;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.1 by Frank Force
|
||||
|
||||
const zzfxR = 44100;
|
||||
function zzfxG
|
||||
(
|
||||
// parameters
|
||||
volume = 1, randomness, frequency = 220, attack = 0, sustain = 0,
|
||||
release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0,
|
||||
pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0,
|
||||
bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0, filter = 0
|
||||
)
|
||||
{
|
||||
// init parameters
|
||||
let PI2 = PI*2, sampleRate = zzfxR,
|
||||
startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
|
||||
startFrequency = frequency *= PI2 / sampleRate, // no randomness
|
||||
// rand(1 + randomness, 1-randomness) * PI2 / sampleRate,
|
||||
b = [], t = 0, tm = 0, i = 0, j = 1, r = 0, c = 0, s = 0, f, length,
|
||||
|
||||
// biquad LP/HP filter
|
||||
quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
|
||||
cos = Math.cos(w), alpha = Math.sin(w) / 2 / quality,
|
||||
a0 = 1 + alpha, a1 = -2*cos / a0, a2 = (1 - alpha) / a0,
|
||||
b0 = (1 + sign(filter) * cos) / 2 / a0,
|
||||
b1 = -(sign(filter) + cos) / a0, b2 = b0,
|
||||
x2 = 0, x1 = 0, y2 = 0, y1 = 0;
|
||||
|
||||
// scale by sample rate
|
||||
attack = attack * sampleRate + 9; // minimum attack to prevent pop
|
||||
decay *= sampleRate;
|
||||
sustain *= sampleRate;
|
||||
release *= sampleRate;
|
||||
delay *= sampleRate;
|
||||
deltaSlide *= 500 * PI2 / sampleRate**3;
|
||||
modulation *= PI2 / sampleRate;
|
||||
pitchJump *= PI2 / sampleRate;
|
||||
pitchJumpTime *= sampleRate;
|
||||
repeatTime = repeatTime * sampleRate | 0;
|
||||
|
||||
ASSERT(shape != 3 && shape != 2); // need save space
|
||||
|
||||
// generate waveform
|
||||
for(length = attack + decay + sustain + release + delay | 0;
|
||||
i < length; b[i++] = s * volume) // sample
|
||||
{
|
||||
if (!(++c%(bitCrush*100|0))) // bit crush
|
||||
{
|
||||
s = shape? shape>1?
|
||||
//shape>2? shape>3? // wave shape
|
||||
//Math.sin(t**3) : // 4 noise
|
||||
//clamp(Math.tan(t),1,-1): // 3 tan
|
||||
1-(2*t/PI2%2+2)%2: // 2 saw
|
||||
1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle
|
||||
Math.sin(t); // 0 sin
|
||||
|
||||
s = (repeatTime ?
|
||||
1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo
|
||||
: 1) *
|
||||
sign(s)*(abs(s)**shapeCurve) * // curve
|
||||
(i < attack ? i/attack : // attack
|
||||
i < attack + decay ? // decay
|
||||
1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
|
||||
i < attack + decay + sustain ? // sustain
|
||||
sustainVolume : // sustain volume
|
||||
i < length - delay ? // release
|
||||
(length - i - delay)/release * // release falloff
|
||||
sustainVolume : // release volume
|
||||
0); // post release
|
||||
|
||||
s = delay ? s/2 + (delay > i ? 0 : // delay
|
||||
(i<length-delay? 1 : (length-i)/delay) * // release delay
|
||||
b[i-delay|0]/2/volume) : s; // sample delay
|
||||
|
||||
if (filter) // apply filter
|
||||
s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
|
||||
}
|
||||
|
||||
f = (frequency += slide += deltaSlide) *// frequency
|
||||
Math.cos(modulation*tm++); // modulation
|
||||
t += f + f*noise*Math.sin(i**5); // noise
|
||||
|
||||
if (j && ++j > pitchJumpTime) // pitch jump
|
||||
{
|
||||
frequency += pitchJump; // apply pitch jump
|
||||
startFrequency += pitchJump; // also apply to start
|
||||
j = 0; // stop pitch jump time
|
||||
}
|
||||
|
||||
if (repeatTime && !(++r % repeatTime)) // repeat
|
||||
{
|
||||
frequency = startFrequency; // reset frequency
|
||||
slide = startSlide; // reset slide
|
||||
j = j || 1; // reset pitch jump time
|
||||
}
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
261
vue/public/race/debug.js
Normal file
261
vue/public/race/debug.js
Normal file
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
const debug = 1;
|
||||
let enhancedMode = 1;
|
||||
let enableAsserts = 1;
|
||||
let devMode = 0;
|
||||
let downloadLink, debugMesh, debugTile, debugCapture, debugCanvas;
|
||||
let debugGenerativeCanvas=0, debugInfo=0, debugSkipped=0;
|
||||
let debugGenerativeCanvasCached, showMap;
|
||||
let freeCamPos, freeCamRot, mouseDelta;
|
||||
const js13kBuildLevel2 = 0; // more space is needed for js13k
|
||||
|
||||
function ASSERT(assert, output)
|
||||
{ enableAsserts&&(output ? console.assert(assert, output) : console.assert(assert)); }
|
||||
function LOG() { console.log(...arguments); }
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function debugInit()
|
||||
{
|
||||
freeCamPos = vec3();
|
||||
freeCamRot = vec3();
|
||||
mouseDelta = vec3();
|
||||
debugCanvas = document.createElement('canvas');
|
||||
downloadLink = document.createElement('a');
|
||||
}
|
||||
function debugUpdate()
|
||||
{
|
||||
if (!devMode)
|
||||
return;
|
||||
|
||||
if (keyWasPressed('KeyG')) // free Cam
|
||||
{
|
||||
freeCamMode = !freeCamMode;
|
||||
if (!freeCamMode)
|
||||
{
|
||||
document.exitPointerLock();
|
||||
cameraPos = vec3();
|
||||
cameraRot = vec3();
|
||||
}
|
||||
}
|
||||
if (freeCamMode)
|
||||
{
|
||||
if (!document.pointerLockElement)
|
||||
{
|
||||
mainCanvas.requestPointerLock();
|
||||
freeCamPos = cameraPos.copy();
|
||||
freeCamRot = cameraRot.copy();
|
||||
}
|
||||
|
||||
const input = vec3(
|
||||
keyIsDown('KeyD') - keyIsDown('KeyA'),
|
||||
keyIsDown('KeyE') - keyIsDown('KeyQ'),
|
||||
keyIsDown('KeyW') - keyIsDown('KeyS'));
|
||||
|
||||
const moveSpeed = keyIsDown('ShiftLeft') ? 500 : 100;
|
||||
const turnSpeed = 2;
|
||||
const moveDirection = input.rotateX(freeCamRot.x).rotateY(-freeCamRot.y);
|
||||
freeCamPos = freeCamPos.add(moveDirection.scale(moveSpeed));
|
||||
freeCamRot = freeCamRot.add(vec3(mouseDelta.y,mouseDelta.x).scale(turnSpeed));
|
||||
freeCamRot.x = clamp(freeCamRot.x, -PI/2, PI/2);
|
||||
mouseDelta = vec3();
|
||||
}
|
||||
|
||||
if (keyWasPressed('Digit1') || keyWasPressed('Digit2'))
|
||||
{
|
||||
const d = keyWasPressed('Digit2') ? 1 : -1;
|
||||
playerVehicle.pos.z += d * checkpointDistance;
|
||||
playerVehicle.pos.z = max(playerVehicle.pos.z, 0);
|
||||
checkpointTimeLeft = 40;
|
||||
debugSkipped = 1;
|
||||
}
|
||||
if (keyIsDown('Digit3') || keyIsDown('Digit4'))
|
||||
{
|
||||
const v = keyIsDown('Digit4') ? 1e3 : -1e3;
|
||||
playerVehicle.pos.z += v;
|
||||
playerVehicle.pos.z = max(playerVehicle.pos.z, 0);
|
||||
|
||||
const trackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
|
||||
playerVehicle.pos.y = trackInfo.offset.y;
|
||||
playerVehicle.pos.x = 0;
|
||||
|
||||
// update world heading based on speed and track turn
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
worldHeading += v*cameraTrackInfo.offset.x/turnWorldScale;
|
||||
debugSkipped = 1;
|
||||
}
|
||||
if (keyWasPressed('Digit5'))
|
||||
checkpointTimeLeft=12
|
||||
if (keyWasPressed('Digit6'))
|
||||
{
|
||||
// randomize track
|
||||
trackSeed = randInt(1e9);
|
||||
|
||||
//initGenerative();
|
||||
const endLevel = levelInfoList.pop();
|
||||
shuffle(endLevel.scenery);
|
||||
shuffle(levelInfoList);
|
||||
for(let i=levelInfoList.length; i--;)
|
||||
{
|
||||
const info = levelInfoList[i];
|
||||
info.level = i;
|
||||
info.randomize();
|
||||
}
|
||||
levelInfoList.push(endLevel);
|
||||
buildTrack();
|
||||
|
||||
for(const s in spriteList)
|
||||
{
|
||||
const sprite = spriteList[s];
|
||||
if (sprite instanceof GameSprite)
|
||||
sprite.randomize();
|
||||
}
|
||||
|
||||
const playerTrackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
|
||||
playerVehicle.pos.y = playerTrackInfo.offset.y;
|
||||
//gameStart();
|
||||
}
|
||||
if (keyWasPressed('Digit7'))
|
||||
debugGenerativeCanvas = !debugGenerativeCanvas;
|
||||
if (keyWasPressed('Digit0'))
|
||||
debugCapture = 1;
|
||||
if (keyWasPressed('KeyQ') && !freeCamMode)
|
||||
testDrive = !testDrive
|
||||
if (keyWasPressed('KeyU'))
|
||||
sound_win.play();
|
||||
if (debug && keyWasPressed('KeyV'))
|
||||
spawnVehicle(playerVehicle.pos.z-1300)
|
||||
//if (!document.hasFocus())
|
||||
// testDrive = 1;
|
||||
}
|
||||
|
||||
function debugDraw()
|
||||
{
|
||||
if (!debug)
|
||||
return;
|
||||
|
||||
if (debugInfo && !debugCapture)
|
||||
drawHUDText((averageFPS|0) + 'fps / ' + glBatchCountTotal + ' / ' + glDrawCalls + ' / ' + vehicles.length, vec3(.98,.12),.03, undefined, 'monospace','right');
|
||||
|
||||
const c = mainCanvas;
|
||||
const context = mainContext;
|
||||
|
||||
if (testDrive && !titleScreenMode && !freeRide)
|
||||
drawHUDText('AUTO', vec3(.5,.95),.05,RED);
|
||||
|
||||
if (showMap)
|
||||
{
|
||||
// draw track map preview
|
||||
context.save();
|
||||
context.beginPath();
|
||||
for(let k=2;k--;)
|
||||
{
|
||||
let x=0, v=0;
|
||||
let p = vec3();
|
||||
let d = vec3(0,-.5);
|
||||
for(let i=0; i < 1e3; i++)
|
||||
{
|
||||
let j = playerVehicle.pos.z/trackSegmentLength+i-100|0;
|
||||
if (!track[j])
|
||||
continue;
|
||||
|
||||
const t = track[j];
|
||||
const o = t.offset;
|
||||
v += o.x;
|
||||
p = p.add(d.rotateZ(v*.005));
|
||||
if (j%5==0)
|
||||
{
|
||||
let y = o.y;
|
||||
let w = t.width/199;
|
||||
const h = k ? 5 : -y*.01;
|
||||
context.fillStyle=hsl(y*.0001,1,k?0:.5,k?.5:1);
|
||||
context.fillRect(c.width-200+p.x,c.height-100+p.y+h,w,w);
|
||||
//context.fillRect(c.width-200+x/199,c.height-100-i/2+o,w,w);
|
||||
}
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (debugGenerativeCanvas)
|
||||
{
|
||||
const s = 512;
|
||||
//context.imageSmoothingEnabled = false;
|
||||
context.drawImage(debugGenerativeCanvasCached, 0, 0, s, s);
|
||||
// context.strokeRect(0, 0, s, s);
|
||||
}
|
||||
|
||||
if (debugCapture)
|
||||
{
|
||||
debugCapture = 0;
|
||||
const context = debugCanvas.getContext('2d');
|
||||
debugCanvas.width = mainCanvas.width;
|
||||
debugCanvas.height = mainCanvas.height;
|
||||
context.fillStyle = '#000';
|
||||
context.fillRect(0,0,mainCanvas.width,mainCanvas.height);
|
||||
context.drawImage(glCanvas, 0, 0);
|
||||
context.drawImage(mainCanvas, 0, 0);
|
||||
debugSaveCanvas(debugCanvas);
|
||||
}
|
||||
|
||||
{
|
||||
// test render
|
||||
//debugMesh = cylinderMesh;
|
||||
debugMesh && debugMesh.render(buildMatrix(cameraPos.add(vec3(0,400,1000)), vec3(0,time,0), vec3(200)), WHITE);
|
||||
|
||||
//debugTile = vec3(0,1)
|
||||
if (debugTile)
|
||||
{
|
||||
const s = 256*2, w = generativeTileSize, v = debugTile.scale(w);
|
||||
const x = mainCanvas.width/2-s/2;
|
||||
context.fillStyle = '#5f5';
|
||||
context.fillRect(x, 0, s, s);
|
||||
context.drawImage(debugGenerativeCanvasCached, v.x, v.y, w, w, x, 0, s, s);
|
||||
context.strokeRect(x, 0, s, s);
|
||||
//pushTrackObject(cameraPos.add(vec3(0,0,100)), vec3(100), WHITE, debugTile);
|
||||
}
|
||||
}
|
||||
|
||||
if (0) // world cube
|
||||
{
|
||||
const r = vec3(0,-worldHeading,0);
|
||||
const m1 = buildMatrix(vec3(2220,1e3,2e3), r, vec3(200));
|
||||
cubeMesh.render(m1, hsl(0,.8,.5));
|
||||
}
|
||||
|
||||
if (0)
|
||||
{
|
||||
// test noise
|
||||
context.fillStyle = '#fff';
|
||||
context.fillRect(0, 0, 500, 500);
|
||||
context.fillStyle = '#000';
|
||||
for(let i=0; i < 1e3; i++)
|
||||
{
|
||||
const n = noise1D(i/129-time*9)*99;
|
||||
context.fillRect(i, 200+n, 9, 9);
|
||||
}
|
||||
}
|
||||
|
||||
//cubeMesh.render(buildMatrix(vec3(0,-500,0), vec3(0), vec3(1e5,10,1e5)), RED); // ground
|
||||
//cylinderMesh.render(buildMatrix(cameraPos.add(vec3(0,400,1000)), vec3(time,time/2,time/3), vec3(200)), WHITE);
|
||||
//let t = new Tile(vec3(64*2,0), vec3(128));
|
||||
//pushSprite(cameraPos.add(vec3(0,400,1000)), vec3(200), WHITE, t);
|
||||
|
||||
glRender();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function debugSaveCanvas(canvas, filename='screenshot', type='image/png')
|
||||
{ debugSaveDataURL(canvas.toDataURL(type), filename); }
|
||||
|
||||
function debugSaveText(text, filename='text', type='text/plain')
|
||||
{ debugSaveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename); }
|
||||
|
||||
function debugSaveDataURL(dataURL, filename)
|
||||
{
|
||||
downloadLink.download = filename;
|
||||
downloadLink.href = dataURL;
|
||||
downloadLink.click();
|
||||
}
|
||||
472
vue/public/race/draw.js
Normal file
472
vue/public/race/draw.js
Normal file
@@ -0,0 +1,472 @@
|
||||
'use strict';
|
||||
|
||||
let cubeMesh, quadMesh, shadowMesh, cylinderMesh, carMesh, carWheel;
|
||||
|
||||
const bleedPixels = 8;
|
||||
|
||||
const WHITE = rgb();
|
||||
const BLACK = rgb(0,0,0);
|
||||
const RED = rgb(1,0,0);
|
||||
const ORANGE = rgb(1,.5,0);
|
||||
const YELLOW = rgb(1,1,0);
|
||||
const GREEN = rgb(0,1,0);
|
||||
const CYAN = rgb(0,1,1);
|
||||
const BLUE = rgb(0,0,1);
|
||||
const PURPLE = rgb(.5,0,1);
|
||||
const MAGENTA= rgb(1,0,1);
|
||||
const GRAY = rgb(.5,.5,.5);
|
||||
let spriteList;
|
||||
let testGameSprite;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function initSprites()
|
||||
{
|
||||
//spriteList
|
||||
//(tilePos, size=1e3, sizeRandomness=0, windScale=0, collideSize=60)
|
||||
spriteList = {};
|
||||
|
||||
// trees
|
||||
spriteList.tree_palm = new GameSprite(vec3(0,1),1500,.2,.1,.04);
|
||||
spriteList.tree_palm.trackFace = 1;
|
||||
spriteList.tree_oak = new GameSprite(vec3(1,1),2e3,.5,.06,.1);
|
||||
spriteList.tree_stump = new GameSprite(vec3(2,1),1e3,.6,.04);
|
||||
spriteList.tree_dead = new GameSprite(vec3(3,1),1e3,.3,.1,.06);
|
||||
spriteList.tree_pink = new GameSprite(vec3(4,1),1500,.3,.1,.04);
|
||||
spriteList.tree_pink.trackFace = 1;
|
||||
spriteList.tree_bush = new GameSprite(vec3(5,1),1e3,.5,.1,.06);
|
||||
spriteList.tree_fall = new GameSprite(vec3(6,1),1500,.3,.1,.1);
|
||||
//TB(spriteList.tree_flower = new GameSprite(vec3(7,1),2e3,.3,.05,200));
|
||||
spriteList.tree_snow = new GameSprite(vec3(4,3),1300,.3,.06,.1)
|
||||
spriteList.tree_yellow = new GameSprite(vec3(5,3),1e3,.3,.06,.1)
|
||||
spriteList.tree_huge = new GameSprite(vec3(3,1),1e4,.5,.1,.1)
|
||||
spriteList.tree_huge.colorHSL = vec3(.8, 0, .5);
|
||||
spriteList.tree_huge.shadowScale = 0;
|
||||
|
||||
// smaller tree shadows
|
||||
spriteList.tree_palm.shadowScale =
|
||||
spriteList.tree_oak.shadowScale =
|
||||
spriteList.tree_stump.shadowScale =
|
||||
spriteList.tree_dead.shadowScale =
|
||||
spriteList.tree_pink.shadowScale =
|
||||
spriteList.tree_bush.shadowScale =
|
||||
spriteList.tree_fall.shadowScale =
|
||||
spriteList.tree_snow.shadowScale =
|
||||
spriteList.tree_yellow.shadowScale = .7;
|
||||
|
||||
// grass and flowers
|
||||
spriteList.grass_plain = new GameSprite(vec3(0,3),500,.5,1);
|
||||
spriteList.grass_plain.colorHSL = vec3(.3, .4, .5);
|
||||
spriteList.grass_dead = new GameSprite(vec3(0,3),600,.3,1);
|
||||
spriteList.grass_dead.colorHSL = vec3(.13, .6, .7);
|
||||
spriteList.grass_flower1 = new GameSprite(vec3(1,3),500,.3,1);
|
||||
spriteList.grass_flower2 = new GameSprite(vec3(2,3),500,.3,1);
|
||||
spriteList.grass_flower3 = new GameSprite(vec3(3,3),500,.3,1);
|
||||
spriteList.grass_red = new GameSprite(vec3(0,3),700,.3,1)
|
||||
spriteList.grass_red.colorHSL = vec3(0, .8, .5);
|
||||
spriteList.grass_snow = new GameSprite(vec3(0,3),300,.5,1)
|
||||
spriteList.grass_snow.colorHSL = vec3(.4, 1, .9);
|
||||
spriteList.grass_large = new GameSprite(vec3(0,3),1e3,.5,1);
|
||||
spriteList.grass_large.colorHSL = vec3(.4, .4, .5);
|
||||
//spriteList.grass_huge = new GameSprite(vec3(0,3),1e4,.6,.5,5e3);
|
||||
//spriteList.grass_huge.colorHSL = vec3(.8, .5, .5);
|
||||
//spriteList.grass_huge.hueRandomness = .2;
|
||||
|
||||
// billboards
|
||||
spriteList.billboards = [];
|
||||
const PB = (s)=>spriteList.billboards.push(s);
|
||||
PB(spriteList.sign_opGames = new GameSprite(vec3(5,2),600,0,.02,.5,0));
|
||||
PB(spriteList.sign_js13k = new GameSprite(vec3(0,2),600,0,.02,1,0));
|
||||
PB(spriteList.sign_zzfx = new GameSprite(vec3(1,2),500,0,.02,.5,0));
|
||||
PB(spriteList.sign_avalanche = new GameSprite(vec3(7,2),600,0,.02,1,0));
|
||||
PB(spriteList.sign_github = new GameSprite(vec3(2,2),750,0,.02,.5,0));
|
||||
//PB(spriteList.sign_littlejs = new GameSprite(vec3(4,2),600,0,.02,1,0));
|
||||
spriteList.sign_frankForce = new GameSprite(vec3(3,2),500,0,.02,1,0);
|
||||
//PB(spriteList.sign_dwitter = new GameSprite(vec3(6,2),550,0,.02,1,0));
|
||||
|
||||
// signs
|
||||
spriteList.sign_turn = new GameSprite(vec3(0,5),500,0,.05,.5);
|
||||
spriteList.sign_turn.trackFace = 1; // signs face track
|
||||
//spriteList.sign_curve = new GameSprite(vec3(1,5),500,0,.05,.5);
|
||||
//spriteList.sign_curve.trackFace = 1; // signs face track
|
||||
//spriteList.sign_warning = new GameSprite(vec3(2,5),500,0,.05,1,0);
|
||||
//spriteList.sign_speed = new GameSprite(vec3(4,5),500,0,.05,50,0);
|
||||
//spriteList.sign_interstate = new GameSprite(vec3(5,5),500,0,.05,50,0);
|
||||
|
||||
// rocks
|
||||
spriteList.rock_tall = new GameSprite(vec3(1,4),1e3,.3,0,.6,0);
|
||||
spriteList.rock_big = new GameSprite(vec3(2,4),800,.2,0,.57,0);
|
||||
spriteList.rock_huge = new GameSprite(vec3(1,4),5e3,.7,0,.6,0);
|
||||
spriteList.rock_huge.shadowScale = 0;
|
||||
spriteList.rock_huge.colorHSL = vec3(.08, 1, .8);
|
||||
spriteList.rock_huge.hueRandomness = .01;
|
||||
spriteList.rock_huge2 = new GameSprite(vec3(2,4),8e3,.5,0,.25,0);
|
||||
spriteList.rock_huge2.shadowScale = 0;
|
||||
spriteList.rock_huge2.colorHSL = vec3(.05, 1, .8);
|
||||
spriteList.rock_huge2.hueRandomness = .01;
|
||||
spriteList.rock_huge3 = new GameSprite(vec3(2,4),8e3,.7,0,.5,0);
|
||||
spriteList.rock_huge3.shadowScale = 0;
|
||||
spriteList.rock_huge3.colorHSL = vec3(.05, 1, .8);
|
||||
spriteList.rock_huge3.hueRandomness = .01;
|
||||
spriteList.rock_weird = new GameSprite(vec3(2,4),5e3,.5,0,1,0);
|
||||
spriteList.rock_weird.shadowScale = 0;
|
||||
spriteList.rock_weird.colorHSL = vec3(.8, 1, .8);
|
||||
spriteList.rock_weird.hueRandomness = .2;
|
||||
spriteList.rock_weird2 = new GameSprite(vec3(1,4),1e3,.5,0,.5,0);
|
||||
spriteList.rock_weird2.colorHSL = vec3(0, 0, .2);
|
||||
spriteList.tunnel1 = new GameSprite(vec3(6,4),1e4,.0,0,0,0);
|
||||
spriteList.tunnel1.shadowScale = 0;
|
||||
spriteList.tunnel1.colorHSL = vec3(.05, 1, .8);
|
||||
spriteList.tunnel1.tunnelArch = 1;
|
||||
spriteList.tunnel2 = new GameSprite(vec3(7,4),5e3,0,0,0,0);
|
||||
spriteList.tunnel2.shadowScale = 0;
|
||||
spriteList.tunnel2.colorHSL = vec3(0, 0, .1);
|
||||
spriteList.tunnel2.tunnelLong = 1;
|
||||
spriteList.tunnel2Front = new GameSprite(vec3(7,4),5e3,0,0,0,0);
|
||||
spriteList.tunnel2Front.shadowScale = 0;
|
||||
spriteList.tunnel2Front.colorHSL = vec3(0,0,.8);
|
||||
//spriteList.tunnel2_rock = new GameSprite(vec3(6,6),1e4,.2,0,.5,0);
|
||||
//spriteList.tunnel2_rock.colorHSL = vec3(.15, .5, .8);
|
||||
|
||||
// hazards
|
||||
spriteList.hazard_rocks = new GameSprite(vec3(3,4),600,.2,0,.9);
|
||||
spriteList.hazard_rocks.shadowScale = 0;
|
||||
spriteList.hazard_rocks.isBump = 1;
|
||||
spriteList.hazard_rocks.spriteYOffset = -.02;
|
||||
spriteList.hazard_sand = new GameSprite(vec3(4,4),600,.2,0,.9);
|
||||
spriteList.hazard_sand.shadowScale = 0;
|
||||
spriteList.hazard_sand.isSlow = 1;
|
||||
spriteList.hazard_sand.spriteYOffset = -.02;
|
||||
//spriteList.hazard_snow = new GameSprite(vec3(6,6),500,.1,0,300,0);
|
||||
//spriteList.hazard_snow.isSlow = 1;
|
||||
|
||||
// special sprites
|
||||
spriteList.water = new GameSprite(vec3(5,4),6e3,.5,1);
|
||||
spriteList.water.shadowScale = 0;
|
||||
spriteList.sign_start = new GameSprite(vec3(1,6),2300,0,.01,0,0);
|
||||
spriteList.sign_start.shadowScale = 0;
|
||||
spriteList.sign_goal = new GameSprite(vec3(0,6),2300,0,.01,0,0);
|
||||
spriteList.sign_goal.shadowScale = 0;
|
||||
spriteList.sign_checkpoint1 = new GameSprite(vec3(6,0),1e3,0,.01,0,0);
|
||||
spriteList.sign_checkpoint1.shadowScale = 0;
|
||||
spriteList.sign_checkpoint2 = new GameSprite(vec3(7,0),1e3,0,.01,0,0);
|
||||
spriteList.sign_checkpoint2.shadowScale = 0;
|
||||
spriteList.telephonePole = new GameSprite(vec3(0,4),1800,0,0,.03,0);
|
||||
//spriteList.parts_girder = new GameSprite(vec3(0,6),500,0,.05,30,0);
|
||||
spriteList.telephonePole.shadowScale = .3;
|
||||
spriteList.grave_stone = new GameSprite(vec3(2,6),500,.3,.05,.5,0);
|
||||
spriteList.grave_stone.lightnessRandomness = .5;
|
||||
spriteList.light_tunnel = new GameSprite(vec3(0,0),200,0,0,0,0);
|
||||
spriteList.light_tunnel.shadowScale = 0;
|
||||
|
||||
// horizon sprites
|
||||
spriteList.horizon_city = new GameSprite(vec3(3,6),0,0,0,0,1);
|
||||
spriteList.horizon_city.hueRandomness =
|
||||
spriteList.horizon_city.lightnessRandomness = .15;
|
||||
spriteList.horizon_city.colorHSL = vec3(1); // vary color
|
||||
|
||||
spriteList.horizon_islands = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_islands.colorHSL = vec3(.25, .5, .6);
|
||||
spriteList.horizon_islands.canMirror = 0;
|
||||
spriteList.horizon_redMountains = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_redMountains.colorHSL = vec3(.05, .7, .7);
|
||||
spriteList.horizon_redMountains.canMirror = 0;
|
||||
spriteList.horizon_brownMountains = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_brownMountains.colorHSL = vec3(.1, .5, .6);
|
||||
spriteList.horizon_brownMountains.canMirror = 0;
|
||||
spriteList.horizon_smallMountains = new GameSprite(vec3(6,6));
|
||||
spriteList.horizon_smallMountains.colorHSL = vec3(.1, .5, .6);
|
||||
spriteList.horizon_smallMountains.canMirror = 0;
|
||||
spriteList.horizon_desert = new GameSprite(vec3(6,6));
|
||||
spriteList.horizon_desert.colorHSL = vec3(.15, .5, .8);
|
||||
spriteList.horizon_desert.canMirror = 0;
|
||||
spriteList.horizon_snow = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_snow.colorHSL = vec3(0,0,1);
|
||||
spriteList.horizon_snow.canMirror = 0;
|
||||
spriteList.horizon_graveyard = new GameSprite(vec3(6,6));
|
||||
spriteList.horizon_graveyard.colorHSL = vec3(.2, .4, .8);
|
||||
spriteList.horizon_graveyard.canMirror = 0;
|
||||
spriteList.horizon_weird = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_weird.colorHSL = vec3(.7, .5, .6);
|
||||
spriteList.horizon_weird.canMirror = 0;
|
||||
if (!js13kBuildLevel2)
|
||||
{
|
||||
spriteList.horizon_mountains = new GameSprite(vec3(7,6));
|
||||
spriteList.horizon_mountains.colorHSL = vec3(0, 0, .7);
|
||||
spriteList.horizon_mountains.canMirror = 0;
|
||||
}
|
||||
|
||||
// more sprites
|
||||
spriteList.circle = new GameSprite(vec3());
|
||||
spriteList.dot = new GameSprite(vec3(1,0));
|
||||
spriteList.carShadow = new GameSprite(vec3(2,0));
|
||||
spriteList.carLicense = new GameSprite(vec3(3,0));
|
||||
spriteList.carNumber = new GameSprite(vec3(4,0));
|
||||
}
|
||||
|
||||
// a sprite that can be placed on the track
|
||||
class GameSprite
|
||||
{
|
||||
constructor(tilePos, size=1e3, sizeRandomness=0, windScale=0, collideScale=0, canMirror=1)
|
||||
{
|
||||
this.spriteTile = vec3(
|
||||
(tilePos.x * generativeTileSize + bleedPixels) / generativeCanvasSize,
|
||||
(tilePos.y * generativeTileSize + bleedPixels) / generativeCanvasSize,
|
||||
);
|
||||
|
||||
this.size = size;
|
||||
this.sizeRandomness = sizeRandomness;
|
||||
this.windScale = windScale;
|
||||
this.collideScale = collideScale;
|
||||
this.canMirror = canMirror; // allow mirroring
|
||||
this.trackFace = 0; // face track if close
|
||||
this.spriteYOffset = 0; // how much to offset the sprite from the ground
|
||||
this.shadowScale = 1.2;
|
||||
|
||||
// color
|
||||
this.colorHSL = vec3(0,0,1);
|
||||
this.hueRandomness = .05;
|
||||
this.lightnessRandomness = .01;
|
||||
}
|
||||
|
||||
getRandomSpriteColor()
|
||||
{
|
||||
const c = this.colorHSL.copy();
|
||||
c.x += random.floatSign(this.hueRandomness);
|
||||
c.z += random.floatSign(this.lightnessRandomness);
|
||||
return c.getHSLColor();
|
||||
}
|
||||
|
||||
getRandomSpriteScale() { return 1+random.floatSign(this.sizeRandomness); }
|
||||
|
||||
randomize()
|
||||
{
|
||||
this.colorHSL.x = random.float(-.1,.1);
|
||||
this.colorHSL.y = clamp(this.colorHSL.y+random.float(-.1,.1));
|
||||
this.colorHSL.z = clamp(this.colorHSL.z+random.float(-.1,.1));
|
||||
this.hueRandomness = .05;
|
||||
this.lightnessRandomness = .01;
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const getAspect =()=> mainCanvasSize.x/mainCanvasSize.y;
|
||||
|
||||
function drawInit()
|
||||
{
|
||||
{
|
||||
// cube
|
||||
const points = [vec3(-1,1),vec3(1,1),vec3(1,-1),vec3(-1,-1)];
|
||||
cubeMesh = new Mesh().buildExtrude(points);
|
||||
}
|
||||
{
|
||||
// quad
|
||||
const points1 = [vec3(-1,1),vec3(1,1),vec3(-1,-1),vec3(1,-1)];
|
||||
const uvs1 = points1.map(p=>p.multiply(vec3(.5,-.5,.5)).add(vec3(.5)));
|
||||
quadMesh = new Mesh(points1, points1.map(p=>vec3(0,0,1)), uvs1);
|
||||
shadowMesh = quadMesh.transform(0,vec3(PI/2,0));
|
||||
}
|
||||
{
|
||||
// cylinder
|
||||
const points = [];
|
||||
const sides = 12;
|
||||
for(let i=sides; i--;)
|
||||
{
|
||||
const a = i/sides*PI*2;
|
||||
points.push(vec3(1,0).rotateZ(a));
|
||||
}
|
||||
cylinderMesh = new Mesh().buildExtrude(points);
|
||||
}
|
||||
{
|
||||
// car bottom
|
||||
const points =
|
||||
[
|
||||
vec3(-1,.5),
|
||||
vec3(-.7,.4),
|
||||
vec3(-.2,.5),
|
||||
vec3(.1,.5),
|
||||
vec3(1,.2),
|
||||
vec3(1,.2),
|
||||
vec3(1,0),
|
||||
vec3(-1,0),
|
||||
]
|
||||
|
||||
carMesh = new Mesh().buildExtrude(points,.5);
|
||||
carMesh = carMesh.transform(0,vec3(0,-PI/2));
|
||||
carWheel = cylinderMesh.transform(0,vec3(0,-PI/2));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class Mesh
|
||||
{
|
||||
constructor(points, normals, uvs)
|
||||
{
|
||||
this.points = points;
|
||||
this.normals = normals;
|
||||
this.uvs = uvs;
|
||||
}
|
||||
|
||||
render(transform, color)
|
||||
{
|
||||
glPushVerts(this.points, this.normals, color);
|
||||
glRender(transform);
|
||||
}
|
||||
|
||||
renderTile(transform, color, tile)
|
||||
{
|
||||
//ASSERT(tile instanceof SpriteTile);
|
||||
const uvs = this.uvs.map(uv=>(vec3(spriteSize-spriteSize*uv.x+tile.x,uv.y*spriteSize+tile.y)));
|
||||
// todo, figure out why this is backwards
|
||||
//const uvs = this.uvs.map(uv=>uv.multiply(tile.size).add(tile.pos));
|
||||
|
||||
glPushVerts(this.points, this.normals, color, uvs);
|
||||
glRender(transform);
|
||||
}
|
||||
|
||||
buildExtrude(facePoints, size=1)
|
||||
{
|
||||
// convert list of 2d points into a 3d shape
|
||||
const points = [], normals = [];
|
||||
const vertCount = facePoints.length + 2;
|
||||
for (let k=2; k--;)
|
||||
for (let i=vertCount; i--;)
|
||||
{
|
||||
// build top and bottom of mesh
|
||||
const j = clamp(i-1, 0, vertCount-3); // degenerate tri at ends
|
||||
const h = j>>1;
|
||||
|
||||
let m = j%2 == vertCount%2 ? h : vertCount-3-h;
|
||||
if (!k) // hack to fix glitch in mesh due to concave shape
|
||||
m = mod(vertCount+2-m, facePoints.length);
|
||||
const point = facePoints[m].copy();
|
||||
point.z = k?size:-size;
|
||||
points.push(point);
|
||||
normals.push(vec3(0,0,point.z));
|
||||
}
|
||||
|
||||
for (let i = facePoints.length; i--;)
|
||||
{
|
||||
// build sides of mesh
|
||||
const point1 = facePoints[i];
|
||||
const point2 = facePoints[(i+1)%facePoints.length];
|
||||
const s = vec3(0,0,size);
|
||||
const pointA = point1.add(s);
|
||||
const pointB = point2.add(s);
|
||||
const pointC = point1.subtract(s);
|
||||
const pointD = point2.subtract(s);
|
||||
const sidePoints = [pointA, pointA, pointB, pointC, pointD, pointD];
|
||||
const normal = pointC.subtract(pointD).cross(pointA.subtract(pointC)).normalize();
|
||||
for (const p of sidePoints)
|
||||
{
|
||||
points.push(p);
|
||||
normals.push(normal);
|
||||
}
|
||||
}
|
||||
|
||||
return new Mesh(points, normals);
|
||||
}
|
||||
|
||||
transform(pos, rot, scale)
|
||||
{
|
||||
const m = buildMatrix(pos, rot, scale);
|
||||
const m2 = buildMatrix(0, rot);
|
||||
return new Mesh(
|
||||
this.points.map(p=>p.transform(m)),
|
||||
this.normals.map(p=>p.transform(m2)),
|
||||
this.uvs
|
||||
);
|
||||
}
|
||||
|
||||
/*combine(mesh, pos, rot, scale)
|
||||
{
|
||||
const m = buildMatrix(pos, rot, scale);
|
||||
const m2 = buildMatrix(0, rot);
|
||||
this.points.push(...mesh.points.map(p=>p.transform(m)));
|
||||
this.normals && this.normals.push(...mesh.normals.map(p=>p.transform(m2)));
|
||||
this.uvs && this.uvs.push(...mesh.uvs);
|
||||
return this;
|
||||
}*/
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function pushGradient(pos, size, color, color2)
|
||||
{
|
||||
const mesh = quadMesh;
|
||||
const points = mesh.points.map(p=>p.multiply(size).addSelf(pos));
|
||||
const colors = [color, color, color2, color2];
|
||||
glPushColoredVerts(points, colors);
|
||||
}
|
||||
|
||||
function pushSprite(pos, size, color, tile, skew=0)
|
||||
{
|
||||
const mesh = quadMesh;
|
||||
const points = mesh.points.map(p=>vec3(p.x*abs(size.x)+pos.x, p.y*abs(size.y)+pos.y,pos.z));
|
||||
|
||||
// apply skew
|
||||
const o = skew*size.y;
|
||||
points[0].x += o;
|
||||
points[1].x += o;
|
||||
|
||||
// apply texture
|
||||
if (tile)
|
||||
{
|
||||
//ASSERT(tile instanceof SpriteTile);
|
||||
let tilePosX = tile.x;
|
||||
let tilePosY = tile.y;
|
||||
let tileSizeX = spriteSize;
|
||||
let tileSizeY = spriteSize;
|
||||
if (size.x < 0)
|
||||
tilePosX -= tileSizeX *= -1;
|
||||
if (size.y < 0)
|
||||
tilePosY -= tileSizeY *= -1;
|
||||
const uvs = mesh.uvs.map(uv=>
|
||||
vec3(uv.x*tileSizeX+tilePosX, uv.y*tileSizeY+tilePosY));
|
||||
glPushVertsCapped(points, 0, color, uvs);
|
||||
}
|
||||
else
|
||||
glPushVertsCapped(points, 0, color);
|
||||
}
|
||||
|
||||
function pushShadow(pos, xSize, zSize)
|
||||
{
|
||||
if (optimizedCulling && pos.z > 2e4)
|
||||
return; // cull far shadows
|
||||
|
||||
const color = rgb(0,0,0,.7)
|
||||
const tile = spriteList.dot.spriteTile;
|
||||
const mesh = shadowMesh;
|
||||
const points = mesh.points.map(p=>vec3(p.x*xSize+pos.x,pos.y,p.z*zSize+pos.z));
|
||||
const uvs = mesh.uvs.map(uv=>
|
||||
vec3(uv.x*spriteSize+tile.x, uv.y*spriteSize+tile.y));
|
||||
glPushVertsCapped(points, 0, color, uvs);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Fullscreen mode
|
||||
|
||||
/** Returns true if fullscreen mode is active
|
||||
* @return {Boolean}
|
||||
* @memberof Draw */
|
||||
function isFullscreen() { return !!document.fullscreenElement; }
|
||||
|
||||
/** Toggle fullsceen mode
|
||||
* @memberof Draw */
|
||||
function toggleFullscreen()
|
||||
{
|
||||
const element = document.body;
|
||||
if (isFullscreen())
|
||||
{
|
||||
if (document.exitFullscreen)
|
||||
document.exitFullscreen();
|
||||
}
|
||||
else if (element.requestFullscreen)
|
||||
element.requestFullscreen();
|
||||
else if (element.webkitRequestFullscreen)
|
||||
element.webkitRequestFullscreen();
|
||||
else if (element.mozRequestFullScreen)
|
||||
element.mozRequestFullScreen();
|
||||
}
|
||||
433
vue/public/race/game.js
Normal file
433
vue/public/race/game.js
Normal file
@@ -0,0 +1,433 @@
|
||||
'use strict';
|
||||
|
||||
// debug settings
|
||||
let testLevel;
|
||||
let quickStart;
|
||||
let disableAiVehicles;
|
||||
let testDrive;
|
||||
let freeCamMode;
|
||||
let testLevelInfo;
|
||||
let testQuick;
|
||||
const js13kBuild = 1; // fixes for legacy code made during js13k
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
|
||||
// settings
|
||||
const pixelate = 0;
|
||||
const canvasFixedSize = 0;
|
||||
const frameRate = 60;
|
||||
const timeDelta = 1/frameRate;
|
||||
const pixelateScale = 3;
|
||||
const clampAspectRatios = enhancedMode;
|
||||
const optimizedCulling = 1;
|
||||
const random = new Random;
|
||||
let autoPause = enhancedMode;
|
||||
let autoFullscreen = 0;
|
||||
|
||||
// setup
|
||||
const laneWidth = 1400; // how wide is track
|
||||
const trackSegmentLength = 100; // length of each segment
|
||||
const drawDistance = 1e3; // how many track segments to draw
|
||||
const cameraPlayerOffset = vec3(0,680,1050);
|
||||
const checkpointTrackSegments = testQuick?1e3:4500;
|
||||
const checkpointDistance = checkpointTrackSegments*trackSegmentLength;
|
||||
const startCheckpointTime = 45;
|
||||
const extraCheckpointTime = 40;
|
||||
const levelLerpRange = .1;
|
||||
const levelGoal = 10;
|
||||
const playerStartZ = 2e3;
|
||||
const turnWorldScale = 2e4;
|
||||
const testStartZ = testLevel ? testLevel*checkpointDistance-1e3 : quickStart&&!testLevelInfo?5e3:0;
|
||||
|
||||
let mainCanvasSize;// = pixelate ? vec3(640, 420) : vec3(1280, 720);
|
||||
let mainCanvas, mainContext;
|
||||
let time, frame, frameTimeLastMS, averageFPS, frameTimeBufferMS, paused;
|
||||
let checkpointTimeLeft, startCountdown, startCountdownTimer, gameOverTimer, nextCheckpointDistance;
|
||||
let raceTime, playerLevel, playerWin, playerNewDistanceRecord, playerNewRecord;
|
||||
let checkpointSoundCount, checkpointSoundTimer, vehicleSpawnTimer;
|
||||
let titleScreenMode = 1, titleModeStartCount = 0;
|
||||
let trackSeed = 1331;
|
||||
|
||||
///////////////////////////////
|
||||
// game variables
|
||||
|
||||
let cameraPos, cameraRot, cameraOffset;
|
||||
let worldHeading, mouseControl;
|
||||
let track, vehicles, playerVehicle;
|
||||
let freeRide;
|
||||
|
||||
///////////////////////////////
|
||||
|
||||
function gameInit()
|
||||
{
|
||||
if (enhancedMode)
|
||||
{
|
||||
console.log(`Dr1v3n Wild by Frank Force`);
|
||||
console.log(`www.frankforce.com 🚗🌴`);
|
||||
}
|
||||
|
||||
if (quickStart || testLevel)
|
||||
titleScreenMode = 0;
|
||||
|
||||
debug && debugInit();
|
||||
glInit();
|
||||
|
||||
document.body.appendChild(mainCanvas = document.createElement('canvas'));
|
||||
mainContext = mainCanvas.getContext('2d');
|
||||
|
||||
const styleCanvas = 'position:absolute;' + // position
|
||||
(clampAspectRatios?'top:50%;left:50%;transform:translate(-50%,-50%);':'') + // center
|
||||
(pixelate?' image-rendering: pixelated':''); // pixelated
|
||||
|
||||
glCanvas.style.cssText = mainCanvas.style.cssText = styleCanvas;
|
||||
|
||||
if (!clampAspectRatios)
|
||||
document.body.style.margin = '0px';
|
||||
|
||||
drawInit();
|
||||
inputInit()
|
||||
initGenerative();
|
||||
initSprites();
|
||||
initLevelInfos();
|
||||
gameStart();
|
||||
gameUpdate();
|
||||
}
|
||||
|
||||
function gameStart()
|
||||
{
|
||||
time = frame = frameTimeLastMS = averageFPS = frameTimeBufferMS =
|
||||
cameraOffset = checkpointTimeLeft = raceTime = playerLevel = playerWin = playerNewDistanceRecord = playerNewRecord = freeRide = checkpointSoundCount = 0;
|
||||
startCountdown = quickStart || testLevel ? 0 : 4;
|
||||
worldHeading = titleScreenMode ? rand(7) : .8;
|
||||
checkpointTimeLeft = startCheckpointTime;
|
||||
nextCheckpointDistance = checkpointDistance;
|
||||
startCountdownTimer = new Timer;
|
||||
gameOverTimer = new Timer;
|
||||
vehicleSpawnTimer = new Timer;
|
||||
checkpointSoundTimer = new Timer;
|
||||
cameraPos = vec3();
|
||||
cameraRot = vec3();
|
||||
vehicles = [];
|
||||
buildTrack();
|
||||
vehicles.push(playerVehicle = new PlayerVehicle(testStartZ?testStartZ:playerStartZ, hsl(0,.8,.5)));
|
||||
|
||||
if (titleScreenMode)
|
||||
{
|
||||
const level = titleModeStartCount*2%9;
|
||||
playerVehicle.pos.z = 8e4+level*checkpointDistance;
|
||||
}
|
||||
|
||||
if (enhancedMode)
|
||||
{
|
||||
// match camera to ground at start
|
||||
cameraOffset = playerVehicle.pos.z - cameraPlayerOffset.z;
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
cameraPos.y = cameraTrackInfo.offset.y;
|
||||
cameraRot.x = cameraTrackInfo.pitch/3;
|
||||
}
|
||||
}
|
||||
|
||||
function gameUpdateInternal()
|
||||
{
|
||||
if (titleScreenMode)
|
||||
{
|
||||
// update title screen
|
||||
if (mouseWasPressed(0) || keyWasPressed('Space') || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9)))
|
||||
{
|
||||
titleScreenMode = 0;
|
||||
gameStart();
|
||||
}
|
||||
if (time > 60)
|
||||
{
|
||||
// restart
|
||||
++titleModeStartCount;
|
||||
gameStart();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (startCountdown > 0 && !startCountdownTimer.active())
|
||||
{
|
||||
--startCountdown;
|
||||
sound_beep.play(1,startCountdown?1:2);
|
||||
//speak(startCountdown || 'GO!' );
|
||||
startCountdownTimer.set(1);
|
||||
}
|
||||
|
||||
if (gameOverTimer.get() > 1 && (mouseWasPressed(0) || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9))) || gameOverTimer.get() > 9)
|
||||
{
|
||||
// go back to title screen after a while
|
||||
titleScreenMode = 1;
|
||||
titleModeStartCount = 0;
|
||||
gameStart();
|
||||
}
|
||||
if (keyWasPressed('Escape') || isUsingGamepad && gamepadWasPressed(8))
|
||||
{
|
||||
// go back to title screen
|
||||
sound_bump.play(2);
|
||||
titleScreenMode = 1;
|
||||
++titleModeStartCount;
|
||||
gameStart();
|
||||
}
|
||||
/*if (keyWasPressed('KeyR'))
|
||||
{
|
||||
titleScreenMode = 0;
|
||||
sound_lose.play(1,2);
|
||||
gameStart();
|
||||
}*/
|
||||
|
||||
if (freeRide)
|
||||
{
|
||||
// free ride mode
|
||||
startCountdown = 0;
|
||||
}
|
||||
else if (keyWasPressed('KeyF'))
|
||||
{
|
||||
// enter free ride mode
|
||||
freeRide = 1;
|
||||
sound_lose.play(.5,3);
|
||||
}
|
||||
|
||||
if (!startCountdown && !freeRide && !gameOverTimer.isSet())
|
||||
{
|
||||
// race mode
|
||||
raceTime += timeDelta;
|
||||
const lastCheckpointTimeLeft = checkpointTimeLeft;
|
||||
checkpointTimeLeft -= timeDelta;
|
||||
if (checkpointTimeLeft < 4)
|
||||
if ((lastCheckpointTimeLeft|0) != (checkpointTimeLeft|0))
|
||||
{
|
||||
// low time warning
|
||||
sound_beep.play(1,3);
|
||||
}
|
||||
|
||||
const playerDistance = playerVehicle.pos.z;
|
||||
const minRecordDistance = 5e3;
|
||||
if (bestDistance && !playerNewDistanceRecord && playerDistance > bestDistance && playerDistance > minRecordDistance)
|
||||
{
|
||||
// new distance record
|
||||
sound_win.play(1,2);
|
||||
playerNewDistanceRecord = 1;
|
||||
//speak('NEW RECORD');
|
||||
}
|
||||
|
||||
if (checkpointTimeLeft <= 0)
|
||||
{
|
||||
if (!(debug && debugSkipped))
|
||||
if (playerDistance > minRecordDistance)
|
||||
if (!bestDistance || playerDistance > bestDistance)
|
||||
{
|
||||
playerNewDistanceRecord = 1;
|
||||
bestDistance = playerDistance;
|
||||
writeSaveData();
|
||||
}
|
||||
|
||||
// game over
|
||||
checkpointTimeLeft = 0;
|
||||
//speak('GAME OVER');
|
||||
gameOverTimer.set();
|
||||
sound_lose.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
updateCars();
|
||||
}
|
||||
|
||||
function gameUpdate(frameTimeMS=0)
|
||||
{
|
||||
if (!clampAspectRatios)
|
||||
mainCanvasSize = vec3(mainCanvas.width=innerWidth, mainCanvas.height=innerHeight);
|
||||
else
|
||||
{
|
||||
// more complex aspect ratio handling
|
||||
const innerAspect = innerWidth / innerHeight;
|
||||
if (canvasFixedSize)
|
||||
{
|
||||
// clear canvas and set fixed size
|
||||
mainCanvas.width = mainCanvasSize.x;
|
||||
mainCanvas.height = mainCanvasSize.y;
|
||||
}
|
||||
else
|
||||
{
|
||||
const minAspect = .45, maxAspect = 3;
|
||||
const correctedWidth = innerAspect > maxAspect ? innerHeight * maxAspect :
|
||||
innerAspect < minAspect ? innerHeight * minAspect : innerWidth;
|
||||
if (pixelate)
|
||||
{
|
||||
const w = correctedWidth / pixelateScale | 0;
|
||||
const h = innerHeight / pixelateScale | 0;
|
||||
mainCanvasSize = vec3(mainCanvas.width = w, mainCanvas.height = h);
|
||||
}
|
||||
else
|
||||
mainCanvasSize = vec3(mainCanvas.width=correctedWidth, mainCanvas.height=innerHeight);
|
||||
}
|
||||
|
||||
// fit to window by adding space on top or bottom if necessary
|
||||
const fixedAspect = mainCanvas.width / mainCanvas.height;
|
||||
mainCanvas.style.width = glCanvas.style.width = innerAspect < fixedAspect ? '100%' : '';
|
||||
mainCanvas.style.height = glCanvas.style.height = innerAspect < fixedAspect ? '' : '100%';
|
||||
}
|
||||
|
||||
if (enhancedMode)
|
||||
{
|
||||
document.body.style.cursor = // fun cursors!
|
||||
!mouseControl ? 'auto': mouseIsDown(2) ? 'grabbing' : mouseIsDown(0) ? 'pointer' : 'grab';
|
||||
|
||||
if (paused)
|
||||
{
|
||||
// hack: special input handling when paused
|
||||
inputUpdate();
|
||||
if (keyWasPressed('Space') || keyWasPressed('KeyP')
|
||||
|| mouseWasPressed(0) || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9)))
|
||||
{
|
||||
paused = 0;
|
||||
sound_checkpoint.play(.5);
|
||||
}
|
||||
if (keyWasPressed('Escape') || isUsingGamepad && gamepadWasPressed(8))
|
||||
{
|
||||
// go back to title screen
|
||||
paused = 0;
|
||||
sound_bump.play(2);
|
||||
titleScreenMode = 1;
|
||||
++titleModeStartCount;
|
||||
gameStart();
|
||||
}
|
||||
inputUpdatePost();
|
||||
}
|
||||
}
|
||||
|
||||
// update time keeping
|
||||
let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
|
||||
frameTimeLastMS = frameTimeMS;
|
||||
const debugSpeedUp = devMode && (keyIsDown('Equal')|| keyIsDown('NumpadAdd')); // +
|
||||
const debugSpeedDown = devMode && keyIsDown('Minus') || keyIsDown('NumpadSubtract'); // -
|
||||
if (debug) // +/- to speed/slow time
|
||||
frameTimeDeltaMS *= debugSpeedUp ? 20 : debugSpeedDown ? .1 : 1;
|
||||
averageFPS = lerp(.05, averageFPS, 1e3/(frameTimeDeltaMS||1));
|
||||
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
|
||||
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp in case of slow framerate
|
||||
|
||||
// apply flux capacitor, improves smoothness of framerate in some browsers
|
||||
let fluxCapacitor = 0;
|
||||
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
|
||||
{
|
||||
// the flux capacitor is what makes time travel possible
|
||||
// force at least one update each frame since it is waiting for refresh
|
||||
// -9 needed to prevent fast speeds on > 60fps monitors
|
||||
fluxCapacitor = frameTimeBufferMS;
|
||||
frameTimeBufferMS = 0;
|
||||
}
|
||||
|
||||
// update multiple frames if necessary in case of slow framerate
|
||||
for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3/frameRate)
|
||||
{
|
||||
// increment frame and update time
|
||||
time = frame++ / frameRate;
|
||||
gameUpdateInternal();
|
||||
enhancedModeUpdate();
|
||||
debugUpdate();
|
||||
inputUpdate();
|
||||
|
||||
if (enhancedMode && !titleScreenMode)
|
||||
if (keyWasPressed('KeyP') || isUsingGamepad && gamepadWasPressed(9))
|
||||
if (!gameOverTimer.isSet())
|
||||
{
|
||||
// update pause
|
||||
paused = 1;
|
||||
sound_checkpoint.play(.5,.5);
|
||||
}
|
||||
|
||||
updateCamera();
|
||||
trackPreUpdate();
|
||||
inputUpdatePost();
|
||||
}
|
||||
|
||||
// add the time smoothing back in
|
||||
frameTimeBufferMS += fluxCapacitor;
|
||||
|
||||
//mainContext.imageSmoothingEnabled = !pixelate;
|
||||
//glContext.imageSmoothingEnabled = !pixelate;
|
||||
|
||||
glPreRender(mainCanvasSize);
|
||||
drawScene();
|
||||
touchGamepadRender();
|
||||
drawHUD();
|
||||
debugDraw();
|
||||
requestAnimationFrame(gameUpdate);
|
||||
}
|
||||
|
||||
function enhancedModeUpdate()
|
||||
{
|
||||
if (!enhancedMode)
|
||||
return;
|
||||
|
||||
if (document.hasFocus())
|
||||
{
|
||||
if (autoFullscreen && !isFullscreen())
|
||||
toggleFullscreen();
|
||||
autoFullscreen = 0;
|
||||
}
|
||||
|
||||
if (!titleScreenMode && !isTouchDevice && autoPause && !document.hasFocus())
|
||||
paused = 1; // pause when losing focus
|
||||
|
||||
if (keyWasPressed('Home')) // dev mode
|
||||
devMode || (debugInfo = devMode = 1);
|
||||
if (keyWasPressed('KeyI')) // debug info
|
||||
debugInfo = !debugInfo;
|
||||
if (keyWasPressed('KeyM')) // toggle mute
|
||||
{
|
||||
if (soundVolume)
|
||||
sound_bump.play(.4,3);
|
||||
soundVolume = soundVolume ? 0 : .3;
|
||||
if (soundVolume)
|
||||
sound_bump.play();
|
||||
}
|
||||
if (keyWasPressed('KeyR')) // restart
|
||||
{
|
||||
titleScreenMode = 0;
|
||||
sound_lose.play(1,2);
|
||||
gameStart();
|
||||
}
|
||||
}
|
||||
|
||||
function updateCamera()
|
||||
{
|
||||
// update camera
|
||||
const lastCameraOffset = cameraOffset;
|
||||
cameraOffset = playerVehicle.pos.z - cameraPlayerOffset.z;
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
const playerTrackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
|
||||
|
||||
// update world heading based on speed and track turn
|
||||
const v = cameraOffset - lastCameraOffset;
|
||||
worldHeading += v*cameraTrackInfo.offset.x/turnWorldScale;
|
||||
|
||||
// put camera above player
|
||||
cameraPos.y = playerTrackInfo.offset.y + (titleScreenMode?1e3:cameraPlayerOffset.y);
|
||||
|
||||
// move camera with player
|
||||
cameraPos.x = playerVehicle.pos.x;
|
||||
|
||||
// slight tilt camera with road
|
||||
cameraRot.x = lerp(.1,cameraRot.x, cameraTrackInfo.pitch/3);
|
||||
|
||||
if (freeCamMode)
|
||||
{
|
||||
cameraPos = freeCamPos.copy();
|
||||
cameraRot = freeCamRot.copy();
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////
|
||||
// save data
|
||||
|
||||
const saveName = 'DW';
|
||||
let bestTime = localStorage[saveName+3]*1 || 0;
|
||||
let bestDistance = localStorage[saveName+4]*1 || 0;
|
||||
|
||||
function writeSaveData()
|
||||
{
|
||||
localStorage[saveName+3] = bestTime;
|
||||
localStorage[saveName+4] = bestDistance;
|
||||
}
|
||||
1057
vue/public/race/generative.js
Normal file
1057
vue/public/race/generative.js
Normal file
File diff suppressed because it is too large
Load Diff
168
vue/public/race/hud.js
Normal file
168
vue/public/race/hud.js
Normal file
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
|
||||
const showTitle = 1;
|
||||
|
||||
function drawHUD()
|
||||
{
|
||||
if (freeCamMode)
|
||||
return;
|
||||
|
||||
if (enhancedMode && paused)
|
||||
{
|
||||
// paused
|
||||
drawHUDText('-暂停-', vec3(.5,.9), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
}
|
||||
|
||||
if (titleScreenMode)
|
||||
{
|
||||
if (showTitle)
|
||||
for(let j=2;j--;)
|
||||
{
|
||||
// draw logo
|
||||
const text = '零界时速';
|
||||
const pos = vec3(.5,.3-j*.15).multiply(mainCanvasSize);
|
||||
let size = mainCanvasSize.y/9;
|
||||
const weight = 900;
|
||||
const style = 'italic';
|
||||
const font = 'arial';
|
||||
if (enhancedMode && getAspect() < .6)
|
||||
size = mainCanvasSize.x/5;
|
||||
|
||||
const context = mainContext;
|
||||
context.strokeStyle = BLACK;
|
||||
context.textAlign = 'center';
|
||||
|
||||
let totalWidth = 0;
|
||||
for(let k=2;k--;)
|
||||
for(let i=0;i<text.length;i++)
|
||||
{
|
||||
const p = Math.sin(i-time*2-j*2);
|
||||
let size2 = (size + p*mainCanvasSize.y/20);
|
||||
if (enhancedMode)
|
||||
size2 *= lerp(time*2-2+j,0,1)
|
||||
context.font = `${style} ${weight} ${size2}px ${font}`;
|
||||
const c = text[i];
|
||||
const w = context.measureText(c).width;
|
||||
if (k)
|
||||
{
|
||||
totalWidth += w;
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = pos.x+w/3-totalWidth/2;
|
||||
for(let f = 2;f--;)
|
||||
{
|
||||
const o = f*mainCanvasSize.y/99;
|
||||
context.fillStyle = hsl(.15-p/9,1,f?0:.75-p*.25);
|
||||
context.fillText(c, x+o, pos.y+o);
|
||||
}
|
||||
pos.x += w;
|
||||
}
|
||||
}
|
||||
|
||||
if (!enhancedMode || time > 5)
|
||||
{
|
||||
if (bestTime && (!enhancedMode || time%20<10))
|
||||
{
|
||||
const timeString = formatTimeString(bestTime);
|
||||
if (!js13kBuildLevel2)
|
||||
drawHUDText('最佳时间', vec3(.5,.9), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
drawHUDText(timeString, vec3(.5,.97), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
}
|
||||
else if (enhancedMode && !isTouchDevice)
|
||||
{
|
||||
drawHUDText('点击开始', vec3(.5,.97), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (startCountdownTimer.active() || startCountdown)
|
||||
{
|
||||
// count down
|
||||
const a = 1-time%1;
|
||||
const t = !startCountdown && startCountdownTimer.active() ? '出发!' : startCountdown|0;
|
||||
const c = (startCountdown?RED:GREEN).copy();
|
||||
c.a = a;
|
||||
drawHUDText(t, vec3(.5,.2), .25-a*.1, c, undefined,undefined,900,undefined,undefined,.03);
|
||||
}
|
||||
else
|
||||
{
|
||||
const wave1 = .04*(1 - abs(Math.sin(time*2)));
|
||||
if (gameOverTimer.isSet())
|
||||
{
|
||||
// win screen
|
||||
const c = playerWin?YELLOW:WHITE;
|
||||
const wave2 = .04*(1 - abs(Math.sin(time*2+PI/2)));
|
||||
drawHUDText(playerWin?'你':'游戏', vec3(.5,.2), .1+wave1, c, undefined,undefined,900,'italic',.5,undefined,4);
|
||||
drawHUDText(playerWin?'获胜!':'结束!', vec3(.5,.3), .1+wave2, c, undefined,undefined,900,'italic',.5,undefined,4);
|
||||
|
||||
if (playerNewRecord || playerNewDistanceRecord && !bestTime)
|
||||
drawHUDText('新纪录', vec3(.5,.6), .08+wave1/4, RED, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
}
|
||||
else if (!startCountdownTimer.active() && !freeRide)
|
||||
{
|
||||
// big center checkpoint time
|
||||
const c = checkpointTimeLeft < 4 ? RED : checkpointTimeLeft < 11 ? YELLOW : WHITE;
|
||||
const t = checkpointTimeLeft|0;
|
||||
|
||||
let y=.13, s=.14;
|
||||
if (enhancedMode && getAspect() < .6)
|
||||
y=.14, s=.1;
|
||||
|
||||
drawHUDText(t, vec3(.5,y), s, c, undefined,undefined,900,undefined,undefined,.04);
|
||||
}
|
||||
|
||||
if (!freeRide)
|
||||
{
|
||||
if (playerWin)
|
||||
{
|
||||
// current time
|
||||
const timeString = formatTimeString(raceTime);
|
||||
if (!js13kBuildLevel2)
|
||||
drawHUDText('时间', vec3(.5,.43), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
drawHUDText(timeString, vec3(.5), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
|
||||
}
|
||||
else
|
||||
{
|
||||
// current time
|
||||
const timeString = formatTimeString(raceTime);
|
||||
drawHUDText(timeString, vec3(.01,.05), .05, undefined, 'monospace','left');
|
||||
|
||||
// current stage
|
||||
const level = debug&&testLevelInfo ? testLevelInfo.level+1 :playerLevel+1;
|
||||
drawHUDText('关卡 '+level, vec3(.99,.05), .05, undefined, 'monospace','right');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debugInfo&&!titleScreenMode) // mph
|
||||
{
|
||||
const mph = playerVehicle.velocity.z|0;
|
||||
const mphPos = vec3(.01,.95);
|
||||
drawHUDText(mph+' 公里/时', mphPos, .08, undefined,undefined,'left',900,'italic');
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function drawHUDText(text, pos, size=.1, color=WHITE, font='arial', textAlign='center', weight=400, style='', width, shadowScale=.07, outline)
|
||||
{
|
||||
size *= mainCanvasSize.y;
|
||||
if (width)
|
||||
width *= mainCanvasSize.y;
|
||||
pos = pos.multiply(mainCanvasSize);
|
||||
|
||||
const context = mainContext;
|
||||
context.lineCap = context.lineJoin = 'round';
|
||||
context.font = `${style} ${weight} ${size}px ${font}`;
|
||||
context.textAlign = textAlign;
|
||||
|
||||
const shadowOffset = size*shadowScale;
|
||||
context.fillStyle = rgb(0,0,0,color.a);
|
||||
if (shadowOffset)
|
||||
context.fillText(text, pos.x+shadowOffset, pos.y+shadowOffset, width);
|
||||
|
||||
context.lineWidth = outline;
|
||||
outline && context.strokeText(text, pos.x, pos.y, width);
|
||||
context.fillStyle = color;
|
||||
context.fillText(text, pos.x, pos.y, width);
|
||||
}
|
||||
36
vue/public/race/index.html
Normal file
36
vue/public/race/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Race Game</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="./release.js"></script>
|
||||
<script src="./utilities.js"></script>
|
||||
<script src="./audio.js"></script>
|
||||
<script src="./draw.js"></script>
|
||||
<script src="./game.js"></script>
|
||||
<script src="./generative.js"></script>
|
||||
<script src="./hud.js"></script>
|
||||
<script src="./input.js"></script>
|
||||
<script src="./levels.js"></script>
|
||||
<script src="./scene.js"></script>
|
||||
<script src="./sounds.js"></script>
|
||||
<script src="./track.js"></script>
|
||||
<script src="./trackGen.js"></script>
|
||||
<script src="./vehicle.js"></script>
|
||||
<script src="./webgl.js"></script>
|
||||
<script src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
402
vue/public/race/input.js
Normal file
402
vue/public/race/input.js
Normal file
@@ -0,0 +1,402 @@
|
||||
'use strict';
|
||||
|
||||
const gamepadsEnable = enhancedMode;
|
||||
const inputWASDEmulateDirection = enhancedMode;
|
||||
const allowTouch = enhancedMode;
|
||||
const isTouchDevice = allowTouch && window.ontouchstart !== undefined;
|
||||
const touchGamepadEnable = enhancedMode;
|
||||
const touchGamepadAlpha = .3;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Input user functions
|
||||
|
||||
const keyIsDown = (key) => inputData[key] & 1;
|
||||
const keyWasPressed = (key) => inputData[key] & 2 ? 1 : 0;
|
||||
const keyWasReleased = (key) => inputData[key] & 4 ? 1 : 0;
|
||||
const clearInput = () => inputData = [];
|
||||
|
||||
let mousePos = vec3();
|
||||
const mouseIsDown = keyIsDown;
|
||||
const mouseWasPressed = keyWasPressed;
|
||||
const mouseWasReleased = keyWasReleased;
|
||||
|
||||
let isUsingGamepad;
|
||||
const gamepadIsDown = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 1);
|
||||
const gamepadWasPressed = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 2);
|
||||
const gamepadWasReleased = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 4);
|
||||
const gamepadStick = (stick, gamepad=0) =>
|
||||
gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec3() : vec3();
|
||||
const gamepadGetValue = (key, gamepad=0) => gamepadDataValues[gamepad][key];
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Input event handlers
|
||||
|
||||
let inputData = []; // track what keys are down
|
||||
|
||||
function inputInit()
|
||||
{
|
||||
if (gamepadsEnable)
|
||||
{
|
||||
gamepadData = [];
|
||||
gamepadStickData = [];
|
||||
gamepadDataValues = [];
|
||||
gamepadData[0] = [];
|
||||
gamepadDataValues[0] = [];
|
||||
}
|
||||
|
||||
onkeydown = (e)=>
|
||||
{
|
||||
isUsingGamepad = 0;
|
||||
if (!e.repeat)
|
||||
{
|
||||
inputData[e.code] = 3;
|
||||
if (inputWASDEmulateDirection)
|
||||
inputData[remapKey(e.code)] = 3;
|
||||
}
|
||||
}
|
||||
|
||||
onkeyup = (e)=>
|
||||
{
|
||||
inputData[e.code] = 4;
|
||||
if (inputWASDEmulateDirection)
|
||||
inputData[remapKey(e.code)] = 4;
|
||||
}
|
||||
|
||||
// mouse event handlers
|
||||
onmousedown = (e)=>
|
||||
{
|
||||
isUsingGamepad = 0;
|
||||
inputData[e.button] = 3;
|
||||
mousePos = mouseToScreen(vec3(e.x,e.y));
|
||||
}
|
||||
onmouseup = (e)=> inputData[e.button] = inputData[e.button] & 2 | 4;
|
||||
onmousemove = (e)=>
|
||||
{
|
||||
mousePos = mouseToScreen(vec3(e.x,e.y));
|
||||
if (freeCamMode)
|
||||
{
|
||||
mouseDelta.x += e.movementX/mainCanvasSize.x;
|
||||
mouseDelta.y += e.movementY/mainCanvasSize.y;
|
||||
}
|
||||
}
|
||||
oncontextmenu = (e)=> false; // prevent right click menu
|
||||
|
||||
// handle remapping wasd keys to directions
|
||||
const remapKey = (c) => inputWASDEmulateDirection ?
|
||||
c == 'KeyW' ? 'ArrowUp' :
|
||||
c == 'KeyS' ? 'ArrowDown' :
|
||||
c == 'KeyA' ? 'ArrowLeft' :
|
||||
c == 'KeyD' ? 'ArrowRight' : c : c;
|
||||
|
||||
// init touch input
|
||||
isTouchDevice && touchInputInit();
|
||||
}
|
||||
|
||||
function inputUpdate()
|
||||
{
|
||||
// clear input when lost focus (prevent stuck keys)
|
||||
isTouchDevice || document.hasFocus() || clearInput();
|
||||
gamepadsEnable && gamepadsUpdate();
|
||||
}
|
||||
|
||||
function inputUpdatePost()
|
||||
{
|
||||
// clear input to prepare for next frame
|
||||
for (const i in inputData)
|
||||
inputData[i] &= 1;
|
||||
}
|
||||
|
||||
// convert a mouse position to screen space
|
||||
const mouseToScreen = (mousePos) =>
|
||||
{
|
||||
if (!clampAspectRatios)
|
||||
{
|
||||
// canvas always takes up full screen
|
||||
return vec3(mousePos.x/mainCanvasSize.x,mousePos.y/mainCanvasSize.y);
|
||||
}
|
||||
else
|
||||
{
|
||||
const rect = mainCanvas.getBoundingClientRect();
|
||||
return vec3(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// gamepad input
|
||||
|
||||
// gamepad internal variables
|
||||
let gamepadData, gamepadStickData, gamepadDataValues;
|
||||
|
||||
// gamepads are updated by engine every frame automatically
|
||||
function gamepadsUpdate()
|
||||
{
|
||||
const applyDeadZones = (v)=>
|
||||
{
|
||||
const min=.2, max=.8;
|
||||
const deadZone = (v)=>
|
||||
v > min ? percent( v, min, max) :
|
||||
v < -min ? -percent(-v, min, max) : 0;
|
||||
return vec3(deadZone(v.x), deadZone(-v.y)).clampLength();
|
||||
}
|
||||
|
||||
// update touch gamepad if enabled
|
||||
isTouchDevice && touchGamepadUpdate();
|
||||
|
||||
// return if gamepads are disabled or not supported
|
||||
if (!navigator || !navigator.getGamepads)
|
||||
return;
|
||||
|
||||
// only poll gamepads when focused or in debug mode (allow playing when not focused in debug)
|
||||
if (!devMode && !document.hasFocus())
|
||||
return;
|
||||
|
||||
// poll gamepads
|
||||
const gamepads = navigator.getGamepads();
|
||||
for (let i = gamepads.length; i--;)
|
||||
{
|
||||
// get or create gamepad data
|
||||
const gamepad = gamepads[i];
|
||||
const data = gamepadData[i] || (gamepadData[i] = []);
|
||||
const dataValue = gamepadDataValues[i] || (gamepadDataValues[i] = []);
|
||||
const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
|
||||
|
||||
if (gamepad)
|
||||
{
|
||||
// read analog sticks
|
||||
for (let j = 0; j < gamepad.axes.length-1; j+=2)
|
||||
sticks[j>>1] = applyDeadZones(vec3(gamepad.axes[j],gamepad.axes[j+1]));
|
||||
|
||||
// read buttons
|
||||
for (let j = gamepad.buttons.length; j--;)
|
||||
{
|
||||
const button = gamepad.buttons[j];
|
||||
const wasDown = gamepadIsDown(j,i);
|
||||
data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
||||
dataValue[j] = percent(button.value||0,.1,.9); // apply deadzone
|
||||
isUsingGamepad ||= !i && button.pressed;
|
||||
}
|
||||
|
||||
const gamepadDirectionEmulateStick = 1;
|
||||
if (gamepadDirectionEmulateStick)
|
||||
{
|
||||
// copy dpad to left analog stick when pressed
|
||||
const dpad = vec3(
|
||||
(gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
|
||||
(gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
|
||||
if (dpad.lengthSquared())
|
||||
sticks[0] = dpad.clampLength();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// touch input
|
||||
|
||||
// try to enable touch mouse
|
||||
function touchInputInit()
|
||||
{
|
||||
// add non passive touch event listeners
|
||||
let handleTouch = handleTouchDefault;
|
||||
if (touchGamepadEnable)
|
||||
{
|
||||
// touch input internal variables
|
||||
handleTouch = handleTouchGamepad;
|
||||
touchGamepadButtons = [];
|
||||
touchGamepadStick = vec3();
|
||||
}
|
||||
document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
|
||||
document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
|
||||
document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
|
||||
|
||||
// override mouse events
|
||||
onmousedown = onmouseup = ()=> 0;
|
||||
|
||||
// handle all touch events the same way
|
||||
let wasTouching;
|
||||
function handleTouchDefault(e)
|
||||
{
|
||||
// fix stalled audio requiring user interaction
|
||||
if (soundEnable && !audioContext)
|
||||
audioContext = new AudioContext; // create audio context
|
||||
//if (soundEnable && audioContext && audioContext.state != 'running')
|
||||
// sound_bump.play(); // play sound to fix audio
|
||||
|
||||
// check if touching and pass to mouse events
|
||||
const touching = e.touches.length;
|
||||
const button = 0; // all touches are left mouse button
|
||||
if (touching)
|
||||
{
|
||||
// average all touch positions
|
||||
const p = vec3();
|
||||
for (let touch of e.touches)
|
||||
{
|
||||
p.x += touch.clientX/e.touches.length;
|
||||
p.y += touch.clientY/e.touches.length;
|
||||
}
|
||||
|
||||
mousePos = mouseToScreen(p);
|
||||
wasTouching ? 0 : inputData[button] = 3;
|
||||
}
|
||||
else if (wasTouching)
|
||||
inputData[button] = inputData[button] & 2 | 4;
|
||||
|
||||
// set was touching
|
||||
wasTouching = touching;
|
||||
|
||||
// prevent default handling like copy and magnifier lens
|
||||
if (document.hasFocus()) // allow document to get focus
|
||||
e.preventDefault();
|
||||
|
||||
// must return true so the document will get focus
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// touch gamepad
|
||||
|
||||
// touch gamepad internal variables
|
||||
let touchGamepadTimer = new Timer, touchGamepadButtons, touchGamepadStick, touchGamepadSize;
|
||||
|
||||
// special handling for virtual gamepad mode
|
||||
function handleTouchGamepad(e)
|
||||
{
|
||||
if (soundEnable)
|
||||
{
|
||||
if (!audioContext)
|
||||
audioContext = new AudioContext; // create audio context
|
||||
|
||||
// fix stalled audio
|
||||
if (audioContext.state != 'running')
|
||||
audioContext.resume();
|
||||
}
|
||||
|
||||
// clear touch gamepad input
|
||||
touchGamepadStick = vec3();
|
||||
touchGamepadButtons = [];
|
||||
isUsingGamepad = true;
|
||||
|
||||
const touching = e.touches.length;
|
||||
if (touching)
|
||||
{
|
||||
touchGamepadTimer.set();
|
||||
if (paused || titleScreenMode || gameOverTimer.isSet())
|
||||
{
|
||||
// touch anywhere to press start
|
||||
touchGamepadButtons[9] = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// get center of left and right sides
|
||||
const stickCenter = vec3(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
||||
const buttonCenter = mainCanvasSize.subtract(vec3(touchGamepadSize, touchGamepadSize));
|
||||
const startCenter = mainCanvasSize.scale(.5);
|
||||
|
||||
// check each touch point
|
||||
for (const touch of e.touches)
|
||||
{
|
||||
let touchPos = mouseToScreen(vec3(touch.clientX, touch.clientY));
|
||||
touchPos = touchPos.multiply(mainCanvasSize);
|
||||
if (touchPos.distance(stickCenter) < touchGamepadSize)
|
||||
{
|
||||
// virtual analog stick
|
||||
touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize);
|
||||
//touchGamepadStick = touchGamepadStick.clampLength(); // circular clamp
|
||||
touchGamepadStick.x = clamp(touchGamepadStick.x,-1,1);
|
||||
touchGamepadStick.y = clamp(touchGamepadStick.y,-1,1);
|
||||
}
|
||||
else if (touchPos.distance(buttonCenter) < touchGamepadSize)
|
||||
{
|
||||
// virtual face buttons
|
||||
const button = touchPos.y > buttonCenter.y ? 1 : 0;
|
||||
touchGamepadButtons[button] = 1;
|
||||
}
|
||||
else if (touchPos.distance(startCenter) < touchGamepadSize)
|
||||
{
|
||||
// hidden virtual start button in center
|
||||
touchGamepadButtons[9] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// call default touch handler so normal touch events still work
|
||||
//handleTouchDefault(e);
|
||||
|
||||
// prevent default handling like copy and magnifier lens
|
||||
if (document.hasFocus()) // allow document to get focus
|
||||
e.preventDefault();
|
||||
|
||||
// must return true so the document will get focus
|
||||
return true;
|
||||
}
|
||||
|
||||
// update the touch gamepad, called automatically by the engine
|
||||
function touchGamepadUpdate()
|
||||
{
|
||||
if (!touchGamepadEnable)
|
||||
return;
|
||||
|
||||
// adjust for thin canvas
|
||||
touchGamepadSize = clamp(mainCanvasSize.y/8, 99, mainCanvasSize.x/2);
|
||||
|
||||
ASSERT(touchGamepadButtons, 'set touchGamepadEnable before calling init!');
|
||||
if (!touchGamepadTimer.isSet())
|
||||
return;
|
||||
|
||||
// read virtual analog stick
|
||||
const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
|
||||
sticks[0] = touchGamepadStick.copy();
|
||||
|
||||
// read virtual gamepad buttons
|
||||
const data = gamepadData[0];
|
||||
for (let i=10; i--;)
|
||||
{
|
||||
const wasDown = gamepadIsDown(i,0);
|
||||
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// render the touch gamepad, called automatically by the engine
|
||||
function touchGamepadRender()
|
||||
{
|
||||
if (!touchGamepadEnable || !touchGamepadTimer.isSet())
|
||||
return;
|
||||
|
||||
// fade off when not touching or paused
|
||||
const alpha = percent(touchGamepadTimer.get(), 4, 3);
|
||||
if (!alpha || paused)
|
||||
return;
|
||||
|
||||
// setup the canvas
|
||||
const context = mainContext;
|
||||
context.save();
|
||||
context.globalAlpha = alpha*touchGamepadAlpha;
|
||||
context.strokeStyle = '#fff';
|
||||
context.lineWidth = 3;
|
||||
|
||||
// draw left analog stick
|
||||
context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
|
||||
context.beginPath();
|
||||
|
||||
// draw circle shaped gamepad
|
||||
const leftCenter = vec3(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
||||
context.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
// draw right face buttons
|
||||
const rightCenter = vec3(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
||||
for (let i=2; i--;)
|
||||
{
|
||||
const pos = rightCenter.add(vec3(0,(i?1:-1)*touchGamepadSize/2));
|
||||
context.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000';
|
||||
context.beginPath();
|
||||
context.arc(pos.x, pos.y, touchGamepadSize/3, 0, 9);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
// set canvas back to normal
|
||||
context.restore();
|
||||
}
|
||||
447
vue/public/race/levels.js
Normal file
447
vue/public/race/levels.js
Normal file
@@ -0,0 +1,447 @@
|
||||
'use strict';
|
||||
|
||||
let levelInfoList;
|
||||
|
||||
function initLevelInfos()
|
||||
{
|
||||
levelInfoList = [];
|
||||
let LI, level=0;
|
||||
|
||||
// Level 1 - beach -
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_plain,
|
||||
spriteList.tree_palm,
|
||||
spriteList.rock_big,
|
||||
], spriteList.tree_palm);
|
||||
LI.horizonSpriteSize = .7;
|
||||
LI.waterSide = -1;
|
||||
//LI.tunnel = spriteList.tunnel2; // test tunnel
|
||||
LI.billboardChance = .3 // more billboards at start
|
||||
//LI.trafficDensity = .7; // less traffic start
|
||||
|
||||
// mostly straight with few well defined turns or bumps
|
||||
LI.turnChance = .6;
|
||||
LI.turnMin = .2;
|
||||
//LI.turnMax = .6;
|
||||
//LI.bumpChance = .5;
|
||||
LI.bumpFreqMin = .2;
|
||||
LI.bumpFreqMax = .4;
|
||||
LI.bumpScaleMin = 10;
|
||||
LI.bumpScaleMax = 20;
|
||||
|
||||
// Level 2 - forest -
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.tree_oak,
|
||||
spriteList.grass_plain,
|
||||
spriteList.tree_bush,
|
||||
spriteList.tree_stump,
|
||||
spriteList.grass_flower1,
|
||||
spriteList.grass_flower3,
|
||||
spriteList.grass_flower2,
|
||||
], spriteList.tree_bush, spriteList.horizon_smallMountains);
|
||||
LI.horizonSpriteSize = 10;
|
||||
LI.trackSideRate = 10;
|
||||
LI.sceneryListBias = 9;
|
||||
//LI.skyColorTop = WHITE;
|
||||
LI.skyColorBottom = hsl(.5,.3,.5);
|
||||
LI.roadColor = hsl(.05,.4,.2);
|
||||
LI.groundColor = hsl(.2,.4,.4);
|
||||
LI.cloudColor = hsl(0,0,1,.3);
|
||||
LI.cloudHeight = .2;
|
||||
LI.sunHeight = .7;
|
||||
LI.billboardChance = .1 // less billboards in forest type areas
|
||||
//LI.trafficDensity = .7; // less traffic in forest
|
||||
|
||||
// trail through forest
|
||||
LI.turnChance = .7; // more small turns
|
||||
//LI.turnMin = 0;
|
||||
//LI.turnMax = .6;
|
||||
LI.bumpChance = .8;
|
||||
LI.bumpFreqMin = .4;
|
||||
//LI.bumpFreqMax = .7;
|
||||
//LI.bumpScaleMin = 50;
|
||||
LI.bumpScaleMax = 140;
|
||||
|
||||
// Level 3 - desert -
|
||||
// has long straight thin roads and tunnel
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_dead,
|
||||
spriteList.tree_dead,
|
||||
spriteList.rock_big,
|
||||
spriteList.tree_stump,
|
||||
], spriteList.telephonePole, spriteList.horizon_desert);
|
||||
LI.trackSideRate = 50;
|
||||
LI.trackSideChance = 1;
|
||||
LI.skyColorTop = hsl(.15,1,.9);
|
||||
LI.skyColorBottom = hsl(.5,.7,.6);
|
||||
LI.roadColor = hsl(.1,.2,.2);
|
||||
LI.lineColor = hsl(0,0,1,.5);
|
||||
LI.groundColor = hsl(.1,.2,.5);
|
||||
LI.trackSideForce = 1; // telephone poles on right side
|
||||
LI.cloudHeight = .05;
|
||||
LI.sunHeight = .9;
|
||||
LI.sideStreets = 1;
|
||||
LI.laneCount = 2;
|
||||
LI.hazardType = spriteList.hazard_sand;
|
||||
LI.hazardChance = .005;
|
||||
LI.tunnel = spriteList.tunnel2;
|
||||
LI.trafficDensity = .7; // less traffic in desert, only 2 lanes
|
||||
LI.billboardRate = 87;
|
||||
LI.billboardScale = 8;
|
||||
|
||||
// flat desert
|
||||
//LI.turnChance = .5;
|
||||
LI.turnMin = .2;
|
||||
LI.turnMax = .6;
|
||||
LI.bumpChance = 1;
|
||||
//LI.bumpFreqMin = 0;
|
||||
LI.bumpFreqMax = .2;
|
||||
LI.bumpScaleMin = 30;
|
||||
LI.bumpScaleMax = 60;
|
||||
|
||||
// Level 4 - snow area -
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_snow,
|
||||
spriteList.tree_dead,
|
||||
spriteList.tree_snow,
|
||||
spriteList.rock_big,
|
||||
spriteList.tree_stump,
|
||||
], spriteList.tree_snow, spriteList.horizon_snow);
|
||||
LI.sceneryListBias = 9;
|
||||
LI.trackSideRate = 21;
|
||||
LI.skyColorTop = hsl(.5,.2,.4);
|
||||
LI.skyColorBottom = WHITE;
|
||||
LI.roadColor = hsl(0,0,.5,.5);
|
||||
LI.groundColor = hsl(.6,.3,.9);
|
||||
LI.cloudColor = hsl(0,0,.8,.5);
|
||||
LI.horizonSpriteSize = 2;
|
||||
LI.lineColor = hsl(0,0,1,.5);
|
||||
LI.sunHeight = .7;
|
||||
LI.hazardType = spriteList.hazard_rocks;
|
||||
LI.hazardChance = .002;
|
||||
LI.trafficDensity = 1.2; // extra traffic through snow
|
||||
|
||||
// snowy mountains
|
||||
//LI.turnChance = .5;
|
||||
LI.turnMin = .4;
|
||||
//LI.turnMax = .6;
|
||||
LI.bumpChance = .8;
|
||||
LI.bumpFreqMin = .2;
|
||||
LI.bumpFreqMax = .6;
|
||||
//LI.bumpFreqMax = .7;
|
||||
LI.bumpScaleMin = 50;
|
||||
LI.bumpScaleMax = 100;
|
||||
|
||||
// Level 5 - canyon -
|
||||
// has winding roads, hills, and sand onground
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.rock_huge,
|
||||
spriteList.grass_dead,
|
||||
spriteList.tree_fall,
|
||||
spriteList.rock_huge2,
|
||||
spriteList.grass_flower2,
|
||||
spriteList.tree_dead,
|
||||
spriteList.tree_stump,
|
||||
spriteList.rock_big,
|
||||
], spriteList.tree_fall,spriteList.horizon_brownMountains);
|
||||
LI.sceneryListBias = 2;
|
||||
LI.trackSideRate = 31;
|
||||
LI.skyColorTop = hsl(.7,1,.7);
|
||||
LI.skyColorBottom = hsl(.2,1,.9);
|
||||
LI.roadColor = hsl(0,0,.15);
|
||||
LI.groundColor = hsl(.1,.4,.5);
|
||||
LI.cloudColor = hsl(0,0,1,.3);
|
||||
LI.cloudHeight = .1;
|
||||
LI.sunColor = hsl(0,1,.7);
|
||||
//LI.laneCount = 3;
|
||||
LI.billboardChance = .1 // less billboards in forest type areas
|
||||
LI.trafficDensity = .7; // less traffic in canyon
|
||||
|
||||
// rocky canyon
|
||||
LI.turnChance = 1; // must turn to block vision
|
||||
LI.turnMin = .2;
|
||||
LI.turnMax = .8;
|
||||
LI.bumpChance = .9;
|
||||
LI.bumpFreqMin = .4;
|
||||
//LI.bumpFreqMax = .7;
|
||||
//LI.bumpScaleMin = 50;
|
||||
LI.bumpScaleMax = 120;
|
||||
|
||||
// Level 6 - red fields and city
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_red,
|
||||
spriteList.tree_yellow,
|
||||
spriteList.rock_big,
|
||||
spriteList.tree_stump,
|
||||
//spriteList.rock_wide,
|
||||
], spriteList.tree_yellow,spriteList.horizon_city);
|
||||
LI.trackSideRate = 31;
|
||||
LI.skyColorTop = YELLOW;
|
||||
LI.skyColorBottom = RED;
|
||||
LI.roadColor = hsl(0,0,.1);
|
||||
LI.lineColor = hsl(.15,1,.7);
|
||||
LI.groundColor = hsl(.05,.5,.4);
|
||||
LI.cloudColor = hsl(.15,1,.5,.5);
|
||||
//LI.cloudHeight = .3;
|
||||
LI.billboardRate = 23; // more billboards in city
|
||||
LI.billboardChance = .5
|
||||
LI.horizonSpriteSize = 1.5;
|
||||
if (!js13kBuildLevel2)
|
||||
LI.horizonFlipChance = .3;
|
||||
LI.sunHeight = .5;
|
||||
LI.sunColor = hsl(.15,1,.8);
|
||||
LI.sideStreets = 1;
|
||||
LI.laneCount = 5;
|
||||
LI.trafficDensity = 2; // extra traffic in city
|
||||
|
||||
// in front of city
|
||||
LI.turnChance = .3;
|
||||
LI.turnMin = .5
|
||||
LI.turnMax = .9; // bigger turns since lanes are wide
|
||||
//LI.bumpChance = .5;
|
||||
LI.bumpFreqMin = .3;
|
||||
LI.bumpFreqMax = .6;
|
||||
LI.bumpScaleMin = 80;
|
||||
LI.bumpScaleMax = 200;
|
||||
|
||||
// Level 7 - graveyard -
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_dead,
|
||||
spriteList.grass_plain,
|
||||
spriteList.grave_stone,
|
||||
spriteList.tree_dead,
|
||||
spriteList.tree_stump,
|
||||
], spriteList.tree_oak, spriteList.horizon_graveyard);
|
||||
LI.sceneryListBias = 2;
|
||||
LI.trackSideRate = 50;
|
||||
LI.skyColorTop = hsl(.5,1,.5);
|
||||
LI.skyColorBottom = hsl(0,1,.8);
|
||||
LI.roadColor = hsl(.6,.3,.15);
|
||||
LI.groundColor = hsl(.2,.3,.5);
|
||||
LI.lineColor = hsl(0,0,1,.5);
|
||||
LI.billboardChance = 0; // no ads in graveyard
|
||||
LI.cloudColor = hsl(.15,1,.9,.3);
|
||||
LI.horizonSpriteSize = 4;
|
||||
LI.sunHeight = 1.5;
|
||||
//LI.laneCount = 3;
|
||||
//LI.trafficDensity = .7;
|
||||
LI.trackSideChance = 1; // more trees
|
||||
|
||||
// thin road over hills in graveyard
|
||||
//LI.turnChance = .5;
|
||||
LI.turnMax = .6;
|
||||
LI.bumpChance = .6;
|
||||
LI.bumpFreqMin = LI.bumpFreqMax = .7;
|
||||
LI.bumpScaleMin = 80;
|
||||
//LI.bumpScaleMax = 150;
|
||||
|
||||
// Level 8 - jungle - dirt road, many trees
|
||||
// has lots of physical hazards
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_large,
|
||||
spriteList.tree_palm,
|
||||
spriteList.grass_flower1,
|
||||
spriteList.rock_tall,
|
||||
spriteList.rock_big,
|
||||
spriteList.rock_huge2,
|
||||
], spriteList.rock_big, spriteList.horizon_redMountains);
|
||||
LI.sceneryListBias = 5;
|
||||
LI.trackSideRate = 25;
|
||||
LI.skyColorTop = hsl(0,1,.8);
|
||||
LI.skyColorBottom = hsl(.6,1,.6);
|
||||
LI.lineColor = hsl(0,0,0,0);
|
||||
LI.roadColor = hsl(0,.6,.2,.8);
|
||||
LI.groundColor = hsl(.1,.5,.4);
|
||||
LI.waterSide = 1;
|
||||
LI.cloudColor = hsl(0,1,.96,.8);
|
||||
LI.cloudWidth = .6;
|
||||
//LI.cloudHeight = .3;
|
||||
LI.sunHeight = .7;
|
||||
LI.sunColor = hsl(.1,1,.7);
|
||||
LI.hazardType = spriteList.rock_big;
|
||||
LI.hazardChance = .2;
|
||||
LI.trafficDensity = 0; // no other cars in jungle
|
||||
|
||||
// bumpy jungle road
|
||||
LI.turnChance = .8;
|
||||
//LI.turnMin = 0;
|
||||
LI.turnMax = .3; // lots of slight turns
|
||||
LI.bumpChance = 1;
|
||||
LI.bumpFreqMin = .4;
|
||||
LI.bumpFreqMax = .6;
|
||||
LI.bumpScaleMin = 10;
|
||||
LI.bumpScaleMax = 80;
|
||||
|
||||
// Level 9 - strange area
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_red,
|
||||
spriteList.rock_weird,
|
||||
spriteList.tree_huge,
|
||||
], spriteList.rock_weird2, spriteList.horizon_weird);
|
||||
LI.trackSideRate = 50;
|
||||
LI.skyColorTop = hsl(.05,1,.8);
|
||||
LI.skyColorBottom = hsl(.15,1,.7);
|
||||
LI.lineColor = hsl(0,1,.9);
|
||||
LI.roadColor = hsl(.6,1,.1);
|
||||
LI.groundColor = hsl(.6,1,.6);
|
||||
LI.cloudColor = hsl(.9,1,.5,.3);
|
||||
LI.cloudHeight = .2;
|
||||
LI.sunColor = BLACK;
|
||||
LI.laneCount = 4;
|
||||
LI.trafficDensity = 1.5; // extra traffic to increase difficulty here
|
||||
|
||||
// large strange hills
|
||||
LI.turnChance = .7;
|
||||
LI.turnMin = .3;
|
||||
LI.turnMax = .8;
|
||||
LI.bumpChance = 1;
|
||||
LI.bumpFreqMin = .5;
|
||||
LI.bumpFreqMax = .9;
|
||||
LI.bumpScaleMin = 100;
|
||||
LI.bumpScaleMax = 200;
|
||||
|
||||
// Level 10 - mountains - hilly, rocks on sides
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_plain,
|
||||
spriteList.rock_huge3,
|
||||
spriteList.grass_flower1,
|
||||
spriteList.rock_huge2,
|
||||
spriteList.rock_huge,
|
||||
], spriteList.tree_pink);
|
||||
LI.trackSideRate = 21;
|
||||
LI.skyColorTop = hsl(.2,1,.9);
|
||||
LI.skyColorBottom = hsl(.55,1,.5);
|
||||
LI.roadColor = hsl(0,0,.1);
|
||||
LI.groundColor = hsl(.1,.5,.7);
|
||||
LI.cloudColor = hsl(0,0,1,.5);
|
||||
LI.tunnel = spriteList.tunnel1;
|
||||
if (js13kBuildLevel2)
|
||||
LI.horizonSpriteSize = 0;
|
||||
else
|
||||
{
|
||||
LI.sunHeight = .6;
|
||||
LI.horizonSprite = spriteList.horizon_mountains
|
||||
LI.horizonSpriteSize = 1;
|
||||
}
|
||||
|
||||
// mountains, most difficult level
|
||||
LI.turnChance = LI.turnMax = .8;
|
||||
//LI.turnMin = 0;
|
||||
LI.bumpChance = 1;
|
||||
LI.bumpFreqMin = .3;
|
||||
LI.bumpFreqMax = .9;
|
||||
//LI.bumpScaleMin = 50;
|
||||
LI.bumpScaleMax = 80;
|
||||
|
||||
// Level 11 - win area
|
||||
LI = new LevelInfo(level++, [
|
||||
spriteList.grass_flower1,
|
||||
spriteList.grass_flower2,
|
||||
spriteList.grass_flower3,
|
||||
spriteList.grass_plain,
|
||||
spriteList.tree_oak,
|
||||
spriteList.tree_bush,
|
||||
], spriteList.tree_oak);
|
||||
LI.sceneryListBias = 1;
|
||||
LI.groundColor = hsl(.2,.3,.5);
|
||||
LI.trackSideRate = LI.billboardChance = 0;
|
||||
LI.bumpScaleMin = 1e3; // hill in the distance
|
||||
|
||||
// match settings to previous level
|
||||
if (js13kBuildLevel2)
|
||||
LI.horizonSpriteSize = 0;
|
||||
else
|
||||
{
|
||||
LI.sunHeight = .6;
|
||||
LI.horizonSprite = spriteList.horizon_mountains
|
||||
LI.horizonSpriteSize = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelInfo = (level) => testLevelInfo || levelInfoList[level|0] || levelInfoList[0];
|
||||
|
||||
// info about how to build and draw each level
|
||||
class LevelInfo
|
||||
{
|
||||
constructor(level, scenery, trackSideSprite,horizonSprite=spriteList.horizon_islands)
|
||||
{
|
||||
// add self to list
|
||||
levelInfoList[level] = this;
|
||||
|
||||
if (debug)
|
||||
{
|
||||
for(const s of scenery)
|
||||
ASSERT(s, 'missing scenery!');
|
||||
}
|
||||
|
||||
this.level = level;
|
||||
this.scenery = scenery;
|
||||
this.trackSideSprite = trackSideSprite;
|
||||
this.sceneryListBias = 29;
|
||||
this.waterSide = 0;
|
||||
|
||||
this.billboardChance = .2;
|
||||
this.billboardRate = 45;
|
||||
this.billboardScale = 1;
|
||||
this.trackSideRate = 5;
|
||||
this.trackSideForce = 0;
|
||||
this.trackSideChance = .5;
|
||||
|
||||
this.groundColor = hsl(.08,.2, .7);
|
||||
this.skyColorTop = WHITE;
|
||||
this.skyColorBottom = hsl(.57,1,.5);
|
||||
this.lineColor = WHITE;
|
||||
this.roadColor = hsl(0, 0, .5);
|
||||
|
||||
// horizon stuff
|
||||
this.cloudColor = hsl(.15,1,.95,.7);
|
||||
this.cloudWidth = 1;
|
||||
this.cloudHeight = .3;
|
||||
this.horizonSprite = horizonSprite;
|
||||
this.horizonSpriteSize = 2;
|
||||
this.sunHeight = .8;
|
||||
this.sunColor = hsl(.15,1,.95);
|
||||
|
||||
// track generation
|
||||
this.laneCount = 3;
|
||||
this.trafficDensity = 1;
|
||||
|
||||
// default turns and bumps
|
||||
this.turnChance = .5;
|
||||
this.turnMin = 0;
|
||||
this.turnMax = .6;
|
||||
this.bumpChance = .5;
|
||||
this.bumpFreqMin = 0; // no bumps
|
||||
this.bumpFreqMax = .7; // more often bumps
|
||||
this.bumpScaleMin = 50; // rapid bumps
|
||||
this.bumpScaleMax = 150; // largest hills
|
||||
}
|
||||
|
||||
randomize()
|
||||
{
|
||||
shuffle(this.scenery);
|
||||
this.sceneryListBias = random.float(5,30);
|
||||
this.groundColor = random.mutateColor(this.groundColor);
|
||||
this.skyColorTop = random.mutateColor(this.skyColorTop);
|
||||
this.skyColorBottom = random.mutateColor(this.skyColorBottom);
|
||||
this.lineColor = random.mutateColor(this.lineColor);
|
||||
this.roadColor = random.mutateColor(this.roadColor);
|
||||
this.cloudColor = random.mutateColor(this.cloudColor);
|
||||
this.sunColor = random.mutateColor(this.sunColor);
|
||||
|
||||
// track generation
|
||||
this.laneCount = random.int(2,5);
|
||||
this.trafficDensity = random.float(.5,1.5);
|
||||
|
||||
// default turns and bumps
|
||||
this.turnChance = random.float();
|
||||
this.turnMin = random.float();
|
||||
this.turnMax = random.float();
|
||||
this.bumpChance = random.float();
|
||||
this.bumpFreqMin = random.float(.5); // no bumps
|
||||
this.bumpFreqMax = random.float(); // more often bumps
|
||||
this.bumpScaleMin = random.float(20,50); // rapid bumps
|
||||
this.bumpScaleMax = random.float(50,150); // largest hills
|
||||
this.hazardChance = 0;
|
||||
}
|
||||
}
|
||||
41
vue/public/race/main.js
Normal file
41
vue/public/race/main.js
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
|
||||
Dr1v3n Wild by Frank Force
|
||||
A 13k game for js13kGames 2024
|
||||
|
||||
Controls
|
||||
- Arrows or Mouse = Drive
|
||||
- Spacebar = Brake
|
||||
- F = Free Ride Mode
|
||||
- Escape = Title Screen
|
||||
|
||||
Features
|
||||
- 10 stages with unique visuals
|
||||
- Fast custom WebGL rendering
|
||||
- Procedural art (trees, rocks, scenery)
|
||||
- Track generator
|
||||
- Arcade style driving physics
|
||||
- 2 types of AI vehicles
|
||||
- Parallax horizon and sky
|
||||
- ZZFX sounds
|
||||
- Persistent save data
|
||||
- Keyboard or mouse input
|
||||
- All written from scratch in vanilla JS
|
||||
|
||||
*/
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
|
||||
// debug settings
|
||||
//devMode = debugInfo = 1
|
||||
//soundVolume = 0
|
||||
//debugGenerativeCanvas = 1
|
||||
//autoPause = 0
|
||||
//quickStart = 1
|
||||
//disableAiVehicles = 1
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
|
||||
gameInit();
|
||||
16
vue/public/race/release.js
Normal file
16
vue/public/race/release.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const debug = 0;
|
||||
const enhancedMode = 1;
|
||||
let debugInfo, debugMesh, debugTile, debugGenerativeCanvas, devMode;
|
||||
const js13kBuildLevel2 = 0; // more space is needed for js13k
|
||||
|
||||
// disable debug features
|
||||
function ASSERT() {}
|
||||
function debugInit() {}
|
||||
function drawDebug() {}
|
||||
function debugUpdate() {}
|
||||
function debugSaveCanvas() {}
|
||||
function debugSaveText() {}
|
||||
function debugDraw() {}
|
||||
function debugSaveDataURL() {}
|
||||
15
vue/public/race/releaseJS13K.js
Normal file
15
vue/public/race/releaseJS13K.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const debug = 0;
|
||||
let debugInfo, debugMesh, debugTile, debugGenerativeCanvas, devMode, enhancedMode;
|
||||
const js13kBuildLevel2 = 1; // more space is needed for js13k
|
||||
|
||||
// disable debug features
|
||||
function ASSERT() {}
|
||||
function debugInit() {}
|
||||
function drawDebug() {}
|
||||
function debugUpdate() {}
|
||||
function debugSaveCanvas() {}
|
||||
function debugSaveText() {}
|
||||
function debugDraw() {}
|
||||
function debugSaveDataURL() {}
|
||||
120
vue/public/race/scene.js
Normal file
120
vue/public/race/scene.js
Normal file
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
function drawScene()
|
||||
{
|
||||
drawSky();
|
||||
drawTrack();
|
||||
drawCars();
|
||||
drawTrackScenery();
|
||||
}
|
||||
|
||||
function drawSky()
|
||||
{
|
||||
glEnableLighting = glEnableFog = 0;
|
||||
glSetDepthTest(0,0);
|
||||
random.setSeed(13);
|
||||
|
||||
// lerp level stuff
|
||||
const levelFloat = cameraOffset/checkpointDistance;
|
||||
const levelInfo = getLevelInfo(levelFloat);
|
||||
const levelInfoLast = getLevelInfo(levelFloat-1);
|
||||
const levelPercent = levelFloat%1;
|
||||
const levelLerpPercent = percent(levelPercent, 0, levelLerpRange);
|
||||
|
||||
// sky
|
||||
const skyTop = 13e2; // slightly above camera
|
||||
const skyZ = 1e3;
|
||||
const skyW = 5e3;
|
||||
const skyH = 8e2;
|
||||
{
|
||||
// top/bottom gradient
|
||||
const skyColorTop = levelInfoLast.skyColorTop.lerp(levelInfo.skyColorTop, levelLerpPercent);
|
||||
const skyColorBottom = levelInfoLast.skyColorBottom.lerp(levelInfo.skyColorBottom, levelLerpPercent);
|
||||
pushGradient(vec3(0,skyH,skyZ).addSelf(cameraPos), vec3(skyW,skyH), skyColorTop, skyColorBottom);
|
||||
|
||||
// light settings from sky
|
||||
glLightDirection = vec3(0,1,1).rotateY(worldHeading).normalize();
|
||||
glLightColor = skyColorTop.lerp(WHITE,.8).lerp(BLACK,.1);
|
||||
glAmbientColor = skyColorBottom.lerp(WHITE,.8).lerp(BLACK,.6);
|
||||
glFogColor = skyColorBottom.lerp(WHITE,.5);
|
||||
}
|
||||
|
||||
const headingScale = -5e3;
|
||||
const circleSpriteTile = spriteList.circle.spriteTile;
|
||||
const dotSpriteTile = spriteList.dot.spriteTile;
|
||||
{
|
||||
// sun
|
||||
const sunSize = 2e2;
|
||||
const sunHeight = skyTop*lerp(levelLerpPercent, levelInfoLast.sunHeight, levelInfo.sunHeight);
|
||||
const sunColor = levelInfoLast.sunColor.lerp(levelInfo.sunColor, levelLerpPercent);
|
||||
const x = mod(worldHeading+PI,2*PI)-PI;
|
||||
for(let i=0;i<1;i+=.05)
|
||||
{
|
||||
sunColor.a = i?(1-i)**7:1;
|
||||
pushSprite(vec3( x*headingScale, sunHeight, skyZ).addSelf(cameraPos), vec3(sunSize*(1+i*30)), sunColor, i?dotSpriteTile:circleSpriteTile);
|
||||
}
|
||||
}
|
||||
|
||||
// clouds
|
||||
const range = 1e4;
|
||||
const windSpeed = 50;
|
||||
for(let i=99;i--;)
|
||||
{
|
||||
const cloudColor = levelInfoLast.cloudColor.lerp(levelInfo.cloudColor, levelLerpPercent);
|
||||
const cloudWidth = lerp(levelLerpPercent, levelInfoLast.cloudWidth, levelInfo.cloudWidth);
|
||||
const cloudHeight = lerp(levelLerpPercent, levelInfoLast.cloudHeight, levelInfo.cloudHeight);
|
||||
|
||||
let x = worldHeading*headingScale + random.float(range) + time*windSpeed*random.float(1,1.5);
|
||||
x = mod(x,range) - range/2;
|
||||
const y = random.float(skyTop);
|
||||
const s = random.float(3e2,8e2);
|
||||
pushSprite(vec3( x, y, skyZ).addSelf(cameraPos), vec3(s*cloudWidth,s*cloudHeight), cloudColor, dotSpriteTile)
|
||||
}
|
||||
|
||||
// parallax
|
||||
const horizonSprite = levelInfo.horizonSprite;
|
||||
const horizonSpriteTile = horizonSprite.spriteTile;
|
||||
const horizonSpriteSize = levelInfo.horizonSpriteSize;
|
||||
for(let i=99;i--;)
|
||||
{
|
||||
const p = i/99;
|
||||
const ltp = lerp(p,1,.5);
|
||||
const ltt = .1;
|
||||
const levelTransition = levelFloat<.5 || levelFloat > levelGoal-.5 ? 1 : levelPercent < ltt ? (levelPercent/ltt)**ltp :
|
||||
levelPercent > 1-ltt ? 1-((levelPercent-1)/ltt+1)**ltp : 1;
|
||||
|
||||
const parallax = lerp(p, .9, .98);
|
||||
const s = random.float(1e2,2e2)*horizonSpriteSize* lerp(p,1,.5)
|
||||
const size = vec3(random.float(1,2)*(horizonSprite.canMirror ? s*random.sign() : s),s,s);
|
||||
const x = mod(worldHeading*headingScale/parallax + random.float(range),range) - range/2;
|
||||
|
||||
const yMax = size.y*.75;
|
||||
if (!js13kBuildLevel2 && levelInfo.horizonFlipChance)
|
||||
{
|
||||
// horizon spites that can be flipped vertically
|
||||
if (random.bool(levelInfo.horizonFlipChance))
|
||||
size.y *= -1;
|
||||
}
|
||||
const y = lerp(levelTransition, -yMax*1.5, yMax);
|
||||
const c = horizonSprite.getRandomSpriteColor();
|
||||
pushSprite(vec3( x, y, skyZ).addSelf(cameraPos), size, c, horizonSpriteTile);
|
||||
}
|
||||
|
||||
{
|
||||
// get ahead of player for horizon ground color to match track
|
||||
const lookAhead = .2;
|
||||
const levelFloatAhead = levelFloat + lookAhead;
|
||||
const levelInfo = getLevelInfo(levelFloatAhead);
|
||||
const levelInfoLast = getLevelInfo(levelFloatAhead-1);
|
||||
const levelPercent = levelFloatAhead%1;
|
||||
const levelLerpPercent = percent(levelPercent, 0, levelLerpRange);
|
||||
|
||||
// horizon bottom
|
||||
const groundColor = levelInfoLast.groundColor.lerp(levelInfo.groundColor, levelLerpPercent).brighten(.1);
|
||||
pushSprite(vec3(0,-skyH,skyZ).addSelf(cameraPos), vec3(skyW,skyH), groundColor);
|
||||
}
|
||||
|
||||
glRender();
|
||||
glSetDepthTest();
|
||||
glEnableLighting = glEnableFog = 1;
|
||||
}
|
||||
9
vue/public/race/sounds.js
Normal file
9
vue/public/race/sounds.js
Normal file
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
const sound_beep = new Sound([,0,220,.01,.08,.05,,.5,,,,,,,,,.3,.9,.01,,-99]); // beep
|
||||
const sound_engine = new Sound([,,40,.2,.5,.5,,,,,,,,300,,,,,,,-80]); // engine
|
||||
const sound_hit = new Sound([,.3,90,,,.2,,3,,,,,,9,,.3,,.3,.01]); // crash
|
||||
const sound_bump = new Sound([4,.2,400,.01,.01,.01,,.8,-60,-70,,,.03,.1,,,.1,.5,.01,.4,400]); // bump
|
||||
const sound_checkpoint = new Sound([.3,0,980,,,,,3,,,,,,,,.03,,,,,500]); // checkpoint
|
||||
const sound_win = new Sound([1.5,,110,.04,,2,,6,,1,330,.07,.05,,,,.4,.8,,.5,1e3]); // win
|
||||
const sound_lose = new Sound([,,120,.1,,1,,3,,.6,,,,1,,.2,.4,.1,1,,500]); // lose
|
||||
425
vue/public/race/track.js
Normal file
425
vue/public/race/track.js
Normal file
@@ -0,0 +1,425 @@
|
||||
'use strict';
|
||||
|
||||
function trackPreUpdate()
|
||||
{
|
||||
// calcuate track x offsets and projections (iterate in reverse)
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
const cameraTrackSegment = cameraTrackInfo.segmentIndex;
|
||||
const cameraTrackSegmentPercent = cameraTrackInfo.percent;
|
||||
const turnScale = 2;
|
||||
for(let x=0, v=0, i=0; i<drawDistance; ++i)
|
||||
{
|
||||
const j = cameraTrackSegment+i;
|
||||
if (!track[j])
|
||||
continue;
|
||||
|
||||
// create track world position
|
||||
const s = i < 1 ? 1-cameraTrackSegmentPercent : 1;
|
||||
track[j].pos = track[j].offset.copy();
|
||||
track[j].pos.x = x += v += turnScale*s*track[j].pos.x;
|
||||
track[j].pos.z -= cameraOffset;
|
||||
}
|
||||
}
|
||||
|
||||
function drawTrack()
|
||||
{
|
||||
glEnableFog = 0; // track looks better without fog
|
||||
drawRoad(1); // first draw just flat ground with z write
|
||||
glSetDepthTest(0,0); // disable z testing
|
||||
drawRoad(); // draw ground and road
|
||||
|
||||
// set evertyhing back to normal
|
||||
glEnableFog = 1;
|
||||
glSetDepthTest();
|
||||
}
|
||||
|
||||
function drawRoad(zwrite)
|
||||
{
|
||||
// draw the road segments
|
||||
const drawLineDistance = 500;
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
const cameraTrackSegment = cameraTrackInfo.segmentIndex;
|
||||
for(let i = drawDistance, segment1, segment2; i--; )
|
||||
{
|
||||
const segmentIndex = cameraTrackSegment+i;
|
||||
segment1 = track[segmentIndex];
|
||||
if (!segment1 || !segment2)
|
||||
{
|
||||
segment2 = segment1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i % (lerp(i/drawDistance,1,8)|0)) // fade in road resolution
|
||||
continue;
|
||||
|
||||
const p1 = segment1.pos;
|
||||
const p2 = segment2.pos;
|
||||
const normals = [segment1.normal, segment1.normal, segment2.normal, segment2.normal];
|
||||
function pushRoadVerts(width, color, offset=0, width2=width, offset2=offset, oy=0)
|
||||
{
|
||||
const point1a = vec3(p1.x+width+offset, p1.y+oy, p1.z);
|
||||
const point1b = vec3(p1.x-width+offset, p1.y+oy, p1.z);
|
||||
const point2a = vec3(p2.x+width2+offset2, p2.y+oy, p2.z);
|
||||
const point2b = vec3(p2.x-width2+offset2, p2.y+oy, p2.z);
|
||||
const poly = [point1a, point1b, point2a, point2b];
|
||||
color.a && glPushVertsCapped(poly, normals, color);
|
||||
}
|
||||
|
||||
{
|
||||
// ground
|
||||
const color = segment1.colorGround;
|
||||
const width = 1e5; // fill the width of the screen
|
||||
pushRoadVerts(width, color);
|
||||
}
|
||||
|
||||
if (!zwrite)
|
||||
{
|
||||
const roadHeight = 10;
|
||||
|
||||
// road
|
||||
const color = segment1.colorRoad;
|
||||
const width = segment1.width;
|
||||
const width2 = segment2.width;
|
||||
pushRoadVerts(width, color, undefined, width2,undefined,roadHeight);
|
||||
|
||||
if (i < drawLineDistance)
|
||||
{
|
||||
// lines on road
|
||||
const w = segment1.width;
|
||||
const lineBias = .2
|
||||
const laneCount = 2*w/laneWidth - lineBias;
|
||||
for(let j=1; j<laneCount; ++j)
|
||||
{
|
||||
const color = segment1.colorLine;
|
||||
const lineWidth = 30;
|
||||
const offset = j*laneWidth-segment1.width;
|
||||
const offset2 = j*laneWidth-segment2.width;
|
||||
pushRoadVerts(lineWidth, color, offset, undefined, offset2,roadHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
segment2 = segment1;
|
||||
}
|
||||
|
||||
glRender();
|
||||
}
|
||||
|
||||
function drawTrackScenery()
|
||||
{
|
||||
// this is last pass from back to front so do do not write to depth
|
||||
glSetDepthTest(1, 0);
|
||||
glEnableLighting = 0;
|
||||
|
||||
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
|
||||
const cameraTrackSegment = cameraTrackInfo.segmentIndex;
|
||||
for(let i=drawDistance; i--; )
|
||||
{
|
||||
const segmentIndex = cameraTrackSegment+i;
|
||||
const trackSegment = track[segmentIndex];
|
||||
if (!trackSegment)
|
||||
continue;
|
||||
|
||||
// draw objets for this segment
|
||||
random.setSeed(trackSeed+segmentIndex);
|
||||
for(const trackObject of trackSegment.trackObjects)
|
||||
trackObject.draw();
|
||||
|
||||
// random scenery
|
||||
const levelInfo = getLevelInfo(trackSegment.level);
|
||||
const levelFloat = trackSegment.offset.z/checkpointDistance;
|
||||
const levelInfoNext = getLevelInfo(levelFloat+1);
|
||||
const levelLerpPercent = percent(levelFloat%1, 1-levelLerpRange, 1);
|
||||
const w = trackSegment.width;
|
||||
|
||||
if (enhancedMode && trackSegment.level == 3)
|
||||
{
|
||||
// snow
|
||||
const x = random.floatSign(1e4);
|
||||
const h = 1e4;
|
||||
const y = h-(random.float(h)+time*2e3)%h;
|
||||
pushSprite(vec3(x + 1e3*trackSegment.getWind(),y).addSelf(trackSegment.pos), vec3(50), WHITE, spriteList.dot.spriteTile);
|
||||
}
|
||||
|
||||
if (!trackSegment.sideStreet) // no sprites on side streets
|
||||
for(let k=3;k--;)
|
||||
{
|
||||
const spriteSide = (segmentIndex+k)%2 ? 1 : -1;
|
||||
if (spriteSide == levelInfo.waterSide)
|
||||
{
|
||||
// water
|
||||
const sprite = spriteList.water;
|
||||
const s = sprite.size*sprite.getRandomSpriteScale();
|
||||
const o2 = w+random.float(12e3,8e4);
|
||||
const o = spriteSide * o2;
|
||||
// get taller in distance to cover horizon
|
||||
const h = .4;
|
||||
const wave = time-segmentIndex/70;
|
||||
const p = vec3(o+2e3*Math.sin(wave),0).addSelf(trackSegment.pos);
|
||||
const waveWind = 9*Math.cos(wave); // fake wind to make wave seam more alive
|
||||
pushTrackObject(p, vec3(spriteSide*s,s*h,s), WHITE, sprite, waveWind);
|
||||
}
|
||||
else
|
||||
{
|
||||
// lerp in next level scenery at end
|
||||
const sceneryLevelInfo = random.bool(levelLerpPercent) ? levelInfoNext : levelInfo;
|
||||
|
||||
// scenery on far side like grass and flowers
|
||||
const sceneryList = sceneryLevelInfo.scenery;
|
||||
const sceneryListBias = sceneryLevelInfo.sceneryListBias;
|
||||
if (sceneryLevelInfo.scenery)
|
||||
{
|
||||
const sprite = random.fromList(sceneryList,sceneryListBias);
|
||||
const s = sprite.size*sprite.getRandomSpriteScale();
|
||||
|
||||
// push farther away if big collision
|
||||
const xm = w+sprite.size+6*sprite.collideScale*s;
|
||||
const o = spriteSide * random.float(xm,3e4);
|
||||
const p = vec3(o,0).addSelf(trackSegment.pos);
|
||||
const wind = trackSegment.getWind();
|
||||
const color = sprite.getRandomSpriteColor();
|
||||
const scale = vec3(sprite.canMirror && random.bool() ? -s : s,s,s);
|
||||
pushTrackObject(p, scale, color, sprite, wind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glRender();
|
||||
|
||||
if (!js13kBuild) // final thing rendered, so no need to reset
|
||||
{
|
||||
glSetDepthTest();
|
||||
glEnableLighting = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function pushTrackObject(pos, scale, color, sprite, trackWind)
|
||||
{
|
||||
if (optimizedCulling)
|
||||
{
|
||||
const cullScale = 200;
|
||||
if (cullScale*scale.y < pos.z)
|
||||
return; // cull out small sprites
|
||||
if (abs(pos.x)-abs(scale.x) > pos.z*4+2e3)
|
||||
return; // out of view
|
||||
if (pos.z < 0)
|
||||
return; // behind camera
|
||||
}
|
||||
|
||||
const shadowScale = sprite.shadowScale;
|
||||
const wind = sprite.windScale * trackWind;
|
||||
const yShadowOffset = freeCamMode ? cameraPos.y/20 : 10; // fix shadows in free cam mode
|
||||
const spriteYOffset = scale.y*(1+sprite.spriteYOffset) + (freeCamMode?cameraPos.y/20:0);
|
||||
|
||||
pos.y += yShadowOffset;
|
||||
if (shadowScale)
|
||||
pushShadow(pos, scale.y*shadowScale, scale.y*shadowScale/6);
|
||||
|
||||
// draw on top of shadow
|
||||
pos.y += spriteYOffset - yShadowOffset;
|
||||
pushSprite(pos, scale, color, sprite.spriteTile, wind);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/*function draw3DTrackScenery()
|
||||
{
|
||||
const cameraTrackSegment = cameraTrackInfo.segmentIndex;
|
||||
|
||||
// 3d scenery
|
||||
for(let i=drawDistance, segment1, segment2; i--; )
|
||||
{
|
||||
segment2 = segment1;
|
||||
const segmentIndex = cameraTrackSegment+i;
|
||||
segment1 = track[segmentIndex];
|
||||
if (!segment1 || !segment2)
|
||||
continue;
|
||||
|
||||
if (segmentIndex%7)
|
||||
continue
|
||||
|
||||
const d = segment1.pos.subtract(segment2.pos);
|
||||
const heading = PI-Math.atan2(d.x, d.z);
|
||||
|
||||
// random scenery
|
||||
random.setSeed(trackSeed+segmentIndex);
|
||||
const w = segment1.width;
|
||||
const o =(segmentIndex%2?1:-1)*(random.float(5e4,1e5))
|
||||
const r = vec3(0,-heading,0);
|
||||
const p = vec3(-o,0).addSelf(segment1.pos);
|
||||
|
||||
const s = vec3(random.float(500,1e3),random.float(1e3,4e3),random.float(500,1e3));
|
||||
//const s = vec3(500,random.float(2e3,2e4),500);
|
||||
const m4 = buildMatrix(p,r,s);
|
||||
const c = hsl(0,0,random.float(.2,1));
|
||||
cubeMesh.render(m4, c);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// an instance of a sprite
|
||||
class TrackObject
|
||||
{
|
||||
constructor(trackSegment, sprite, offset, color=WHITE, sizeScale=1)
|
||||
{
|
||||
this.trackSegment = trackSegment;
|
||||
this.sprite = sprite;
|
||||
this.offset = offset;
|
||||
this.color = color;
|
||||
|
||||
const scale = sprite.size * sizeScale;
|
||||
this.scale = vec3(scale);
|
||||
const trackWidth = trackSegment.width;
|
||||
const trackside = offset.x < trackWidth*2 && offset.x > -trackWidth*2;
|
||||
if (trackside && sprite.trackFace)
|
||||
this.scale.x *= sign(offset.x);
|
||||
else if (sprite.canMirror && random.bool())
|
||||
this.scale.x *= -1;
|
||||
this.collideSize = sprite.collideScale*abs(scale);
|
||||
}
|
||||
|
||||
draw()
|
||||
{
|
||||
const trackSegment = this.trackSegment;
|
||||
const pos = trackSegment.pos.add(this.offset);
|
||||
const wind = trackSegment.getWind();
|
||||
pushTrackObject(pos, this.scale, this.color, this.sprite, wind);
|
||||
}
|
||||
}
|
||||
|
||||
class TrackSegment
|
||||
{
|
||||
constructor(segmentIndex,offset,width)
|
||||
{
|
||||
if (segmentIndex >= levelGoal*checkpointTrackSegments)
|
||||
width = 0; // no track after end
|
||||
|
||||
this.offset = offset;
|
||||
this.width = width;
|
||||
this.pitch = 0;
|
||||
this.normal = vec3();
|
||||
|
||||
this.trackObjects = [];
|
||||
const levelFloat = segmentIndex/checkpointTrackSegments;
|
||||
const level = this.level = testLevelInfo ? testLevelInfo.level : levelFloat|0;
|
||||
const levelInfo = getLevelInfo(level);
|
||||
const levelInfoNext = getLevelInfo(levelFloat+1);
|
||||
const levelLerpPercent = percent(levelFloat%1, 1-levelLerpRange, 1);
|
||||
|
||||
const checkpointLine = segmentIndex > 25 && segmentIndex < 30
|
||||
|| segmentIndex%checkpointTrackSegments > checkpointTrackSegments-10;
|
||||
const recordPoint = bestDistance/trackSegmentLength;
|
||||
const recordPointLine = segmentIndex>>3 == recordPoint>>3;
|
||||
this.sideStreet = levelInfo.sideStreets && ((segmentIndex%checkpointTrackSegments)%495<36);
|
||||
|
||||
{
|
||||
// setup colors
|
||||
const groundColor = levelInfo.groundColor.lerp(levelInfoNext.groundColor,levelLerpPercent);
|
||||
const lineColor = levelInfo.lineColor.lerp(levelInfoNext.lineColor,levelLerpPercent);
|
||||
const roadColor = levelInfo.roadColor.lerp(levelInfoNext.roadColor,levelLerpPercent);
|
||||
|
||||
const largeSegmentIndex = segmentIndex/9|0;
|
||||
const stripe = largeSegmentIndex% 2 ? .1: 0;
|
||||
this.colorGround = groundColor.brighten(Math.cos(segmentIndex*2/PI)/20);
|
||||
this.colorRoad = roadColor.brighten(stripe&&.05);
|
||||
if (recordPointLine)
|
||||
this.colorRoad = hsl(0,.8,.5);
|
||||
else if (checkpointLine)
|
||||
this.colorRoad = WHITE; // starting line
|
||||
this.colorLine = lineColor;
|
||||
if (stripe)
|
||||
this.colorLine.a = 0;
|
||||
if (this.sideStreet)
|
||||
this.colorLine = this.colorGround = this.colorRoad;
|
||||
}
|
||||
|
||||
// spawn track objects
|
||||
if (debug && testGameSprite)
|
||||
{
|
||||
// test sprite
|
||||
this.addSprite(testGameSprite,random.floatSign(width/2,1e4));
|
||||
}
|
||||
else if (debug && testTrackBillboards)
|
||||
{
|
||||
// test billboard
|
||||
const billboardSprite = random.fromList(spriteList.billboards);
|
||||
this.addSprite(billboardSprite,random.floatSign(width/2,1e4));
|
||||
}
|
||||
else if (segmentIndex == levelGoal*checkpointTrackSegments)
|
||||
{
|
||||
// goal!
|
||||
this.addSprite(spriteList.sign_goal);
|
||||
}
|
||||
else if (segmentIndex%checkpointTrackSegments == 0)
|
||||
{
|
||||
// checkpoint
|
||||
if (segmentIndex < levelGoal*checkpointTrackSegments)
|
||||
{
|
||||
this.addSprite(spriteList.sign_checkpoint1,-width+500);
|
||||
this.addSprite(spriteList.sign_checkpoint2, width-500);
|
||||
}
|
||||
}
|
||||
|
||||
if (segmentIndex == 30)
|
||||
{
|
||||
// starting area
|
||||
this.addSprite(spriteList.sign_start);
|
||||
|
||||
// left
|
||||
const ol = -(width+100);
|
||||
this.addSprite(spriteList.sign_opGames,ol,1450);
|
||||
this.addSprite(spriteList.sign_zzfx,ol,850);
|
||||
this.addSprite(spriteList.sign_avalanche,ol);
|
||||
|
||||
// right
|
||||
const or = width+100;
|
||||
this.addSprite(spriteList.sign_frankForce,or,1500);
|
||||
this.addSprite(spriteList.sign_github,or,350);
|
||||
this.addSprite(spriteList.sign_js13k,or);
|
||||
if (js13kBuild)
|
||||
random.seed = 1055752394; // hack, reset seed for js13k
|
||||
}
|
||||
}
|
||||
|
||||
getWind()
|
||||
{
|
||||
const offset = this.offset;
|
||||
const noiseScale = .001;
|
||||
return Math.sin(time+(offset.x+offset.z)*noiseScale)/2;
|
||||
}
|
||||
|
||||
addSprite(sprite,x=0,y=0,extraScale=1)
|
||||
{
|
||||
// add a sprite to the track as a new track object
|
||||
const offset = vec3(x,y);
|
||||
const sizeScale = extraScale*sprite.getRandomSpriteScale();
|
||||
const color = sprite.getRandomSpriteColor();
|
||||
const trackObject = new TrackObject(this, sprite, offset, color, sizeScale);
|
||||
this.trackObjects.push(trackObject);
|
||||
}
|
||||
}
|
||||
|
||||
// get lerped info about a track segment
|
||||
class TrackSegmentInfo
|
||||
{
|
||||
constructor(z)
|
||||
{
|
||||
const segment = this.segmentIndex = z/trackSegmentLength|0;
|
||||
const percent = this.percent = z/trackSegmentLength%1;
|
||||
if (track[segment] && track[segment+1])
|
||||
{
|
||||
if (track[segment].pos && track[segment+1].pos)
|
||||
this.pos = track[segment].pos.lerp(track[segment+1].pos, percent);
|
||||
else
|
||||
this.pos = vec3(0,0,z);
|
||||
this.pitch = lerp(percent, track[segment].pitch, track[segment+1].pitch);
|
||||
this.offset = track[segment].offset.lerp(track[segment+1].offset, percent);
|
||||
this.width = lerp(percent, track[segment].width,track[segment+1].width);
|
||||
}
|
||||
else
|
||||
this.offset = this.pos = vec3(this.pitch = this.width = 0,0,z);
|
||||
}
|
||||
}
|
||||
274
vue/public/race/trackGen.js
Normal file
274
vue/public/race/trackGen.js
Normal file
@@ -0,0 +1,274 @@
|
||||
'use strict';
|
||||
|
||||
const testTrackBillboards=0;
|
||||
|
||||
// build the road with procedural generation
|
||||
function buildTrack()
|
||||
{
|
||||
// set random seed & time
|
||||
random.setSeed(trackSeed);
|
||||
track = [];
|
||||
|
||||
let sectionXEndDistance = 0;
|
||||
let sectionYEndDistance = 0;
|
||||
let sectionTurn = 0;
|
||||
let noisePos = random.int(1e5);
|
||||
let sectionBumpFrequency = 0;
|
||||
let sectionBumpScale = 1;
|
||||
let currentNoiseFrequency = 0;
|
||||
let currentNoiseScale = 1;
|
||||
|
||||
let turn = 0;
|
||||
|
||||
// generate the road
|
||||
const trackEnd = levelGoal*checkpointTrackSegments;
|
||||
const roadTransitionRange = testQuick?min(checkpointTrackSegments,500):500;
|
||||
for(let i=0; i < trackEnd + 5e4; ++i)
|
||||
{
|
||||
const levelFloat = i/checkpointTrackSegments;
|
||||
const level = levelFloat|0;
|
||||
const levelInfo = getLevelInfo(level);
|
||||
const levelInfoLast = getLevelInfo(levelFloat-1);
|
||||
const levelLerpPercent = percent(i%checkpointTrackSegments, 0, roadTransitionRange);
|
||||
|
||||
if (js13kBuild && i==31496)
|
||||
random.setSeed(7); // mess with seed to randomize jungle
|
||||
|
||||
const roadGenWidth = laneWidth/2*lerp(levelLerpPercent, levelInfoLast.laneCount, levelInfo.laneCount);
|
||||
|
||||
let height = 0;
|
||||
let width = roadGenWidth;
|
||||
|
||||
const startOfTrack = !level && i < 400;
|
||||
const checkpointSegment = i%checkpointTrackSegments;
|
||||
const levelBetweenRange = 100;
|
||||
let isBetweenLevels = checkpointSegment < levelBetweenRange ||
|
||||
checkpointSegment > checkpointTrackSegments - levelBetweenRange;
|
||||
isBetweenLevels |= startOfTrack; // start of track
|
||||
//const nextCheckpoint = (level+1)*checkpointTrackSegments;
|
||||
|
||||
if (isBetweenLevels)
|
||||
{
|
||||
// transition at start or end of level
|
||||
sectionXEndDistance = sectionYEndDistance = sectionTurn = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// turns
|
||||
const turnChance = levelInfo.turnChance; // chance of turn
|
||||
const turnMin = levelInfo.turnMin; // min turn
|
||||
const turnMax = levelInfo.turnMax; // max turn
|
||||
const sectionDistanceMin = 100;
|
||||
const sectionDistanceMax = 400;
|
||||
if (sectionXEndDistance-- < 0)
|
||||
{
|
||||
// pick random section distance
|
||||
sectionXEndDistance = random.int(sectionDistanceMin,sectionDistanceMax);
|
||||
sectionTurn = random.bool(turnChance) ? random.floatSign(turnMin,turnMax) : 0;
|
||||
}
|
||||
|
||||
// bumps
|
||||
const bumpChance = levelInfo.bumpChance; // chance of bump
|
||||
const bumpFreqMin = levelInfo.bumpFreqMin; // no bumps
|
||||
const bumpFreqMax = levelInfo.bumpFreqMax; // raipd bumps
|
||||
const bumpScaleMin = levelInfo.bumpScaleMin; // small rapid bumps
|
||||
const bumpScaleMax = levelInfo.bumpScaleMax; // large hills
|
||||
if (sectionYEndDistance-- < 0)
|
||||
{
|
||||
// pick random section distance
|
||||
sectionYEndDistance = random.int(sectionDistanceMin,sectionDistanceMax);
|
||||
if (random.bool(bumpChance))
|
||||
{
|
||||
sectionBumpFrequency = random.float(bumpFreqMin,bumpFreqMax);
|
||||
sectionBumpScale = random.float(bumpScaleMin,bumpScaleMax);
|
||||
}
|
||||
else
|
||||
{
|
||||
sectionBumpFrequency = 0;
|
||||
sectionBumpScale = bumpScaleMin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (i > trackEnd - 500)
|
||||
sectionTurn = 0; // no turns at end
|
||||
|
||||
turn = lerp(.02,turn, sectionTurn); // smooth out turns
|
||||
|
||||
// apply noise to height
|
||||
const noiseFrequency = currentNoiseFrequency
|
||||
= lerp(.01, currentNoiseFrequency, sectionBumpFrequency);
|
||||
const noiseSize = currentNoiseScale
|
||||
= lerp(.01, currentNoiseScale, sectionBumpScale);
|
||||
|
||||
//noiseFrequency = 1; noiseSize = 50;
|
||||
if (currentNoiseFrequency)
|
||||
noisePos += noiseFrequency/noiseSize;
|
||||
const noiseConstant = 20;
|
||||
height = noise1D(noisePos)*noiseConstant*noiseSize;
|
||||
|
||||
//turn = .7; height = 0;
|
||||
//turn = Math.sin(i/100)*.7;
|
||||
//height = noise1D((i-50)/99)*2700;turn =0; // jumps test
|
||||
|
||||
// create track segment
|
||||
const o = vec3(turn, height, i*trackSegmentLength);
|
||||
track[i] = new TrackSegment(i, o, width);
|
||||
}
|
||||
|
||||
// second pass
|
||||
let hazardWait = 0;
|
||||
let tunnelOn = 0;
|
||||
let tunnelTime = 0;
|
||||
let trackSideChanceScale = 1;
|
||||
for(let i=0; i < track.length; ++i)
|
||||
{
|
||||
// calculate pitch
|
||||
const iCheckpoint = i%checkpointTrackSegments;
|
||||
const t = track[i];
|
||||
const levelInfo = getLevelInfo(t.level);
|
||||
ASSERT(t.level == levelInfo.level || t.level > levelGoal);
|
||||
|
||||
const previous = track[i-1];
|
||||
if (previous)
|
||||
{
|
||||
t.pitch = Math.atan2(previous.offset.y-t.offset.y, trackSegmentLength);
|
||||
const d = vec3(0,t.offset.y-previous.offset.y, trackSegmentLength);
|
||||
t.normal = d.cross(vec3(1,0)).normalize();
|
||||
}
|
||||
|
||||
if (!iCheckpoint)
|
||||
{
|
||||
// reset level settings
|
||||
trackSideChanceScale = 1;
|
||||
}
|
||||
|
||||
if (t.sideStreet || i < 50)
|
||||
{
|
||||
tunnelOn = 0;
|
||||
continue; // no objects on side streets
|
||||
}
|
||||
|
||||
// check what kinds of turns are ahead
|
||||
const lookAheadTurn = 150;
|
||||
const lookAheadStep = 20;
|
||||
let leftTurns = 0, rightTurns = 0;
|
||||
for(let k=0; k<lookAheadTurn; k+=lookAheadStep)
|
||||
{
|
||||
const t2 = track[i+k];
|
||||
if (!t2)
|
||||
continue;
|
||||
|
||||
if (k < lookAheadTurn)
|
||||
{
|
||||
const x = t2.offset.x;
|
||||
if (x > 0) leftTurns = max(leftTurns, x);
|
||||
else rightTurns = max(rightTurns, -x);
|
||||
}
|
||||
}
|
||||
|
||||
// spawn road signs
|
||||
const roadSignRate = 10;
|
||||
const turnWarning = 0.5;
|
||||
let signSide;
|
||||
if (i < levelGoal*checkpointTrackSegments) // end of level
|
||||
if (rightTurns > turnWarning || leftTurns > turnWarning)
|
||||
{
|
||||
// turn
|
||||
signSide = sign(rightTurns - leftTurns);
|
||||
if (i%roadSignRate == 0)
|
||||
t.addSprite(spriteList.sign_turn,signSide*(t.width+500));
|
||||
}
|
||||
|
||||
// todo prevent sprites from spawning near road signs?
|
||||
//levelInfo.tunnel = spriteList.tunnel2; // test tuns
|
||||
if (levelInfo.tunnel)
|
||||
{
|
||||
const isRockArch = levelInfo.tunnel.tunnelArch;
|
||||
const isLongTunnel = levelInfo.tunnel.tunnelLong;
|
||||
if (iCheckpoint > 100 && iCheckpoint < checkpointTrackSegments - 100)
|
||||
{
|
||||
const wasOn = tunnelOn;
|
||||
if (tunnelTime-- < 0)
|
||||
{
|
||||
tunnelOn = !tunnelOn;
|
||||
tunnelTime = tunnelOn?
|
||||
isRockArch ? 10 : random.int(200,600) :
|
||||
tunnelTime = random.int(300,600); // longer when off
|
||||
}
|
||||
|
||||
if (tunnelOn)
|
||||
{
|
||||
// brighter front of tunnel
|
||||
const sprite = isLongTunnel && !wasOn ?
|
||||
spriteList.tunnel2Front : levelInfo.tunnel;
|
||||
t.addSprite(sprite, 0);
|
||||
|
||||
if (isLongTunnel && i%50==0)
|
||||
{
|
||||
// lights on top of tunnel
|
||||
const lightSprite = spriteList.light_tunnel;
|
||||
const tunnelHeight = 1600;
|
||||
t.addSprite(lightSprite, 0, tunnelHeight);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// restart tunnel wait
|
||||
tunnelOn = tunnelTime = 0;
|
||||
}
|
||||
|
||||
{
|
||||
// sprites on sides of track
|
||||
const billboardChance = levelInfo.billboardChance;
|
||||
const billboardRate = levelInfo.billboardRate;
|
||||
if (i%billboardRate == 0 && random.bool(billboardChance))
|
||||
{
|
||||
// random billboards
|
||||
const extraScale = levelInfo.billboardScale; // larger in desert
|
||||
const width = t.width*extraScale;
|
||||
const count = spriteList.billboards.length;
|
||||
const billboardSprite = spriteList.billboards[random.int(count)];
|
||||
const billboardSide = signSide ? -signSide : random.sign();
|
||||
t.addSprite(billboardSprite,billboardSide*random.float(width+600,width+800),0,extraScale);
|
||||
continue;
|
||||
}
|
||||
if (levelInfo.trackSideSprite)
|
||||
{
|
||||
// vary how often side objects spawn
|
||||
if (random.bool(.001))
|
||||
{
|
||||
trackSideChanceScale =
|
||||
random.bool(.4) ? 1 : // normal to spawn often
|
||||
random.bool(.1) ? 0 : // small chance of none
|
||||
random.float(); // random scale
|
||||
}
|
||||
|
||||
// track side objects
|
||||
const trackSideRate = levelInfo.trackSideRate;
|
||||
const trackSideChance = levelInfo.trackSideChance;
|
||||
if (i%trackSideRate == 0 && random.bool(trackSideChance*trackSideChanceScale))
|
||||
{
|
||||
const trackSideForce = levelInfo.trackSideForce;
|
||||
const side = trackSideForce || (i%(trackSideRate*2)<trackSideRate?1:-1);
|
||||
t.addSprite(levelInfo.trackSideSprite, side*(t.width+random.float(700,1e3)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (iCheckpoint > 40 && iCheckpoint < checkpointTrackSegments - 40)
|
||||
if (hazardWait-- < 0 && levelInfo.hazardType && random.bool(levelInfo.hazardChance))
|
||||
{
|
||||
// hazards on the ground in road to slow player
|
||||
const sprite = levelInfo.hazardType;
|
||||
t.addSprite(sprite,random.floatSign(t.width/.9));
|
||||
|
||||
// wait to spawn another hazard
|
||||
hazardWait = random.float(40,80);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
vue/public/race/utilities.js
Normal file
229
vue/public/race/utilities.js
Normal file
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Math Stuff
|
||||
|
||||
const PI = Math.PI;
|
||||
const abs = (value) => Math.abs(value);
|
||||
const min = (valueA, valueB) => Math.min(valueA, valueB);
|
||||
const max = (valueA, valueB) => Math.max(valueA, valueB);
|
||||
const sign = (value) => value < 0 ? -1 : 1;
|
||||
const mod = (dividend, divisor=1) => ((dividend % divisor) + divisor) % divisor;
|
||||
const clamp = (value, min=0, max=1) => value < min ? min : value > max ? max : value;
|
||||
const clampAngle = (value) => ((value+PI) % (2*PI) + 2*PI) % (2*PI) - PI;
|
||||
const percent = (value, valueA, valueB) => (valueB-=valueA) ? clamp((value-valueA)/valueB) : 0;
|
||||
const lerp = (percent, valueA, valueB) => valueA + clamp(percent) * (valueB-valueA);
|
||||
const rand = (valueA=1, valueB=0) => lerp(Math.random(), valueA, valueB);
|
||||
const randInt = (valueA, valueB=0) => rand(valueA, valueB)|0;
|
||||
const smoothStep = (p) => p * p * (3 - 2 * p);
|
||||
const isOverlapping = (posA, sizeA, posB, sizeB=vec3()) =>
|
||||
abs(posA.x - posB.x)*2 < sizeA.x + sizeB.x && abs(posA.y - posB.y)*2 < sizeA.y + sizeB.y;
|
||||
function buildMatrix(pos, rot, scale)
|
||||
{
|
||||
const R2D = 180/PI;
|
||||
let m = new DOMMatrix;
|
||||
pos && m.translateSelf(pos.x, pos.y, pos.z);
|
||||
rot && m.rotateSelf(rot.x*R2D, rot.y*R2D, rot.z*R2D);
|
||||
scale && m.scaleSelf(scale.x, scale.y, scale.z);
|
||||
return m;
|
||||
}
|
||||
function shuffle(array)
|
||||
{
|
||||
for(let currentIndex = array.length; currentIndex;)
|
||||
{
|
||||
const randomIndex = random.int(currentIndex--);
|
||||
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
function formatTimeString(t)
|
||||
{
|
||||
const timeS = t%60|0;
|
||||
const timeM = t/60|0;
|
||||
const timeMS = t%1*1e3|0;
|
||||
return `${timeM}:${timeS<10?'0'+timeS:timeS}.${(timeMS<10?'00':timeMS<100?'0':'')+timeMS}`;
|
||||
}
|
||||
|
||||
function noise1D(x)
|
||||
{
|
||||
const hash = x=>(new Random(x)).float(-1,1);
|
||||
return lerp(smoothStep(mod(x,1)), hash(x), hash(x+1));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Vector3
|
||||
|
||||
const vec3 = (x, y, z)=> y == undefined && z == undefined ? new Vector3(x, x, x) : new Vector3(x, y, z);
|
||||
const isVector3 = (v) => v instanceof Vector3;
|
||||
const isNumber = (value) => typeof value === 'number';
|
||||
const ASSERT_VEC3 = (v) => ASSERT(isVector3(v));
|
||||
|
||||
class Vector3
|
||||
{
|
||||
constructor(x=0, y=0, z=0)
|
||||
{
|
||||
ASSERT(isNumber(x) && isNumber(y) && isNumber(z));
|
||||
this.x=x; this.y=y; this.z=z;
|
||||
}
|
||||
copy() { return vec3(this.x, this.y, this.z); }
|
||||
add(v) { ASSERT_VEC3(v); return vec3(this.x + v.x, this.y + v.y, this.z + v.z); }
|
||||
addSelf(v) { ASSERT_VEC3(v); this.x += v.x, this.y += v.y, this.z += v.z; return this }
|
||||
subtract(v) { ASSERT_VEC3(v); return vec3(this.x - v.x, this.y - v.y, this.z - v.z); }
|
||||
multiply(v) { ASSERT_VEC3(v); return vec3(this.x * v.x, this.y * v.y, this.z * v.z); }
|
||||
divide(v) { ASSERT_VEC3(v); return vec3(this.x / v.x, this.y / v.y, this.z / v.z); }
|
||||
scale(s) { ASSERT(isNumber(s)); return vec3(this.x * s, this.y * s, this.z * s); }
|
||||
length() { return this.lengthSquared()**.5; }
|
||||
lengthSquared() { return this.x**2 + this.y**2 + this.z**2; }
|
||||
distance(v) { ASSERT_VEC3(v); return this.distanceSquared(v)**.5; }
|
||||
distanceSquared(v) { ASSERT_VEC3(v); return this.subtract(v).lengthSquared(); }
|
||||
normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : vec3(length); }
|
||||
clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; }
|
||||
dot(v) { ASSERT_VEC3(v); return this.x*v.x + this.y*v.y + this.z*v.z; }
|
||||
angleBetween(v) { ASSERT_VEC3(v); return Math.acos(clamp(this.dot(v), -1, 1)); }
|
||||
clamp(a, b) { return vec3(clamp(this.x, a, b), clamp(this.y, a, b), clamp(this.z, a, b)); }
|
||||
cross(v) { ASSERT_VEC3(v); return vec3(this.y*v.z-this.z*v.y, this.z*v.x-this.x*v.z, this.x*v.y-this.y*v.x); }
|
||||
lerp(v, p) { ASSERT_VEC3(v); return v.subtract(this).scale(clamp(p)).addSelf(this); }
|
||||
rotateX(a)
|
||||
{
|
||||
const c=Math.cos(a), s=Math.sin(a);
|
||||
return vec3(this.x, this.y*c - this.z*s, this.y*s + this.z*c);
|
||||
}
|
||||
rotateY(a)
|
||||
{
|
||||
const c=Math.cos(a), s=Math.sin(a);
|
||||
return vec3(this.x*c - this.z*s, this.y, this.x*s + this.z*c);
|
||||
}
|
||||
rotateZ(a)
|
||||
{
|
||||
const c=Math.cos(a), s=Math.sin(a);
|
||||
return vec3(this.x*c - this.y*s, this.x*s + this.y*c, this.z);
|
||||
}
|
||||
transform(matrix)
|
||||
{
|
||||
const p = matrix.transformPoint(this);
|
||||
return vec3(p.x, p.y, p.z);
|
||||
}
|
||||
getHSLColor(a=1) { return hsl(this.x, this.y, this.z, a); }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Color
|
||||
|
||||
const rgb = (r, g, b, a) => new Color(r, g, b, a);
|
||||
const hsl = (h, s, l, a) => rgb().setHSLA(h, s, l, a);
|
||||
const isColor = (c) => c instanceof Color;
|
||||
|
||||
class Color
|
||||
{
|
||||
constructor(r=1, g=1, b=1, a=1)
|
||||
{
|
||||
this.r = r;
|
||||
this.g = g;
|
||||
this.b = b;
|
||||
this.a = a;
|
||||
}
|
||||
|
||||
copy() { return rgb(this.r, this.g, this.b, this.a); }
|
||||
|
||||
lerp(c, percent)
|
||||
{
|
||||
ASSERT(isColor(c));
|
||||
percent = clamp(percent);
|
||||
return rgb(
|
||||
lerp(percent, this.r, c.r),
|
||||
lerp(percent, this.g, c.g),
|
||||
lerp(percent, this.b, c.b),
|
||||
lerp(percent, this.a, c.a),
|
||||
);
|
||||
}
|
||||
|
||||
brighten(amount=.1)
|
||||
{
|
||||
return rgb
|
||||
(
|
||||
clamp(this.r + amount),
|
||||
clamp(this.g + amount),
|
||||
clamp(this.b + amount),
|
||||
this.a
|
||||
);
|
||||
}
|
||||
|
||||
setHSLA(h=0, s=0, l=1, a=1)
|
||||
{
|
||||
h = mod(h,1);
|
||||
s = clamp(s);
|
||||
l = clamp(l);
|
||||
const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q,
|
||||
f = (p, q, t)=>
|
||||
(t = mod(t,1))*6 < 1 ? p+(q-p)*6*t :
|
||||
t*2 < 1 ? q :
|
||||
t*3 < 2 ? p+(q-p)*(4-t*6) : p;
|
||||
this.r = f(p, q, h + 1/3);
|
||||
this.g = f(p, q, h);
|
||||
this.b = f(p, q, h - 1/3);
|
||||
this.a = a;
|
||||
return this;
|
||||
}
|
||||
|
||||
toString()
|
||||
{ return `rgb(${this.r*255},${this.g*255},${this.b*255},${this.a})`; }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Random
|
||||
|
||||
class Random
|
||||
{
|
||||
constructor(seed) { this.setSeed(seed); }
|
||||
setSeed(seed)
|
||||
{
|
||||
this.seed = seed+1|0;
|
||||
this.float();this.float();this.float();// warmup
|
||||
}
|
||||
float(a=1, b=0)
|
||||
{
|
||||
// xorshift
|
||||
this.seed ^= this.seed << 13;
|
||||
this.seed ^= this.seed >>> 17;
|
||||
this.seed ^= this.seed << 5;
|
||||
if (js13kBuild)
|
||||
return b + (a-b) * Math.abs(this.seed % 1e9) / 1e9; // bias low values due to float error
|
||||
else
|
||||
return b + (a-b) * Math.abs(this.seed % 1e8) / 1e8;
|
||||
}
|
||||
floatSign(a, b) { return this.float(a,b) * this.sign(); }
|
||||
int(a, b) { return this.float(a, b)|0; }
|
||||
bool(chance = .5) { return this.float() < chance; }
|
||||
sign() { return this.bool() ? -1 : 1; }
|
||||
circle(radius=0, bias = .5)
|
||||
{
|
||||
const r = this.float()**bias*radius;
|
||||
const a = this.float(PI*2);
|
||||
return vec3(r*Math.cos(a), r*Math.sin(a));
|
||||
}
|
||||
mutateColor(color, amount=.1, brightnessAmount=0)
|
||||
{
|
||||
return rgb
|
||||
(
|
||||
clamp(random.float(1,1-brightnessAmount)*(color.r + this.floatSign(amount))),
|
||||
clamp(random.float(1,1-brightnessAmount)*(color.g + this.floatSign(amount))),
|
||||
clamp(random.float(1,1-brightnessAmount)*(color.b + this.floatSign(amount))),
|
||||
color.a
|
||||
);
|
||||
}
|
||||
fromList(list,startBias=1) { return list[this.float()**startBias*list.length|0]; }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class Timer
|
||||
{
|
||||
constructor(timeLeft)
|
||||
{ this.time = timeLeft == undefined ? undefined : time + timeLeft; }
|
||||
set(timeLeft=0) { this.time = time + timeLeft; }
|
||||
unset() { this.time = undefined; }
|
||||
isSet() { return this.time != undefined; }
|
||||
active() { return time < this.time; }
|
||||
elapsed() { return time >= this.time; }
|
||||
get() { return this.isSet()? time - this.time : 0; }
|
||||
}
|
||||
625
vue/public/race/vehicle.js
Normal file
625
vue/public/race/vehicle.js
Normal file
@@ -0,0 +1,625 @@
|
||||
'use strict';
|
||||
|
||||
function drawCars()
|
||||
{
|
||||
for(const v of vehicles)
|
||||
v.draw();
|
||||
}
|
||||
|
||||
function updateCars()
|
||||
{
|
||||
// spawn in more vehicles
|
||||
const playerIsSlow = titleScreenMode || playerVehicle.velocity.z < 20;
|
||||
const trafficPosOffset = playerIsSlow? 0 : 16e4; // check in front/behind
|
||||
const trafficLevel = (playerVehicle.pos.z+trafficPosOffset)/checkpointDistance;
|
||||
const trafficLevelInfo = getLevelInfo(trafficLevel);
|
||||
const trafficDensity = trafficLevelInfo.trafficDensity;
|
||||
const maxVehicleCount = 10*trafficDensity;
|
||||
if (trafficDensity)
|
||||
if (vehicles.length<maxVehicleCount && !gameOverTimer.isSet() && !vehicleSpawnTimer.active())
|
||||
{
|
||||
const spawnOffset = playerIsSlow ? -1300 : rand(5e4,6e4);
|
||||
spawnVehicle(playerVehicle.pos.z + spawnOffset);
|
||||
vehicleSpawnTimer.set(rand(1,2)/trafficDensity);
|
||||
}
|
||||
|
||||
for(const v of vehicles)
|
||||
v.update();
|
||||
vehicles = vehicles.filter(o=>!o.destroyed);
|
||||
}
|
||||
|
||||
function spawnVehicle(z)
|
||||
{
|
||||
if (disableAiVehicles)
|
||||
return;
|
||||
|
||||
const v = new Vehicle(z);
|
||||
vehicles.push(v);
|
||||
v.update();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class Vehicle
|
||||
{
|
||||
constructor(z, color)
|
||||
{
|
||||
this.pos = vec3(0,0,z);
|
||||
this.color = color;
|
||||
this.isBraking =
|
||||
this.drawTurn =
|
||||
this.drawPitch =
|
||||
this.wheelTurn = 0;
|
||||
this.collisionSize = vec3(230,200,380);
|
||||
this.velocity = vec3();
|
||||
|
||||
if (!this.color)
|
||||
{
|
||||
this.color = // random color
|
||||
randInt(9) ? hsl(rand(), rand(.5,.9),.5) :
|
||||
randInt(2) ? WHITE : hsl(0,0,.1);
|
||||
|
||||
// not player if no color
|
||||
//if (!isPlayer)
|
||||
{
|
||||
if (this.isTruck = randInt(2)) // random trucks
|
||||
{
|
||||
this.collisionSize.z = 450;
|
||||
this.truckColor = hsl(rand(),rand(.5,1),rand(.2,1));
|
||||
}
|
||||
|
||||
// do not pick same lane as player if behind
|
||||
const levelInfo = getLevelInfo(this.pos.z/checkpointDistance);
|
||||
this.lane = randInt(levelInfo.laneCount);
|
||||
if (!titleScreenMode && z < playerVehicle.pos.z)
|
||||
this.lane = playerVehicle.pos.x > 0 ? 0 : levelInfo.laneCount-1;
|
||||
this.laneOffset = this.getLaneOffset();
|
||||
this.velocity.z = this.getTargetSpeed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTargetSpeed()
|
||||
{
|
||||
const levelInfo = getLevelInfo(this.pos.z/checkpointDistance);
|
||||
const lane = levelInfo.laneCount - 1 - this.lane; // flip side
|
||||
return max(120,120 + lane*20); // faster on left
|
||||
}
|
||||
|
||||
getLaneOffset()
|
||||
{
|
||||
const levelInfo = getLevelInfo(this.pos.z/checkpointDistance);
|
||||
const o = (levelInfo.laneCount-1)*laneWidth/2;
|
||||
return this.lane*laneWidth - o;
|
||||
}
|
||||
|
||||
update()
|
||||
{
|
||||
ASSERT(this != playerVehicle);
|
||||
|
||||
// update ai vehicles
|
||||
const targetSpeed = this.getTargetSpeed();
|
||||
const accel = this.isBraking ? (--this.isBraking, -1) :
|
||||
this.velocity.z < targetSpeed ? .5 :
|
||||
this.velocity.z > targetSpeed+10 ? -.5 : 0;
|
||||
|
||||
const trackInfo = new TrackSegmentInfo(this.pos.z);
|
||||
const trackInfo2 = new TrackSegmentInfo(this.pos.z+trackSegmentLength);
|
||||
const level = this.pos.z/checkpointDistance | 0;
|
||||
const levelInfo = getLevelInfo(level);
|
||||
|
||||
{
|
||||
// update lanes
|
||||
this.lane = min(this.lane, levelInfo.laneCount-1);
|
||||
//if (rand() < .01 && this.pos.z > playerVehicle.pos.z)
|
||||
// this.lane = randInt(levelInfo.laneCount);
|
||||
|
||||
// move into lane
|
||||
const targetLaneOffset = this.getLaneOffset();
|
||||
this.laneOffset = lerp(.01, this.laneOffset, targetLaneOffset);
|
||||
const lanePos = this.laneOffset;
|
||||
this.pos.x = lanePos;
|
||||
}
|
||||
|
||||
// update physics
|
||||
this.pos.z += this.velocity.z = max(0, this.velocity.z+accel);
|
||||
|
||||
// slow down if too close to other vehicles
|
||||
const x = this.laneOffset;
|
||||
for(const v of vehicles)
|
||||
{
|
||||
// slow down if behind
|
||||
if (v != this && v != playerVehicle)
|
||||
if (this.pos.z < v.pos.z + 500 && this.pos.z > v.pos.z - 2e3)
|
||||
if (abs(x-v.laneOffset) < 500) // lane space
|
||||
{
|
||||
if (this.pos.z >= v.pos.z)
|
||||
this.destroyed = 1; // get rid of overlaps
|
||||
this.velocity.z = min(this.velocity.z, v.velocity.z++); // clamp velocity & push
|
||||
this.isBraking = 30;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// move ai vehicles
|
||||
this.pos.x = trackInfo.pos.x + x;
|
||||
this.pos.y = trackInfo.offset.y;
|
||||
|
||||
// get projected track angle
|
||||
const delta = trackInfo2.pos.subtract(trackInfo.pos);
|
||||
this.drawTurn = Math.atan2(delta.x, delta.z);
|
||||
this.wheelTurn = this.drawTurn / 2;
|
||||
this.drawPitch = trackInfo.pitch;
|
||||
|
||||
// remove in front or behind
|
||||
const playerDelta = this.pos.z - playerVehicle.pos.z;
|
||||
this.destroyed |= playerDelta > 7e4 || playerDelta < -2e3;
|
||||
}
|
||||
|
||||
draw()
|
||||
{
|
||||
const trackInfo = new TrackSegmentInfo(this.pos.z);
|
||||
const vehicleHeight = 75;
|
||||
const p = this.pos.copy();
|
||||
p.y += vehicleHeight;
|
||||
p.z = p.z - cameraOffset;
|
||||
|
||||
if (p.z < 0 && !freeCamMode)
|
||||
{
|
||||
// causes glitches if rendered
|
||||
return; // behind camera
|
||||
}
|
||||
|
||||
/*{ // test cube
|
||||
//p.y = trackInfo.offset.y;
|
||||
const heading = this.drawTurn+PI/2;
|
||||
const trackPitch = trackInfo.pitch;
|
||||
const m2 = buildMatrix(p.add(vec3(0,-vehicleHeight,0)), vec3(trackPitch,0,0));
|
||||
const m1 = m2.multiply(buildMatrix(0, vec3(0,heading,0), 0));
|
||||
cubeMesh.render(m1.multiply(buildMatrix(0, 0, vec3(50,20,2e3))), this.color);
|
||||
// return
|
||||
}*/
|
||||
|
||||
// car
|
||||
const heading = this.drawTurn;
|
||||
const trackPitch = trackInfo.pitch;
|
||||
|
||||
const carPitch = this.drawPitch;
|
||||
const mHeading = buildMatrix(0, vec3(0,heading), 0);
|
||||
const m1 = buildMatrix(p, vec3(carPitch,0)).multiply(mHeading);
|
||||
const mcar = m1.multiply(buildMatrix(0, 0, vec3(450,this.isTruck?700:500,450)));
|
||||
|
||||
{
|
||||
// shadow
|
||||
glSetDepthTest(this != playerVehicle,0); // no depth test for player shadow
|
||||
glPolygonOffset(60);
|
||||
const lightOffset = vec3(0,0,-60).rotateY(worldHeading);
|
||||
const shadowColor = rgb(0,0,0,.5);
|
||||
const shadowPosBase = vec3(p.x,trackInfo.pos.y,p.z).addSelf(lightOffset);
|
||||
const shadowSize = vec3(-720,200,600); // why x negative?
|
||||
|
||||
const m2 = buildMatrix(shadowPosBase, vec3(trackPitch,0)).multiply(mHeading);
|
||||
const mshadow = m2.multiply(buildMatrix(0, 0, shadowSize));
|
||||
shadowMesh.renderTile(mshadow, shadowColor, spriteList.carShadow.spriteTile);
|
||||
glPolygonOffset();
|
||||
glSetDepthTest();
|
||||
}
|
||||
|
||||
carMesh.render(mcar, this.color);
|
||||
//cubeMesh.render(m1.multiply(buildMatrix(0, 0, this.collisionSize)), BLACK); // collis
|
||||
|
||||
let bumperY = 130, bumperZ = -440;
|
||||
if (this.isTruck)
|
||||
{
|
||||
bumperY = 50;
|
||||
bumperZ = -560;
|
||||
const truckO = vec3(0,290,-250);
|
||||
const truckColor = this.truckColor;
|
||||
const truckSize = vec3(240,truckO.y,300);
|
||||
glPolygonOffset(20);
|
||||
cubeMesh.render(m1.multiply(buildMatrix(truckO, 0, truckSize)), truckColor);
|
||||
}
|
||||
glPolygonOffset(); // turn it off!
|
||||
|
||||
if (optimizedCulling)
|
||||
{
|
||||
const distanceFromPlayer = this.pos.z - playerVehicle.pos.z;
|
||||
if (distanceFromPlayer > 4e4)
|
||||
return; // cull too far
|
||||
}
|
||||
|
||||
// wheels
|
||||
const wheelRadius = 110;
|
||||
const wheelSpinScale = 400;
|
||||
const wheelSize = vec3(50,wheelRadius,wheelRadius);
|
||||
const wheelM1 = buildMatrix(0,vec3(this.pos.z/wheelSpinScale,this.wheelTurn),wheelSize);
|
||||
const wheelM2 = buildMatrix(0,vec3(this.pos.z/wheelSpinScale,0),wheelSize);
|
||||
const wheelColor = hsl(0,0,.2);
|
||||
const wheelOffset1 = vec3(240,25,220);
|
||||
const wheelOffset2 = vec3(240,25,-300);
|
||||
for (let i=4;i--;)
|
||||
{
|
||||
const wo = i<2? wheelOffset1 : wheelOffset2;
|
||||
|
||||
glPolygonOffset(this.isTruck && i>1 && 20);
|
||||
const o = vec3(i%2?wo.x:-wo.x, wo.y, i<2? wo.z : wo.z);
|
||||
carWheel.render(m1.multiply(buildMatrix(o)).multiply(i<2 ? wheelM1 :wheelM2), wheelColor);
|
||||
}
|
||||
|
||||
// decals
|
||||
glPolygonOffset(40);
|
||||
|
||||
// bumpers
|
||||
cubeMesh.render(m1.multiply(buildMatrix(vec3(0,bumperY,bumperZ), 0, vec3(140,50,20))), hsl(0,0,.1));
|
||||
|
||||
// break lights
|
||||
const isBraking = this.isBraking;
|
||||
for(let i=2;i--;)
|
||||
{
|
||||
const color = isBraking ? hsl(0,1,.5) : hsl(0,1,.2);
|
||||
glEnableLighting = !isBraking; // make it full bright when braking
|
||||
cubeMesh.render(m1.multiply(buildMatrix(vec3((i?1:-1)*180,bumperY-25,bumperZ-10), 0, vec3(40,25,5))), color);
|
||||
glEnableLighting = 1;
|
||||
cubeMesh.render(m1.multiply(buildMatrix(vec3((i?1:-1)*180,bumperY+25,bumperZ-10), 0, vec3(40,25,5))), WHITE);
|
||||
}
|
||||
|
||||
if (this == playerVehicle)
|
||||
{
|
||||
// only player needs front bumper
|
||||
cubeMesh.render(m1.multiply(buildMatrix(vec3(0,10,440), 0, vec3(240,30,30))), hsl(0,0,.5));
|
||||
|
||||
// license plate
|
||||
quadMesh.renderTile(m1.multiply(buildMatrix(vec3(0,bumperY-80,bumperZ-20), vec3(0,PI,0), vec3(80,25,1))),WHITE, spriteList.carLicense.spriteTile);
|
||||
|
||||
// top number
|
||||
const m3 = buildMatrix(0,vec3(0,PI)); // flip for some reason
|
||||
quadMesh.renderTile(m1.multiply(buildMatrix(vec3(0,230,-200), vec3(PI/2-.2,0,0), vec3(140)).multiply(m3)),WHITE, spriteList.carNumber.spriteTile);
|
||||
}
|
||||
|
||||
glPolygonOffset();
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class PlayerVehicle extends Vehicle
|
||||
{
|
||||
constructor(z, color)
|
||||
{
|
||||
super(z, color, 1);
|
||||
this.playerTurn =
|
||||
this.bumpTime =
|
||||
this.onGround =
|
||||
this.engineTime = 0;
|
||||
this.hitTimer = new Timer;
|
||||
}
|
||||
|
||||
draw() { titleScreenMode || super.draw(); }
|
||||
|
||||
update()
|
||||
{
|
||||
if (titleScreenMode)
|
||||
{
|
||||
this.pos.z += this.velocity.z = 20;
|
||||
return;
|
||||
}
|
||||
|
||||
const playHitSound=()=>
|
||||
{
|
||||
if (!this.hitTimer.active())
|
||||
{
|
||||
sound_hit.play(percent(this.velocity.z, 0, 50));
|
||||
this.hitTimer.set(.5);
|
||||
}
|
||||
}
|
||||
|
||||
const hitBump=(amount = .98)=>
|
||||
{
|
||||
this.velocity.z *= amount;
|
||||
if (this.bumpTime < 0)
|
||||
{
|
||||
sound_bump.play(percent(this.velocity.z, 0, 50));
|
||||
this.bumpTime = 500*rand(1,1.5);
|
||||
this.velocity.y += min(50, this.velocity.z)*rand(.1,.2);
|
||||
}
|
||||
}
|
||||
|
||||
this.bumpTime -= this.velocity.z;
|
||||
|
||||
if (!freeRide && checkpointSoundCount > 0 && !checkpointSoundTimer.active())
|
||||
{
|
||||
sound_checkpoint.play();
|
||||
checkpointSoundTimer.set(.26);
|
||||
checkpointSoundCount--;
|
||||
}
|
||||
|
||||
const playerDistance = playerVehicle.pos.z;
|
||||
if (!gameOverTimer.isSet())
|
||||
if (playerDistance > nextCheckpointDistance)
|
||||
{
|
||||
// checkpoint
|
||||
++playerLevel;
|
||||
nextCheckpointDistance += checkpointDistance;
|
||||
checkpointTimeLeft += extraCheckpointTime;
|
||||
if (enhancedMode)
|
||||
checkpointTimeLeft = min(60,checkpointTimeLeft);
|
||||
|
||||
if (playerLevel >= levelGoal && !gameOverTimer.isSet())
|
||||
{
|
||||
// end of game
|
||||
playerWin = 1;
|
||||
sound_win.play();
|
||||
gameOverTimer.set();
|
||||
if (!(debug && debugSkipped))
|
||||
if (!freeRide)
|
||||
{
|
||||
bestDistance = 0; // reset best distance
|
||||
if (raceTime < bestTime || !bestTime)
|
||||
{
|
||||
// new fastest time
|
||||
bestTime = raceTime;
|
||||
playerNewRecord = 1;
|
||||
}
|
||||
writeSaveData();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//speak('CHECKPOINT');
|
||||
checkpointSoundCount = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// check for collisions
|
||||
if (!testDrive)
|
||||
for(const v of vehicles)
|
||||
{
|
||||
const d = this.pos.subtract(v.pos);
|
||||
const s = this.collisionSize.add(v.collisionSize);
|
||||
if (v != this && abs(d.x) < s.x && abs(d.z) < s.z)
|
||||
{
|
||||
// collision
|
||||
const oldV = this.velocity.z;
|
||||
this.velocity.z = v.velocity.z/2;
|
||||
//console.log(v.velocity.z, oldV*.9);
|
||||
v.velocity.z = max(v.velocity.z, oldV*.9); // push other car
|
||||
this.velocity.x = 99*sign(d.x); // push away from car
|
||||
playHitSound();
|
||||
}
|
||||
}
|
||||
|
||||
// get player input
|
||||
let playerInputTurn = keyIsDown('ArrowRight') - keyIsDown('ArrowLeft');
|
||||
let playerInputGas = keyIsDown('ArrowUp');
|
||||
let playerInputBrake = keyIsDown('Space') || keyIsDown('ArrowDown');
|
||||
|
||||
if (isUsingGamepad)
|
||||
{
|
||||
playerInputTurn = gamepadStick(0).x;
|
||||
playerInputGas = gamepadIsDown(0) || gamepadIsDown(7);
|
||||
playerInputBrake = gamepadIsDown(1) || gamepadIsDown(2) || gamepadIsDown(3) || gamepadIsDown(6);
|
||||
|
||||
const analogGas = gamepadGetValue(7);
|
||||
if (analogGas)
|
||||
playerInputGas = analogGas;
|
||||
const analogBrake = gamepadGetValue(6);
|
||||
if (analogBrake)
|
||||
playerInputBrake = analogBrake;
|
||||
}
|
||||
|
||||
if (playerInputGas)
|
||||
mouseControl = 0;
|
||||
if (debug && (mouseWasPressed(0) || mouseWasPressed(2) || isUsingGamepad && gamepadWasPressed(0)))
|
||||
testDrive = 0;
|
||||
|
||||
if (mouseControl || mouseIsDown(0))
|
||||
{
|
||||
mouseControl = 1;
|
||||
playerInputTurn = clamp(5*(mousePos.x-.5),-1,1);
|
||||
playerInputGas = mouseIsDown(0);
|
||||
playerInputBrake = mouseIsDown(2);
|
||||
|
||||
if (isTouchDevice && mouseIsDown(0))
|
||||
{
|
||||
const touch = 1.8 - 2*mousePos.y;
|
||||
playerInputGas = percent(touch, .1, .2);
|
||||
playerInputBrake = touch < 0;
|
||||
playerInputTurn = clamp(3*(mousePos.x-.5),-1,1);
|
||||
}
|
||||
}
|
||||
if (freeCamMode)
|
||||
playerInputGas = playerInputTurn = playerInputBrake = 0;
|
||||
if (testDrive)
|
||||
playerInputGas = 1, playerInputTurn=0;
|
||||
if (gameOverTimer.isSet())
|
||||
playerInputGas = playerInputTurn = playerInputBrake = 0;
|
||||
this.isBraking = playerInputBrake;
|
||||
|
||||
const sound_velocity = max(40+playerInputGas*50,this.velocity.z);
|
||||
this.engineTime += sound_velocity*sound_velocity/5e4;
|
||||
if (this.engineTime > 1)
|
||||
{
|
||||
if (--this.engineTime > 1)
|
||||
this.engineTime = 0;
|
||||
const f = sound_velocity;
|
||||
sound_engine.play(.1,f*f/4e3+rand(.1));
|
||||
}
|
||||
|
||||
const playerTrackInfo = new TrackSegmentInfo(this.pos.z);
|
||||
const playerTrackSegment = playerTrackInfo.segmentIndex;
|
||||
|
||||
// gravity
|
||||
const gravity = -3; // gravity to apply in y axis
|
||||
this.velocity.y += gravity;
|
||||
|
||||
// player settings
|
||||
const forwardDamping = .998; // dampen player z speed
|
||||
const lateralDamping = .5; // dampen player x speed
|
||||
const playerAccel = 1; // player acceleration
|
||||
const playerBrake = 2; // player acceleration when braking
|
||||
const playerMaxSpeed = 200; // limit max player speed
|
||||
const speedPercent = this.velocity.z/playerMaxSpeed;
|
||||
const centrifugal = .5;
|
||||
|
||||
// update physics
|
||||
const velocityAdjusted = this.velocity.copy();
|
||||
const trackHeadingScale = 20;
|
||||
const trackHeading = Math.atan2(trackHeadingScale*playerTrackInfo.offset.x, trackSegmentLength);
|
||||
const trackScaling = 1 / (1 + (this.pos.x/(2*laneWidth)) * Math.tan(-trackHeading));
|
||||
velocityAdjusted.z *= trackScaling;
|
||||
this.pos.addSelf(velocityAdjusted);
|
||||
|
||||
// clamp player x position
|
||||
const maxPlayerX = playerTrackInfo.width + 500;
|
||||
this.pos.x = clamp(this.pos.x, -maxPlayerX, maxPlayerX);
|
||||
|
||||
// check if on ground
|
||||
const wasOnGround = this.onGround;
|
||||
this.onGround = this.pos.y < playerTrackInfo.offset.y;
|
||||
if (this.onGround)
|
||||
{
|
||||
this.pos.y = playerTrackInfo.offset.y;
|
||||
const trackPitch = playerTrackInfo.pitch;
|
||||
this.drawPitch = lerp(.2,this.drawPitch, trackPitch);
|
||||
|
||||
// bounce off track
|
||||
const trackNormal = vec3(0, 1, 0).rotateX(trackPitch);
|
||||
const elasticity = 1.2;
|
||||
const normalDotVel = this.velocity.dot(trackNormal);
|
||||
const reflectVelocity = trackNormal.scale(-elasticity * normalDotVel);
|
||||
|
||||
if (!gameOverTimer.isSet()) // dont roll in game over
|
||||
this.velocity.addSelf(reflectVelocity);
|
||||
if (!wasOnGround)
|
||||
{
|
||||
const p = percent(reflectVelocity.length(), 20, 80);
|
||||
sound_bump.play(p*2,.5);
|
||||
}
|
||||
|
||||
const trackSegment = track[playerTrackSegment];
|
||||
if (trackSegment && !trackSegment.sideStreet) // side streets are not offroad
|
||||
if (abs(this.pos.x) > playerTrackInfo.width - this.collisionSize.x && !testDrive)
|
||||
hitBump(); // offroad
|
||||
|
||||
// update velocity
|
||||
if (playerInputBrake)
|
||||
this.velocity.z -= playerBrake*playerInputBrake;
|
||||
else if (playerInputGas)
|
||||
{
|
||||
// extra boost at low speeds
|
||||
//const lowSpeedPercent = this.velocity.z**2/1e4;
|
||||
const lowSpeedPercent = percent(this.velocity.z, 150, 0)**2;
|
||||
const accel = playerInputGas*playerAccel*lerp(speedPercent, 1, .5)
|
||||
* lerp(lowSpeedPercent, 1, 3);
|
||||
//console.log(lerp(lowSpeedPercent, 1, 9))
|
||||
|
||||
// apply acceleration in angle of road
|
||||
//const accelVec = vec3(0,0,accel).rotateX(trackSegment.pitch);
|
||||
//this.velocity.addSelf(accelVec);
|
||||
this.velocity.z += accel;
|
||||
}
|
||||
else if (this.velocity.z < 30)
|
||||
this.velocity.z *= .9; // slow to stop
|
||||
|
||||
// dampen z velocity & clamp
|
||||
this.velocity.z = max(0, this.velocity.z*forwardDamping);
|
||||
this.velocity.x *= lateralDamping;
|
||||
}
|
||||
else
|
||||
{
|
||||
// in air
|
||||
this.drawPitch *= .99; // level out pitch
|
||||
this.onGround = 0;
|
||||
}
|
||||
|
||||
{
|
||||
// turning
|
||||
let desiredPlayerTurn = startCountdown ? 0 : playerInputTurn;
|
||||
if (testDrive)
|
||||
{
|
||||
desiredPlayerTurn = clamp(-this.pos.x/2e3, -1, 1);
|
||||
this.pos.x = clamp(this.pos.x, -playerTrackInfo.width, playerTrackInfo.width);
|
||||
}
|
||||
|
||||
// scale desired turn input
|
||||
desiredPlayerTurn *= .4;
|
||||
const playerMaxTurnStart = 50; // fade on turning visual
|
||||
const turnVisualRamp = clamp(this.velocity.z/playerMaxTurnStart,0,.1);
|
||||
this.wheelTurn = lerp(.1, this.wheelTurn, 1.3*desiredPlayerTurn);
|
||||
this.playerTurn = lerp(.05, this.playerTurn, desiredPlayerTurn);
|
||||
this.drawTurn = lerp(turnVisualRamp, this.drawTurn, this.playerTurn);
|
||||
|
||||
// centripetal force
|
||||
const centripetalForce = -velocityAdjusted.z * playerTrackInfo.offset.x * centrifugal;
|
||||
this.pos.x += centripetalForce
|
||||
|
||||
// apply turn velocity and slip
|
||||
const physicsTurn = this.onGround ? this.playerTurn : 0;
|
||||
const maxStaticFriction = 30;
|
||||
const slip = maxStaticFriction/max(maxStaticFriction,abs(centripetalForce));
|
||||
|
||||
const turnStrength = .8;
|
||||
const turnForce = turnStrength * physicsTurn * this.velocity.z;
|
||||
this.velocity.x += turnForce*slip;
|
||||
}
|
||||
|
||||
if (playerWin)
|
||||
this.drawTurn = lerp(gameOverTimer.get(), this.drawTurn, -1);
|
||||
if (startCountdown)
|
||||
this.velocity.z = 0; // wait to start
|
||||
if (gameOverTimer.isSet())
|
||||
this.velocity = this.velocity.scale(.95);
|
||||
|
||||
if (!testDrive)
|
||||
{
|
||||
// check for collisions
|
||||
const collisionCheckDistance = 20; // segments to check
|
||||
for(let i = -collisionCheckDistance; i < collisionCheckDistance; ++i)
|
||||
{
|
||||
const segmentIndex = playerTrackSegment+i;
|
||||
const trackSegment = track[segmentIndex];
|
||||
if (!trackSegment)
|
||||
continue;
|
||||
|
||||
// collidable objects
|
||||
for(const trackObject of trackSegment.trackObjects)
|
||||
{
|
||||
if (!trackObject.collideSize)
|
||||
continue;
|
||||
|
||||
// check for overlap
|
||||
const pos = trackSegment.offset.add(trackObject.offset);
|
||||
const dp = this.pos.subtract(pos);
|
||||
const csx = this.collisionSize.x+trackObject.collideSize;
|
||||
if (abs(dp.z) > 430 || abs(dp.x) > csx)
|
||||
continue;
|
||||
|
||||
if (trackObject.sprite.isBump)
|
||||
{
|
||||
trackObject.collideSize = 0; // prevent colliding again
|
||||
hitBump(.8); // hit a bump
|
||||
}
|
||||
else if (trackObject.sprite.isSlow)
|
||||
{
|
||||
trackObject.collideSize = 0; // prevent colliding again
|
||||
sound_bump.play(percent(this.velocity.z, 0, 50)*3,.2);
|
||||
// just slow down the player
|
||||
this.velocity.z *= .85;
|
||||
}
|
||||
else
|
||||
{
|
||||
// push player away
|
||||
const onSideOfTrack = abs(pos.x)+csx+200 > playerTrackInfo.width;
|
||||
const pushDirection = onSideOfTrack ?
|
||||
-pos.x : // push towards center
|
||||
dp.x; // push away from object
|
||||
|
||||
this.velocity.x = 99*sign(pushDirection);
|
||||
this.velocity.z *= .7;
|
||||
playHitSound();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
305
vue/public/race/webgl.js
Normal file
305
vue/public/race/webgl.js
Normal file
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
|
||||
Small and fast dynamic webgl rendering engine for Dr1v3n Wild
|
||||
|
||||
Features
|
||||
- batch rendering
|
||||
- direct and ambient lighting
|
||||
- fog with alpha blending
|
||||
- texture mapping
|
||||
- vertex color
|
||||
|
||||
Potential improvements
|
||||
- everything is using dynamic buffer, which is slow but flexible
|
||||
- it would be faster to use static buffers for static geometry
|
||||
- the colors could be passed in as 32 bit integers rather then vec4s
|
||||
- specular lighting would also be pretty easy to include
|
||||
- the fog calculation could possibly be moved to the vertex shader
|
||||
- a mip map of the passed in texture could be auto generated for smoother scaling
|
||||
- additive blending would also be easy to implement
|
||||
- there should be an easier way to set the fog range
|
||||
|
||||
*/
|
||||
|
||||
const glRenderScale = 100; // fixes floating point issues on some devices
|
||||
const glSpecular = 0; // experimental specular test
|
||||
let glCanvas, glContext, glShader, glVertexData;
|
||||
let glBatchCount, glBatchCountTotal, glDrawCalls;
|
||||
let glEnableLighting, glLightDirection, glLightColor, glAmbientColor;
|
||||
let glEnableFog, glFogColor;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// webgl setup
|
||||
|
||||
function glInit()
|
||||
{
|
||||
// create the canvas
|
||||
const hasAlpha = false; // there should be no alpha for the background texture
|
||||
document.body.appendChild(glCanvas = document.createElement('canvas'));
|
||||
glContext = glCanvas.getContext('webgl2', {alpha: hasAlpha});
|
||||
ASSERT(glContext, 'Failed to create WebGL canvas!');
|
||||
|
||||
// setup vertex and fragment shaders
|
||||
glShader = glCreateProgram(
|
||||
'#version 300 es\n' + // specify GLSL ES version
|
||||
'precision highp float;'+ // use highp for better accuracy
|
||||
'uniform vec4 l,g,a,f;' + // light direction, color, ambient light, fog
|
||||
'uniform mat4 m,o;'+ // projection matrix, object matrix
|
||||
'in vec4 p,n,u,c;'+ // in: position, normal, uv, color
|
||||
'out vec4 v,d,q;'+ // out: uv, color, fog
|
||||
'void main(){'+ // shader entry point
|
||||
'gl_Position=m*o*p;'+ // transform position
|
||||
'v=u,q=f;'+ // pass uv and fog to fragment shader
|
||||
'd=c*vec4(a.xyz+g.xyz*max(0.,dot(l.xyz,'+ // lighting
|
||||
'normalize((transpose(inverse(o))*n).xyz))),1);' + // transform light
|
||||
'}' // end of shader
|
||||
,
|
||||
'#version 300 es\n' + // specify GLSL ES version
|
||||
'precision highp float;'+ // use highp for better accuracy
|
||||
'in vec4 v,d,q;'+ // uv, color, fog
|
||||
'uniform sampler2D s;'+ // texture
|
||||
'out vec4 c;'+ // out color
|
||||
'void main(){'+ // shader entry point
|
||||
'c=v.z>0.?d:texture(s,v.xy)*d;'+ // color or texture
|
||||
'float f=gl_FragCoord.z/gl_FragCoord.w;'+ // fog depth
|
||||
'v.w>0.?c:c=vec4(mix(c.xyz,q.xyz,clamp(f*f/1e10,0.,1.)),'+ // fog color
|
||||
'c.a*clamp(4.-f/2e4,0.,1.));'+ // fog alpha
|
||||
//'c.w);'+ // disable fog alpha
|
||||
//'if (c.a == 0.) discard;'+ // discard if no alpha
|
||||
'}' // end of shader
|
||||
);
|
||||
|
||||
// set up the shader
|
||||
glContext.useProgram(glShader);
|
||||
glContext.bindBuffer(gl_ARRAY_BUFFER, glContext.createBuffer());
|
||||
glContext.bufferData(gl_ARRAY_BUFFER, gl_VERTEX_BUFFER_SIZE, gl_DYNAMIC_DRAW);
|
||||
glContext.blendFunc(gl_SRC_ALPHA, gl_ONE_MINUS_SRC_ALPHA);
|
||||
glSetCapability(gl_BLEND);
|
||||
glSetCapability(gl_CULL_FACE); // not culling causeses thin black lines sometimes
|
||||
glVertexData = new Float32Array(new ArrayBuffer(gl_VERTEX_BUFFER_SIZE));
|
||||
|
||||
// set vertex attributes
|
||||
let offset = 0;
|
||||
const vertexAttribute = (name)=>
|
||||
{
|
||||
const type = gl_FLOAT, stride = gl_VERTEX_BYTE_STRIDE;
|
||||
const size = 4, byteCount = 4;
|
||||
const location = glContext.getAttribLocation(glShader, name);
|
||||
glContext.enableVertexAttribArray(location);
|
||||
glContext.vertexAttribPointer(location, size, type, 0, stride, offset);
|
||||
offset += size*byteCount;
|
||||
}
|
||||
vertexAttribute('p'); // position
|
||||
vertexAttribute('n'); // normal
|
||||
vertexAttribute('u'); // uv
|
||||
vertexAttribute('c'); // color
|
||||
}
|
||||
|
||||
function glCompileShader(source, type)
|
||||
{
|
||||
// build the shader
|
||||
const shader = glContext.createShader(type);
|
||||
glContext.shaderSource(shader, source);
|
||||
glContext.compileShader(shader);
|
||||
|
||||
// check for errors
|
||||
if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS))
|
||||
throw glContext.getShaderInfoLog(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
function glCreateProgram(vsSource, fsSource)
|
||||
{
|
||||
// build the program
|
||||
const program = glContext.createProgram();
|
||||
glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER));
|
||||
glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER));
|
||||
glContext.linkProgram(program);
|
||||
|
||||
// check for errors
|
||||
if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS))
|
||||
throw glContext.getProgramInfoLog(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function glCreateTexture(image)
|
||||
{
|
||||
// build the texture
|
||||
const texture = glContext.createTexture();
|
||||
glContext.bindTexture(gl_TEXTURE_2D, texture);
|
||||
glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function glPreRender(canvasSize)
|
||||
{
|
||||
// set size of canvas and viewport which also clears it
|
||||
glContext.viewport(0, 0, glCanvas.width = canvasSize.x, glCanvas.height = canvasSize.y);
|
||||
glDrawCalls = glBatchCount = glBatchCountTotal = 0; // reset draw counts
|
||||
//debug && glContext.clearColor(1, 0, 1, 1); // test background color
|
||||
//glContext.clear(gl_DEPTH_BUFFER_BIT|gl_COLOR_BUFFER_BIT); // auto cleared
|
||||
|
||||
// use point filtering for pixelated rendering
|
||||
glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, gl_NEAREST);
|
||||
glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, gl_NEAREST);
|
||||
|
||||
// set up the camera transform
|
||||
const viewMatrix = buildMatrix(cameraPos, cameraRot).inverse();
|
||||
const combinedMatrix = glCreateProjectionMatrix().multiply(viewMatrix);
|
||||
glContext.uniformMatrix4fv(glUniform('m'), 0, combinedMatrix.toFloat32Array());
|
||||
}
|
||||
|
||||
function glRender(transform=new DOMMatrix)
|
||||
{
|
||||
// set up the lights and fog
|
||||
const initUniform4f = (name, x, y, z)=> glContext.uniform4f(glUniform(name), x, y, z, 0);
|
||||
const lightColor = glEnableLighting ? glLightColor : BLACK;
|
||||
const ambientColor = glEnableLighting ? glAmbientColor : WHITE;
|
||||
initUniform4f('g', lightColor.r, lightColor.g, lightColor.b);
|
||||
initUniform4f('a', ambientColor.r, ambientColor.g, ambientColor.b);
|
||||
initUniform4f('f', glFogColor.r, glFogColor.g, glFogColor.b);
|
||||
initUniform4f('l', glLightDirection.x, glLightDirection.y, glLightDirection.z);
|
||||
|
||||
// render the verts
|
||||
ASSERT(glBatchCount < gl_MAX_BATCH, 'Too many points!');
|
||||
const vertexData = glVertexData.subarray(0, glBatchCount * gl_INDICIES_PER_VERT);
|
||||
const m = transform.scaleSelf(glRenderScale, glRenderScale, glRenderScale);
|
||||
glContext.uniformMatrix4fv(glUniform('o'), 0, m.toFloat32Array());
|
||||
glContext.bufferSubData(gl_ARRAY_BUFFER, 0, vertexData);
|
||||
glContext.drawArrays(gl_TRIANGLE_STRIP, 0, glBatchCount);
|
||||
glBatchCountTotal += glBatchCount;
|
||||
glBatchCount = 0;
|
||||
++glDrawCalls;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// webgl helper functions
|
||||
|
||||
const glUniform = (name) => glContext.getUniformLocation(glShader, name);
|
||||
|
||||
function glSetCapability(cap, enable=1)
|
||||
{ enable ? glContext.enable(cap) : glContext.disable(cap); }
|
||||
|
||||
function glPolygonOffset(units=0)
|
||||
{ glContext.polygonOffset(0, -units); glSetCapability(gl_POLYGON_OFFSET_FILL, !!units); }
|
||||
|
||||
function glSetDepthTest(depthTest=1, depthWrite=1)
|
||||
{ glSetCapability(gl_DEPTH_TEST, !!depthTest); glContext.depthMask(!!depthWrite); }
|
||||
|
||||
function glCreateProjectionMatrix(fov=.5, near = 1, far = 1e4)
|
||||
{
|
||||
const aspect = glCanvas.width / glCanvas.height;
|
||||
const f = 1 / Math.tan(fov), range = far - near;
|
||||
return new DOMMatrix
|
||||
([
|
||||
f / aspect, 0, 0, 0,
|
||||
0, f, 0, 0,
|
||||
0, 0, (near + far) / range, 2 * near * far / range,
|
||||
0, 0, -1, 0
|
||||
]);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// drawing functions
|
||||
|
||||
const vectorOne = vec3(1); // no lighting/texture
|
||||
|
||||
// push a list of colored verts with optonal normals and uvs
|
||||
function glPushVerts(points, normals, color, uvs)
|
||||
{
|
||||
const count = points.length;
|
||||
if (!(count < gl_MAX_BATCH - glBatchCount))
|
||||
glRender();
|
||||
|
||||
const na = vectorOne; // no lighting/texture
|
||||
for(let i=count; i--;)
|
||||
glPushVert(points[i], normals ? normals[i] : na, uvs ? uvs[i] : na, color);
|
||||
}
|
||||
|
||||
// push a list of colored verts with optonal normals and uvs
|
||||
// this is also capped with degenerate verts to close the shape
|
||||
function glPushVertsCapped(points, normals, color, uvs)
|
||||
{
|
||||
// push points with extra degenerate verts to cap both sides
|
||||
const count = points.length;
|
||||
if (!(count+2 < gl_MAX_BATCH - glBatchCount))
|
||||
glRender();
|
||||
|
||||
const na = vectorOne; // no lighting/texture
|
||||
glPushVert(points[count-1], na, na, color);
|
||||
for(let i=count; i--;)
|
||||
glPushVert(points[i], normals ? normals[i] : na, uvs ? uvs[i] : na, color);
|
||||
glPushVert(points[0], na, na, color);
|
||||
}
|
||||
|
||||
// push a list of colored verts without normals or uvs
|
||||
function glPushColoredVerts(points, colors)
|
||||
{
|
||||
// push points with a list of vertex colors
|
||||
const count = points.length;
|
||||
if (!(count+2 < gl_MAX_BATCH - glBatchCount))
|
||||
glRender();
|
||||
|
||||
const na = vectorOne; // no lighting/texture
|
||||
glPushVert(points[count-1], na, na, colors[count-1]);
|
||||
for(let i=count; i--;)
|
||||
glPushVert(points[i], na, na, colors[i]);
|
||||
glPushVert(points[0], na, na, colors[0]);
|
||||
}
|
||||
|
||||
// push a single vert to the buffer
|
||||
function glPushVert(pos, normal, uv, color)
|
||||
{
|
||||
let offset = glBatchCount++ * gl_INDICIES_PER_VERT;
|
||||
glVertexData[offset++] = pos.x/glRenderScale;
|
||||
glVertexData[offset++] = pos.y/glRenderScale;
|
||||
glVertexData[offset++] = pos.z/glRenderScale;
|
||||
glVertexData[offset++] = 1;
|
||||
glVertexData[offset++] = normal.x;
|
||||
glVertexData[offset++] = normal.y;
|
||||
glVertexData[offset++] = normal.z;
|
||||
glVertexData[offset++] = 0;
|
||||
glVertexData[offset++] = uv.x;
|
||||
glVertexData[offset++] = uv.y;
|
||||
glVertexData[offset++] = uv.z; // >0 if untextured
|
||||
glVertexData[offset++] = !glEnableFog;
|
||||
glVertexData[offset++] = color.r;
|
||||
glVertexData[offset++] = color.g;
|
||||
glVertexData[offset++] = color.b;
|
||||
glVertexData[offset++] = color.a;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// store webgl constants as integers so they can be minifed
|
||||
|
||||
const
|
||||
gl_TRIANGLE_STRIP = 5,
|
||||
gl_DEPTH_BUFFER_BIT = 256,
|
||||
gl_SRC_ALPHA = 770,
|
||||
gl_ONE_MINUS_SRC_ALPHA = 771,
|
||||
gl_CULL_FACE = 2884,
|
||||
gl_DEPTH_TEST = 2929,
|
||||
gl_BLEND = 3042,
|
||||
gl_TEXTURE_2D = 3553,
|
||||
gl_UNSIGNED_BYTE = 5121,
|
||||
gl_FLOAT = 5126,
|
||||
gl_RGBA = 6408,
|
||||
gl_NEAREST = 9728,
|
||||
gl_TEXTURE_MAG_FILTER = 10240,
|
||||
gl_TEXTURE_MIN_FILTER = 10241,
|
||||
gl_COLOR_BUFFER_BIT = 16384,
|
||||
gl_POLYGON_OFFSET_FILL = 32823,
|
||||
gl_ARRAY_BUFFER = 34962,
|
||||
gl_DYNAMIC_DRAW = 35048,
|
||||
gl_FRAGMENT_SHADER = 35632,
|
||||
gl_VERTEX_SHADER = 35633,
|
||||
gl_COMPILE_STATUS = 35713,
|
||||
gl_LINK_STATUS = 35714,
|
||||
|
||||
// constants for batch rendering
|
||||
gl_MAX_BATCH = 2e4, // max verts per batch
|
||||
gl_INDICIES_PER_VERT = (1 * 4) * 4, // vec4 * 4
|
||||
gl_VERTEX_BYTE_STRIDE = gl_INDICIES_PER_VERT * 4, // 4 bytes per float
|
||||
gl_VERTEX_BUFFER_SIZE = gl_MAX_BATCH * gl_VERTEX_BYTE_STRIDE;
|
||||
BIN
vue/public/t_race/favicon.png
Normal file
BIN
vue/public/t_race/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
1
vue/public/t_race/index.html
Normal file
1
vue/public/t_race/index.html
Normal file
File diff suppressed because one or more lines are too long
1
vue/public/vite.svg
Normal file
1
vue/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
846
vue/src/App.vue
Normal file
846
vue/src/App.vue
Normal file
@@ -0,0 +1,846 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowUpBold,
|
||||
Delete,
|
||||
Document,
|
||||
EditPen,
|
||||
FolderOpened,
|
||||
House,
|
||||
Monitor,
|
||||
Plus,
|
||||
Right,
|
||||
School,
|
||||
Trophy,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
|
||||
type SectionId = 'overview' | 'explorer' | 'games' | 'school'
|
||||
type ExplorerItemKind = 'folder' | 'file'
|
||||
type GameId = 'race' | 't_race'
|
||||
|
||||
interface ExplorerItem {
|
||||
id: string
|
||||
parentId: string | null
|
||||
kind: ExplorerItemKind
|
||||
name: string
|
||||
}
|
||||
|
||||
interface GameOption {
|
||||
id: GameId
|
||||
label: string
|
||||
subtitle: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface SectionNavItem {
|
||||
id: SectionId
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
interface ExplorerFolderTreeItem {
|
||||
id: string
|
||||
name: string
|
||||
level: number
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
const text = {
|
||||
skip: '跳到主要内容',
|
||||
loginTitle: 'Workspace Login',
|
||||
loginSubtitle: '输入账号后进入工作区视图。',
|
||||
username: '用户名',
|
||||
usernamePlaceholder: '输入用户名…',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '输入密码…',
|
||||
loginButton: '进入工作区',
|
||||
welcome: '欢迎回来',
|
||||
}
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loginError = ref('')
|
||||
const statusMessage = ref('')
|
||||
const isLoggedIn = ref(false)
|
||||
const activeSection = ref<SectionId>('overview')
|
||||
const selectedGameId = ref<GameId | null>(null)
|
||||
const isGameFullscreen = ref(false)
|
||||
const explorerCurrentFolderId = ref('root')
|
||||
const explorerSelectedItemId = ref<string | null>(null)
|
||||
const expandedFolderIds = ref(new Set<string>(['root']))
|
||||
const nextExplorerId = ref(1000)
|
||||
const gamePlayerRef = ref<HTMLElement | null>(null)
|
||||
const loginRef = ref<HTMLElement | null>(null)
|
||||
const workspaceRef = ref<HTMLElement | null>(null)
|
||||
const sidebarRef = ref<HTMLElement | null>(null)
|
||||
const sidebarIndicatorStyle = ref({
|
||||
transform: 'translateY(0px)',
|
||||
height: '0px',
|
||||
opacity: '0',
|
||||
})
|
||||
const sidebarIndicatorJelly = ref(false)
|
||||
let sidebarJellyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let glowFrameId: number | null = null
|
||||
let latestPointer: { x: number; y: number } | null = null
|
||||
let latestPointerTarget: 'workspace' | 'login' | null = null
|
||||
const lightTargetSelector =
|
||||
'.nav-item, .ghost-btn, .primary-btn, .icon-btn, .game-card, .file-main, .folder-item, .topbar, .sidebar, .panel, .hero-card, .metric-card, .explorer-toolbar, .folder-list, .file-list, .file-card, .game-player, .study-card, .path-segment, .status'
|
||||
const loginLightTargetSelector = '.login-card, .login-form button, .login-input-shell, .login-card h1'
|
||||
|
||||
const navItems: SectionNavItem[] = [
|
||||
{ id: 'overview', title: '总览', subtitle: '项目入口与状态', icon: House },
|
||||
{ id: 'explorer', title: '文件', subtitle: '管理目录与文件', icon: FolderOpened },
|
||||
{ id: 'games', title: '游戏', subtitle: '启动内置小游戏', icon: Trophy },
|
||||
{ id: 'school', title: '学习', subtitle: '课程与路线图', icon: School },
|
||||
]
|
||||
|
||||
const explorerItems = ref<ExplorerItem[]>([
|
||||
{ id: 'root', parentId: null, kind: 'folder', name: 'Workspace' },
|
||||
{ id: 'f-projects', parentId: 'root', kind: 'folder', name: 'Projects' },
|
||||
{ id: 'f-docs', parentId: 'root', kind: 'folder', name: 'Docs' },
|
||||
{ id: 'f-media', parentId: 'root', kind: 'folder', name: 'Assets' },
|
||||
{ id: 'f-ui', parentId: 'f-projects', kind: 'folder', name: 'UI-Experiments' },
|
||||
{ id: 'file-readme', parentId: 'root', kind: 'file', name: 'Readme.txt' },
|
||||
{ id: 'file-plan', parentId: 'f-docs', kind: 'file', name: 'Roadmap.md' },
|
||||
{ id: 'file-shot', parentId: 'f-media', kind: 'file', name: 'Preview.png' },
|
||||
])
|
||||
|
||||
const gameOptions: GameOption[] = [
|
||||
{ id: 'race', label: 'Race', subtitle: '经典 JS13K 版本', path: '/race/index.html' },
|
||||
{ id: 't_race', label: 'HTML Race', subtitle: '新版 HTML 版本', path: '/t_race/index.html' },
|
||||
]
|
||||
|
||||
const currentUser = computed(() => username.value.trim() || 'Guest')
|
||||
|
||||
const explorerCurrentFolder = computed(
|
||||
() => explorerItems.value.find((item) => item.id === explorerCurrentFolderId.value) ?? explorerItems.value[0],
|
||||
)
|
||||
|
||||
const explorerChildItems = computed(() =>
|
||||
explorerItems.value
|
||||
.filter((item) => item.parentId === explorerCurrentFolderId.value)
|
||||
.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}),
|
||||
)
|
||||
|
||||
const explorerPath = computed(() => {
|
||||
const result: ExplorerItem[] = []
|
||||
const itemMap = new Map(explorerItems.value.map((item) => [item.id, item]))
|
||||
let cursorId: string | null = explorerCurrentFolderId.value
|
||||
|
||||
while (cursorId) {
|
||||
const current = itemMap.get(cursorId)
|
||||
if (!current) break
|
||||
result.unshift(current)
|
||||
cursorId = current.parentId
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const explorerFolders = computed(() =>
|
||||
explorerItems.value
|
||||
.filter((item) => item.kind === 'folder')
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const explorerFolderChildrenMap = computed(() => {
|
||||
const folderMap = new Map<string, ExplorerItem[]>()
|
||||
for (const folder of explorerFolders.value) {
|
||||
folderMap.set(folder.id, [])
|
||||
}
|
||||
for (const item of explorerFolders.value) {
|
||||
if (!item.parentId) continue
|
||||
if (!folderMap.has(item.parentId)) continue
|
||||
folderMap.get(item.parentId)!.push(item)
|
||||
}
|
||||
for (const children of folderMap.values()) {
|
||||
children.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
return folderMap
|
||||
})
|
||||
|
||||
const explorerFolderTreeItems = computed<ExplorerFolderTreeItem[]>(() => {
|
||||
const root = explorerItems.value.find((item) => item.id === 'root' && item.kind === 'folder')
|
||||
if (!root) return []
|
||||
|
||||
const result: ExplorerFolderTreeItem[] = []
|
||||
const walk = (folder: ExplorerItem, level: number) => {
|
||||
const children = explorerFolderChildrenMap.value.get(folder.id) ?? []
|
||||
const expanded = expandedFolderIds.value.has(folder.id)
|
||||
result.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
level,
|
||||
hasChildren: children.length > 0,
|
||||
expanded,
|
||||
})
|
||||
if (!expanded) return
|
||||
for (const child of children) {
|
||||
walk(child, level + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
return result
|
||||
})
|
||||
|
||||
const activeGame = computed(() => gameOptions.find((option) => option.id === selectedGameId.value) ?? null)
|
||||
|
||||
function setSection(nextSection: SectionId) {
|
||||
activeSection.value = nextSection
|
||||
statusMessage.value = `已切换到${navItems.find((item) => item.id === nextSection)?.title ?? ''}视图。`
|
||||
}
|
||||
|
||||
function applyMouseLighting(clientX: number, clientY: number) {
|
||||
const workspace = workspaceRef.value
|
||||
if (!workspace) return
|
||||
|
||||
const targets = workspace.querySelectorAll<HTMLElement>(lightTargetSelector)
|
||||
for (const target of targets) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
target.style.setProperty('--lx', `${clientX - rect.left}px`)
|
||||
target.style.setProperty('--ly', `${clientY - rect.top}px`)
|
||||
}
|
||||
}
|
||||
|
||||
function applyLoginLighting(clientX: number, clientY: number) {
|
||||
const login = loginRef.value
|
||||
if (!login) return
|
||||
login.classList.add('lighting-active')
|
||||
|
||||
const targets = login.querySelectorAll<HTMLElement>(loginLightTargetSelector)
|
||||
for (const target of targets) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const x = `${clientX - rect.left}px`
|
||||
const y = `${clientY - rect.top}px`
|
||||
target.style.setProperty('--lx', x)
|
||||
target.style.setProperty('--ly', y)
|
||||
target.style.setProperty('--mx', x)
|
||||
target.style.setProperty('--my', y)
|
||||
}
|
||||
}
|
||||
|
||||
function flushMouseLighting() {
|
||||
glowFrameId = null
|
||||
if (!latestPointer) return
|
||||
if (latestPointerTarget === 'workspace') {
|
||||
applyMouseLighting(latestPointer.x, latestPointer.y)
|
||||
} else if (latestPointerTarget === 'login') {
|
||||
applyLoginLighting(latestPointer.x, latestPointer.y)
|
||||
}
|
||||
}
|
||||
|
||||
function onWorkspacePointerMove(event: PointerEvent) {
|
||||
latestPointerTarget = 'workspace'
|
||||
latestPointer = { x: event.clientX, y: event.clientY }
|
||||
if (glowFrameId !== null) return
|
||||
glowFrameId = requestAnimationFrame(flushMouseLighting)
|
||||
}
|
||||
|
||||
function onWorkspacePointerLeave() {
|
||||
const workspace = workspaceRef.value
|
||||
if (!workspace) return
|
||||
const targets = workspace.querySelectorAll<HTMLElement>(lightTargetSelector)
|
||||
for (const target of targets) {
|
||||
target.style.setProperty('--lx', '-9999px')
|
||||
target.style.setProperty('--ly', '-9999px')
|
||||
}
|
||||
latestPointerTarget = null
|
||||
}
|
||||
|
||||
function onLoginPointerMove(event: PointerEvent) {
|
||||
latestPointerTarget = 'login'
|
||||
latestPointer = { x: event.clientX, y: event.clientY }
|
||||
if (glowFrameId !== null) return
|
||||
glowFrameId = requestAnimationFrame(flushMouseLighting)
|
||||
}
|
||||
|
||||
function onLoginPointerLeave() {
|
||||
const login = loginRef.value
|
||||
if (!login) return
|
||||
login.classList.remove('lighting-active')
|
||||
const targets = login.querySelectorAll<HTMLElement>(loginLightTargetSelector)
|
||||
for (const target of targets) {
|
||||
target.style.setProperty('--lx', '-9999px')
|
||||
target.style.setProperty('--ly', '-9999px')
|
||||
target.style.setProperty('--mx', '-9999px')
|
||||
target.style.setProperty('--my', '-9999px')
|
||||
}
|
||||
latestPointerTarget = null
|
||||
}
|
||||
|
||||
function updateSidebarIndicator(triggerJelly = false) {
|
||||
const sidebar = sidebarRef.value
|
||||
if (!sidebar) return
|
||||
|
||||
const activeButton = sidebar.querySelector<HTMLButtonElement>(`.nav-item[data-section-id="${activeSection.value}"]`)
|
||||
if (!activeButton) return
|
||||
|
||||
sidebarIndicatorStyle.value = {
|
||||
transform: `translateY(${activeButton.offsetTop}px)`,
|
||||
height: `${activeButton.offsetHeight}px`,
|
||||
opacity: '1',
|
||||
}
|
||||
|
||||
if (!triggerJelly) return
|
||||
|
||||
sidebarIndicatorJelly.value = false
|
||||
requestAnimationFrame(() => {
|
||||
sidebarIndicatorJelly.value = true
|
||||
})
|
||||
|
||||
if (sidebarJellyTimer) {
|
||||
clearTimeout(sidebarJellyTimer)
|
||||
}
|
||||
sidebarJellyTimer = setTimeout(() => {
|
||||
sidebarIndicatorJelly.value = false
|
||||
}, 560)
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
loginError.value = ''
|
||||
if (!username.value.trim()) {
|
||||
loginError.value = '请输入用户名。'
|
||||
return
|
||||
}
|
||||
if (!password.value.trim()) {
|
||||
loginError.value = '请输入密码。'
|
||||
return
|
||||
}
|
||||
|
||||
isLoggedIn.value = true
|
||||
statusMessage.value = `${text.welcome},${username.value.trim()}。`
|
||||
}
|
||||
|
||||
function logout() {
|
||||
isLoggedIn.value = false
|
||||
password.value = ''
|
||||
selectedGameId.value = null
|
||||
activeSection.value = 'overview'
|
||||
statusMessage.value = '你已退出登录。'
|
||||
}
|
||||
|
||||
function openFolder(folderId: string) {
|
||||
explorerCurrentFolderId.value = folderId
|
||||
explorerSelectedItemId.value = null
|
||||
expandFolderPath(folderId)
|
||||
}
|
||||
|
||||
function explorerGoUp() {
|
||||
const current = explorerCurrentFolder.value
|
||||
if (!current?.parentId) return
|
||||
explorerCurrentFolderId.value = current.parentId
|
||||
explorerSelectedItemId.value = null
|
||||
}
|
||||
|
||||
function goToPathFolder(folderId: string) {
|
||||
explorerCurrentFolderId.value = folderId
|
||||
explorerSelectedItemId.value = null
|
||||
expandFolderPath(folderId)
|
||||
}
|
||||
|
||||
function toggleFolderExpand(folderId: string) {
|
||||
const nextExpanded = new Set(expandedFolderIds.value)
|
||||
if (nextExpanded.has(folderId)) {
|
||||
if (folderId !== 'root') {
|
||||
nextExpanded.delete(folderId)
|
||||
}
|
||||
} else {
|
||||
nextExpanded.add(folderId)
|
||||
}
|
||||
expandedFolderIds.value = nextExpanded
|
||||
}
|
||||
|
||||
function expandFolderPath(folderId: string) {
|
||||
const parentMap = new Map(explorerFolders.value.map((folder) => [folder.id, folder.parentId]))
|
||||
const nextExpanded = new Set(expandedFolderIds.value)
|
||||
|
||||
let cursor: string | null = folderId
|
||||
while (cursor) {
|
||||
nextExpanded.add(cursor)
|
||||
cursor = parentMap.get(cursor) ?? null
|
||||
}
|
||||
|
||||
nextExpanded.add('root')
|
||||
expandedFolderIds.value = nextExpanded
|
||||
}
|
||||
|
||||
function nextName(baseName: string, parentId: string, kind: ExplorerItemKind) {
|
||||
const siblingNames = new Set(
|
||||
explorerItems.value
|
||||
.filter((item) => item.parentId === parentId && item.kind === kind)
|
||||
.map((item) => item.name),
|
||||
)
|
||||
if (!siblingNames.has(baseName)) return baseName
|
||||
|
||||
let index = 2
|
||||
while (siblingNames.has(`${baseName} (${index})`)) {
|
||||
index += 1
|
||||
}
|
||||
return `${baseName} (${index})`
|
||||
}
|
||||
|
||||
function createExplorerItem(kind: ExplorerItemKind) {
|
||||
const parentId = explorerCurrentFolderId.value
|
||||
const baseName = kind === 'folder' ? 'New Folder' : 'New File.txt'
|
||||
const nextItem: ExplorerItem = {
|
||||
id: `${kind}-${nextExplorerId.value++}`,
|
||||
parentId,
|
||||
kind,
|
||||
name: nextName(baseName, parentId, kind),
|
||||
}
|
||||
explorerItems.value.push(nextItem)
|
||||
explorerSelectedItemId.value = nextItem.id
|
||||
if (kind === 'folder') {
|
||||
expandFolderPath(parentId)
|
||||
}
|
||||
statusMessage.value = `已创建${kind === 'folder' ? '文件夹' : '文件'}:${nextItem.name}`
|
||||
}
|
||||
|
||||
function renameExplorerItem(itemId: string) {
|
||||
const item = explorerItems.value.find((entry) => entry.id === itemId)
|
||||
if (!item) return
|
||||
|
||||
const input = window.prompt('输入新名称', item.name)
|
||||
if (input === null) return
|
||||
const next = input.trim()
|
||||
if (!next) {
|
||||
statusMessage.value = '名称不能为空。'
|
||||
return
|
||||
}
|
||||
|
||||
item.name = next
|
||||
statusMessage.value = `已重命名为:${item.name}`
|
||||
}
|
||||
|
||||
function collectDescendantIds(rootId: string) {
|
||||
const removed = new Set<string>([rootId])
|
||||
const stack = [rootId]
|
||||
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!
|
||||
for (const item of explorerItems.value) {
|
||||
if (item.parentId !== current) continue
|
||||
if (removed.has(item.id)) continue
|
||||
removed.add(item.id)
|
||||
stack.push(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
function deleteExplorerItem(itemId: string) {
|
||||
const target = explorerItems.value.find((item) => item.id === itemId)
|
||||
if (!target || target.id === 'root') return
|
||||
|
||||
const confirmed = window.confirm(
|
||||
target.kind === 'folder'
|
||||
? `确定删除文件夹“${target.name}”及其内容?`
|
||||
: `确定删除文件“${target.name}”?`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const removedIds = collectDescendantIds(itemId)
|
||||
explorerItems.value = explorerItems.value.filter((item) => !removedIds.has(item.id))
|
||||
|
||||
if (explorerSelectedItemId.value && removedIds.has(explorerSelectedItemId.value)) {
|
||||
explorerSelectedItemId.value = null
|
||||
}
|
||||
|
||||
if (removedIds.has(explorerCurrentFolderId.value)) {
|
||||
explorerCurrentFolderId.value = target.parentId ?? 'root'
|
||||
}
|
||||
|
||||
const nextExpanded = new Set(expandedFolderIds.value)
|
||||
for (const removedId of removedIds) {
|
||||
nextExpanded.delete(removedId)
|
||||
}
|
||||
nextExpanded.add('root')
|
||||
expandedFolderIds.value = nextExpanded
|
||||
|
||||
statusMessage.value = `已删除:${target.name}`
|
||||
}
|
||||
|
||||
function openExplorerItem(item: ExplorerItem) {
|
||||
explorerSelectedItemId.value = item.id
|
||||
if (item.kind === 'folder') {
|
||||
openFolder(item.id)
|
||||
statusMessage.value = `已进入文件夹:${item.name}`
|
||||
return
|
||||
}
|
||||
statusMessage.value = `已打开文件:${item.name}`
|
||||
}
|
||||
|
||||
function selectGame(gameId: GameId) {
|
||||
selectedGameId.value = gameId
|
||||
statusMessage.value = `已启动游戏:${gameOptions.find((item) => item.id === gameId)?.label ?? ''}`
|
||||
}
|
||||
|
||||
function backToGameChooser() {
|
||||
selectedGameId.value = null
|
||||
statusMessage.value = '已返回游戏列表。'
|
||||
}
|
||||
|
||||
async function toggleGameFullscreen() {
|
||||
if (!gamePlayerRef.value) return
|
||||
try {
|
||||
if (document.fullscreenElement === gamePlayerRef.value) {
|
||||
await document.exitFullscreen()
|
||||
return
|
||||
}
|
||||
await gamePlayerRef.value.requestFullscreen()
|
||||
} catch {
|
||||
statusMessage.value = '当前浏览器不支持全屏或全屏被阻止。'
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isGameFullscreen.value = document.fullscreenElement === gamePlayerRef.value
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
updateSidebarIndicator(false)
|
||||
}
|
||||
|
||||
watch(
|
||||
activeSection,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateSidebarIndicator(true)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(isLoggedIn, async (loggedIn) => {
|
||||
if (!loggedIn) return
|
||||
await nextTick()
|
||||
updateSidebarIndicator(false)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
nextTick(() => {
|
||||
updateSidebarIndicator(false)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (sidebarJellyTimer) {
|
||||
clearTimeout(sidebarJellyTimer)
|
||||
}
|
||||
if (glowFrameId !== null) {
|
||||
cancelAnimationFrame(glowFrameId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="skip-link" href="#main-content">{{ text.skip }}</a>
|
||||
|
||||
<main
|
||||
v-if="!isLoggedIn"
|
||||
id="main-content"
|
||||
ref="loginRef"
|
||||
class="login-view"
|
||||
@pointermove="onLoginPointerMove"
|
||||
@pointerleave="onLoginPointerLeave"
|
||||
>
|
||||
<section class="login-card" aria-labelledby="login-title">
|
||||
<p class="eyebrow">Workspace Console</p>
|
||||
<h1 id="login-title">{{ text.loginTitle }}</h1>
|
||||
<p class="subtitle">{{ text.loginSubtitle }}</p>
|
||||
|
||||
<form class="login-form" @submit.prevent="submitLogin">
|
||||
<label for="username">{{ text.username }}</label>
|
||||
<div class="login-input-shell">
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
:placeholder="text.usernamePlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="password">{{ text.password }}</label>
|
||||
<div class="login-input-shell">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
type="password"
|
||||
:placeholder="text.passwordPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="loginError" class="form-error" aria-live="polite">{{ loginError }}</p>
|
||||
<button type="submit">{{ text.loginButton }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main
|
||||
v-else
|
||||
id="main-content"
|
||||
ref="workspaceRef"
|
||||
class="workspace-view"
|
||||
@pointermove="onWorkspacePointerMove"
|
||||
@pointerleave="onWorkspacePointerLeave"
|
||||
>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>Personal Command Center</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<p class="user-chip">{{ currentUser }}</p>
|
||||
<button type="button" class="ghost-btn" @click="logout">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace-layout">
|
||||
<aside ref="sidebarRef" class="sidebar" aria-label="section-navigation">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="nav-active-indicator"
|
||||
:class="{ jelly: sidebarIndicatorJelly }"
|
||||
:style="sidebarIndicatorStyle"
|
||||
></span>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:data-section-id="item.id"
|
||||
:class="{ active: activeSection === item.id }"
|
||||
@click="setSection(item.id)"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">
|
||||
<component :is="item.icon" class="nav-icon-glyph" />
|
||||
</span>
|
||||
<span class="nav-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.subtitle }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="panel" aria-live="polite">
|
||||
<div v-if="activeSection === 'overview'" class="panel-body overview-panel">
|
||||
<article class="hero-card">
|
||||
<h2>一眼进入高频任务</h2>
|
||||
<p>从这里切换到文件、游戏或学习模块。相比原先桌面拖拽窗口模式,这里改为稳定导航 + 单主工作区,减少操作成本。</p>
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="primary-btn" @click="setSection('explorer')">打开文件管理</button>
|
||||
<button type="button" class="ghost-btn" @click="setSection('games')">打开游戏中心</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<p>Folders</p>
|
||||
<strong>{{ explorerFolders.length }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p>Items In Current Folder</p>
|
||||
<strong>{{ explorerChildItems.length }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p>Games</p>
|
||||
<strong>{{ gameOptions.length }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'explorer'" class="panel-body explorer-panel">
|
||||
<header class="explorer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:disabled="!explorerCurrentFolder?.parentId"
|
||||
aria-label="返回上级目录"
|
||||
@click="explorerGoUp"
|
||||
>
|
||||
<ArrowUpBold class="inline-icon" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<nav class="pathbar" aria-label="当前路径">
|
||||
<button
|
||||
v-for="(pathItem, index) in explorerPath"
|
||||
:key="pathItem.id"
|
||||
type="button"
|
||||
class="path-segment"
|
||||
@click="goToPathFolder(pathItem.id)"
|
||||
>
|
||||
<FolderOpened class="inline-icon" aria-hidden="true" />
|
||||
<span>{{ pathItem.name }}</span>
|
||||
<Right v-if="index < explorerPath.length - 1" class="path-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<button type="button" class="ghost-btn" @click="createExplorerItem('folder')">
|
||||
<Plus class="inline-icon" aria-hidden="true" />新建文件夹
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" @click="createExplorerItem('file')">
|
||||
<Document class="inline-icon" aria-hidden="true" />新建文件
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="explorer-layout">
|
||||
<aside class="folder-list" aria-label="所有文件夹">
|
||||
<div
|
||||
v-for="folder in explorerFolderTreeItems"
|
||||
:key="folder.id"
|
||||
class="tree-row"
|
||||
:style="{ paddingLeft: `${8 + folder.level * 14}px` }"
|
||||
>
|
||||
<button
|
||||
v-if="folder.hasChildren"
|
||||
type="button"
|
||||
class="tree-toggle"
|
||||
:aria-label="folder.expanded ? '折叠文件夹' : '展开文件夹'"
|
||||
@click="toggleFolderExpand(folder.id)"
|
||||
>
|
||||
<Right class="tree-chevron" :class="{ expanded: folder.expanded }" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="tree-placeholder" aria-hidden="true"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: explorerCurrentFolderId === folder.id }"
|
||||
@click="openFolder(folder.id)"
|
||||
>
|
||||
<FolderOpened class="inline-icon" aria-hidden="true" />
|
||||
<span>{{ folder.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="file-list" role="list">
|
||||
<article v-for="item in explorerChildItems" :key="item.id" class="file-card" role="listitem">
|
||||
<button
|
||||
type="button"
|
||||
class="file-main"
|
||||
:class="{ selected: explorerSelectedItemId === item.id }"
|
||||
@click="openExplorerItem(item)"
|
||||
>
|
||||
<span class="file-icon" aria-hidden="true">
|
||||
<FolderOpened v-if="item.kind === 'folder'" class="inline-icon" />
|
||||
<Document v-else class="inline-icon" />
|
||||
</span>
|
||||
<span class="file-text">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.kind === 'folder' ? '文件夹' : '文件' }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:aria-label="`重命名 ${item.name}`"
|
||||
@click="renameExplorerItem(item.id)"
|
||||
>
|
||||
<EditPen class="inline-icon" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
:aria-label="`删除 ${item.name}`"
|
||||
@click="deleteExplorerItem(item.id)"
|
||||
>
|
||||
<Delete class="inline-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="!explorerChildItems.length" class="empty">当前目录为空。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'games'" class="panel-body games-panel">
|
||||
<div v-if="!activeGame" class="game-grid">
|
||||
<button
|
||||
v-for="game in gameOptions"
|
||||
:key="game.id"
|
||||
type="button"
|
||||
class="game-card"
|
||||
@click="selectGame(game.id)"
|
||||
>
|
||||
<span class="game-icon" aria-hidden="true">
|
||||
<Trophy class="inline-icon" />
|
||||
</span>
|
||||
<strong>{{ game.label }}</strong>
|
||||
<small>{{ game.subtitle }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else ref="gamePlayerRef" class="game-player">
|
||||
<header class="game-player-bar">
|
||||
<div class="game-title-wrap">
|
||||
<Monitor class="inline-icon" aria-hidden="true" />
|
||||
<strong>{{ activeGame.label }}</strong>
|
||||
</div>
|
||||
<div class="game-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
:aria-label="isGameFullscreen ? '退出全屏' : '进入全屏'"
|
||||
@click="toggleGameFullscreen"
|
||||
>
|
||||
{{ isGameFullscreen ? '退出全屏' : '全屏' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" @click="backToGameChooser">返回列表</button>
|
||||
</div>
|
||||
</header>
|
||||
<iframe
|
||||
class="game-frame"
|
||||
:title="activeGame.label"
|
||||
:src="activeGame.path"
|
||||
loading="lazy"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-body school-panel">
|
||||
<article class="hero-card compact">
|
||||
<h2>学习路径</h2>
|
||||
<p>你可以把课程链接、阶段任务、周计划集中放在这里。</p>
|
||||
</article>
|
||||
<div class="study-grid">
|
||||
<article class="study-card">
|
||||
<h3>Frontend</h3>
|
||||
<p>Vue + TypeScript 组件拆分、状态设计、可访问性。</p>
|
||||
</article>
|
||||
<article class="study-card">
|
||||
<h3>Graphics</h3>
|
||||
<p>游戏渲染循环、碰撞检测、输入系统与性能优化。</p>
|
||||
</article>
|
||||
<article class="study-card">
|
||||
<h3>Deployment</h3>
|
||||
<p>构建产物、缓存策略、静态资源托管与监控。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="status" aria-live="polite">{{ statusMessage }}</p>
|
||||
</main>
|
||||
</template>
|
||||
1
vue/src/assets/vue.svg
Normal file
1
vue/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
vue/src/components/HelloWorld.vue
Normal file
41
vue/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
5
vue/src/main.ts
Normal file
5
vue/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
1482
vue/src/style.css
Normal file
1482
vue/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
16
vue/tsconfig.app.json
Normal file
16
vue/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
vue/tsconfig.json
Normal file
7
vue/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
vue/tsconfig.node.json
Normal file
26
vue/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vue/vite.config.ts
Normal file
7
vue/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Reference in New Issue
Block a user