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