commit d669738967bb296ca81273ce748ce20168eb231e Author: yoyuzh Date: Fri Feb 27 14:29:05 2026 +0800 init diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..402c09a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "test1", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/todo_list.md b/todo_list.md new file mode 100644 index 0000000..c56a7eb --- /dev/null +++ b/todo_list.md @@ -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 环境演练 + 回滚方案 + diff --git a/vue/.gitignore b/vue/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/vue/.gitignore @@ -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? diff --git a/vue/.vscode/extensions.json b/vue/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/vue/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/vue/README.md b/vue/README.md new file mode 100644 index 0000000..4e00c2c --- /dev/null +++ b/vue/README.md @@ -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 ` + + diff --git a/vue/package-lock.json b/vue/package-lock.json new file mode 100644 index 0000000..05afe2a --- /dev/null +++ b/vue/package-lock.json @@ -0,0 +1,1488 @@ +{ + "name": "test1", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test1", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz", + "integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz", + "integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/vue/package.json b/vue/package.json new file mode 100644 index 0000000..460abf8 --- /dev/null +++ b/vue/package.json @@ -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 ` + + + + + + + + + + + + + + + + + diff --git a/vue/public/race/input.js b/vue/public/race/input.js new file mode 100644 index 0000000..690618a --- /dev/null +++ b/vue/public/race/input.js @@ -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(); +} \ No newline at end of file diff --git a/vue/public/race/levels.js b/vue/public/race/levels.js new file mode 100644 index 0000000..9bca181 --- /dev/null +++ b/vue/public/race/levels.js @@ -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; + } +} \ No newline at end of file diff --git a/vue/public/race/main.js b/vue/public/race/main.js new file mode 100644 index 0000000..ecb3e05 --- /dev/null +++ b/vue/public/race/main.js @@ -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(); \ No newline at end of file diff --git a/vue/public/race/release.js b/vue/public/race/release.js new file mode 100644 index 0000000..dc81a7e --- /dev/null +++ b/vue/public/race/release.js @@ -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() {} \ No newline at end of file diff --git a/vue/public/race/releaseJS13K.js b/vue/public/race/releaseJS13K.js new file mode 100644 index 0000000..b216599 --- /dev/null +++ b/vue/public/race/releaseJS13K.js @@ -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() {} \ No newline at end of file diff --git a/vue/public/race/scene.js b/vue/public/race/scene.js new file mode 100644 index 0000000..18d0760 --- /dev/null +++ b/vue/public/race/scene.js @@ -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; +} \ No newline at end of file diff --git a/vue/public/race/sounds.js b/vue/public/race/sounds.js new file mode 100644 index 0000000..61d700f --- /dev/null +++ b/vue/public/race/sounds.js @@ -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 \ No newline at end of file diff --git a/vue/public/race/track.js b/vue/public/race/track.js new file mode 100644 index 0000000..817cff9 --- /dev/null +++ b/vue/public/race/track.js @@ -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 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); + } +} diff --git a/vue/public/race/trackGen.js b/vue/public/race/trackGen.js new file mode 100644 index 0000000..49e833f --- /dev/null +++ b/vue/public/race/trackGen.js @@ -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 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) 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); + } + } + } +} \ No newline at end of file diff --git a/vue/public/race/utilities.js b/vue/public/race/utilities.js new file mode 100644 index 0000000..91f5c9f --- /dev/null +++ b/vue/public/race/utilities.js @@ -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; } +} \ No newline at end of file diff --git a/vue/public/race/vehicle.js b/vue/public/race/vehicle.js new file mode 100644 index 0000000..8d15de8 --- /dev/null +++ b/vue/public/race/vehicle.js @@ -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!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(); + } + } + } + } + } +} \ No newline at end of file diff --git a/vue/public/race/webgl.js b/vue/public/race/webgl.js new file mode 100644 index 0000000..bc5844d --- /dev/null +++ b/vue/public/race/webgl.js @@ -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; \ No newline at end of file diff --git a/vue/public/t_race/favicon.png b/vue/public/t_race/favicon.png new file mode 100644 index 0000000..17aaf40 Binary files /dev/null and b/vue/public/t_race/favicon.png differ diff --git a/vue/public/t_race/index.html b/vue/public/t_race/index.html new file mode 100644 index 0000000..2670170 --- /dev/null +++ b/vue/public/t_race/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vue/public/vite.svg b/vue/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/vue/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vue/src/App.vue b/vue/src/App.vue new file mode 100644 index 0000000..632da29 --- /dev/null +++ b/vue/src/App.vue @@ -0,0 +1,846 @@ + + +