commit 8430ba0452a5158b93fba8d8520fba05a79b06ab
Author: yanpeng200212 <32453@DESKTOP-HTJ2T0B>
Date: Thu Oct 8 10:43:59 2026 +0800
first commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f4d799
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# 依赖
+node_modules/
+
+# 构建产物
+dist/
+bin/
+obj/
+publish/
+
+# 运行时临时文件 / 日志
+*.log
+_runlog/
+_tmp_hubtest/
+_rename_backup/
+*.db-shm
+*.db-wal
+
+# 证书私钥
+cert.pem
+key.pem
+
+# IDE / 系统
+.vs/
+.idea/
+.vscode/
+*.user
+Thumbs.db
+Desktop.ini
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f377ebf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,169 @@
+# f9-web-ai-test
+
+企业级 RBAC 权限管理系统
+
+一个基于 .NET 8 分层架构 + Vue 3 + Element Plus 的企业内部管理系统,采用经典的面包屑后台布局,实现完整的 RBAC(基于角色的访问控制)权限体系。
+
+## 技术栈
+
+- **后端**:.NET 8(分层架构)+ NSwag(OpenAPI 文档)
+- **前端**:Vue 3 + Vite + Element Plus + Pinia + Vue Router(绿色主题)
+- **数据库**:SQLite(EF Core)
+- **认证**:JWT 双令牌(access token + refresh token)+ BCrypt 密码加密
+- **API 客户端**:NSwag 自动生成 TypeScript 客户端(`src/api/client.ts`)
+
+## 后端分层架构
+
+```
+backend/
+├── F9WebAiTest.Domain/ # 实体层
+│ ├── Common/ # 基类 + 仓储接口 IRepository
+│ └── Entities/ # 实体(部门/职工/角色/菜单/关联/日志)
+├── F9WebAiTest.Infrastructure/ # 仓储实现层
+│ ├── Data/ # DbContext + 种子数据
+│ └── Repositories/ # Repository 通用实现
+├── F9WebAiTest.Application/ # 服务层
+│ ├── Common/ # ApiResult / ServiceException
+│ ├── Dtos/ # 数据传输对象
+│ └── Services/ # 业务逻辑(认证/部门/职工/角色/菜单)
+└── F9WebAiTest.Api/ # 表示层(Web API 控制器)
+```
+
+依赖方向:`Api → Application → Domain ← Infrastructure`
+
+## RBAC 核心功能
+
+| 模块 | 说明 |
+|------|------|
+| 认证登录 | JWT 登录、动态菜单、权限标识下发 |
+| 部门管理 | 树形部门结构、负责人、子部门、排序 |
+| 职工管理 | 职工增删改查、分页搜索、分配角色、重置密码 |
+| 角色管理 | 角色增删改查、菜单权限分配(树形勾选) |
+| 菜单管理 | 目录/菜单/按钮三级,动态路由、权限标识 |
+| 权限控制 | 动态路由 + 按钮级权限指令 v-permission |
+| 考勤打卡 | 上班签到/下班签退,迟到/早退自动判定 |
+| 我的考勤 | 个人考勤记录 + 月度统计(出勤/迟到/早退/缺勤/请假) |
+| 请假管理 | 请假申请、审批(批准/驳回)、多类型(事假/病假/年假等) |
+| 考勤记录 | 管理员按日期/部门/姓名查询全员考勤 |
+| 工艺庄口 | 庄口基础数据(年度/本地外地/基地外购/产地等) |
+| 生丝规格 | 规格指标(中心范围/野纤/内控/劳动竞赛等上下限) |
+| 生丝定级指标 | 等级指标(厂标/国标/个人,偏差/大差/清洁/洁净等) |
+| 机组车台 | 车间机组与车台号配置 |
+
+## 数据模型关系
+
+```
+Department (部门) 1 ── N Employee (职工)
+Employee (职工) N ── N Role (角色) via EmployeeRole
+Role (角色) N ── N Menu (菜单) via RoleMenu
+Menu (菜单) 自引用树形(目录/菜单/按钮)
+```
+
+## 启动方式
+
+### 1. 启动后端
+
+```bash
+cd backend/F9WebAiTest.Api
+dotnet run
+```
+
+后端运行在 `http://localhost:3000`,Swagger 文档 `http://localhost:3000/swagger`。
+
+### 2. 启动前端
+
+```bash
+cd web
+npm install
+npm run dev
+```
+
+前端运行在 `http://localhost:5173`,已配置代理 `/api` 转发到后端。
+
+### 3. 生成 API 客户端(NSwag)
+
+后端运行时,用 NSwag 从 OpenAPI 文档自动生成 TypeScript 客户端:
+
+```bash
+# 先安装 NSwag CLI 工具(只需一次)
+dotnet tool install --global NSwag.ConsoleCore
+
+# 生成 TypeScript 客户端到 src/api/client.ts
+cd web
+npm run gen:api
+```
+
+生成的 `src/api/client.ts` 包含所有 DTO 类型和 API 方法,类型安全地调用后端接口。
+
+### 3. 访问系统
+
+打开 `http://localhost:5173`
+
+**默认账号**:`admin` / `123456`(超级管理员,拥有全部权限)
+
+## 前端目录结构
+
+```
+web/src/
+├── layout/index.vue # 主布局(侧边栏 + 面包屑 + 内容区)
+├── router/index.js # 路由 + 动态路由构建
+├── store/user.js # 用户状态(Pinia)
+├── directives/permission.js # 权限指令 v-permission
+├── utils/request.js # axios 封装(拦截器)
+└── views/
+ ├── login/ # 登录页
+ ├── dashboard/ # 首页
+ └── system/
+ ├── department/ # 部门管理
+ ├── employee/ # 职工管理
+ ├── role/ # 角色管理(含菜单分配)
+ ├── menu/ # 菜单管理
+ └── log/ # 操作日志
+```
+
+## 权限使用示例
+
+```vue
+
+新增
+
+
+操作
+```
+
+## API 接口
+
+- `POST /api/auth/login` - 登录(返回 access token + refresh token)
+- `POST /api/auth/refresh` - 刷新令牌(用 refresh token 换新 access token)
+- `POST /api/auth/logout` - 退出(撤销 refresh token)
+- `GET /api/auth/me` - 当前用户信息
+- `GET /api/auth/menus` - 用户菜单树
+- `GET /api/auth/permissions` - 用户权限标识
+- `PUT /api/auth/password` - 修改密码
+- `PUT /api/auth/profile` - 更新资料
+- `GET /api/department/tree` - 部门树
+- `POST /api/department` - 新增部门
+- `PUT /api/department/:id` - 更新部门
+- `DELETE /api/department/:id` - 删除部门
+- `GET /api/employee` - 职工分页
+- `POST /api/employee` - 新增职工
+- `PUT /api/employee/:id` - 更新职工
+- `DELETE /api/employee/:id` - 删除职工
+- `PUT /api/employee/:id/password` - 重置密码
+- `GET /api/role` - 角色列表
+- `GET /api/role/:id/menus` - 角色菜单
+- `POST /api/role` - 新增角色
+- `PUT /api/role/:id` - 更新角色
+- `DELETE /api/role/:id` - 删除角色
+- `GET /api/menu/tree` - 菜单树
+- `POST /api/menu` - 新增菜单
+- `PUT /api/menu/:id` - 更新菜单
+- `DELETE /api/menu/:id` - 删除菜单
+- `POST /api/attendance/punch` - 打卡(签到/签退)
+- `GET /api/attendance/my` - 我的考勤记录
+- `GET /api/attendance/my/summary` - 我的考勤统计
+- `GET /api/attendance` - 全员考勤记录(管理员)
+- `POST /api/attendance/leave` - 申请请假
+- `GET /api/attendance/leave/my` - 我的请假
+- `GET /api/attendance/leave` - 全部请假(管理员)
+- `PUT /api/attendance/leave/:id/approve` - 审批请假
diff --git a/TempleRun/GameForm.cs b/TempleRun/GameForm.cs
new file mode 100644
index 0000000..c8a30b1
--- /dev/null
+++ b/TempleRun/GameForm.cs
@@ -0,0 +1,132 @@
+using System.Diagnostics;
+using System.Drawing.Drawing2D;
+
+namespace TempleRun;
+
+///
+/// 主窗体:负责游戏循环(定时器 + 高精度计时)、键盘输入、双缓冲绘制调度。
+///
+public class GameForm : Form
+{
+ private readonly GameWorld _world = new();
+ private readonly System.Windows.Forms.Timer _timer;
+ private readonly Stopwatch _watch = Stopwatch.StartNew();
+
+ private double _lastTime;
+ private double _fpsAccum;
+ private int _frameCount;
+ private bool _showFps;
+
+ public GameForm()
+ {
+ Text = "神庙逃亡 · Temple Run (WinForms)";
+ ClientSize = new Size(1024, 720);
+ MinimumSize = new Size(760, 540);
+ StartPosition = FormStartPosition.CenterScreen;
+ BackColor = Color.FromArgb(12, 12, 20);
+ DoubleBuffered = true;
+ ResizeRedraw = true;
+ AutoScaleMode = AutoScaleMode.None;
+ SetStyle(ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.UserPaint
+ | ControlStyles.OptimizedDoubleBuffer, true);
+
+ try { Icon = BuildIcon(); } catch { /* 图标失败不影响运行 */ }
+
+ _lastTime = _watch.Elapsed.TotalSeconds;
+
+ _timer = new System.Windows.Forms.Timer { Interval = 15 }; // ≈66Hz
+ _timer.Tick += OnTick;
+ _timer.Start();
+ }
+
+ // ------------------------------------------------------------------ 主循环
+ private void OnTick(object sender, EventArgs e)
+ {
+ double now = _watch.Elapsed.TotalSeconds;
+ float dt = (float)(now - _lastTime);
+ _lastTime = now;
+
+ if (dt <= 0f) return;
+ if (dt > 0.06f) dt = 0.06f; // 卡顿/拖动窗口后不产生穿模
+
+ // FPS 统计
+ _frameCount++;
+ _fpsAccum += dt;
+ if (_fpsAccum >= 0.5)
+ {
+ _world.Fps = (float)(_frameCount / _fpsAccum);
+ _frameCount = 0;
+ _fpsAccum = 0;
+ }
+
+ _world.Update(dt);
+ Invalidate();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ GameRenderer.Draw(e.Graphics, ClientSize.Width, ClientSize.Height, _world, _showFps);
+ }
+
+ // ------------------------------------------------------------------ 输入
+ protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
+ {
+ Keys key = keyData & Keys.KeyCode;
+
+ if (key == Keys.F1) { _showFps = !_showFps; Invalidate(); return true; }
+ if (key == Keys.F11)
+ {
+ WindowState = WindowState == FormWindowState.Maximized
+ ? FormWindowState.Normal : FormWindowState.Maximized;
+ return true;
+ }
+ if (key == Keys.F12) { _world.Muted = !_world.Muted; return true; }
+
+ if (_world.HandleKey(key)) return true;
+
+ return base.ProcessCmdKey(ref msg, keyData);
+ }
+
+ protected override void OnMouseClick(MouseEventArgs e)
+ {
+ base.OnMouseClick(e);
+ // 鼠标左键也能触发"开始/重开",方便触屏设备
+ if (e.Button == MouseButtons.Left)
+ {
+ if (_world.State == RunState.Ready) _world.Start();
+ else if (_world.State == RunState.GameOver && _world.CanRestart)
+ {
+ _world.Start();
+ }
+ }
+ }
+
+ protected override void OnFormClosed(FormClosedEventArgs e)
+ {
+ _timer.Stop();
+ _timer.Dispose();
+ GameRenderer.ResetCache();
+ base.OnFormClosed(e);
+ }
+
+ // ------------------------------------------------------------------ 图标
+ private static Icon BuildIcon()
+ {
+ using var bmp = new Bitmap(32, 32);
+ using (var g = Graphics.FromImage(bmp))
+ {
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ g.Clear(Color.Transparent);
+ using (var bg = new SolidBrush(Color.FromArgb(46, 34, 28)))
+ g.FillEllipse(bg, 0, 0, 31, 31);
+ using (var gold = new SolidBrush(Cfg.Gold))
+ g.FillEllipse(gold, 7, 7, 18, 18);
+ using (var dark = new Pen(Cfg.GoldDark, 2.4f))
+ g.DrawEllipse(dark, 7, 7, 18, 18);
+ using (var hi = new SolidBrush(Color.FromArgb(230, 255, 250, 220)))
+ g.FillEllipse(hi, 11, 10, 5, 4);
+ }
+ return Icon.FromHandle(bmp.GetHicon());
+ }
+}
diff --git a/TempleRun/GameModel.cs b/TempleRun/GameModel.cs
new file mode 100644
index 0000000..015b011
--- /dev/null
+++ b/TempleRun/GameModel.cs
@@ -0,0 +1,202 @@
+using System.Drawing.Drawing2D;
+
+namespace TempleRun;
+
+///
+/// 全局常量与可调参数。想改手感直接改这里。
+/// 世界坐标约定:X 向右为正,Y 离地向上为正,Z 玩家前方为正。
+/// 玩家永远停在 Z = 0,世界向 Z 负方向滚动。
+///
+public static class Cfg
+{
+ // ---- 相机(透视投影参数)----
+ public const float Focal = 520f; // 焦距:越大视野越窄、远处物体越大
+ public const float CamZ = 235f; // 相机位于玩家后方多远
+ public const float CamHeight = 142f; // 相机离地高度
+ public const float FarZ = 3000f; // 渲染最远距离
+ public const float MinDepth = 45f; // 相机空间最小深度,防止除零
+
+ // ---- 跑道 ----
+ public const int LaneCount = 3;
+ public const float LaneWidth = 112f; // 单条车道宽
+ public const float RoadHalfWidth = LaneWidth * 1.5f;
+
+ // ---- 玩家 ----
+ public const float PlayerHeight = 62f; // 站立高度
+ public const float SlideHeight = 30f; // 滑铲高度
+ public const float PlayerHalfDepth = 22f;
+ public const float JumpSpeed = 430f;
+ public const float Gravity = 950f;
+ public const float LaneLerp = 13f; // 换道插值速度
+ public const float SlideDuration = 0.55f;
+
+ // ---- 速度与难度 ----
+ public const float StartSpeed = 620f;
+ public const float MaxSpeed = 1900f;
+ public const float SpeedAccel = 3.4f; // 每秒自然加速
+ public const float SpeedPerCoin = 5f; // 每金币额外加速
+ public const float SpawnIntervalMin = 0.62f; // 相邻障碍组最小时间间隔(秒)
+ public const float SpawnIntervalMax = 1.15f;
+
+ // ---- 颜色(丛林日落 + 古神庙)----
+ public static readonly Color SkyTop = Color.FromArgb(30, 34, 72);
+ public static readonly Color SkyMid = Color.FromArgb(122, 78, 96);
+ public static readonly Color SkyHorizon = Color.FromArgb(240, 168, 96);
+ public static readonly Color Fog = Color.FromArgb(200, 152, 118);
+ public static readonly Color GroundNear = Color.FromArgb(44, 36, 28);
+ public static readonly Color GroundFar = Color.FromArgb(122, 100, 78);
+ public static readonly Color RoadA = Color.FromArgb(178, 150, 110);
+ public static readonly Color RoadB = Color.FromArgb(156, 130, 94);
+ public static readonly Color Stone = Color.FromArgb(120, 112, 104);
+ public static readonly Color Wood = Color.FromArgb(122, 82, 48);
+ public static readonly Color Vine = Color.FromArgb(74, 116, 62);
+ public static readonly Color Gold = Color.FromArgb(255, 202, 62);
+ public static readonly Color GoldDark = Color.FromArgb(196, 140, 24);
+ public static readonly Color Player = Color.FromArgb(226, 92, 62);
+
+ public static float LaneX(int lane) => (lane - 1) * LaneWidth;
+
+ public static Color Lerp(Color a, Color b, float t)
+ {
+ t = Math.Clamp(t, 0f, 1f);
+ return Color.FromArgb(
+ (int)(a.R + (b.R - a.R) * t),
+ (int)(a.G + (b.G - a.G) * t),
+ (int)(a.B + (b.B - a.B) * t));
+ }
+}
+
+public enum RunState { Ready, Playing, Paused, GameOver }
+
+public enum ObstacleKind
+{
+ /// 矮障碍:必须跳跃
+ Low,
+ /// 悬空横梁:必须滑铲
+ High,
+ /// 整道石墙:必须换道
+ Full
+}
+
+/// 透视相机:把世界坐标投影到屏幕坐标。
+public static class Cam
+{
+ public static float CenterX;
+ public static float HorizonY;
+ public static int Width;
+ public static int Height;
+
+ public static void Setup(int w, int h)
+ {
+ Width = w;
+ Height = h;
+ CenterX = w * 0.5f;
+ HorizonY = h * 0.30f;
+ }
+
+ ///
+ /// 投影一个世界点。
+ /// scale 为"每世界单位对应多少像素",用于缩放实体自身尺寸。
+ ///
+ public static void Project(float x, float y, float z, out float sx, out float sy, out float scale)
+ {
+ float depth = z + Cfg.CamZ;
+ if (depth < Cfg.MinDepth) depth = Cfg.MinDepth;
+ scale = Cfg.Focal / depth;
+ sx = CenterX + x * scale;
+ sy = HorizonY + (Cfg.CamHeight - y) * scale;
+ }
+
+ /// 只投影地面上的点(y = 0)。
+ public static void ProjectGround(float x, float z, out float sx, out float sy, out float scale)
+ => Project(x, 0f, z, out sx, out sy, out scale);
+
+ /// 距离雾化系数:0 = 完全清晰,1 = 完全融入雾色。
+ public static float FogAmount(float z)
+ {
+ float t = Math.Clamp(z / Cfg.FarZ, 0f, 1f);
+ return t * t; // 平方衰减,近处保持通透
+ }
+}
+
+public class Obstacle
+{
+ public float Z;
+ public int Lane;
+ public ObstacleKind Kind;
+ public bool Scored; // 是否已计入"躲避成功"
+
+ public float Width => Kind == ObstacleKind.Full ? 96f : 90f;
+ public float Depth => Kind == ObstacleKind.Full ? 54f : 56f;
+ public float BottomY => Kind == ObstacleKind.High ? 40f : 0f;
+ public float TopY => Kind switch
+ {
+ ObstacleKind.Low => 46f, // 低于跳跃高度,跳过去
+ ObstacleKind.High => 98f, // 悬空横梁,滑铲钻过去
+ _ => 108f // 整道石墙,只能换道
+ };
+ public float CenterX => Cfg.LaneX(Lane);
+}
+
+public class Coin
+{
+ public float Z;
+ public int Lane;
+ public float Y = 36f;
+ public bool Taken;
+ public float Phase;
+ public float CenterX => Cfg.LaneX(Lane);
+}
+
+public class Particle
+{
+ public float X, Y, Z;
+ public float Vx, Vy, Vz;
+ public float Life;
+ public float MaxLife;
+ public float Size;
+ public float GravityScale = 1f;
+ public Color Color;
+}
+
+/// 路边装饰物:石柱、火把、树、雕像碎块。
+public class SideProp
+{
+ public float Z;
+ public int Side; // -1 左,+1 右
+ public int Type; // 0=石柱 1=火把 2=枯树 3=碎石
+ public float Height;
+ public float Phase;
+
+ public float CenterX => Side * (Cfg.RoadHalfWidth + 205f + (Type == 3 ? 40f : 0f));
+}
+
+public class Player
+{
+ public float X; // 当前横向世界坐标(插值中)
+ public int TargetLane = 1; // 目标车道 0/1/2
+ public float Y; // 离地高度
+ public float Vy;
+ public bool OnGround = true;
+ public bool Sliding;
+ public float SlideTimer;
+ public float RunPhase; // 跑动动画相位
+ public bool Alive = true;
+ public float Tilt; // 换道时的侧倾(视觉)
+
+ public float Height => Sliding ? Cfg.SlideHeight : Cfg.PlayerHeight;
+
+ public void Reset()
+ {
+ TargetLane = 1;
+ X = Cfg.LaneX(1);
+ Y = 0;
+ Vy = 0;
+ OnGround = true;
+ Sliding = false;
+ SlideTimer = 0;
+ RunPhase = 0;
+ Alive = true;
+ Tilt = 0;
+ }
+}
diff --git a/TempleRun/GameRenderer.cs b/TempleRun/GameRenderer.cs
new file mode 100644
index 0000000..0e9997b
--- /dev/null
+++ b/TempleRun/GameRenderer.cs
@@ -0,0 +1,902 @@
+using System.Drawing.Drawing2D;
+using System.Drawing.Text;
+
+namespace TempleRun;
+
+///
+/// 全部绘制逻辑。使用 GDI+ 手工做透视投影,不依赖任何 3D 库。
+///
+public static class GameRenderer
+{
+ // ---------------------------------------------------------------- 缓存对象
+ private static Font _fHuge, _fTitle, _fBig, _fMid, _fSmall, _fTiny;
+ private static readonly List<(float Z, int Type, object Obj)> _drawList = new(512);
+ private const int TypeObstacle = 0;
+ private const int TypeCoin = 1;
+
+ private static void EnsureFonts()
+ {
+ if (_fBig != null) return;
+ const string face = "Microsoft YaHei UI";
+ _fHuge = new Font(face, 46f, FontStyle.Bold, GraphicsUnit.Pixel);
+ _fTitle = new Font(face, 30f, FontStyle.Bold, GraphicsUnit.Pixel);
+ _fBig = new Font(face, 26f, FontStyle.Bold, GraphicsUnit.Pixel);
+ _fMid = new Font(face, 19f, FontStyle.Regular, GraphicsUnit.Pixel);
+ _fSmall = new Font(face, 15f, FontStyle.Regular, GraphicsUnit.Pixel);
+ _fTiny = new Font(face, 12f, FontStyle.Regular, GraphicsUnit.Pixel);
+ }
+
+ public static void ResetCache() { _fBig = null; }
+
+ // ---------------------------------------------------------------- 入口
+ public static void Draw(Graphics g, int w, int h, GameWorld world, bool showFps = false)
+ {
+ if (w <= 0 || h <= 0) return;
+ EnsureFonts();
+
+ Cam.Setup(w, h);
+ g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
+
+ g.Clear(Color.FromArgb(12, 12, 20));
+
+ // 屏幕震动
+ float shakeX = 0f, shakeY = 0f;
+ if (world.Shake > 0.15f)
+ {
+ float s = world.Shake;
+ shakeX = (float)(Random.Shared.NextDouble() * 2 - 1) * s;
+ shakeY = (float)(Random.Shared.NextDouble() * 2 - 1) * s * 0.7f;
+ }
+
+ GraphicsState st = g.Save();
+ g.TranslateTransform(shakeX, shakeY);
+
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ DrawSky(g, w, h);
+ DrawDistantScenery(g, w, h);
+ DrawGround(g, w, h, world);
+ DrawProps(g, world);
+ DrawEntities(g, world);
+ DrawParticles(g, world);
+ DrawPlayer(g, world);
+
+ g.Restore(st);
+
+ // 撞击红闪
+ if (world.HitFlash > 0.01f)
+ {
+ using var fb = new SolidBrush(Color.FromArgb((int)(130 * world.HitFlash), 220, 40, 30));
+ g.FillRectangle(fb, 0, 0, w, h);
+ }
+
+ DrawHud(g, w, h, world, showFps);
+ }
+
+ // ---------------------------------------------------------------- 投影辅助
+ private static PointF P(float x, float y, float z)
+ {
+ Cam.Project(x, y, z, out float sx, out float sy, out _);
+ return new PointF(sx, sy);
+ }
+
+ private static float PX(float x, float y, float z)
+ {
+ Cam.Project(x, y, z, out float sx, out _, out _);
+ return sx;
+ }
+
+ private static float PY(float x, float y, float z)
+ {
+ Cam.Project(x, y, z, out _, out float sy, out _);
+ return sy;
+ }
+
+ private static float PS(float x, float y, float z)
+ {
+ Cam.Project(x, y, z, out _, out _, out float s);
+ return s;
+ }
+
+ // ---------------------------------------------------------------- 天空
+ private static void DrawSky(Graphics g, int w, int h)
+ {
+ int skyBottom = (int)Cam.HorizonY + 30;
+ var rect = new Rectangle(0, -30, w, skyBottom + 30);
+ if (rect.Height < 2) return;
+
+ using var br = new LinearGradientBrush(rect, Color.Black, Color.White, 90f)
+ {
+ InterpolationColors = new ColorBlend(3)
+ {
+ Colors = new[] { Cfg.SkyTop, Cfg.SkyMid, Cfg.SkyHorizon },
+ Positions = new[] { 0f, 0.60f, 1f }
+ }
+ };
+ g.FillRectangle(br, rect);
+
+ // 太阳
+ float sunX = w * 0.74f;
+ float sunY = Cam.HorizonY - h * 0.115f;
+ float sunR = Math.Max(16f, h * 0.072f);
+
+ using (var path = new GraphicsPath())
+ {
+ path.AddEllipse(sunX - sunR * 3.4f, sunY - sunR * 3.4f, sunR * 6.8f, sunR * 6.8f);
+ using var glow = new PathGradientBrush(path)
+ {
+ CenterColor = Color.FromArgb(140, 255, 220, 150),
+ SurroundColors = new[] { Color.FromArgb(0, 255, 200, 120) }
+ };
+ g.FillPath(glow, path);
+ }
+ using (var sb = new SolidBrush(Color.FromArgb(255, 255, 240, 200)))
+ g.FillEllipse(sb, sunX - sunR, sunY - sunR, sunR * 2, sunR * 2);
+
+ // 云条
+ using (var cb = new SolidBrush(Color.FromArgb(46, 255, 226, 190)))
+ {
+ g.FillEllipse(cb, w * 0.10f, Cam.HorizonY - h * 0.20f, w * 0.16f, h * 0.022f);
+ g.FillEllipse(cb, w * 0.30f, Cam.HorizonY - h * 0.255f, w * 0.11f, h * 0.017f);
+ g.FillEllipse(cb, w * 0.62f, Cam.HorizonY - h * 0.16f, w * 0.20f, h * 0.020f);
+ }
+ }
+
+ // ---------------------------------------------------------------- 远景(山脊 + 神庙)
+ private static void DrawDistantScenery(Graphics g, int w, int h)
+ {
+ float hy = Cam.HorizonY;
+
+ // 远山脊
+ using (var far = new SolidBrush(Cfg.Lerp(Cfg.Fog, Color.FromArgb(72, 62, 104), 0.42f)))
+ {
+ using var path = new GraphicsPath();
+ path.AddPolygon(new[]
+ {
+ new PointF(-w * 0.05f, hy + 2),
+ new PointF(w * 0.08f, hy - h * 0.075f),
+ new PointF(w * 0.20f, hy - h * 0.012f),
+ new PointF(w * 0.33f, hy - h * 0.105f),
+ new PointF(w * 0.47f, hy - h * 0.020f),
+ new PointF(w * 0.60f, hy - h * 0.088f),
+ new PointF(w * 0.74f, hy - h * 0.028f),
+ new PointF(w * 0.88f, hy - h * 0.070f),
+ new PointF(w * 1.05f, hy + 2)
+ });
+ g.FillPath(far, path);
+ }
+
+ // 神庙剪影(左)
+ DrawTemple(g, w * 0.19f, hy + 4, w * 0.115f, h * 0.155f,
+ Cfg.Lerp(Cfg.Fog, Color.FromArgb(58, 48, 78), 0.55f), 5);
+
+ // 神庙剪影(右,稍小)
+ DrawTemple(g, w * 0.83f, hy + 4, w * 0.085f, h * 0.115f,
+ Cfg.Lerp(Cfg.Fog, Color.FromArgb(66, 56, 86), 0.46f), 4);
+
+ // 地平线雾带
+ using var haze = new LinearGradientBrush(
+ new Rectangle(0, (int)hy - 26, w, 58),
+ Color.FromArgb(0, Cfg.Fog), Color.FromArgb(190, Cfg.Fog), 90f);
+ g.FillRectangle(haze, 0, hy - 26, w, 58);
+ }
+
+ private static void DrawTemple(Graphics g, float cx, float baseY, float w, float h, Color c, int steps)
+ {
+ using var br = new SolidBrush(c);
+ float sh = h / steps;
+ for (int i = 0; i < steps; i++)
+ {
+ float frac = 1f - (float)i / steps * 0.70f;
+ float ww = w * frac;
+ float y = baseY - sh * (i + 1);
+ g.FillRectangle(br, cx - ww * 0.5f, y, ww, sh + 1.2f);
+ }
+ // 塔尖
+ float topW = w * 0.30f;
+ float topY = baseY - h;
+ g.FillPolygon(br, new[]
+ {
+ new PointF(cx - topW * 0.5f, topY + 1),
+ new PointF(cx, topY - sh * 0.85f),
+ new PointF(cx + topW * 0.5f, topY + 1)
+ });
+ }
+
+ // ---------------------------------------------------------------- 地面与跑道
+ private static void DrawGround(Graphics g, int w, int h, GameWorld world)
+ {
+ // 整体地面渐变
+ int gy = (int)Cam.HorizonY;
+ var rect = new Rectangle(0, gy - 2, w, h - gy + 4);
+ if (rect.Height > 1)
+ {
+ using var gb = new LinearGradientBrush(rect, Color.White, Color.White, 90f)
+ {
+ InterpolationColors = new ColorBlend(4)
+ {
+ Colors = new[] { Cfg.GroundFar, Cfg.Lerp(Cfg.GroundFar, Cfg.GroundNear, 0.35f), Cfg.GroundNear, Color.FromArgb(52, 40, 30) },
+ Positions = new[] { 0f, 0.18f, 0.62f, 1f }
+ }
+ };
+ g.FillRectangle(gb, rect);
+ }
+
+ SmoothingMode old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.None; // 条纹用无抗锯齿更快
+
+ const float period = 190f;
+ const float drawNear = -230f;
+
+ // 跑道两侧的暗色沟渠
+ DrawSideBand(g, w, -1);
+ DrawSideBand(g, w, +1);
+
+ // 跑道条纹(世界坐标固定,随世界滚动)
+ float offset = world.Distance % (period * 2f);
+ for (int i = -8; i < 40; i++)
+ {
+ float z0 = i * period * 2f - offset;
+ float z1 = z0 + period;
+ if (z1 < drawNear) continue;
+ if (z0 > Cfg.FarZ) break;
+
+ z0 = Math.Max(z0, drawNear);
+ z1 = Math.Min(z1, Cfg.FarZ);
+ if (z1 <= z0) continue;
+
+ float fog = Cam.FogAmount(z0);
+ Color baseColor = ((i % 2) + 2) % 2 == 0 ? Cfg.RoadA : Cfg.RoadB;
+ Color c = Cfg.Lerp(baseColor, Cfg.Fog, fog * 0.92f);
+
+ float lx0 = PX(-Cfg.RoadHalfWidth, 0, z0);
+ float rx0 = PX(+Cfg.RoadHalfWidth, 0, z0);
+ float ly0 = PY(0, 0, z0);
+ float ly1 = PY(0, 0, z1);
+ float lx1 = PX(-Cfg.RoadHalfWidth, 0, z1);
+ float rx1 = PX(+Cfg.RoadHalfWidth, 0, z1);
+
+ using var br = new SolidBrush(c);
+ g.FillPolygon(br, new[]
+ {
+ new PointF(lx0, ly0), new PointF(rx0, ly0),
+ new PointF(rx1, ly1), new PointF(lx1, ly1)
+ });
+ }
+
+ // 跑道边缘描边
+ DrawRoadEdge(g, -1);
+ DrawRoadEdge(g, +1);
+
+ // 车道分隔虚线
+ DrawLaneMark(g, -Cfg.LaneWidth * 0.5f, world, period);
+ DrawLaneMark(g, +Cfg.LaneWidth * 0.5f, world, period);
+
+ g.SmoothingMode = old;
+ }
+
+ private static void DrawRoadEdge(Graphics g, int side)
+ {
+ float x = side * Cfg.RoadHalfWidth;
+ var pts = new List();
+ for (float z = Cfg.FarZ; z >= -230f; z -= 120f)
+ pts.Add(new PointF(PX(x, 0, z), PY(0, 0, z)));
+
+ if (pts.Count < 2) return;
+ using var pen = new Pen(Color.FromArgb(120, 58, 44, 32), 3.2f);
+ g.DrawLines(pen, pts.ToArray());
+ }
+
+ private static void DrawSideBand(Graphics g, int w, int side)
+ {
+ float xi = side * Cfg.RoadHalfWidth;
+ float xo = side * (Cfg.RoadHalfWidth + 170f);
+
+ float yFar = PY(0, 0, Cfg.FarZ);
+ float yNear = PY(0, 0, -230f);
+ var poly = new[]
+ {
+ new PointF(PX(xi, 0, Cfg.FarZ), yFar), new PointF(PX(xo, 0, Cfg.FarZ), yFar),
+ new PointF(PX(xo, 0, -230f), yNear), new PointF(PX(xi, 0, -230f), yNear)
+ };
+ using var br = new SolidBrush(Color.FromArgb(96, 34, 26, 20));
+ g.FillPolygon(br, poly);
+ }
+
+ private static void DrawLaneMark(Graphics g, float x, GameWorld world, float period)
+ {
+ float offset = world.Distance % (period * 2f);
+ for (int i = -8; i < 40; i++)
+ {
+ float z0 = i * period * 2f - offset;
+ float z1 = z0 + period * 0.45f;
+ if (z1 < -230f) continue;
+ if (z0 > Cfg.FarZ) break;
+ z0 = Math.Max(z0, -230f);
+ z1 = Math.Min(z1, Cfg.FarZ);
+ if (z1 <= z0) continue;
+
+ float fog = Cam.FogAmount(z0);
+ var c = Cfg.Lerp(Color.FromArgb(168, 146, 108), Cfg.Fog, fog * 0.9f);
+ using var br = new SolidBrush(Color.FromArgb((byte)(120 * (1 - fog) + 30), c));
+ g.FillPolygon(br, new[]
+ {
+ new PointF(PX(x - 4, 0, z0), PY(0, 0, z0)), new PointF(PX(x + 4, 0, z0), PY(0, 0, z0)),
+ new PointF(PX(x + 4, 0, z1), PY(0, 0, z1)), new PointF(PX(x - 4, 0, z1), PY(0, 0, z1))
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------- 路边装饰
+ private static void DrawProps(Graphics g, GameWorld world)
+ {
+ var props = world.Props;
+ for (int i = 0; i < props.Count; i++)
+ {
+ var p = props[i];
+ if (p.Z < 70f) continue; // 太近会盖住跑道,直接跳过
+
+ float fog = Cam.FogAmount(p.Z);
+ if (fog > 0.96f) continue;
+
+ float wx = p.CenterX; // 世界坐标
+ float sx = PX(wx, 0f, p.Z); // ★ 屏幕 x(原来误用了世界坐标)
+ float top = PY(wx, p.Height, p.Z);
+ float bottom = PY(wx, 0f, p.Z);
+ float s = PS(wx, 0f, p.Z);
+ float wpx = Math.Min(40f * s, 220f); // 限制近处被夸张放大的尺寸
+ if (bottom < Cam.HorizonY - 60) continue;
+
+ switch (p.Type)
+ {
+ case 0: // 石柱
+ {
+ var body = Cfg.Lerp(Color.FromArgb(96, 86, 76), Cfg.Fog, fog);
+ using var br = new SolidBrush(body);
+ g.FillRectangle(br, sx - wpx * 0.5f, top, wpx, bottom - top);
+ using var cap = new SolidBrush(Cfg.Lerp(Color.FromArgb(124, 110, 96), Cfg.Fog, fog));
+ g.FillRectangle(cap, sx - wpx * 0.62f, top - wpx * 0.22f, wpx * 1.24f, wpx * 0.26f);
+ using var pen = new Pen(Color.FromArgb(80, 34, 28, 22), Math.Max(1f, s * 1.4f));
+ g.DrawLine(pen, sx - wpx * 0.18f, top, sx - wpx * 0.18f, bottom);
+ break;
+ }
+ case 1: // 火把
+ {
+ using var br = new SolidBrush(Cfg.Lerp(Color.FromArgb(88, 60, 38), Cfg.Fog, fog));
+ g.FillRectangle(br, sx - wpx * 0.16f, top, wpx * 0.32f, bottom - top);
+ float fr = wpx * 0.52f * (1f + 0.14f * MathF.Sin(world.Clock * 6f + p.Phase));
+ using var glow = new SolidBrush(Color.FromArgb((int)(130 * (1 - fog)), 255, 170, 62));
+ g.FillEllipse(glow, sx - fr, top - fr * 1.1f, fr * 2, fr * 2);
+ using var core = new SolidBrush(Color.FromArgb((int)(235 * (1 - fog)), 255, 234, 158));
+ g.FillEllipse(core, sx - fr * 0.42f, top - fr * 0.95f, fr * 0.84f, fr * 0.95f);
+ break;
+ }
+ case 2: // 枯树
+ {
+ var wood = Cfg.Lerp(Color.FromArgb(64, 50, 38), Cfg.Fog, fog);
+ using var br = new SolidBrush(wood);
+ float trunkW = Math.Max(2f, wpx * 0.20f);
+ g.FillRectangle(br, sx - trunkW * 0.5f, top, trunkW, bottom - top);
+ using var pen = new Pen(wood, Math.Max(1.6f, wpx * 0.11f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ float midY = top + (bottom - top) * 0.42f;
+ g.DrawLine(pen, sx, midY, sx - wpx * 0.85f, midY - (bottom - top) * 0.30f);
+ g.DrawLine(pen, sx, midY + (bottom - top) * 0.12f, sx + wpx * 0.90f, midY - (bottom - top) * 0.20f);
+ break;
+ }
+ default: // 碎石
+ {
+ using var br = new SolidBrush(Cfg.Lerp(Color.FromArgb(84, 76, 68), Cfg.Fog, fog));
+ float hgt = (bottom - top) * 1.1f;
+ g.FillEllipse(br, sx - wpx * 0.7f, bottom - hgt, wpx * 1.4f, hgt);
+ break;
+ }
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- 实体(障碍 + 金币)
+ private static void DrawEntities(Graphics g, GameWorld world)
+ {
+ _drawList.Clear();
+ for (int i = 0; i < world.Obstacles.Count; i++)
+ {
+ var o = world.Obstacles[i];
+ if (o.Z > Cfg.FarZ + 200f || o.Z < -260f) continue;
+ _drawList.Add((o.Z, TypeObstacle, o));
+ }
+ for (int i = 0; i < world.Coins.Count; i++)
+ {
+ var c = world.Coins[i];
+ if (c.Taken || c.Z > Cfg.FarZ + 200f || c.Z < -260f) continue;
+ _drawList.Add((c.Z, TypeCoin, c));
+ }
+
+ _drawList.Sort(static (a, b) => b.Z.CompareTo(a.Z)); // 远 → 近
+
+ for (int i = 0; i < _drawList.Count; i++)
+ {
+ var it = _drawList[i];
+ if (it.Type == TypeObstacle) DrawObstacle(g, (Obstacle)it.Obj);
+ else DrawCoin(g, (Coin)it.Obj);
+ }
+ }
+
+ private static void DrawObstacle(Graphics g, Obstacle o)
+ {
+ float fog = Cam.FogAmount(o.Z);
+ if (fog > 0.97f) return;
+
+ float cx = o.CenterX;
+ float hw = o.Width * 0.5f;
+ float zn = o.Z - o.Depth * 0.5f;
+ float zf = o.Z + o.Depth * 0.5f;
+ float yb = o.BottomY, yt = o.TopY;
+
+ // 8 个顶点
+ PointF nlb = P(cx - hw, yb, zn), nrb = P(cx + hw, yb, zn);
+ PointF nlt = P(cx - hw, yt, zn), nrt = P(cx + hw, yt, zn);
+ PointF flb = P(cx - hw, yb, zf), frb = P(cx + hw, yb, zf);
+ PointF flt = P(cx - hw, yt, zf), frt = P(cx + hw, yt, zf);
+
+ // 落地阴影(让障碍"坐"在跑道上)
+ if (o.BottomY <= 1f)
+ {
+ Cam.Project(cx, 0.6f, o.Z, out float shx, out float shy, out float shs);
+ using var shb = new SolidBrush(Color.FromArgb(78, 0, 0, 0));
+ g.FillEllipse(shb, shx - o.Width * 0.62f * shs, shy - 8f * shs,
+ o.Width * 1.24f * shs, 16f * shs);
+ }
+
+ Color cFront, cTop, cSide;
+ switch (o.Kind)
+ {
+ case ObstacleKind.Low:
+ cFront = Color.FromArgb(78, 70, 62);
+ cTop = Color.FromArgb(120, 108, 94);
+ cSide = Color.FromArgb(56, 50, 44);
+ break;
+ case ObstacleKind.High:
+ cFront = Color.FromArgb(112, 72, 40);
+ cTop = Color.FromArgb(150, 106, 58);
+ cSide = Color.FromArgb(80, 52, 28);
+ break;
+ default:
+ cFront = Color.FromArgb(64, 60, 58);
+ cTop = Color.FromArgb(104, 96, 90);
+ cSide = Color.FromArgb(46, 44, 42);
+ break;
+ }
+
+ cFront = Cfg.Lerp(cFront, Cfg.Fog, fog);
+ cTop = Cfg.Lerp(cTop, Cfg.Fog, fog);
+ cSide = Cfg.Lerp(cSide, Cfg.Fog, fog);
+
+ // 顶面
+ using (var br = new SolidBrush(cTop))
+ g.FillPolygon(br, new[] { nlt, nrt, frt, flt });
+
+ // 侧面(朝向画面中心的那一面)
+ bool showLeft = cx > 0f;
+ using (var br = new SolidBrush(cSide))
+ {
+ var quad = showLeft
+ ? new[] { nlb, nlt, flt, flb }
+ : new[] { nrb, nrt, frt, frb };
+ g.FillPolygon(br, quad);
+ }
+
+ // 近面
+ using (var br = new SolidBrush(cFront))
+ g.FillPolygon(br, new[] { nlb, nrb, nrt, nlt });
+
+ // 近面描边 + 细节
+ float lw = Math.Max(1f, PS(cx, 0, o.Z) * 1.6f);
+ using (var pen = new Pen(Color.FromArgb(150, 34, 26, 20), lw))
+ g.DrawPolygon(pen, new[] { nlb, nrb, nrt, nlt });
+
+ float s = PS(cx, 0, o.Z);
+ if (s > 0.35f)
+ {
+ if (o.Kind == ObstacleKind.High)
+ {
+ // 横梁上的藤蔓
+ using var vine = new Pen(Cfg.Lerp(Cfg.Vine, Cfg.Fog, fog), Math.Max(1.5f, s * 2.4f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ float topY = (nlt.Y + nrt.Y) * 0.5f;
+ float botY = (nlb.Y + nrb.Y) * 0.5f;
+ for (int k = 0; k < 3; k++)
+ {
+ float fx = nlb.X + (nrb.X - nlb.X) * (0.22f + k * 0.28f);
+ g.DrawLine(vine, fx, topY + (botY - topY) * 0.30f, fx + s * 6f, botY + s * 10f);
+ }
+ }
+ else if (o.Kind == ObstacleKind.Full)
+ {
+ // 石墙刻纹
+ using var pen = new Pen(Color.FromArgb(90, 40, 34, 30), Math.Max(1f, s * 1.8f));
+ for (int k = 1; k <= 2; k++)
+ {
+ float t = k / 3f;
+ float y = nlb.Y + (nlt.Y - nlb.Y) * t;
+ g.DrawLine(pen, nlb.X + s * 4f, y, nrb.X - s * 4f, y);
+ }
+ }
+ else
+ {
+ // 矮石堆的碎石纹理
+ using var br2 = new SolidBrush(Color.FromArgb(70, 46, 38, 30));
+ g.FillEllipse(br2, nlb.X + (nrb.X - nlb.X) * 0.16f, nlb.Y - (nlb.Y - nlt.Y) * 0.55f,
+ (nrb.X - nlb.X) * 0.26f, (nlb.Y - nlt.Y) * 0.30f);
+ }
+ }
+ }
+
+ private static void DrawCoin(Graphics g, Coin c)
+ {
+ float fog = Cam.FogAmount(c.Z);
+ if (fog > 0.96f) return;
+
+ Cam.Project(c.CenterX, c.Y, c.Z, out float sx, out float sy, out float s);
+ float r = Math.Min(10f * s, 46f);
+ if (r < 0.6f) return;
+
+ float wobble = MathF.Abs(MathF.Cos(c.Phase));
+ float rx = r * (0.30f + 0.70f * wobble);
+ float ry = r;
+
+ int fade = (int)(255 * (1f - fog * 0.9f));
+ Color gold = Cfg.Lerp(Cfg.Gold, Cfg.Fog, fog * 0.7f);
+ Color goldDark = Cfg.Lerp(Cfg.GoldDark, Cfg.Fog, fog * 0.7f);
+
+ // 光晕
+ using (var glow = new SolidBrush(Color.FromArgb(Math.Max(0, fade / 6), gold)))
+ g.FillEllipse(glow, sx - ry * 1.4f, sy - ry * 1.4f, ry * 2.8f, ry * 2.8f);
+
+ using (var br = new SolidBrush(Color.FromArgb(fade, gold)))
+ g.FillEllipse(br, sx - rx, sy - ry, rx * 2, ry * 2);
+
+ using (var pen = new Pen(Color.FromArgb(fade, goldDark), Math.Max(1f, r * 0.16f)))
+ g.DrawEllipse(pen, sx - rx, sy - ry, rx * 2, ry * 2);
+
+ // 高光
+ if (r > 3f)
+ {
+ using var hl = new SolidBrush(Color.FromArgb((int)(fade * 0.75f), 255, 250, 220));
+ g.FillEllipse(hl, sx - rx * 0.42f, sy - ry * 0.52f, rx * 0.46f, ry * 0.40f);
+ }
+ }
+
+ // ---------------------------------------------------------------- 粒子
+ private static void DrawParticles(Graphics g, GameWorld world)
+ {
+ var list = world.Particles;
+ for (int i = 0; i < list.Count; i++)
+ {
+ var p = list[i];
+ float t = p.Life / p.MaxLife;
+ int alpha = (int)(255 * (1f - t * t));
+ if (alpha <= 4) continue;
+
+ Cam.Project(p.X, p.Y, p.Z, out float sx, out float sy, out float s);
+ float r = Math.Max(0.8f, p.Size * s * (1f - t * 0.45f));
+
+ using var br = new SolidBrush(Color.FromArgb(alpha, p.Color));
+ g.FillEllipse(br, sx - r, sy - r, r * 2, r * 2);
+ }
+ }
+
+ // ---------------------------------------------------------------- 玩家
+ private static void DrawPlayer(Graphics g, GameWorld world)
+ {
+ var h = world.Hero;
+
+ float footX = PX(h.X, h.Y, 0f);
+ float footY = PY(h.X, h.Y, 0f);
+ float headY = PY(h.X, h.Y + h.Height, 0f);
+ float s = PS(h.X, 0f, 0f);
+
+ float bodyH = Math.Max(3f, footY - headY);
+
+ // ---- 地面阴影 ----
+ float shX = PX(h.X, 0f, 0f);
+ float shY = PY(h.X, 0f, 0f);
+ float lift = Math.Clamp(h.Y / 220f, 0f, 1f);
+ float shW = 58f * s * (1f - lift * 0.55f);
+ float shH = 17f * s * (1f - lift * 0.50f);
+ if (shW > 1f)
+ {
+ using var sb = new SolidBrush(Color.FromArgb((int)(120 * (1f - lift)), 0, 0, 0));
+ g.FillEllipse(sb, shX - shW * 0.5f, shY - shH * 0.5f, shW, shH);
+ }
+
+ if (world.State == RunState.GameOver && !h.Alive)
+ {
+ // 倒地:简单压扁 + 变灰
+ DrawHero(g, footX, footY, bodyH, s, h, flattened: true);
+ return;
+ }
+
+ DrawHero(g, footX, footY, bodyH, s, h, flattened: false);
+ }
+
+ private static void DrawHero(Graphics g, float footX, float footY, float bodyH, float s,
+ Player h, bool flattened)
+ {
+ // 影子偏移(换道侧倾)
+ float lean = flattened ? 0f : h.Tilt;
+ float cx = footX + lean * bodyH * 0.45f;
+
+ Color cloth = flattened ? Color.FromArgb(150, 110, 100, 96) : Cfg.Player;
+ Color clothDark = flattened ? Color.FromArgb(150, 84, 74, 70) : Color.FromArgb(176, 62, 40);
+ Color skin = flattened ? Color.FromArgb(150, 170, 150, 132) : Color.FromArgb(242, 196, 152);
+ Color hair = Color.FromArgb(48, 36, 30);
+
+ float unit = bodyH / 62f; // 1 = 站立时的相对比例
+
+ using var penBody = new Pen(cloth, Math.Max(2.2f, bodyH * 0.155f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ using var penLimb = new Pen(clothDark, Math.Max(2f, bodyH * 0.115f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+
+ float hipY = footY - bodyH * 0.42f;
+ float shoulderY = footY - bodyH * 0.76f;
+
+ if (flattened)
+ {
+ // 趴倒
+ float len = bodyH * 1.05f;
+ using var bodyPen = new Pen(cloth, Math.Max(2.4f, bodyH * 0.17f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ g.DrawLine(bodyPen, cx - len * 0.32f, footY - bodyH * 0.16f, cx + len * 0.38f, footY - bodyH * 0.10f);
+ using var headBr = new SolidBrush(skin);
+ float hr = bodyH * 0.135f;
+ g.FillEllipse(headBr, cx + len * 0.34f - hr, footY - bodyH * 0.26f - hr, hr * 2, hr * 2);
+ return;
+ }
+
+ // ---- 腿 ----
+ float swing = MathF.Sin(h.RunPhase);
+ bool airborne = !h.OnGround;
+ float legLen = bodyH * 0.42f;
+
+ void DrawLeg(float sign)
+ {
+ float lift = airborne ? 0.42f : MathF.Max(0f, sign * swing) * 0.75f;
+ float fx = cx + sign * 9f * unit + (airborne ? 0f : sign * swing * 12f * unit);
+ float fy = footY - legLen * (airborne ? 0.45f : lift);
+ float kneeX = cx + sign * 6f * unit + (fx - cx) * 0.4f;
+ float kneeY = (hipY + fy) * 0.5f;
+ g.DrawLine(penLimb, cx + sign * 5f * unit, hipY, kneeX, kneeY);
+ g.DrawLine(penLimb, kneeX, kneeY, fx, fy);
+ }
+ DrawLeg(-1f);
+ DrawLeg(+1f);
+
+ // ---- 躯干 ----
+ float torsoW = bodyH * 0.30f;
+ using (var torso = new SolidBrush(cloth))
+ {
+ var rc = new RectangleF(cx - torsoW * 0.5f, shoulderY, torsoW, hipY - shoulderY);
+ using var rp = RoundRect(rc, torsoW * 0.34f);
+ g.FillPath(torso, rp);
+ }
+
+ // ---- 手臂(与腿反向摆动) ----
+ void DrawArm(float sign)
+ {
+ float a = -sign * swing;
+ float ex = cx + sign * (bodyH * 0.20f) + a * bodyH * 0.15f;
+ float ey = shoulderY + bodyH * 0.26f - MathF.Abs(a) * bodyH * 0.10f;
+ float midX = cx + sign * (bodyH * 0.19f);
+ float midY = shoulderY + bodyH * 0.13f;
+ g.DrawLine(penLimb, cx + sign * bodyH * 0.13f, shoulderY + bodyH * 0.03f, midX, midY);
+ g.DrawLine(penLimb, midX, midY, ex, ey);
+ }
+ DrawArm(-1f);
+ DrawArm(+1f);
+
+ // ---- 头 ----
+ float headR = bodyH * 0.145f;
+ float headCY = shoulderY - headR * 0.95f;
+ using (var hb = new SolidBrush(skin))
+ g.FillEllipse(hb, cx - headR, headCY - headR, headR * 2, headR * 2);
+
+ // 头发(上半圆)
+ using (var hb = new SolidBrush(hair))
+ {
+ var rc = new RectangleF(cx - headR * 1.02f, headCY - headR * 1.10f, headR * 2.04f, headR * 1.35f);
+ using var rp = RoundRect(rc, headR * 0.75f);
+ g.FillPath(hb, rp);
+ }
+
+ // 滑铲时的速度线
+ if (h.Sliding)
+ {
+ using var sl = new Pen(Color.FromArgb(130, 255, 240, 210), Math.Max(1.4f, bodyH * 0.05f))
+ { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ for (int i = 0; i < 3; i++)
+ {
+ float yy = footY - bodyH * (0.10f + i * 0.14f);
+ g.DrawLine(sl, cx - bodyH * 0.42f, yy, cx - bodyH * 0.18f, yy);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- HUD
+ private static void DrawHud(Graphics g, int w, int h, GameWorld world, bool showFps)
+ {
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+
+ // ---- 左上:分数 / 金币 ----
+ DrawChip(g, 18, 14, 236, 92);
+
+ string score = world.Score.ToString();
+ using (var sh = new SolidBrush(Color.FromArgb(130, 0, 0, 0)))
+ g.DrawString(score, _fHuge, sh, 32 + 2, 16 + 2);
+ using (var sb = new SolidBrush(Color.White))
+ g.DrawString(score, _fHuge, sb, 32, 16);
+
+ // 金币行
+ float coinY = 78;
+ float pop = 1f + world.CoinPop * 0.35f;
+ float cr = 9f * pop;
+ using (var gb = new SolidBrush(Cfg.Gold))
+ g.FillEllipse(gb, 34 - cr, coinY - cr, cr * 2, cr * 2);
+ using (var gp = new Pen(Cfg.GoldDark, 2f))
+ g.DrawEllipse(gp, 34 - cr, coinY - cr, cr * 2, cr * 2);
+ using (var sb = new SolidBrush(Color.FromArgb(255, 245, 210)))
+ g.DrawString("× " + world.CoinCount, _fMid, sb, 52, coinY - 13);
+
+ // ---- 右上:速度 / 最佳 ----
+ string speedTxt = "速度 ×" + (world.Speed / Cfg.StartSpeed).ToString("0.00");
+ SizeF st = g.MeasureString(speedTxt, _fMid);
+ DrawChip(g, w - st.Width - 44, 16, st.Width + 26, 34);
+ using (var sb = new SolidBrush(Color.FromArgb(255, 226, 170)))
+ g.DrawString(speedTxt, _fMid, sb, w - st.Width - 31, 24);
+
+ string bestTxt = "最佳 " + world.Best;
+ SizeF bt = g.MeasureString(bestTxt, _fSmall);
+ using (var sb = new SolidBrush(Color.FromArgb(210, 236, 226, 214)))
+ g.DrawString(bestTxt, _fSmall, sb, w - bt.Width - 22, 58);
+
+ if (world.Muted)
+ {
+ using var sb = new SolidBrush(Color.FromArgb(150, 236, 226, 214));
+ g.DrawString("已静音 (M)", _fTiny, sb, w - 96, 82);
+ }
+
+ // ---- 中间状态层 ----
+ switch (world.State)
+ {
+ case RunState.Ready: DrawReady(g, w, h, world); break;
+ case RunState.Paused: DrawPaused(g, w, h); break;
+ case RunState.GameOver: DrawGameOver(g, w, h, world); break;
+ }
+
+ // ---- 右下角操作提示 ----
+ using (var sb = new SolidBrush(Color.FromArgb(120, 240, 234, 226)))
+ g.DrawString("← → 换道 ↑ 跳跃 ↓ 滑铲 P 暂停 M 静音 F1 调试 F11 全屏",
+ _fTiny, sb, 18, h - 26);
+
+ if (showFps)
+ {
+ using var db = new SolidBrush(Color.FromArgb(185, 168, 255, 196));
+ g.DrawString(
+ $"FPS {world.Fps:0} 障碍 {world.Obstacles.Count} 金币 {world.Coins.Count}" +
+ $" 装饰 {world.Props.Count} 粒子 {world.Particles.Count} 速度 {world.Speed:0}",
+ _fTiny, db, 18, h - 46);
+ }
+ }
+
+ private static void DrawReady(Graphics g, int w, int h, GameWorld world)
+ {
+ float cy = h * 0.055f; // 贴顶排布,避免压住画面中央的跑者
+
+ DrawCentered(g, "神庙逃亡", _fHuge, w, cy + 3, Color.FromArgb(130, 0, 0, 0));
+ DrawCentered(g, "神庙逃亡", _fHuge, w, cy, Color.FromArgb(255, 232, 176));
+
+ DrawCentered(g, "TEMPLE RUN", _fTitle, w, cy + 58, Color.FromArgb(214, 176, 120));
+
+ string[] lines =
+ {
+ "← → 左右换道",
+ "↑ 或 空格 跳跃越过矮石堆",
+ "↓ 滑铲穿过悬空横梁",
+ "石墙无法越过,只能换道躲避",
+ "收集金币可提高速度倍率"
+ };
+ for (int i = 0; i < lines.Length; i++)
+ DrawCentered(g, lines[i], _fMid, w, cy + 118 + i * 30, Color.FromArgb(226, 216, 202));
+
+ // 闪烁提示
+ float blink = (MathF.Sin(world.Clock * 4.2f) + 1f) * 0.5f;
+ var c = Cfg.Lerp(Color.FromArgb(120, 255, 236, 190), Color.FromArgb(255, 255, 244, 214), blink);
+ DrawCentered(g, "按 空格 开始奔跑", _fBig, w, cy + 118 + lines.Length * 30 + 26, c);
+ }
+
+ private static void DrawPaused(Graphics g, int w, int h)
+ {
+ using (var ov = new SolidBrush(Color.FromArgb(140, 10, 10, 18)))
+ g.FillRectangle(ov, 0, 0, w, h);
+ DrawCentered(g, "已 暂 停", _fHuge, w, h * 0.38f, Color.White);
+ DrawCentered(g, "按 P / 空格 继续 按 R 重新开始", _fMid, w, h * 0.38f + 70,
+ Color.FromArgb(210, 226, 218, 206));
+ }
+
+ private static void DrawGameOver(Graphics g, int w, int h, GameWorld world)
+ {
+ bool newRecord = world.Score >= world.Best && world.Score > 0;
+
+ float pw = Math.Min(520f, w - 80f);
+ float ph = 330f;
+ float px = (w - pw) * 0.5f;
+ float py = h * 0.5f - ph * 0.5f;
+
+ using (var ov = new SolidBrush(Color.FromArgb(150, 8, 8, 14)))
+ g.FillRectangle(ov, 0, 0, w, h);
+
+ var panel = new RectangleF(px, py, pw, ph);
+ using (var pp = RoundRect(panel, 18f))
+ {
+ using var pb = new SolidBrush(Color.FromArgb(226, 26, 24, 34));
+ g.FillPath(pb, pp);
+ using var pen = new Pen(Color.FromArgb(200, 214, 168, 96), 2.4f);
+ g.DrawPath(pen, pp);
+ }
+
+ DrawCentered(g, "游 戏 结 束", _fTitle, w, py + 24, Color.FromArgb(255, 138, 106));
+
+ float y = py + 92;
+ DrawStatRow(g, px, pw, y, "本局得分", world.Score.ToString(),
+ newRecord ? Cfg.Gold : Color.White, _fBig);
+ DrawStatRow(g, px, pw, y + 46, "金币数量", world.CoinCount.ToString(), Color.FromArgb(255, 214, 120), _fBig);
+ DrawStatRow(g, px, pw, y + 92, "奔跑距离", ((int)(world.Distance / 10f)) + " m", Color.FromArgb(200, 226, 216, 200), _fBig);
+ DrawStatRow(g, px, pw, y + 138, "历史最佳", world.Best.ToString(), Color.FromArgb(150, 226, 214, 196), _fBig);
+
+ if (newRecord)
+ DrawCentered(g, "★ 新 纪 录 ★", _fMid, w, py + ph - 88, Cfg.Gold);
+
+ DrawCentered(g, "按 R / 空格 再来一局", _fMid, w, py + ph - 46,
+ Color.FromArgb(226, 236, 226, 210));
+ }
+
+ private static void DrawStatRow(Graphics g, float px, float pw, float y, string label, string value,
+ Color valueColor, Font vf)
+ {
+ using (var lb = new SolidBrush(Color.FromArgb(190, 208, 198, 184)))
+ g.DrawString(label, _fMid, lb, px + 36, y + 6);
+
+ SizeF vs = g.MeasureString(value, vf);
+ using (var vb = new SolidBrush(valueColor))
+ g.DrawString(value, vf, vb, px + pw - 36 - vs.Width, y);
+ }
+
+ private static void DrawCentered(Graphics g, string text, Font f, int w, float y, Color color)
+ {
+ using var br = new SolidBrush(color);
+ SizeF sz = g.MeasureString(text, f);
+ g.DrawString(text, f, br, (w - sz.Width) * 0.5f, y);
+ }
+
+ private static void DrawChip(Graphics g, float x, float y, float w, float h)
+ {
+ var r = new RectangleF(x, y, w, h);
+ using var p = RoundRect(r, 10f);
+ using var br = new SolidBrush(Color.FromArgb(118, 12, 14, 22));
+ g.FillPath(br, p);
+ using var pen = new Pen(Color.FromArgb(86, 226, 198, 140), 1.4f);
+ g.DrawPath(pen, p);
+ }
+
+ private static GraphicsPath RoundRect(RectangleF r, float radius)
+ {
+ var p = new GraphicsPath();
+ float d = Math.Min(radius * 2f, Math.Min(r.Width, r.Height));
+ p.AddArc(r.X, r.Y, d, d, 180, 90);
+ p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
+ p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
+ p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
+ p.CloseFigure();
+ return p;
+ }
+}
diff --git a/TempleRun/GameWorld.cs b/TempleRun/GameWorld.cs
new file mode 100644
index 0000000..0806aff
--- /dev/null
+++ b/TempleRun/GameWorld.cs
@@ -0,0 +1,605 @@
+using System.Media;
+
+namespace TempleRun;
+
+///
+/// 游戏世界:状态机 + 生成器 + 物理 + 碰撞。
+/// 不含任何绘制代码,纯逻辑,便于单独测试。
+///
+public class GameWorld
+{
+ public RunState State = RunState.Ready;
+
+ public readonly Player Hero = new();
+ public readonly List Obstacles = new();
+ public readonly List Coins = new();
+ public readonly List Particles = new();
+ public readonly List Props = new();
+
+ // 统计
+ public float Speed;
+ public float Distance; // 累计前进距离(世界单位)
+ public int CoinCount;
+ public int Best;
+ public int Score => (int)(Distance / 10f) + CoinCount * 10;
+
+ // 表现层效果
+ public float Shake; // 屏幕震动强度(像素)
+ public float HitFlash; // 撞击红闪 0..1
+ public float CoinPop; // 吃金币时的 HUD 弹跳 0..1
+
+ public bool Muted;
+ public float Clock; // 累计运行时间(用于 UI 呼吸/闪烁动画)
+ public float Fps; // 由窗体写入,仅用于调试显示
+
+ /// 死亡动画播完才允许重开,避免手快误触
+ public bool CanRestart => _deathDelay <= 0f;
+
+ private float _nextSpawn; // 距下一组障碍还有多远
+ private float _nextProp; // 距下一组路边装饰还有多远
+ private float _deathDelay; // 死亡动画剩余时间
+ private float _lastCoinSfx;
+ private float _sfxClock;
+
+ private readonly Random _rng = new();
+ private readonly string _bestPath;
+
+ public GameWorld()
+ {
+ try
+ {
+ string dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TempleRun");
+ Directory.CreateDirectory(dir);
+ _bestPath = Path.Combine(dir, "best.txt");
+ if (File.Exists(_bestPath) && int.TryParse(File.ReadAllText(_bestPath), out int b))
+ Best = b;
+ }
+ catch { _bestPath = null; }
+
+ Reset();
+ }
+
+ // ------------------------------------------------------------------
+ // 生命周期
+ // ------------------------------------------------------------------
+
+ public void Reset()
+ {
+ Hero.Reset();
+ Obstacles.Clear();
+ Coins.Clear();
+ Particles.Clear();
+ Props.Clear();
+
+ Speed = Cfg.StartSpeed;
+ Distance = 0;
+ CoinCount = 0;
+ Shake = 0;
+ HitFlash = 0;
+ CoinPop = 0;
+ _deathDelay = 0;
+ _nextSpawn = 1400f;
+ _nextProp = 120f;
+
+ // 预铺一条"石柱长廊",避免开局两侧空荡
+ for (float z = 400; z < Cfg.FarZ; z += 165f)
+ SpawnProp(z);
+
+ State = RunState.Ready;
+ }
+
+ public void Start()
+ {
+ Reset();
+ State = RunState.Playing;
+ }
+
+ // ------------------------------------------------------------------
+ // 输入
+ // ------------------------------------------------------------------
+
+ public bool HandleKey(Keys key)
+ {
+ switch (State)
+ {
+ case RunState.Ready:
+ if (key is Keys.Space or Keys.Enter) { Start(); return true; }
+ break;
+
+ case RunState.Playing:
+ if (key is Keys.Left or Keys.A) { ShiftLane(-1); return true; }
+ if (key is Keys.Right or Keys.D) { ShiftLane(+1); return true; }
+ if (key is Keys.Up or Keys.W or Keys.Space) { Jump(); return true; }
+ if (key is Keys.Down or Keys.S) { Slide(); return true; }
+ if (key is Keys.P or Keys.Escape) { State = RunState.Paused; return true; }
+ break;
+
+ case RunState.Paused:
+ if (key is Keys.P or Keys.Escape or Keys.Space) { State = RunState.Playing; return true; }
+ if (key is Keys.R) { Start(); return true; }
+ break;
+
+ case RunState.GameOver:
+ if (!CanRestart) return true;
+ if (key is Keys.R or Keys.Enter or Keys.Space) { Start(); return true; }
+ break;
+ }
+
+ if (key is Keys.M) { Muted = !Muted; return true; }
+ return false;
+ }
+
+ private void ShiftLane(int dir)
+ {
+ if (!Hero.Alive) return;
+ int t = Math.Clamp(Hero.TargetLane + dir, 0, Cfg.LaneCount - 1);
+ if (t != Hero.TargetLane)
+ Hero.TargetLane = t;
+ }
+
+ private void Jump()
+ {
+ if (!Hero.Alive || !Hero.OnGround) return;
+ Hero.Vy = Cfg.JumpSpeed;
+ Hero.OnGround = false;
+ Hero.Sliding = false;
+ Hero.SlideTimer = 0;
+ SpawnDust(Hero.X, Hero.Y, 0f, 6);
+ }
+
+ private void Slide()
+ {
+ if (!Hero.Alive) return;
+ // 空中按下 → 快速下落(更顺手)
+ if (!Hero.OnGround) Hero.Vy = Math.Min(Hero.Vy, -420f);
+ Hero.Sliding = true;
+ Hero.SlideTimer = Cfg.SlideDuration;
+ SpawnDust(Hero.X, Hero.Y, 0f, 8);
+ }
+
+ // ------------------------------------------------------------------
+ // 主更新
+ // ------------------------------------------------------------------
+
+ public void Update(float dt)
+ {
+ _sfxClock += dt;
+ Clock += dt;
+
+ if (State == RunState.Paused) return;
+
+ if (State == RunState.Ready)
+ {
+ // 待机:背景缓慢滚动 + 原地跑,营造氛围
+ RollWorld(300f * dt);
+ Hero.RunPhase += dt * 7.5f;
+ UpdateParticles(dt);
+ DecayEffects(dt);
+ return;
+ }
+
+ if (State == RunState.GameOver)
+ {
+ UpdateParticles(dt);
+ DecayEffects(dt);
+ if (_deathDelay > 0) _deathDelay -= dt;
+ return;
+ }
+
+ // ---------------- 游戏中 ----------------
+ if (!Hero.Alive)
+ {
+ RollWorld(Speed * dt * 0.25f);
+ UpdateParticles(dt);
+ DecayEffects(dt);
+ _deathDelay -= dt;
+ if (_deathDelay <= 0f) FinishRun();
+ return;
+ }
+
+ // 难度递增
+ float target = Cfg.StartSpeed + Distance * 0.00035f + CoinCount * Cfg.SpeedPerCoin;
+ Speed = Math.Min(Cfg.MaxSpeed, Math.Max(Speed, target));
+ Speed += Cfg.SpeedAccel * dt;
+ Speed = Math.Min(Speed, Cfg.MaxSpeed);
+
+ float move = Speed * dt;
+ Distance += move;
+
+ UpdatePlayer(dt);
+ RollWorld(move);
+ UpdateEntities(dt, move);
+ Generate(move);
+ CheckCollisions();
+ UpdateParticles(dt);
+ DecayEffects(dt);
+ }
+
+ private void DecayEffects(float dt)
+ {
+ Shake = Math.Max(0f, Shake - dt * 60f);
+ HitFlash = Math.Max(0f, HitFlash - dt * 2.6f);
+ CoinPop = Math.Max(0f, CoinPop - dt * 4f);
+ }
+
+ // ------------------------------------------------------------------
+ // 玩家
+ // ------------------------------------------------------------------
+
+ private void UpdatePlayer(float dt)
+ {
+ float targetX = Cfg.LaneX(Hero.TargetLane);
+ float k = 1f - MathF.Exp(-Cfg.LaneLerp * dt); // 帧率无关的指数插值
+ float dx = targetX - Hero.X;
+ Hero.X += dx * k;
+ if (Math.Abs(dx) < 0.6f) Hero.X = targetX;
+
+ // 侧倾:换道时身体倾斜
+ float wantTilt = Math.Clamp(dx / Cfg.LaneWidth, -1f, 1f) * 0.32f;
+ Hero.Tilt += (wantTilt - Hero.Tilt) * Math.Min(1f, dt * 9f);
+
+ // 重力
+ if (!Hero.OnGround || Hero.Vy > 0f)
+ {
+ Hero.Vy -= Cfg.Gravity * dt;
+ Hero.Y += Hero.Vy * dt;
+
+ if (Hero.Y <= 0f)
+ {
+ Hero.Y = 0f;
+ Hero.Vy = 0f;
+ if (!Hero.OnGround)
+ {
+ Hero.OnGround = true;
+ SpawnDust(Hero.X, 0f, 0f, 8);
+ }
+ }
+ }
+
+ // 滑铲计时
+ if (Hero.Sliding)
+ {
+ Hero.SlideTimer -= dt;
+ if (Hero.SlideTimer <= 0f) Hero.Sliding = false;
+ }
+
+ // 跑动动画(速度越快腿摆得越快)
+ Hero.RunPhase += dt * (4.2f + Speed / 165f);
+
+ // 跑动扬尘
+ if (Hero.OnGround && !Hero.Sliding && _rng.NextDouble() < dt * 26)
+ SpawnDust(Hero.X + (float)(_rng.NextDouble() - 0.5) * 30f, 0f, -8f, 1);
+ }
+
+ /// 世界向玩家方向滚动:所有实体 Z 减少。
+ private void RollWorld(float move)
+ {
+ Hero.X += 0f;
+
+ for (int i = Obstacles.Count - 1; i >= 0; i--)
+ Obstacles[i].Z -= move;
+ for (int i = Coins.Count - 1; i >= 0; i--)
+ Coins[i].Z -= move;
+ for (int i = Props.Count - 1; i >= 0; i--)
+ Props[i].Z -= move;
+ for (int i = Particles.Count - 1; i >= 0; i--)
+ Particles[i].Z -= move;
+ }
+
+ private void UpdateEntities(float dt, float move)
+ {
+ // 金币旋转 + 回收
+ for (int i = Coins.Count - 1; i >= 0; i--)
+ {
+ var c = Coins[i];
+ c.Phase += dt * 5.5f;
+ if (c.Taken || c.Z < -260f) Coins.RemoveAt(i);
+ }
+
+ // 障碍回收
+ for (int i = Obstacles.Count - 1; i >= 0; i--)
+ if (Obstacles[i].Z < -300f) Obstacles.RemoveAt(i);
+
+ // 装饰回收
+ for (int i = Props.Count - 1; i >= 0; i--)
+ if (Props[i].Z < -300f) Props.RemoveAt(i);
+ }
+
+ // ------------------------------------------------------------------
+ // 生成器
+ // ------------------------------------------------------------------
+
+ private void Generate(float move)
+ {
+ _nextSpawn -= move;
+ if (_nextSpawn <= 0f)
+ {
+ SpawnRow(Cfg.FarZ);
+ float sec = (float)(Cfg.SpawnIntervalMin +
+ _rng.NextDouble() * (Cfg.SpawnIntervalMax - Cfg.SpawnIntervalMin));
+ // 速度越快,按"时间间隔"换算成距离间隔,保证反应时间一致
+ _nextSpawn = Math.Max(420f, sec * Speed * 0.62f);
+ }
+
+ _nextProp -= move;
+ if (_nextProp <= 0f)
+ {
+ SpawnProp(Cfg.FarZ);
+ _nextProp = 130f + (float)_rng.NextDouble() * 150f;
+ }
+ }
+
+ /// 生成一组障碍 + 可能附带的金币。保证至少留一条可通行车道。
+ private void SpawnRow(float z)
+ {
+ int laneCount = _rng.NextDouble() < 0.78 ? 1 : 2; // 1~2 条道放障碍
+
+ var lanes = new[] { 0, 1, 2 };
+ // 洗牌
+ for (int i = lanes.Length - 1; i > 0; i--)
+ {
+ int j = _rng.Next(i + 1);
+ (lanes[i], lanes[j]) = (lanes[j], lanes[i]);
+ }
+
+ var used = new List();
+ for (int i = 0; i < laneCount; i++)
+ {
+ int lane = lanes[i];
+ used.Add(lane);
+
+ ObstacleKind kind;
+ double r = _rng.NextDouble();
+ // 两条道同时出现时,避免双"整道墙"把路堵死(剩 1 条空道仍可走,允许)
+ kind = r < 0.40 ? ObstacleKind.Low
+ : r < 0.72 ? ObstacleKind.High
+ : ObstacleKind.Full;
+
+ Obstacles.Add(new Obstacle { Z = z, Lane = lane, Kind = kind });
+ }
+
+ // 在空道上撒一串金币
+ if (_rng.NextDouble() < 0.72)
+ {
+ int freeLane = -1;
+ for (int i = 0; i < Cfg.LaneCount; i++)
+ if (!used.Contains(i)) { freeLane = i; break; }
+
+ if (freeLane >= 0)
+ {
+ int n = 3 + _rng.Next(4);
+ float dz = 52f;
+ // 金币以本组障碍为中心分布,稍微错开一点更自然
+ float start = z - (n - 1) * dz * 0.5f + (float)(_rng.NextDouble() - 0.5) * 40f;
+ for (int i = 0; i < n; i++)
+ {
+ Coins.Add(new Coin
+ {
+ Z = start + i * dz,
+ Lane = freeLane,
+ Phase = i * 0.6f
+ });
+ }
+ }
+ }
+ }
+
+ private void SpawnProp(float z)
+ {
+ int side = _rng.NextDouble() < 0.5 ? -1 : 1;
+ int type = _rng.Next(100) switch
+ {
+ < 42 => 0, // 石柱
+ < 62 => 1, // 火把
+ < 90 => 2, // 枯树
+ _ => 3 // 碎石
+ };
+ float h = type switch
+ {
+ 0 => 150f + (float)_rng.NextDouble() * 90f,
+ 1 => 92f,
+ 2 => 130f + (float)_rng.NextDouble() * 70f,
+ _ => 26f + (float)_rng.NextDouble() * 22f
+ };
+ Props.Add(new SideProp
+ {
+ Z = z,
+ Side = side,
+ Type = type,
+ Height = h,
+ Phase = (float)_rng.NextDouble() * 6.28f
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // 碰撞
+ // ------------------------------------------------------------------
+
+ private void CheckCollisions()
+ {
+ float pNear = -Cfg.PlayerHalfDepth;
+ float pFar = Cfg.PlayerHalfDepth;
+ float py0 = Hero.Y + 5f; // 判定区间略微收缩,手感宽容
+ float py1 = Hero.Y + Hero.Height - 5f;
+ float px = Hero.X;
+ const float halfBodyW = 15f;
+
+ // --- 障碍 ---
+ for (int i = 0; i < Obstacles.Count; i++)
+ {
+ var o = Obstacles[i];
+ float oNear = o.Z - o.Depth * 0.5f;
+ float oFar = o.Z + o.Depth * 0.5f;
+ if (oFar < pNear || oNear > pFar) continue; // Z 不重叠
+
+ if (Math.Abs(px - o.CenterX) > o.Width * 0.5f + halfBodyW) continue; // 不在同一道
+
+ // 高度区间是否相交
+ bool hit = py0 < o.TopY && py1 > o.BottomY;
+ //"整道墙"永远相交
+ if (o.Kind == ObstacleKind.Full) hit = true;
+
+ if (hit) { Kill(o); return; }
+
+ if (!o.Scored && o.Z < 0f)
+ {
+ o.Scored = true;
+ // 成功躲避的微小正反馈
+ Shake = Math.Max(Shake, 1.2f);
+ }
+ }
+
+ // --- 金币 ---
+ float coinNear = -34f, coinFar = 34f;
+ for (int i = 0; i < Coins.Count; i++)
+ {
+ var c = Coins[i];
+ if (c.Taken) continue;
+ if (c.Z < coinNear || c.Z > coinFar) continue;
+ if (Math.Abs(px - c.CenterX) > Cfg.LaneWidth * 0.45f) continue;
+ // 金币在 36 高度,跳跃或站立都能吃到,滑铲吃不到
+ float cy = c.Y;
+ if (cy < py0 - 22f || cy > py1 + 22f) continue;
+
+ c.Taken = true;
+ CoinCount++;
+ CoinPop = 1f;
+ Speed = Math.Min(Cfg.MaxSpeed, Speed + Cfg.SpeedPerCoin * 0.35f);
+ SpawnCoinBurst(c.CenterX, c.Y, c.Z);
+ PlayCoinSfx();
+ }
+ }
+
+ private void Kill(Obstacle o)
+ {
+ if (!Hero.Alive) return;
+ Hero.Alive = false;
+ Hero.Sliding = false;
+ _deathDelay = 0.85f;
+ Shake = 16f;
+ HitFlash = 1f;
+ SpawnDebris(o.CenterX, Hero.Y + 26f, 0f);
+ PlayHitSfx();
+ }
+
+ private void FinishRun()
+ {
+ State = RunState.GameOver;
+ if (Score > Best)
+ {
+ Best = Score;
+ try { if (_bestPath != null) File.WriteAllText(_bestPath, Best.ToString()); }
+ catch { /* 忽略写盘失败 */ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // 粒子
+ // ------------------------------------------------------------------
+
+ public void AddParticle(Particle p) => Particles.Add(p);
+
+ private void SpawnDust(float x, float y, float z, int count)
+ {
+ for (int i = 0; i < count; i++)
+ {
+ Particles.Add(new Particle
+ {
+ X = x + (float)(_rng.NextDouble() - 0.5) * 26f,
+ Y = y + (float)_rng.NextDouble() * 8f,
+ Z = z + (float)(_rng.NextDouble() - 0.5) * 20f,
+ Vx = (float)(_rng.NextDouble() - 0.5) * 60f,
+ Vy = 20f + (float)_rng.NextDouble() * 55f,
+ Vz = -30f - (float)_rng.NextDouble() * 40f,
+ MaxLife = 0.35f + (float)_rng.NextDouble() * 0.25f,
+ Life = 0f,
+ Size = 2.4f + (float)_rng.NextDouble() * 3.2f,
+ GravityScale = 0.4f,
+ Color = Color.FromArgb(196, 172, 140)
+ });
+ }
+ }
+
+ private void SpawnCoinBurst(float x, float y, float z)
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ float a = (float)(_rng.NextDouble() * Math.PI * 2);
+ float sp = 40f + (float)_rng.NextDouble() * 90f;
+ Particles.Add(new Particle
+ {
+ X = x, Y = y, Z = z,
+ Vx = (float)Math.Cos(a) * sp,
+ Vy = 50f + (float)_rng.NextDouble() * 100f,
+ Vz = (float)Math.Sin(a) * sp * 0.6f,
+ MaxLife = 0.45f + (float)_rng.NextDouble() * 0.3f,
+ Size = 2.8f + (float)_rng.NextDouble() * 3.4f,
+ GravityScale = 0.8f,
+ Color = _rng.NextDouble() < 0.5 ? Cfg.Gold : Color.FromArgb(255, 240, 170)
+ });
+ }
+ }
+
+ private void SpawnDebris(float x, float y, float z)
+ {
+ for (int i = 0; i < 26; i++)
+ {
+ float a = (float)(_rng.NextDouble() * Math.PI * 2);
+ float sp = 60f + (float)_rng.NextDouble() * 190f;
+ Particles.Add(new Particle
+ {
+ X = x, Y = y, Z = z,
+ Vx = (float)Math.Cos(a) * sp,
+ Vy = 40f + (float)_rng.NextDouble() * 210f,
+ Vz = (float)Math.Sin(a) * sp,
+ MaxLife = 0.6f + (float)_rng.NextDouble() * 0.5f,
+ Size = 3f + (float)_rng.NextDouble() * 5f,
+ GravityScale = 1f,
+ Color = _rng.NextDouble() < 0.5
+ ? Color.FromArgb(210, 90, 60)
+ : Color.FromArgb(150, 140, 130)
+ });
+ }
+ }
+
+ private void UpdateParticles(float dt)
+ {
+ for (int i = Particles.Count - 1; i >= 0; i--)
+ {
+ var p = Particles[i];
+ p.Life += dt;
+ if (p.Life >= p.MaxLife) { Particles.RemoveAt(i); continue; }
+
+ p.X += p.Vx * dt;
+ p.Y += p.Vy * dt;
+ p.Z += p.Vz * dt;
+ p.Vy -= Cfg.Gravity * p.GravityScale * dt;
+
+ if (p.Y < 0f)
+ {
+ p.Y = 0f;
+ p.Vy *= -0.32f; // 轻微反弹
+ p.Vx *= 0.6f;
+ p.Vz *= 0.6f;
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // 音效(系统提示音,零依赖;M 键静音)
+ // ------------------------------------------------------------------
+
+ private void PlayCoinSfx()
+ {
+ if (Muted || _sfxClock - _lastCoinSfx < 0.07f) return;
+ _lastCoinSfx = _sfxClock;
+ try { SystemSounds.Asterisk.Play(); } catch { }
+ }
+
+ private void PlayHitSfx()
+ {
+ if (Muted) return;
+ try { SystemSounds.Hand.Play(); } catch { }
+ }
+}
diff --git a/TempleRun/Program.cs b/TempleRun/Program.cs
new file mode 100644
index 0000000..03cbeb0
--- /dev/null
+++ b/TempleRun/Program.cs
@@ -0,0 +1,14 @@
+namespace TempleRun;
+
+internal static class Program
+{
+ [STAThread]
+ private static void Main()
+ {
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ Application.SetHighDpiMode(HighDpiMode.SystemAware);
+
+ Application.Run(new GameForm());
+ }
+}
diff --git a/TempleRun/TempleRun.csproj b/TempleRun/TempleRun.csproj
new file mode 100644
index 0000000..e210898
--- /dev/null
+++ b/TempleRun/TempleRun.csproj
@@ -0,0 +1,18 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ disable
+ enable
+ latest
+ TempleRun
+ TempleRun
+ app.manifest
+ zh-Hans
+
+ true
+
+
+
diff --git a/TempleRun/_shot/01_ready.png b/TempleRun/_shot/01_ready.png
new file mode 100644
index 0000000..fadb194
Binary files /dev/null and b/TempleRun/_shot/01_ready.png differ
diff --git a/TempleRun/_shot/02_playing.png b/TempleRun/_shot/02_playing.png
new file mode 100644
index 0000000..468c07d
Binary files /dev/null and b/TempleRun/_shot/02_playing.png differ
diff --git a/TempleRun/_shot/03_playing2.png b/TempleRun/_shot/03_playing2.png
new file mode 100644
index 0000000..ea63eb5
Binary files /dev/null and b/TempleRun/_shot/03_playing2.png differ
diff --git a/TempleRun/_smoke.ps1 b/TempleRun/_smoke.ps1
new file mode 100644
index 0000000..ebb3571
--- /dev/null
+++ b/TempleRun/_smoke.ps1
@@ -0,0 +1,53 @@
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+Add-Type -AssemblyName System.Windows.Forms
+
+Add-Type @"
+using System;
+using System.Runtime.InteropServices;
+public class Win32 {
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
+}
+"@
+
+$root = "c:\Users\32453\CodeBuddy\20260825142915\TempleRun"
+$exe = Join-Path $root "bin\Release\net8.0-windows\TempleRun.exe"
+$out = Join-Path $root "_shot"
+New-Item -ItemType Directory -Force -Path $out | Out-Null
+
+$p = Start-Process -FilePath $exe -PassThru
+Start-Sleep -Seconds 3
+$p.Refresh()
+
+function Grab([string]$name) {
+ $h = $p.MainWindowHandle
+ if ($h -eq [IntPtr]::Zero) { Write-Output "no window"; return }
+ [Win32]::SetForegroundWindow($h) | Out-Null
+ Start-Sleep -Milliseconds 700
+ $r = New-Object 'Win32+RECT'
+ [Win32]::GetWindowRect($h, [ref]$r) | Out-Null
+ $w = $r.Right - $r.Left
+ $ht = $r.Bottom - $r.Top
+ if ($w -le 0 -or $ht -le 0) { Write-Output "bad rect $w x $ht"; return }
+ $bmp = New-Object System.Drawing.Bitmap $w, $ht
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.CopyFromScreen($r.Left, $r.Top, 0, 0, $bmp.Size)
+ $path = Join-Path $out "$name.png"
+ $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
+ $g.Dispose(); $bmp.Dispose()
+ Write-Output "saved $name : $w x $ht"
+}
+
+Grab "01_ready"
+
+[System.Windows.Forms.SendKeys]::SendWait(" ")
+Start-Sleep -Seconds 4
+Grab "02_playing"
+
+Start-Sleep -Seconds 4
+Grab "03_playing2"
+
+try { $p.Kill() } catch {}
+Write-Output "exit=$($p.HasExited)"
diff --git a/TempleRun/app.manifest b/TempleRun/app.manifest
new file mode 100644
index 0000000..cdd7f40
--- /dev/null
+++ b/TempleRun/app.manifest
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs b/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs
new file mode 100644
index 0000000..2ed70c5
--- /dev/null
+++ b/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs
@@ -0,0 +1,103 @@
+using F9WebAiTest.Application.Common;
+using F9WebAiTest.Application.Dtos;
+using F9WebAiTest.Application.Dtos.Attendance;
+using F9WebAiTest.Application.Services;
+using F9WebAiTest.Application.Services.Attendance;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace F9WebAiTest.Api.Controllers.Attendance;
+
+[ApiController]
+[Route("api/attendance")]
+[Authorize]
+public class AttendanceController : ControllerBase
+{
+ private readonly IAttendanceService _service;
+
+ public AttendanceController(IAttendanceService service)
+ {
+ _service = service;
+ }
+
+ // POST /api/attendance/punch 打卡
+ [HttpPost("punch")]
+ public async Task Punch()
+ {
+ var result = await _service.PunchAsync(GetUserId());
+ return Ok(ApiResult.Ok(result));
+ }
+
+ // GET /api/attendance/my 我的考勤记录
+ [HttpGet("my")]
+ public async Task GetMy([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10)
+ {
+ var result = await _service.GetMyRecordsAsync(GetUserId(), pageIndex, pageSize);
+ return Ok(ApiResult>.Ok(result));
+ }
+
+ // GET /api/attendance/my/summary 我的考勤统计
+ [HttpGet("my/summary")]
+ public async Task GetMySummary([FromQuery] string month)
+ {
+ var result = await _service.GetMySummaryAsync(GetUserId(), month);
+ return Ok(ApiResult.Ok(result));
+ }
+
+ // GET /api/attendance 全部考勤(管理员)
+ [HttpGet]
+ public async Task GetAll([FromQuery] AttendanceQueryDto query)
+ {
+ var result = await _service.GetAllRecordsAsync(query);
+ return Ok(ApiResult>.Ok(result));
+ }
+
+ // POST /api/attendance/leave 申请请假
+ [HttpPost("leave")]
+ public async Task ApplyLeave([FromBody] CreateLeaveDto dto)
+ {
+ try
+ {
+ var result = await _service.ApplyLeaveAsync(GetUserId(), dto);
+ return Ok(ApiResult.Ok(result, "申请已提交"));
+ }
+ catch (ServiceException ex)
+ {
+ return StatusCode(ex.Code, ApiResult