This commit is contained in:
yoyuzh
2026-02-27 14:29:05 +08:00
commit d669738967
41 changed files with 10270 additions and 0 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "test1",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

267
todo_list.md Normal file
View File

@@ -0,0 +1,267 @@
下面这份是**工程级**的 TODO ListMarkdown按“能上线”的路径拆好了里程碑 → 任务 → 验收点。你前端已经做了一部分,就从 **FE-Desktop / FE-Apps** 里把已完成的勾上即可。
---
# Web Desktop 项目工程 TODO可上线版
> 维护规则:
>
> * 每个任务尽量做到“可交付 + 可验收”。
> * 任务粒度1~4 小时能完成为宜。
> * 每周至少推进一个 Milestone 到可演示状态。
---
## 0. 里程碑总览
* [ ] **M0工程骨架就绪能跑通 dev / staging**
* [ ] **M1账号体系 + 桌面壳可用(基础可演示)**
* [ ] **M2网盘 MVPOSS 直传闭环)**
* [ ] **M3分享/审计/配额/管理后台(上线门槛)**
* [ ] **M4Campus BFF 接 Rust API课表/成绩缓存降级)**
* [ ] **M5论坛/地图完善 + 监控告警 + 上线演练**
---
## 1. M0 工程骨架就绪
### Repo / 工程结构
* [ ] 初始化 mono-repo 或多 repo 结构(建议:`frontend/` `backend/` `infra/`
* [ ] 统一 lint/formatESLint/Prettier + 后端 formatter
* [ ] 统一 commit 规范可选commitlint
* [ ] 统一环境变量模板:`.env.example`(前后端分开)
* [ ] 基础 README本地启动、部署、配置项说明
### 本地开发环境
* [ ] docker-composedb + redis + backend + (可选) nginx
* [ ] 一键启动脚本:`make dev` / `npm run dev:all`
* [ ] staging 配置:独立域名/反代/证书(哪怕自签)
### 基础 CI至少跑检查
* [ ] PR 触发lint + typecheck + unit test最小集合
* [ ] build 产物frontend build / backend build
**验收点**
* [ ] 新电脑 clone 后 30 分钟内能跑起来(含 db
---
## 2. M1 账号体系 + 桌面壳
### BE-Auth
* [ ] 用户注册/登录JWT + refresh 或 session 二选一)
* [ ] 密码加密argon2/bcrypt
* [ ] `GET /auth/me`
* [ ] 登录失败限流(例如 5 次/5 分钟)
* [ ] 基础用户状态normal / banned
* [ ] request_id 全链路middleware
### FE-Auth
* [ ] 登录/注册/找回页面
* [ ] token/会话续期策略
* [ ] 全局错误处理(统一 toast + request_id
### FE-Desktop你已做一部分这里把你已有的勾上
* [ ] 桌面布局:图标/分组/壁纸/主题
* [ ] 窗口系统:打开/关闭/最小化/最大化/拖拽/层级
* [ ] 最近使用 / 收藏
* [ ] 全局搜索:应用搜索(先做)
* [ ] 通知中心壳(先只做 UI
### BE-Desktop
* [ ] user_settings 表layout/theme/wallpaper
* [ ] `GET /desktop/settings` / `PUT /desktop/settings`
* [ ] `GET /desktop/apps`(服务端下发应用配置,方便后续开关)
**验收点**
* [ ] 新用户登录后能看到桌面;布局修改刷新后不丢
* [ ] 被封禁用户无法登录(提示明确)
---
## 3. M2 网盘 MVPOSS 直传闭环)
### BE-Drive 元数据
* [ ] files 表user_id, parent_id, name, size, mime, oss_key, deleted_at…
* [ ] 目录增删改查create folder / rename / move / list
* [ ] 软删除 + 回收站 list/restore
* [ ] 文件名净化(防 XSS/路径注入)
### BE-OSS 直传
* [ ] `POST /drive/upload/init`:生成 oss_key + STS/Policy带过期时间
* [ ] 分片策略chunk_size / multipart建议直接支持
* [ ] `POST /drive/upload/complete`:写入元数据(校验 size/etag
* [ ] `GET /drive/download/{id}`:签名 URL短期有效
* [ ] 下载审计:记录 download_sign
### FE-Drive
* [ ] 文件列表:分页/排序/面包屑
* [ ] 上传:小文件 + 大文件分片 + 断点续传
* [ ] 上传队列:暂停/继续/失败重试
* [ ] 预览:图片/PDF/文本
* [ ] 删除/恢复/彻底删除(回收站)
* [ ] 文件搜索(文件名)
**验收点**
* [ ] 上传→列表出现→预览/下载→删除→回收站恢复闭环
* [ ] 网络断开后能续传(至少同一次会话内)
---
## 4. M3 分享 / 审计 / 配额 / 管理后台(上线门槛)
### BE-Share
* [ ] 创建分享:有效期、提取码、权限(预览/下载)
* [ ] 分享访问页:`GET /share/{token}`
* [ ] 下载:`POST /share/{token}/download`(校验提取码后返回签名 URL
* [ ] 撤销分享:立即失效
* [ ] 分享访问审计ip/ua/time/count
### BE-Quota & RateLimit
* [ ] 用户配额:总容量、单文件大小、日上传/日下载
* [ ] 配额校验upload/init、complete、download/sign
* [ ] 限流:登录、绑定校园、成绩刷新、签名下载、分享访问
### BE-Audit
* [ ] audit_logs关键操作埋点upload_init/upload_complete/download_sign/share_create…
* [ ] 查询接口:按 user/action/time 过滤(管理员)
### Admin最小管理后台
* [ ] 用户管理:封禁/解封
* [ ] 配额配置:默认值 + 单用户覆盖(可选)
* [ ] OSS 配置bucket/STS 策略(至少可查看)
* [ ] 审计查询页
**验收点**
* [ ] 超配额时前后端提示一致且不可绕过
* [ ] 分享链接可用、可撤销、访问可审计
* [ ] 管理员能查到关键操作日志
---
## 5. M4 Campus BFF接 Rust API课表/成绩)
> 核心:**平台后端不让前端直连 Rust API**,统一做鉴权、缓存、熔断、错误码映射。
### BE-Campus 绑定与凭据
* [ ] `POST /campus/bind`:绑定校园账号(加密存储 credential / 或保存 rust session_token
* [ ] `POST /campus/unbind`:解绑并删除凭据
* [ ] 凭据加密密钥不入库env + KMS 可选)
* [ ] 绑定/查询限流(防封控)
### BE-Campus Rust API 网关层
* [ ] Rust API client超时、重试只读、熔断
* [ ] 健康检查:/healthz 探测 + 指标
* [ ] DTO 适配层Rust 返回字段变化不直接打爆前端
* [ ] 错误码映射Rust error → 平台 error code
### BE-Campus 缓存与降级
* [ ] campus_cache课表/成绩 TTL课表 12h成绩 24h
* [ ] 手动刷新冷却时间(成绩建议更长)
* [ ] Rust 不可用时返回缓存 + 标注更新时间
### FE-Campus
* [ ] 绑定页面(学号/密码或 token
* [ ] 课表周视图/日视图
* [ ] 成绩学期视图 + 列表
* [ ] “刷新”按钮(带冷却提示)
* [ ] “数据更新时间 / 当前为缓存”提示
**验收点**
* [ ] Rust API 挂了:仍能展示缓存且不白屏
* [ ] 频繁刷新会被限流并提示
---
## 6. M5 论坛/地图完善 + 监控告警 + 上线演练
### Forum按 Rust API 能力)
* [ ] 板块列表/帖子列表/详情/评论
* [ ] 发帖/评论(幂等键 Idempotency-Key
* [ ] 内容风控:频率限制 + 基础敏感词(最小)
* [ ] 举报入口(最小)
* [ ] 通知:回复/提及(站内通知)
### Map
* [ ] POI 展示:分类 + 搜索
* [ ] 地图 SDK 接入Leaflet/高德/腾讯择一)
* [ ] POI 缓存 7d + 更新策略
* [ ]可选POI 后台维护
### Observability上线前必须补
* [ ] 指标API 错误率、P95、Rust 成功率、OSS 上传失败率
* [ ] 日志:结构化 + request_id
* [ ] 告警Rust 健康异常、错误率激增、DB/Redis 异常
* [ ] 错误追踪Sentry 或同类(可选但强建议)
### 安全加固(上线前必做清单)
* [ ] CSP/安全头X-Frame-Options 等)
* [ ] 上传文件类型限制 + 文件名净化
* [ ] 权限回归测试:越权访问用例全覆盖
* [ ] Secrets 全部迁移到安全配置(不进仓库)
### 上线演练
* [ ] staging 环境全链路演练(含 OSS、Rust API
* [ ] 灰度发布流程(最小:可回滚)
* [ ] 数据库备份与恢复演练
* [ ] 压测(最少测下载签名/列表/校园查询)
**验收点**
* [ ] staging → prod 一键发布可回滚
* [ ] 关键告警触发能收到(邮件/IM 随便一种)
---
## 7. 你当前“前端已做一部分”的对齐清单(快速标记)
把你已经完成的模块在这里勾上,方便我后续给你拆“下一步最优先做什么”:
* [ ] 桌面图标布局
* [ ] 窗口拖拽/层级
* [ ] 应用打开/关闭/最小化
* [ ] 主题/壁纸
* [ ] 网盘 UI列表/上传面板/预览)
* [ ] 校园 UI课表/成绩/论坛/地图)
* [ ] 游戏应用容器
---
## 8. 最小上线 Checklist不做这些别上线
* [ ] 后端鉴权与资源隔离(不可只靠前端)
* [ ] OSS 长期密钥不下发前端(只给 STS/签名)
* [ ] 下载签名短期有效 + 审计
* [ ] 限流(登录/绑定/校园刷新/签名下载/分享访问)
* [ ] Rust API 超时/熔断/缓存降级
* [ ] 结构化日志 + request_id
* [ ] staging 环境演练 + 回滚方案

24
vue/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

11
vue/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

30
vue/package.json Normal file
View 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
View 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
View 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
View 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
View 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;
}

File diff suppressed because it is too large Load Diff

168
vue/public/race/hud.js Normal file
View 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);
}

View 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
View 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
View 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
View 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();

View 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() {}

View 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
View 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;
}

View 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
View 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
View 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);
}
}
}
}

View 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
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

1
vue/public/vite.svg Normal file
View 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
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

16
vue/tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
vue/tsconfig.node.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})

View File

@@ -0,0 +1,479 @@
# 分账户网页版桌面系统 PRD / 需求文档(可上线版)
> 版本v1.1已补齐Rust 校园 API 接口契约 + 错误码规范 + 时序图)
> 目标上线形态可上线、可运维、可扩展OSS 直传 + 校园数据由第三方 Rust 接口提供)
> 当前已确定方案:
> - 校园数据来源:学校学生开发的第三方 **Rust 接口**(建议平台后端做 BFF 统一接入)
> - 网盘上传:**客户端直传**
> - 对象存储:**阿里云 OSS**
> - 校园能力基础Rust 侧可基于 `rsmycqu`Rust 版 `pymycqu`)实现 SSO / 教务网能力与数据模型fileciteturn0file0L1-L20
---
## 1. 背景与目标
### 1.1 产品定位
一个 “Web Desktop网页版桌面” 系统,支持分账户使用。桌面内提供:
- **网盘Cloud Drive**:文件元数据由平台后端管理;文件对象存储在阿里云 OSS上传走客户端直传。
- **校园应用套件Campus Suite**:查课表、查成绩、校园论坛、校园地图。数据通过第三方 Rust API 获取。
- **静态小游戏Games**:作为桌面应用独立运行(你已实现)。
### 1.2 上线目标
- **可上线**:安全、权限隔离、稳定性、可观测性、运维能力具备。
- **分账户隔离**:文件、校园数据缓存、桌面设置、日志审计全部按用户隔离。
- **模块化**:桌面壳与各应用解耦,便于后续接入“远程服务器任务”(转码/解压/扫描/缩略图等)。
### 1.3 非目标(本期不做 / 可延后)
- 在线 Office / 多人实时协作编辑。
- 全量 OCR/全文检索。
- 原生桌面客户端Electron/Flutter Desktop 等)。
---
## 2. 用户、角色与权限
### 2.1 角色
- **普通用户**:使用桌面、网盘、校园套件、小游戏。
- **平台管理员**用户管理、配额、审计、OSS 配置、校园接口配置与健康监控。
> 预留扩展:未来可引入 tenant_id 做组织/学院级隔离;本期以 user_id 强隔离为主。
### 2.2 权限模型RBAC 最小版)
- `USER`仅访问自己的资源files/settings/campus_cache
- `ADMIN`:管理后台权限(用户、配置、审计、系统健康)。
### 2.3 数据隔离硬要求
- 所有资源表都必须带 `user_id`(可选 `tenant_id`),并在后端鉴权层强校验。
- 前端隐藏不是权限控制;必须后端拦截。
---
## 3. 总体功能范围
### 3.1 Web Desktop桌面壳
**功能:**
- 登录/注册/找回密码
- 桌面布局:图标、分组、壁纸、主题、快捷搜索、最近使用
- 窗口系统:打开/最小化/最大化/拖拽/层级管理/多窗口
- 通知中心:上传任务、论坛消息、系统公告、错误提示(携带 request_id
- 全局搜索:应用搜索 + 文件名搜索MVP
**验收:**
- 桌面布局、主题、壁纸可持久化,并在多设备“最终一致”(最后保存覆盖)。
### 3.2 网盘Cloud Drive
#### 3.2.1 核心能力MVP
- 文件/文件夹:新建、重命名、移动、删除(软删除)、恢复
- 上传/下载:支持大文件(分片 + 断点续传)
- 列表与排序:分页,按名称/时间/大小/类型
- 搜索:按文件名模糊搜索
- 预览图片、PDF、文本视频音频后续增强
- 回收站:保留期可配置(默认 30 天)
#### 3.2.2 分享(建议上线即包含)
- 分享链接:可设置有效期、提取码、权限(预览/下载)
- 撤销分享:立即失效
- 分享访问审计IP/UA/时间/次数
#### 3.2.3 配额与风控(上线必备)
- 每用户配额:总容量、单文件最大、日上传量、日下载量
- 限流:上传初始化、下载签名、分享访问
- 滥用处置:异常下载/分享可封禁账号
### 3.3 校园应用Campus Suite第三方 Rust API
> 平台后端建议作为 **BFFBackend For Frontend**:统一鉴权、缓存、熔断、限流、审计与错误码。
> 不建议前端直连 Rust API。
- 查课表:周/日视图、课程详情、导出(可选)
- 查成绩:学期/课程成绩、统计(可选)
- 校园论坛:板块/帖子/评论、发帖/评论、举报/封禁(最小版)
- 校园地图POI 列表、搜索、基础展示
---
## 4. 非功能需求(上线门槛)
### 4.1 性能指标(建议)
- 桌面首屏:≤ 2s常规网络
- 网盘列表P95 ≤ 300ms缓存/索引命中)
- 下载签名接口:≤ 200ms
- 校园数据接口P95 ≤ 1sRust API 正常时)
### 4.2 可靠性与降级
- Rust API 不可用:课表/成绩/POI 返回最近缓存 + 明确提示更新时间
- 熔断Rust API 连续失败达到阈值后短时间熔断,避免雪崩
- 重试仅读接口可重试1~2 次,指数退避)
### 4.3 可观测性(必做)
- 结构化日志:`request_id``user_id`、IP、UA、latency、status
- 指标错误率、P95 延迟、Rust API 成功率、缓存命中率、OSS 上传失败率
- 告警Rust API 健康异常、错误率激增、DB 连接异常、磁盘/内存告警
---
## 5. 安全与合规
### 5.1 身份认证
- access token短期+ refresh token长期或服务端 session二选一
- 密码bcrypt/argon2
- 登录保护:失败次数限制 + 冷却时间
### 5.2 OSS 安全原则(关键)
- 前端 **不得**持有长期 AK/SK
- 仅使用STS 临时凭证 / PostPolicy 签名 / 服务端签名 URL
- 签名 URL短期有效1~10 分钟)
### 5.3 校园凭据安全
- 若平台需保存校园账号凭据:必须服务端加密存储(密钥不入库)
- 提供解绑与删除:删除后不可继续查询
- 绑定/查询限流:避免触发校方风控或 Rust API 封禁
---
## 6. 系统架构(建议落地)
### 6.1 逻辑架构
- **Web 前端**:桌面壳 + 应用(网盘/校园/小游戏)
- **平台后端BFF**Auth、Drive、Campus、Admin、Audit、Task后续
- **第三方 Rust API**对接校内系统SSO/教务/论坛/地图)
- **阿里云 OSS**:文件对象存储
### 6.2 强制约束:平台后端必须做“统一出口”
- 前端只认平台域名Rust API 不对公网直接暴露或至少不暴露给前端(避免绕过鉴权与风控)。
---
## 7. 数据模型(建议表)
### 7.1 核心表MVP
- `users(id, email/phone, password_hash, status, created_at, last_login_at)`
- `user_settings(user_id, desktop_layout_json, theme, wallpaper, updated_at)`
- `files(id, user_id, parent_id, name, size, mime, oss_key, etag, sha256, created_at, deleted_at)`
- `shares(id, owner_user_id, file_id, token, password_hash, perms, expire_at, created_at, revoked_at)`
- `audit_logs(id, user_id, action, target_type, target_id, ip, ua, request_id, created_at, extra_json)`
- `campus_accounts(id, user_id, school_code, encrypted_credential, status, updated_at)`
- `campus_cache(id, user_id, type, payload_json, updated_at, expire_at)`
### 7.2 论坛(若平台侧落库/自建)
- `forum_boards, forum_posts, forum_comments, forum_reports, forum_bans`
---
## 8. API 设计(平台对前端)
> 统一返回格式:见第 10 节《错误码与响应规范》。
> 所有接口默认需要 `Authorization: Bearer <access_token>`(除登录注册、分享页等)。
### 8.1 Auth
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/refresh`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- (可选)`GET /api/auth/sessions``DELETE /api/auth/sessions/{id}`
### 8.2 Desktop
- `GET /api/desktop/settings`
- `PUT /api/desktop/settings`
- `GET /api/desktop/apps`
### 8.3 DriveOSS 直传)
- `GET /api/drive/list?parent_id=...&page=...`
- `POST /api/drive/folder`
- `POST /api/drive/rename`
- `POST /api/drive/move`
- `DELETE /api/drive/delete`
- `GET /api/drive/trash`
- `POST /api/drive/restore`
- `POST /api/drive/upload/init`
- `POST /api/drive/upload/complete`
- `GET /api/drive/download/{file_id}`
- `POST /api/drive/share`
- `POST /api/drive/share/revoke`
### 8.4 Share匿名访问
- `GET /share/{token}`(分享页元信息)
- `POST /share/{token}/download`(提取码校验后返回签名 URL
### 8.5 Campus平台聚合 Rust API
- `POST /api/campus/bind`
- `POST /api/campus/unbind`
- `GET /api/campus/timetable?term=...&week=...`
- `GET /api/campus/grades?term=...`
- `GET /api/campus/forum/boards`
- `GET /api/campus/forum/posts?board_id=...&page=...`
- `GET /api/campus/forum/posts/{post_id}`
- `POST /api/campus/forum/post`
- `POST /api/campus/forum/comment`
- `GET /api/campus/map/poi?campus=...&q=...`
### 8.6 Admin仅 ADMIN
- `GET /api/admin/users`
- `PUT /api/admin/users/{id}/status`(封禁/解封)
- `GET /api/admin/audit`
- `PUT /api/admin/config/storage`OSS/ST S
- `PUT /api/admin/config/campus`Rust API base_url/超时/熔断阈值等)
- `PUT /api/admin/config/limits`(配额与限流参数)
---
## 9. 校园第三方 Rust API接口契约平台对 Rust
> 这部分是“平台后端”与“Rust API”之间的契约。
> 目标Rust API 改动时平台能通过适配层DTO兜住前端。
> Rust 侧能力可基于 `rsmycqu` 完成 SSO 与教务网权限获取等fileciteturn0file0L21-L48并遵循其 Session/Token 存储方式fileciteturn0file0L49-L77。
### 9.1 通用约束
- 通信:平台 -> Rust API 走内网或 mTLS建议
- 超时2~5s
- 幂等:对写接口支持 `Idempotency-Key`(避免重复发帖/评论)
- Rust API 必须提供健康检查:`GET /healthz`
### 9.2 Rust API 统一返回格式(建议)
```json
{
"ok": true,
"data": {},
"error": null,
"request_id": "rust-req-xxxx"
}
```
### 9.3 Rust API 端点(建议命名,可按实际调整)
#### 9.3.1 绑定与会话
- `POST /v1/session/login`
- 入参:`{ "auth": "...", "password": "...", "force_relogin": false }`
- 出参:`{ "session_token": "...", "expires_at": 1700000000 }`
- 说明Rust 侧内部维护 Session类似 `rsmycqu::Session`fileciteturn0file0L23-L41
- `POST /v1/session/logout`
- 入参:`{ "session_token": "..." }`
- 出参:`{ "success": true }`
> 备注:如果 Rust API 不愿管理 session_token也可让平台保存加密凭据并每次调用由 Rust API 现登;但会更慢、更容易触发风控。
#### 9.3.2 课表
- `GET /v1/timetable?session_token=...&term=...&week=...`
- Response `TimetableResponse`
```json
{
"term": "2025-2026-1",
"week": 3,
"updated_at": 1700000000,
"courses": [
{
"name": "数据结构",
"teacher": "张三",
"location": "A区-第3教学楼-201",
"weekday": 1,
"start_section": 1,
"end_section": 2,
"weeks": [1,2,3,4,5,6,7],
"remark": ""
}
]
}
```
#### 9.3.3 成绩
- `GET /v1/grades?session_token=...&term=...`
- Response `GradesResponse`
```json
{
"term": "2025-2026-1",
"updated_at": 1700000000,
"items": [
{ "course": "高等数学", "credit": 4.0, "grade": 92, "gpa": 4.0, "type": "必修" }
],
"summary": { "gpa": 3.62, "credits": 22.0 }
}
```
#### 9.3.4 论坛(读写按 Rust API 能力提供)
- `GET /v1/forum/boards`
- `GET /v1/forum/posts?board_id=...&page=...`
- `GET /v1/forum/posts/{post_id}`
- `POST /v1/forum/post`
- `POST /v1/forum/comment`
写接口建议入参:
```json
{ "session_token": "...", "title": "...", "content": "...", "board_id": "..." }
```
#### 9.3.5 地图 POI
- `GET /v1/map/poi?campus=...&q=...`
- Response
```json
{
"campus": "A",
"updated_at": 1700000000,
"pois": [
{ "id": "lib_a", "name": "图书馆", "lat": 29.123, "lng": 106.456, "category": "library", "desc": "" }
]
}
```
---
## 10. 错误码与响应规范(平台对前端)
### 10.1 平台统一返回格式
```json
{
"ok": false,
"data": null,
"error": {
"code": "CAMPUS_UPSTREAM_DOWN",
"message": "校园服务暂不可用(已返回缓存数据)",
"detail": { "upstream": "rust_api", "retry_after_sec": 60 }
},
"request_id": "req-20260211-xxxx"
}
```
### 10.2 平台错误码(建议最小集合)
| code | 含义 | 典型场景 |
|---|---|---|
| AUTH_UNAUTHORIZED | 未登录/令牌无效 | access token 过期 |
| AUTH_FORBIDDEN | 无权限 | 非 ADMIN 访问管理接口 |
| RATE_LIMITED | 触发限流 | 刷新成绩过频 |
| DRIVE_QUOTA_EXCEEDED | 超出配额 | 上传超容量/超单文件大小 |
| DRIVE_NOT_FOUND | 文件不存在 | file_id 不存在或无权限 |
| DRIVE_UPLOAD_EXPIRED | 上传凭证过期 | init 后太久未上传 |
| SHARE_INVALID | 分享无效 | token 不存在/已撤销 |
| SHARE_PASSWORD_REQUIRED | 需要提取码 | 未提供/错误 |
| CAMPUS_NOT_BOUND | 未绑定校园账号 | 访问课表/成绩 |
| CAMPUS_UPSTREAM_DOWN | Rust API 不可用 | 熔断/超时/5xx |
| CAMPUS_DATA_STALE | 数据过期 | 返回缓存但超过 max_stale |
| INTERNAL_ERROR | 内部错误 | 未分类异常 |
> Rust API 错误应映射为平台错误码(避免前端理解 Rust 的内部枚举)。
---
## 11. 时序图Mermaid
### 11.1 OSS 直传上传(分片 + 断点续传)
```mermaid
sequenceDiagram
autonumber
participant U as Browser
participant B as Platform BFF
participant O as Aliyun OSS
U->>B: POST /api/drive/upload/init (name,size,mime,parent_id)
B-->>U: {upload_id, oss_key, sts/policy, chunk_size, expires_at}
loop multipart chunks
U->>O: UploadPart(oss_key, partNo, signed/STS)
O-->>U: ETag(partNo)
end
U->>O: CompleteMultipartUpload(oss_key, etag_list)
O-->>U: 200 OK (final etag)
U->>B: POST /api/drive/upload/complete (upload_id, oss_key, size, etag, sha256?)
B-->>U: {file_id, created_at}
```
### 11.2 下载(签名 URL + 审计)
```mermaid
sequenceDiagram
autonumber
participant U as Browser
participant B as Platform BFF
participant O as Aliyun OSS
U->>B: GET /api/drive/download/{file_id}
B->>B: AuthZ check(user_id owns file)
B->>B: Write audit_logs(download_sign)
B-->>U: {signed_url, expires_in}
U->>O: GET signed_url
O-->>U: file bytes
```
### 11.3 校园数据查询BFF 缓存 + 熔断降级)
```mermaid
sequenceDiagram
autonumber
participant U as Browser
participant B as Platform BFF
participant R as Rust API
participant C as Cache/DB
U->>B: GET /api/campus/grades?term=...
B->>C: Read campus_cache(grades, user_id, term)
alt cache fresh
C-->>B: cached payload
B-->>U: cached data (ok=true, from_cache=true)
else cache stale/miss
B->>R: GET /v1/grades?session_token=...&term=...
alt rust ok
R-->>B: grades data
B->>C: Write cache(updated_at, expire_at)
B-->>U: ok=true (from_cache=false)
else rust fail/timeout
B->>B: circuit breaker / fallback
B-->>U: ok=true with stale cache OR ok=false with CAMPUS_UPSTREAM_DOWN
end
end
```
---
## 12. 缓存与熔断策略(建议默认)
- 课表TTL 6~24h默认 12h
- 成绩TTL 24h默认 24h
- POITTL 7d默认 7d
- 论坛列表TTL 30~120s默认 60s
熔断Rust API 在 1 分钟窗口内连续失败 ≥ 10 次触发;触发后 30~120 秒熔断(可配置)。
---
## 13. 管理后台(上线最小集合)
- 用户:封禁/解封、查看用量与最近行为
- 配额:容量/单文件/日上行/日下行/分享默认有效期
- OSSbucket、STS 配置、域名/CDN可选
- CampusRust API base_url、超时、重试、熔断参数、健康状态
- 审计:按 user/action/time 查询(支持导出)
---
## 14. 验收标准(上线验收)
### 14.1 功能
- 分账户隔离A 用户无法访问 B 用户的 files/settings/campus_cache
- 网盘闭环:上传(含断点续传)→ 预览/下载 → 删除 → 回收站恢复
- 分享:创建/访问/提取码/撤销 全链路可用
- 校园Rust API 异常时不白屏,缓存降级提示明确(含更新时间)
- 桌面:布局、主题可持久化,刷新后不丢
### 14.2 安全
- 前端无长期 AK/SK签名 URL 短期有效
- 登录/绑定/成绩刷新/下载签名/分享访问均限流生效
- 校园凭据加密存储,解绑后可彻底删除
- 审计日志完整(关键操作必留痕,带 request_id
### 14.3 稳定性
- Rust API 故障触发熔断,系统不雪崩
- 30 分钟高频操作无明显卡死/内存暴涨
---
## 15. 里程碑建议
- M1Auth + Desktop Shell设置持久化
- M2Drive MVP直传 OSS + 元数据 + 下载签名 + 回收站)
- M3分享 + 审计 + Admin 基础
- M4Campus BFF接 Rust API + 缓存/熔断/限流)+ 课表/成绩
- M5论坛/地图完善 + 监控告警 + staging→prod 演练
---
## 16. 附录:对 `rsmycqu` 的依赖注意
- 若 Rust API 基于 `rsmycqu`,建议沿用其 `Session` 设计与权限检查策略fileciteturn0file0L49-L77避免在缺失 token/权限时“晚失败”。
- `rsmycqu` 仍处于快速开发阶段需在平台侧预留接口变更适配层与回滚策略fileciteturn0file0L17-L20。