From 6096c2ca5701e12831bb296e00f821c1a3d4cd04 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:13:49 +0800 Subject: [PATCH 01/64] =?UTF-8?q?docs:=20=E5=88=9B=E4=B8=9A=E6=B2=99?= =?UTF-8?q?=E7=9B=98=20Godot=202D=20=E5=85=A8=E6=A0=88=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-07-13-business-sandbox-godot-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md diff --git a/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md b/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md new file mode 100644 index 0000000..112ec47 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md @@ -0,0 +1,269 @@ +# 创业沙盘 — Godot 2D 全栈实现设计文档 + +> 基于 PDF 创业沙盘规则,复刻一款 2D 商战模拟游戏。 +> 关联项目:Mock-Court(模拟仲裁庭),复用其 Rust API / Next.js 多 Agent 基础设施。 + +--- + +## 1. 项目概述 + +**项目名称**:创业沙盘(商业全价值链模拟器) + +**目标**:基于 PDF 沙盘规则,使用 Godot 2D 前端 + Rust 后端 + Next.js AI Agent 架构,复刻一款多公司在竞争环境下进行 11 年经营推演的商战模拟游戏。 + +**核心交互**:点击式操作,后端实时处理复杂财务和制造逻辑,前端展示直观的图文状态。 + +### 与现有项目的关系 + +该项目是对现有 Mock-Court 项目的能力扩展: +- **复用**:Rust API 的基础设施(网络框架、状态管理)、Next.js 多 Agent 编排能力 +- **新增**:商业沙盘游戏逻辑(全新的领域)、Godot 2D 前端(替换 Next.js 前端) +- **共存**:仲裁庭业务线保留,通过运行时配置切换领域 + +--- + +## 2. 技术架构 + +### 2.1 整体架构 + +``` +┌─────────────────────────────────────────────┐ +│ 🕹️ Godot 4.2+ Client (纯 2D) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ 顶部状态栏 │ │ 中部经营区│ │ 底部财务操作区│ │ +│ │ 年份/现金 │ │ 生产线视窗 │ │ 贷款/按钮 │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 弹窗系统 (年报/订货会/贴现确认/破产) │ │ +│ └─────────────────────────────────────────┘ │ +└──────────────────┬──────────────────────────┘ + │ WebSocket (JSON) +┌──────────────────▼──────────────────────────┐ +│ 🦀 Rust Game Server (端口 3097,复用项目) │ +│ ┌────────────────────────────────────────┐ │ +│ │ 状态维护 | 规则引擎 | 财务计算 │ │ +│ │ 时序逻辑 (年初→4季度→年末) │ │ +│ │ 破产判定 | 贴现计算 | 折旧计提 │ │ +│ └────────────────────────────────────────┘ │ +└──────────────────┬──────────────────────────┘ + │ HTTP / 内部调用 +┌──────────────────▼──────────────────────────┐ +│ ⚛️ Next.js AI Agent 编排 │ +│ ┌────────────────────────────────────────┐ │ +│ │ 多 Agent 群 (5家竞争对手) │ │ +│ │ 群体决策 → JSON 动作 → 输入 Rust │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +### 2.2 技术选型 + +| 层级 | 技术 | 版本 | 说明 | +|------|------|------|------| +| 前端引擎 | Godot | 4.x | 纯 2D,`CanvasItem` + `Control` 节点 | +| 脚本语言 | GDScript | - | Godot 原生脚本,与引擎深度集成 | +| 通信协议 | WebSocket | - | 全双工 JSON 通信 | +| 后端 | Rust | - | 游戏状态维护、规则校验、财务计算 | +| AI 对手 | Next.js | - | 多 Agent 编排,群体决策输出 JSON | +| UI 策略 | Stretch 模式 | - | `Control` 容器布局,窗口自适应 | + +--- + +## 3. 核心游戏循环与状态机 + +### 3.1 时序逻辑(基于 PDF P.10) + +Rust 后端维护唯一完整的游戏状态。前端职责:接收状态并渲染 → 接收用户输入并发送。 + +**年度流程:** + +1. **年初** + - 玩家决策:市场投资、长期贷款、买/租工厂、拟定竞标策略 + - 发送决策数据给 Rust + +2. **季度运营(循环 4 次)** + Rust 按顺序执行以下步骤,每步向前端推送状态更新: + 1. 更新应收账款 + 2. 更新短期贷款利息 + 3. 产品研发进度推进 + 4. 供应商交货/付款 + 5. 新原料订单处理 + 6. 更新生产线状态 + 7. 产线建设/转产 + 8. 新生产/付费 + 9. 订单交付 + 10. 支付行政管理费 + +3. **年末** + - 支付产线维护费 + - 计提 20% 固定资产折旧 + - 支付长期贷款利息 + - 缴纳所得税 + - 生成年度财务报表 + +### 3.2 游戏阶段(对应 PDF 章节) + +| 阶段 | 年份 | 主题 | 核心机制 | +|------|------|------|---------| +| 第1节 | 第1-4年 | 适应期 | 单项产品起步,手工/半自动线,学习规则 | +| 第2节 | 第5-7年 | 市场竞争 | 多产品线,BCG 矩阵,ABC 成本法,差异化策略 | +| 第3节 | 第8-11年 | 高阶经营 | 杜邦分析,ROE/EVA 考核,公司估值 | +| 第4节 | 反思复盘 | - | 现金流分析,商业模式评估,团队效率总结 | + +--- + +## 4. Godot 2D UI 结构 + +### 4.1 整体布局(顶部-中部-底部-弹出层) + +#### A. 顶部状态栏 (`Control` 锚定 Top) +- **左侧**:年份(`Label`)、当前季度(`TextureRect` 图标或文字) +- **右侧**:现金流(金币图标 + 数值)、总资产、总负债 + +#### B. 中部核心经营区 +4 条水平生产线垂直排列(`VBoxContainer`),每条为一个 `ProductionLine` 自定义节点: +- **左侧**:产线名称(手工线/半自动/全自动) +- **中部**:`TextureProgressBar` 或 5 个 `TextureRect` 占位符轨道,标记在产/停工/转产中/建设中 +- **右侧**:原料输入槽(桶标志)和成品输出槽(小车标志) + +#### C. 底部财务与操作区(`GridContainer`) +- **银行卡池**:4 个长贷槽位(1-4年)+ 4 个短贷槽位(1-4季),不同颜色金币堆叠 +- **操作按钮**: + - "下一年初开会" + - "下一季度执行" + - "查看年度报告" + - "紧急贴现(弹窗确认)" + +#### D. 独立弹窗系统 +- **年度报告弹窗**:损益表 / 总资产表 / 费用表 / 负债与股东权益表(四标签页,可滑动) +- **订货会弹窗**:市场预测图 + 广告费输入 + 订单卡选择(产品/批次/单价/账期) +- **贴现确认弹窗**:"手续费为应收账款 1/14 取整,是否确认?" +- **破产弹窗**:黑色遮罩 + 游戏结束原因 + +### 4.2 自适应策略 +- 启用 `Stretch` 模式 +- 使用 `Control` 节点的容器布局(`HBoxContainer` / `VBoxContainer` / `GridContainer`) +- 确保窗口缩放时控件按比例自适应 + +--- + +## 5. 通信协议 + +### 5.1 WebSocket 连接 +- Godot 客户端连接 Rust 服务器的 WebSocket 端口 +- 全双工 JSON 通信 +- Godot 端 `WebSocketManager` 单例管理连接 + +### 5.2 状态推送 JSON 格式 + +```json +{ + "game_year": 2, + "game_quarter": 3, + "cash": 32000000, + "long_term_loans": [ + {"year": 1, "amount": 20000000, "status": "active"}, + {"year": 2, "amount": 40000000, "status": "active"} + ], + "accounts_receivable": [ + {"amount": 36000000, "due_quarter": 2} + ], + "factories": [ + { + "id": "old_factory", + "capacity": 4, + "lines": [ + {"id": 1, "type": "manual", "product": "奔马", "status": "idle", "progress": 0}, + {"id": 2, "type": "fully_auto", "product": "none", "status": "building", "progress": 3} + ] + } + ], + "product_rd": { + "飞鹰": { "status": "in_progress", "progress": 4, "total": 6 } + }, + "message": "季度执行完毕,扣除行政管理费1M" +} +``` + +### 5.3 事件推送 +- `{"event": "game_over", "reason": "cash_flow_break"}` — 游戏结束 +- `{"event": "quarter_complete", ...state}` — 季度执行完毕 +- `{"event": "year_end", ...state}` — 年末结算 +- `{"event": "ai_bids", "competitors": [...]}` — AI 对手竞标策略 + +--- + +## 6. 核心游戏规则(硬编码至 Rust 后端) + +| 规则 | 说明 | 前端表现 | +|------|------|---------| +| **贴现** | 手续费为应收账款 1/14 取整 | 弹窗确认提示 | +| **产线转产** | 全自动线 2 季度 4M,半自动 1 季度 1M,手工 0 成本 | 倒计时显示 | +| **固定资产折旧** | 年底产线资产价值强制减少 20% | UI 数值更新 | +| **破产判定** | 股东权益 < 0 或现金流断裂 | 黑色遮罩 + 冻结操作 | +| **季度管理费** | 每季度扣除 1M | 现金数值实时更新 | +| **所得税** | 年度利润按税率缴税 | 年度报表显示 | + +--- + +## 7. 实施路线图 + +### 阶段 1:地基(UI 骨架 + WebSocket 通信) +- Godot:搭建 MainGame 核心场景,三区域布局 +- Godot:`WebSocketManager` 单例,连接 `127.0.0.1:8000` +- Rust:简单 WebSocket 回声/测试服务端 +- 验证:UI 节点能根据 JSON 改变文字 + +### 阶段 2:财务与时间循环 +- Rust:实现时序引擎(年初→4季度→年末) +- Rust:核心财务计算(现金流、贷款、折旧、税务) +- Godot:各 UI 面板绑定状态更新函数 +- 验证:点击"下一季度",现金减少 1M,状态面板同步更新 + +### 阶段 3:AI 对手接入 +- Next.js:实现 5 个 AI 竞争对手的多 Agent 编排 +- Rust:接收 AI 竞标策略并融入游戏逻辑 +- Godot:市场面板展示 AI 对手状态(激进/稳健/破产) + +### 阶段 4:动态资产动画 +- 将占位符色块替换为贴图 +- 用 `Tween` 动画实现原料→产线→成品的流动效果 +- 发布:Web (HTML5) + 桌面双端构建 + +--- + +## 8. 目录结构规划 + +``` +rust-agent/ +├── godot/ # Godot 项目目录(新增) +│ ├── project.godot +│ ├── scenes/ +│ │ ├── MainGame.tscn # 主游戏场景 +│ │ ├── TopBar.tscn # 顶部状态栏 +│ │ ├── ProductionLine.tscn # 生产线节点 +│ │ ├── BottomPanel.tscn # 底部操作区 +│ │ └── popups/ # 弹窗场景 +│ │ ├── AnnualReport.tscn +│ │ ├── OrderMeeting.tscn +│ │ ├── DiscountConfirm.tscn +│ │ └── GameOver.tscn +│ ├── scripts/ +│ │ ├── main_game.gd # 主场景脚本 +│ │ ├── websocket_manager.gd # WebSocket 单例 +│ │ ├── top_bar.gd +│ │ ├── production_line.gd +│ │ ├── bottom_panel.gd +│ │ └── popups/ +│ ├── ui/ # UI 主题/字体/贴图 +│ └── tests/ # Godot 测试 +├── rust/ # 已有 +│ └── api/src/ +│ └── routes/ +│ └── business_sandbox.rs # 新增:商业沙盘 WebSocket 路由 +├── nextjs/ # 已有(AI Agent 编排层) +│ └── app/api/ +│ └── business-ai/ # 新增:商业 AI 对手接口 +└── docs/superpowers/specs/ + └── 2026-07-13-business-sandbox-godot-design.md # 本文件 +``` From f178a1ca731c432343310c1ed183ebc5d7ebcf0c Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:20:30 +0800 Subject: [PATCH 02/64] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=88=9B?= =?UTF-8?q?=E4=B8=9A=E6=B2=99=E7=9B=98=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=92=8CRust=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-13-business-sandbox-rust.md | 1820 +++++++++++++++++ ...026-07-13-business-sandbox-godot-design.md | 135 +- 2 files changed, 1927 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-business-sandbox-rust.md diff --git a/docs/superpowers/plans/2026-07-13-business-sandbox-rust.md b/docs/superpowers/plans/2026-07-13-business-sandbox-rust.md new file mode 100644 index 0000000..a112f00 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-business-sandbox-rust.md @@ -0,0 +1,1820 @@ +# 创业沙盘 Rust 游戏引擎实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking. + +**Goal:** 在现有 Rust 项目中实现创业沙盘游戏引擎(状态管理 + 游戏循环 + 财务计算 + AI 竞争对手 + WebSocket 通信),不修改现有仲裁庭代码。 + +**架构:** Godot 2D ← WebSocket (JSON) → Rust 游戏引擎。Rust 维护唯一完整游戏状态,AI 竞争对手复用现有 GatewayRegistry / Soul 系统。 + +**Tech Stack:** Rust, Axum, tokio, sqlite (复用已有) + +**设计文档:** `docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md` + +--- + +## 文件结构 + +### 新增文件 +| 文件 | 职责 | +|------|------| +| `rust/api/src/business_sandbox/mod.rs` | 模块入口,重导出 | +| `rust/api/src/business_sandbox/state.rs` | 游戏状态数据结构 | +| `rust/api/src/business_sandbox/engine.rs` | 游戏引擎(时序循环 + 规则执行) | +| `rust/api/src/business_sandbox/finance.rs` | 财务计算(贴现、折旧、贷款、税务) | +| `rust/api/src/business_sandbox/production.rs` | 生产与供应链(产品、产线、库存) | +| `rust/api/src/business_sandbox/market.rs` | 市场与订单(营销、选单、交付) | +| `rust/api/src/business_sandbox/ai_competitor.rs` | AI 竞争对手(复用 Gateway 调用 Soul) | +| `rust/api/src/business_sandbox/ws_handler.rs` | WebSocket 路由 + 事件广播 | +| `rust/api/src/business_sandbox/errors.rs` | 错误类型定义 | + +### 修改文件 +| 文件 | 改动 | +|------|------| +| `rust/api/src/main.rs` | 新增 `mod business_sandbox` + WS 路由挂载 | +| `rust/api/src/state.rs` | 新增 `BusinessSandboxManager` 字段 | +| `rust/api/src/routes/mod.rs` | 无改动(WS 路由在 main.rs 级别挂载) | + +--- + +### Task 1: 游戏状态数据结构 + +**File:** `rust/api/src/business_sandbox/state.rs` + +- [ ] **Step 1: 创建目录和 mod 文件** + +```bash +mkdir -p rust/api/src/business_sandbox +``` + +Write `rust/api/src/business_sandbox/mod.rs`: + +```rust +pub mod state; +pub mod engine; +pub mod finance; +pub mod production; +pub mod market; +pub mod ai_competitor; +pub mod ws_handler; +pub mod errors; +``` + +- [ ] **Step 2: 定义核心枚举和类型** + +Write `rust/api/src/business_sandbox/state.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +/// 产品类型 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum Product { + BenMa, // 奔马 - 基础产品 + MengHu, // 猛虎 - 升级产品 + FeiYing, // 飞鹰 - 中级产品 + TianLong, // 天龙 - 高级产品 +} + +impl Product { + pub fn raw_material_cost(&self) -> u32 { + match self { + Product::BenMa => 1, + Product::MengHu => 2, + Product::FeiYing => 3, + Product::TianLong => 4, + } + } + + pub fn production_cost(&self) -> u32 { + match self { + Product::BenMa | Product::MengHu => 1, + Product::FeiYing | Product::TianLong => 2, + } + } + + pub fn name_cn(&self) -> &'static str { + match self { + Product::BenMa => "奔马", + Product::MengHu => "猛虎", + Product::FeiYing => "飞鹰", + Product::TianLong => "天龙", + } + } +} + +/// 生产线类型 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum LineType { + Manual, // 手工线 + SemiAuto, // 半自动线 + FullAuto, // 全自动线 +} + +impl LineType { + pub fn build_time(&self) -> u32 { // 季度数 + match self { + LineType::Manual => 1, + LineType::SemiAuto => 4, + LineType::FullAuto => 4, + } + } + + pub fn build_cost_per_quarter(&self) -> u32 { + match self { + LineType::Manual => 4, + LineType::SemiAuto => 2, + LineType::FullAuto => 4, + } + } + + pub fn total_build_cost(&self) -> u32 { + match self { + LineType::Manual => 4, + LineType::SemiAuto => 8, + LineType::FullAuto => 16, + } + } + + pub fn switch_time(&self) -> u32 { + match self { + LineType::Manual => 0, + LineType::SemiAuto => 1, + LineType::FullAuto => 2, + } + } + + pub fn switch_cost(&self) -> u32 { + match self { + LineType::Manual => 0, + LineType::SemiAuto => 1, + LineType::FullAuto => 4, // 2M/季 × 2季 + } + } + + pub fn salvage_value(&self) -> u32 { + match self { + LineType::Manual => 2, + LineType::SemiAuto => 3, + LineType::FullAuto => 6, + } + } +} + +/// 生产线状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum LineStatus { + Idle, // 空闲 + Producing(Product), // 生产中 + Building(u32), // 建设中(剩余季度数) + Switching(u32), // 转产中(剩余季度数,目标产品) + SwitchingTo(u32, Product), // 转产中(剩余季度,目标产品) +} + +/// 一条生产线 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductionLine { + pub id: u32, + pub line_type: LineType, + pub status: LineStatus, + pub product: Option, // 当前配置生产的产品 +} + +/// 工厂 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Factory { + pub id: String, + pub name: String, + pub capacity: u32, // 可容纳产线数 + pub value: u32, // 固定资产价值(M) + pub lines: Vec, +} + +/// 产品研发状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductRDA { + pub product: Product, + pub progress: u32, // 已研发季度数 + pub total: u32, // 需要总季度数 + pub completed: bool, +} + +/// 应收账款 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountReceivable { + pub amount: u32, + pub due_quarters: u32, // 剩余账期(季度数) +} + +/// 贷款 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Loan { + pub amount: u32, + pub remaining_quarters: u32, // 长贷:剩余年数×4;短贷:剩余季度数 + pub annual_interest_rate: f64, // 年利率 +} + +/// 长期贷款槽位(按年份) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LongTermLoanSlot { + pub year: u32, // 第几年 + pub amount: u32, + pub active: bool, +} + +/// 市场 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Market { + pub name: String, + pub developed: bool, + pub rank: u32, // 公司在该市场的排名 + pub last_year_sales: u32, // 去年在该市场的销售额 +} + +/// 订单 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Order { + pub id: String, + pub product: Product, + pub quantity: u32, // 批次数量 + pub unit_price: u32, // 单价 + pub account_period: u32, // 账期(季度数) + pub delivered: bool, + pub urgent: bool, +} + +/// 竞标策略(由玩家填写) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiddingStrategy { + pub market_name: String, + pub marketing_spend: u32, // 营销投入(M) +} + +/// 完整游戏状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BusinessGameState { + pub game_year: u32, + pub game_quarter: u32, // 1-4 + pub cash: u32, + pub factories: Vec, + pub products_rd: Vec, + pub raw_material_orders: u32, // 待交付的原料订单数 + pub raw_material_inventory: u32, // 原料库存(个数) + pub work_in_progress: Vec, + pub finished_goods: Vec, + pub accounts_receivable: Vec, + pub long_term_loans: Vec, + pub short_term_loans: Vec, + pub markets: Vec, + pub orders: Vec, + pub bidding_strategies: Vec, + pub phase: u32, // 1-4 + pub game_over: bool, + pub game_over_reason: Option, +} + +/// 在制品 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WIPItem { + pub line_id: u32, + pub product: Product, + pub progress: u32, // 生产进度(季度数) +} + +/// 成品库存 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinishedGoods { + pub product: Product, + pub quantity: u32, +} + +/// 年度财务报表 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnualReport { + pub year: u32, + pub income_statement: IncomeStatement, + pub balance_sheet: BalanceSheet, + pub expense_sheet: ExpenseSheet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomeStatement { + pub revenue: u32, + pub cost_of_goods_sold: u32, + pub gross_profit: i32, + pub sales_expense: u32, + pub admin_expense: u32, + pub rd_expense: u32, + pub depreciation: u32, + pub operating_profit: i32, + pub interest_expense: u32, + pub discount_fee: u32, + pub tax: u32, + pub net_profit: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceSheet { + pub cash: u32, + pub accounts_receivable: u32, + pub raw_material: u32, + pub work_in_process: u32, + pub finished_goods: u32, + pub total_current_assets: u32, + pub factory: u32, + pub production_lines: u32, + pub total_fixed_assets: u32, + pub total_assets: u32, + pub long_term_loans: u32, + pub short_term_loans: u32, + pub total_liabilities: u32, + pub equity: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExpenseSheet { + pub new_market_investment: u32, + pub product_rd_investment: u32, + pub quarterly_admin_fees: u32, + pub factory_rent: u32, + pub line_maintenance: u32, + pub product_switch_fees: u32, + pub sales_expenses: u32, + pub depreciation: u32, + pub interest_and_discount: u32, + pub tax: u32, +} +``` + +- [ ] **Step 3: 定义 WebSocket 消息类型** + +Add to `rust/api/src/business_sandbox/state.rs`: + +```rust +/// 服务端推送的游戏事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", content = "data")] +pub enum GameEvent { + #[serde(rename = "state_update")] + StateUpdate(Box), + #[serde(rename = "phase_change")] + PhaseChange { year: u32, quarter: u32, phase: String }, + #[serde(rename = "ask_decision")] + AskDecision { year: u32, quarter: u32, decision_type: String }, + #[serde(rename = "annual_report")] + AnnualReport(Box), + #[serde(rename = "order_meeting")] + OrderMeeting { market: String, available_orders: Vec }, + #[serde(rename = "game_over")] + GameOver { reason: String }, + #[serde(rename = "message")] + Message(String), +} + +/// 客户端发送的操作 +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "action")] +pub enum PlayerAction { + #[serde(rename = "start_game")] + StartGame, + #[serde(rename = "submit_bidding")] + SubmitBidding { strategies: Vec }, + #[serde(rename = "select_order")] + SelectOrder { order_ids: Vec }, + #[serde(rename = "make_decision")] + MakeDecision { decisions: Vec }, + #[serde(rename = "next_quarter")] + NextQuarter, + #[serde(rename = "discount_receivable")] + DiscountReceivable { amount: u32 }, + #[serde(rename = "take_loan")] + TakeLoan { loan_type: String, amount: u32 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecisionItem { + pub key: String, + pub value: serde_json::Value, +} +``` + +- [ ] **Step 4: 定义错误类型** + +Write `rust/api/src/business_sandbox/errors.rs`: + +```rust +use std::fmt; + +#[derive(Debug)] +pub enum GameError { + GameNotFound, + InvalidAction(String), + InsufficientFunds(u32, u32), // need, have + RuleViolation(String), + Internal(String), +} + +impl fmt::Display for GameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GameError::GameNotFound => write!(f, "Game not found"), + GameError::InvalidAction(msg) => write!(f, "Invalid action: {}", msg), + GameError::InsufficientFunds(need, have) => write!(f, "Insufficient funds: need {}, have {}", need, have), + GameError::RuleViolation(msg) => write!(f, "Rule violation: {}", msg), + GameError::Internal(msg) => write!(f, "Internal error: {}", msg), + } + } +} + +impl std::error::Error for GameError {} +``` + +- [ ] **Step 5: Commit** + +```bash +git add rust/api/src/business_sandbox/ +git commit -m "feat(business-sandbox): 定义游戏状态数据结构和消息类型" +``` + +--- + +### Task 2: 财务计算模块 + +**Files:** +- Create: `rust/api/src/business_sandbox/finance.rs` + +- [ ] **Step 1: 实现财务计算函数** + +```rust +use crate::business_sandbox::state::*; + +/// 计算贴现手续费 = 金额 × 1/14,四舍五入取整 +pub fn calc_discount_fee(amount: u32) -> u32 { + ((amount as f64) / 14.0).round() as u32 +} + +/// 计算固定资产折旧 = 原值 × 20%,四舍五入取整 +pub fn calc_depreciation(value: u32) -> u32 { + ((value as f64) * 0.20).round() as u32 +} + +/// 计算长期贷款年利息 +pub fn calc_long_term_interest(principal: u32) -> u32 { + ((principal as f64) * 0.05).round() as u32 +} + +/// 计算短期贷款季度利息(年息10% ÷ 4) +pub fn calc_short_term_quarterly_interest(principal: u32) -> u32 { + ((principal as f64) * 0.10 / 4.0).round() as u32 +} + +/// 计算营业税 = 销售额 × 3% +pub fn calc_sales_tax(revenue: u32) -> u32 { + ((revenue as f64) * 0.03).round() as u32 +} + +/// 计算所得税 = 税前利润 × 20%(利润为负则免) +pub fn calc_income_tax(profit_before_tax: i32) -> u32 { + if profit_before_tax <= 0 { + return 0; + } + ((profit_before_tax as f64) * 0.20).round() as u32 +} + +/// 计算贷款总额度 = 上年度净资产 × 4(长贷2倍 + 短贷2倍) +pub fn calc_loan_limit(net_assets: i32) -> (u32, u32) { + let base = if net_assets > 0 { net_assets as u32 } else { 0 }; + let long_term_limit = base * 2; + let short_term_limit = base * 2; + (long_term_limit, short_term_limit) +} + +/// 验证贷款额度的合法性(最低20M,20M倍数) +pub fn validate_loan_amount(amount: u32) -> Result { + if amount < 20 { + return Err("最低起贷20M".into()); + } + if amount % 20 != 0 { + return Err("只能贷20M的倍数".into()); + } + Ok(amount) +} + +/// 计算公司估值(第三阶段使用) +pub fn calc_company_valuation( + equity: u32, + last_year_net_profit: i32, + this_year_net_profit: i32, + recent_3yr_investment: u32, +) -> u32 { + let last_profit = if last_year_net_profit > 0 { last_year_net_profit as u32 } else { 0 }; + let this_profit = if this_year_net_profit > 0 { this_year_net_profit as u32 } else { 0 }; + equity + last_profit + this_profit * 2 + recent_3yr_investment / 3 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discount_fee() { + // 1/14 of 14M = 1M + assert_eq!(calc_discount_fee(14), 1); + // 1/14 of 28M = 2M + assert_eq!(calc_discount_fee(28), 2); + // 1/14 of 13M ≈ 0.93 → 1 (四舍五入) + assert_eq!(calc_discount_fee(13), 1); + } + + #[test] + fn test_depreciation() { + // 20% of 20M = 4M + assert_eq!(calc_depreciation(20), 4); + // 20% of 10M = 2M + assert_eq!(calc_depreciation(10), 2); + // 20% of 16M = 3.2 → 3 + assert_eq!(calc_depreciation(16), 3); + } + + #[test] + fn test_loan_interest() { + // 5% of 40M = 2M + assert_eq!(calc_long_term_interest(40), 2); + // 10%/4 of 40M = 1M + assert_eq!(calc_short_term_quarterly_interest(40), 1); + } + + #[test] + fn test_sales_tax() { + // 3% of 100M = 3M + assert_eq!(calc_sales_tax(100), 3); + } + + #[test] + fn test_income_tax() { + // 20% of 50M = 10M + assert_eq!(calc_income_tax(50), 10); + // Negative profit = 0 tax + assert_eq!(calc_income_tax(-10), 0); + } + + #[test] + fn test_loan_validation() { + assert!(validate_loan_amount(20).is_ok()); + assert!(validate_loan_amount(40).is_ok()); + assert!(validate_loan_amount(10).is_err()); // below minimum + assert!(validate_loan_amount(30).is_err()); // not multiple of 20 + } +} +``` + +- [ ] **Step 2: Run tests to verify** + +```bash +cd rust && cargo test --package api business_sandbox::finance 2>&1 | head -30 +``` + +Expected: ALL TESTS PASS + +- [ ] **Step 3: Commit** + +```bash +git add rust/api/src/business_sandbox/finance.rs +git commit -m "feat(business-sandbox): 实现财务计算模块(贴现/折旧/贷款/税务)" +``` + +--- + +### Task 3: 生产与供应链模块 + +**File:** `rust/api/src/business_sandbox/production.rs` + +- [ ] **Step 1: 实现产品研发和生产线管理** + +```rust +use crate::business_sandbox::state::*; + +/// 产品研发所需总季度数 +pub fn product_rd_total_quarters(product: Product, has_feijing: bool) -> u32 { + match product { + Product::BenMa => 0, // 已有 + Product::MengHu => 6, + Product::FeiYing => 8, + Product::TianLong => { + if has_feijing { + 8 + 6 - 5 // 有飞鹰则缩短5季度,但总研发不减? + // PDF原文: "开发周期可以缩短5个季度,相应的开发资金也少投入5M" + // 需要根据实际PDF规则判断 + 8 + 6 - 5 // 简化: 基础8+6=14,缩短5=9 + } else { + 8 + 6 // 基础=14 + } + } + } +} + +/// 产品研发总费用(每季度1M) +pub fn product_rd_total_cost(quarters: u32) -> u32 { + quarters +} + +/// 原材料成本 +pub fn raw_material_cost(product: Product) -> u32 { + product.raw_material_cost() +} + +/// 生产费用 +pub fn production_cost(product: Product) -> u32 { + product.production_cost() +} + +/// 生产线建设费用汇总 +pub fn total_line_build_cost(line_type: LineType) -> u32 { + line_type.total_build_cost() +} + +/// 计算生产线当前固定资产残值 +pub fn line_depreciated_value(original_value: u32, years: u32) -> u32 { + let mut value = original_value; + for _ in 0..years { + let dep = crate::business_sandbox::finance::calc_depreciation(value); + if dep >= value { + value = 0; + } else { + value -= dep; + } + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_product_rd_time() { + // 猛虎研发6季度 + assert_eq!(product_rd_total_quarters(Product::MengHu, false), 6); + // 飞鹰研发8季度 + assert_eq!(product_rd_total_quarters(Product::FeiYing, false), 8); + } + + #[test] + fn test_raw_material_cost() { + assert_eq!(raw_material_cost(Product::BenMa), 1); + assert_eq!(raw_material_cost(Product::TianLong), 4); + } + + #[test] + fn test_production_cost() { + assert_eq!(production_cost(Product::BenMa), 1); + assert_eq!(production_cost(Product::FeiYing), 2); + } + + #[test] + fn test_line_build_cost() { + assert_eq!(total_line_build_cost(LineType::Manual), 4); + assert_eq!(total_line_build_cost(LineType::SemiAuto), 8); + assert_eq!(total_line_build_cost(LineType::FullAuto), 16); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cd rust && cargo test --package api business_sandbox::production 2>&1 | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add rust/api/src/business_sandbox/production.rs +git commit -m "feat(business-sandbox): 实现生产与供应链模块" +``` + +--- + +### Task 4: 市场与订单模块 + +**File:** `rust/api/src/business_sandbox/market.rs` + +- [ ] **Step 1: 实现市场与订单逻辑** + +```rust +use crate::business_sandbox::state::*; + +/// 计算营销投入激活的订单数(每1M激活1张) +pub fn calc_activated_orders(marketing_spend: u32, max_orders: u32) -> u32 { + marketing_spend.min(max_orders) +} + +/// 选单顺序:按上年度市场排名 + 营销投入排序 +pub fn calc_selection_order(companies: &[(u32, u32)]) -> Vec { + // companies: [(last_year_sales_or_rank, marketing_spend), ...] + // 返回排序后的索引:市场第1优先按排名,其余按营销投入 + let mut indices: Vec = (0..companies.len()).collect(); + indices.sort_by(|&a, &b| { + let (rank_a, spend_a) = companies[a]; + let (rank_b, spend_b) = companies[b]; + // 排名越靠前(数字小)越优先 + if rank_a == 1 && rank_b != 1 { + return std::cmp::Ordering::Less; + } + if rank_b == 1 && rank_a != 1 { + return std::cmp::Ordering::Greater; + } + // 其余按营销投入降序 + spend_b.cmp(&spend_a) + }); + indices +} + +/// 计算延期交付惩罚(支付75%) +pub fn calc_late_delivery_penalty(original_amount: u32) -> u32 { + ((original_amount as f64) * 0.75).round() as u32 +} + +/// 生成年度市场报告摘要 +pub fn generate_market_summary(markets: &[Market]) -> Vec { + markets.iter().map(|m| { + MarketSummary { + name: m.name.clone(), + developed: m.developed, + rank: m.rank, + last_year_sales: m.last_year_sales, + } + }).collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketSummary { + pub name: String, + pub developed: bool, + pub rank: u32, + pub last_year_sales: u32, +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add rust/api/src/business_sandbox/market.rs +git commit -m "feat(business-sandbox): 实现市场与订单模块" +``` + +--- + +### Task 5: 游戏引擎(核心时序循环) + +**File:** `rust/api/src/business_sandbox/engine.rs` + +- [ ] **Step 1: 定义 GameManager** + +```rust +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use crate::business_sandbox::state::*; +use crate::business_sandbox::errors::GameError; + +/// 游戏实例管理器 +pub struct GameManager { + games: Mutex>, +} + +pub struct GameInstance { + pub state: BusinessGameState, + pub annual_reports: Vec, + pub current_phase: String, // "year_start" | "quarter_ops" | "year_end" | "waiting" + pub pending_decision: Option, +} + +impl GameManager { + pub fn new() -> Self { + Self { + games: Mutex::new(HashMap::new()), + } + } + + /// 创建新游戏(初始化状态) + pub async fn create_game(&self, game_id: &str) -> Result<(), GameError> { + let mut games = self.games.lock().await; + let state = Self::initial_state(); + games.insert(game_id.to_string(), GameInstance { + state, + annual_reports: Vec::new(), + current_phase: "year_start".into(), + pending_decision: Some("bidding".into()), + }); + Ok(()) + } + + /// 获取游戏状态 + pub async fn get_state(&self, game_id: &str) -> Result { + let games = self.games.lock().await; + games.get(game_id) + .map(|g| g.state.clone()) + .ok_or(GameError::GameNotFound) + } + + /// 处理玩家操作 + pub async fn handle_action( + &self, + game_id: &str, + action: PlayerAction, + ) -> Result, GameError> { + let mut games = self.games.lock().await; + let instance = games.get_mut(game_id) + .ok_or(GameError::GameNotFound)?; + + match action { + PlayerAction::StartGame => self.handle_start_game(instance), + PlayerAction::SubmitBidding { strategies } => { + self.handle_submit_bidding(instance, strategies) + } + PlayerAction::NextQuarter => self.handle_next_quarter(instance), + PlayerAction::DiscountReceivable { amount } => { + self.handle_discount(instance, amount) + } + PlayerAction::TakeLoan { loan_type, amount } => { + self.handle_take_loan(instance, &loan_type, amount) + } + _ => Err(GameError::InvalidAction("未实现的操".into())), + } + } + + fn initial_state() -> BusinessGameState { + BusinessGameState { + game_year: 1, + game_quarter: 1, + cash: 12, // 初始现金12M + factories: vec![Factory { + id: "old_factory".into(), + name: "老工厂".into(), + capacity: 4, + value: 20, + lines: vec![ProductionLine { + id: 1, + line_type: LineType::Manual, + status: LineStatus::Producing(Product::BenMa), + product: Some(Product::BenMa), + }], + }], + products_rd: vec![], + raw_material_orders: 2, + raw_material_inventory: 8, + work_in_progress: vec![WIPItem { + line_id: 1, + product: Product::BenMa, + progress: 1, + }], + finished_goods: vec![FinishedGoods { + product: Product::BenMa, + quantity: 4, + }], + accounts_receivable: vec![AccountReceivable { + amount: 12, + due_quarters: 4, + }], + long_term_loans: vec![], + short_term_loans: vec![], + markets: vec![Market { + name: "平城".into(), + developed: true, + rank: 1, + last_year_sales: 0, + }], + orders: vec![], + bidding_strategies: vec![], + phase: 1, + game_over: false, + game_over_reason: None, + } + } + + fn handle_start_game(&self, instance: &mut GameInstance) -> Result, GameError> { + instance.current_phase = "year_start".into(); + instance.pending_decision = Some("bidding".into()); + Ok(vec![ + GameEvent::StateUpdate(Box::new(instance.state.clone())), + GameEvent::PhaseChange { + year: instance.state.game_year, + quarter: 0, + phase: "year_start".into(), + }, + GameEvent::AskDecision { + year: instance.state.game_year, + quarter: 0, + decision_type: "bidding".into(), + }, + ]) + } + + fn handle_submit_bidding( + &self, + instance: &mut GameInstance, + strategies: Vec, + ) -> Result, GameError> { + // 验证现金流 + let total_spend: u32 = strategies.iter().map(|s| s.marketing_spend).sum(); + if total_spend > instance.state.cash { + return Err(GameError::InsufficientFunds(total_spend, instance.state.cash)); + } + + instance.state.bidding_strategies = strategies.clone(); + instance.state.cash -= total_spend; + + // 将销售费用记入费用 + instance.current_phase = "quarter_ops".into(); + instance.pending_decision = None; + + let mut events = vec![ + GameEvent::StateUpdate(Box::new(instance.state.clone())), + GameEvent::Message(format!("营销费用已扣除{}M", total_spend)), + ]; + + // 开始季度循环 + events.push(GameEvent::PhaseChange { + year: instance.state.game_year, + quarter: 1, + phase: "quarter_ops".into(), + }); + + Ok(events) + } + + fn handle_next_quarter(&self, instance: &mut GameInstance) -> Result, GameError> { + let mut events = Vec::new(); + + // 执行季度10步 + self.execute_quarter(instance, &mut events)?; + + // 检查是否到年末 + if instance.state.game_quarter >= 4 { + // 执行年末结算 + self.execute_year_end(instance, &mut events)?; + instance.state.game_year += 1; + instance.state.game_quarter = 0; + instance.current_phase = "year_start".into(); + + events.push(GameEvent::PhaseChange { + year: instance.state.game_year, + quarter: 0, + phase: "year_end".into(), + }); + + // 生成年度报告 + let report = self.generate_annual_report(instance); + instance.annual_reports.push(report.clone()); + events.push(GameEvent::AnnualReport(Box::new(report))); + + // 破产检查 + if instance.state.cash > 0 || instance.state.factories.iter().any(|f| f.value > 0) { + events.push(GameEvent::AskDecision { + year: instance.state.game_year, + quarter: 0, + decision_type: "bidding".into(), + }); + } + } else { + instance.state.game_quarter += 1; + events.push(GameEvent::PhaseChange { + year: instance.state.game_year, + quarter: instance.state.game_quarter, + phase: "quarter_ops".into(), + }); + events.push(GameEvent::AskDecision { + year: instance.state.game_year, + quarter: instance.state.game_quarter, + decision_type: "next_quarter".into(), + }); + } + + events.push(GameEvent::StateUpdate(Box::new(instance.state.clone()))); + Ok(events) + } + + fn execute_quarter(&self, instance: &mut GameInstance, events: &mut Vec) -> Result<(), GameError> { + let s = &mut instance.state; + + // Step 1: 更新应收账款 + for ar in &mut s.accounts_receivable { + if ar.due_quarters > 0 { + ar.due_quarters -= 1; + if ar.due_quarters == 0 { + s.cash += ar.amount; + } + } + } + s.accounts_receivable.retain(|ar| ar.due_quarters > 0); + + // Step 2: 更新短贷利息 + for loan in &mut s.short_term_loans { + let interest = crate::business_sandbox::finance::calc_short_term_quarterly_interest(loan.amount); + if s.cash >= interest { + s.cash -= interest; + } else { + // 现金流断裂 + s.game_over = true; + s.game_over_reason = Some("现金流断裂(Q2 短贷利息)".into()); + events.push(GameEvent::GameOver { + reason: s.game_over_reason.clone().unwrap(), + }); + return Ok(()); + } + if loan.remaining_quarters > 0 { + loan.remaining_quarters -= 1; + } + } + s.short_term_loans.retain(|l| l.remaining_quarters > 0); + + // Step 3: 产品研发推进 + for rd in &mut s.products_rd { + if !rd.completed { + rd.progress += 1; + if rd.progress >= rd.total { + rd.completed = true; + events.push(GameEvent::Message(format!("{}研发完成!", rd.product.name_cn()))); + } + } + } + + // Step 4: 供应商交货 + if s.raw_material_orders > 0 { + // 每个订单交付1个原料,需付费 + let cost = s.raw_material_orders; // 简化:1M/个 + if s.cash >= cost { + s.cash -= cost; + s.raw_material_inventory += s.raw_material_orders; + s.raw_material_orders = 0; + } + } + + // Step 5: 发出新原料订单(简化:自动按需) + // Step 6: 更新生产状态 + for wip in &mut s.work_in_progress { + wip.progress += 1; + } + // 完成的生产转入成品库 + let mut completed: Vec = Vec::new(); + s.work_in_progress.retain(|wip| { + if wip.progress >= 2 { // 简化:2季度生产周期 + completed.push(wip.clone()); + false + } else { + true + } + }); + for item in &completed { + let existing = s.finished_goods.iter_mut() + .find(|fg| fg.product == item.product); + if let Some(fg) = existing { + fg.quantity += 1; + } else { + s.finished_goods.push(FinishedGoods { + product: item.product, + quantity: 1, + }); + } + } + + // Step 7: 生产线建设推进 + for factory in &mut s.factories { + for line in &mut factory.lines { + match &line.status { + LineStatus::Building(remaining) => { + let new_remaining = *remaining - 1; + if new_remaining == 0 { + line.status = LineStatus::Idle; + events.push(GameEvent::Message(format!("产线{}建设完成", line.id))); + } else { + line.status = LineStatus::Building(new_remaining); + } + } + LineStatus::SwitchingTo(remaining, target) => { + let new_remaining = *remaining - 1; + if new_remaining == 0 { + line.product = Some(*target); + line.status = LineStatus::Idle; + events.push(GameEvent::Message(format!("产线{}转产{}完成", line.id, target.name_cn()))); + } else { + line.status = LineStatus::SwitchingTo(new_remaining, *target); + } + } + _ => {} + } + } + } + + // Step 8: 新生产(简化:生产线空闲则自动开始) + for factory in &mut s.factories { + for line in &mut factory.lines { + if line.status == LineStatus::Idle { + if let Some(product) = line.product { + if s.raw_material_inventory >= 1 { + s.raw_material_inventory -= 1; + let cost = product.production_cost(); + if s.cash >= cost { + s.cash -= cost; + line.status = LineStatus::Producing(product); + s.work_in_progress.push(WIPItem { + line_id: line.id, + product, + progress: 0, + }); + } + } + } + } + } + } + + // Step 9: 订单交付 + for order in &mut s.orders { + if !order.delivered { + let fg = s.finished_goods.iter() + .find(|g| g.product == order.product && g.quantity >= order.quantity); + if let Some(goods) = fg { + goods.quantity -= order.quantity; + order.delivered = true; + let amount = order.quantity * order.unit_price; + s.accounts_receivable.push(AccountReceivable { + amount, + due_quarters: order.account_period, + }); + } + } + } + + // Step 10: 行政管理费 + if s.cash >= 1 { + s.cash -= 1; + } else { + s.game_over = true; + s.game_over_reason = Some("现金流断裂(Q10 行政管理费)".into()); + events.push(GameEvent::GameOver { + reason: s.game_over_reason.clone().unwrap(), + }); + } + + Ok(()) + } + + fn execute_year_end(&self, instance: &mut GameInstance, events: &mut Vec) -> Result<(), GameError> { + let s = &mut instance.state; + + // 1. 生产线管理费:1M/条 + for factory in &mut s.factories { + for line in &mut factory.lines { + if line.status != LineStatus::Building(0) && line.status != LineStatus::Building(0) { + // 建成投产的线收费 + if s.cash >= 1 { + s.cash -= 1; + } + } + } + } + + // 2. 固定资产折旧 20% + for factory in &mut s.factories { + let dep = crate::business_sandbox::finance::calc_depreciation(factory.value); + if dep >= factory.value { + factory.value = 0; + } else { + factory.value -= dep; + } + for line in &mut factory.lines { + match &line.status { + LineStatus::Idle | LineStatus::Producing(_) => { + let line_value = line.line_type.salvage_value().max(1); + let line_dep = crate::business_sandbox::finance::calc_depreciation(line_value); + // 简化:暂时不追踪每条线的独立价值 + } + _ => {} + } + } + } + + // 3. 长期贷款更新 + for loan in &mut s.long_term_loans { + if loan.active { + let interest = crate::business_sandbox::finance::calc_long_term_interest(loan.amount); + if s.cash >= interest { + s.cash -= interest; + } + if loan.remaining_quarters > 0 { + loan.remaining_quarters -= 1; + } + if loan.remaining_quarters == 0 { + // 到期还本 + if s.cash >= loan.amount { + s.cash -= loan.amount; + loan.active = false; + } + } + } + } + + // 4. 税款(简化版,完整版在Task 6实现) + // 5. 破产检查 + let total_equity = self.calc_equity(s); + if total_equity < 0 { + s.game_over = true; + s.game_over_reason = Some("股东权益为负".into()); + events.push(GameEvent::GameOver { + reason: "股东权益<0,公司破产".into(), + }); + } + + // 更新阶段 + if s.game_year >= 5 && s.phase < 2 { s.phase = 2; } + if s.game_year >= 8 && s.phase < 3 { s.phase = 3; } + + Ok(()) + } + + fn calc_equity(&self, s: &BusinessGameState) -> i32 { + let total_assets = s.cash as i32 + + s.accounts_receivable.iter().map(|a| a.amount as i32).sum::() + + s.raw_material_inventory as i32 + + s.finished_goods.iter().map(|g| g.quantity as i32 * g.product.raw_material_cost() as i32 * 2).sum::() + + s.factories.iter().map(|f| f.value as i32).sum::(); + + let total_liabilities = s.long_term_loans.iter().map(|l| l.amount as i32).sum::() + + s.short_term_loans.iter().map(|l| l.amount as i32).sum::(); + + total_assets - total_liabilities + } + + fn generate_annual_report(&self, instance: &GameInstance) -> AnnualReport { + let s = &instance.state; + AnnualReport { + year: s.game_year, + income_statement: IncomeStatement { + revenue: 0, + cost_of_goods_sold: 0, + gross_profit: 0, + sales_expense: 0, + admin_expense: s.game_quarter as u32, + rd_expense: 0, + depreciation: 0, + operating_profit: 0, + interest_expense: 0, + discount_fee: 0, + tax: 0, + net_profit: 0, + }, + balance_sheet: BalanceSheet { + cash: s.cash, + accounts_receivable: s.accounts_receivable.iter().map(|a| a.amount).sum(), + raw_material: s.raw_material_inventory, + work_in_process: 0, + finished_goods: s.finished_goods.iter().map(|g| g.quantity).sum(), + total_current_assets: 0, + factory: s.factories.iter().map(|f| f.value).sum(), + production_lines: 0, + total_fixed_assets: 0, + total_assets: 0, + long_term_loans: s.long_term_loans.iter().map(|l| l.amount).sum(), + short_term_loans: s.short_term_loans.iter().map(|l| l.amount).sum(), + total_liabilities: 0, + equity: 0, + }, + expense_sheet: ExpenseSheet { + new_market_investment: 0, + product_rd_investment: 0, + quarterly_admin_fees: s.game_quarter as u32, + factory_rent: 0, + line_maintenance: 0, + product_switch_fees: 0, + sales_expenses: 0, + depreciation: 0, + interest_and_discount: 0, + tax: 0, + }, + } + } + + fn handle_discount(&self, instance: &mut GameInstance, amount: u32) -> Result, GameError> { + let fee = crate::business_sandbox::finance::calc_discount_fee(amount); + instance.state.cash += amount - fee; + instance.state.accounts_receivable.retain(|ar| { + if ar.amount == amount { + false + } else { + // 只能整笔贴现 + true + } + }); + Ok(vec![ + GameEvent::Message(format!("贴现{}M,手续费{}M,实收{}M", amount, fee, amount - fee)), + GameEvent::StateUpdate(Box::new(instance.state.clone())), + ]) + } + + fn handle_take_loan(&self, instance: &mut GameInstance, loan_type: &str, amount: u32) -> Result, GameError> { + crate::business_sandbox::finance::validate_loan_amount(amount) + .map_err(|e| GameError::RuleViolation(e))?; + + instance.state.cash += amount; + + match loan_type { + "long" => { + instance.state.long_term_loans.push(LongTermLoanSlot { + year: instance.state.game_year, + amount, + active: true, + }); + } + "short" => { + instance.state.short_term_loans.push(Loan { + amount, + remaining_quarters: 4, + annual_interest_rate: 0.10, + }); + } + _ => return Err(GameError::InvalidAction("未知贷款类型".into())), + } + + Ok(vec![ + GameEvent::Message(format!("{}贷款{}M到账", if loan_type == "long" { "长期" } else { "短期" }, amount)), + GameEvent::StateUpdate(Box::new(instance.state.clone())), + ]) + } +} +``` + +- [ ] **Step 2: Add to mod.rs** + +```rust +// in rust/api/src/business_sandbox/mod.rs +pub mod engine; +``` + +- [ ] **Step 3: Commit** + +```bash +git add rust/api/src/business_sandbox/engine.rs +git add rust/api/src/business_sandbox/mod.rs +git commit -m "feat(business-sandbox): 实现游戏引擎核心时序循环" +``` + +--- + +### Task 6: WebSocket 路由和事件广播 + +**Files:** +- Modify: `rust/api/src/ws.rs` +- Modify: `rust/api/src/main.rs` +- Modify: `rust/api/src/state.rs` +- Create: `rust/api/src/business_sandbox/ws_handler.rs` + +- [ ] **Step 1: 创建 WS handler** + +Write `rust/api/src/business_sandbox/ws_handler.rs`: + +```rust +use std::sync::Arc; +use axum::extract::{Path, State, WebSocketUpgrade}; +use axum::response::IntoResponse; +use futures::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use crate::state::AppState; +use crate::business_sandbox::state::*; + +pub async fn game_ws_handler( + ws: WebSocketUpgrade, + State(state): State>, + Path(game_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_game_ws(socket, state, game_id)) +} + +async fn handle_game_ws( + socket: axum::extract::ws::WebSocket, + state: Arc, + game_id: String, +) { + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = mpsc::channel::(256); + + // 注册到游戏管理器 + state.business_sandbox.register_ws(&game_id, tx).await; + + // 创建游戏 + if let Err(e) = state.business_sandbox.manager.create_game(&game_id).await { + let _ = sender.send(axum::extract::ws::Message::Text( + serde_json::to_string(&GameEvent::GameOver { + reason: format!("创建游戏失败: {}", e), + }).unwrap() + )).await; + return; + } + + // 发送初始状态 + if let Ok(initial_state) = state.business_sandbox.manager.get_state(&game_id).await { + let msg = serde_json::to_string(&GameEvent::StateUpdate(Box::new(initial_state))).unwrap(); + let _ = sender.send(axum::extract::ws::Message::Text(msg)).await; + } + + // 发送要求玩家输入竞标策略 + let ask_msg = serde_json::to_string(&GameEvent::AskDecision { + year: 1, quarter: 0, + decision_type: "bidding".into(), + }).unwrap(); + let _ = sender.send(axum::extract::ws::Message::Text(ask_msg)).await; + + // 接收玩家消息 + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if sender.send(axum::extract::ws::Message::Text(msg)).await.is_err() { + break; + } + } + }); + + // 处理玩家输入 + while let Some(Ok(msg)) = receiver.next().await { + if let axum::extract::ws::Message::Text(text) = msg { + if let Ok(action) = serde_json::from_str::(&text) { + match state.business_sandbox.manager.handle_action(&game_id, action).await { + Ok(events) => { + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let _ = state.business_sandbox.broadcast(&game_id, &json).await; + } + } + Err(e) => { + let err_msg = serde_json::to_string( + &GameEvent::Message(format!("错误: {}", e)) + ).unwrap(); + let _ = state.business_sandbox.broadcast(&game_id, &err_msg).await; + } + } + } + } + } + + send_task.abort(); +} +``` + +- [ ] **Step 2: 修改 state.rs 添加 BusinessSandboxManager** + +Edit `rust/api/src/state.rs` - add to `AppState`: + +```rust +// Add import +use crate::business_sandbox::engine::GameManager; +use std::collections::HashMap; +use tokio::sync::mpsc; + +pub struct BusinessSandboxState { + pub manager: GameManager, + ws_senders: tokio::sync::Mutex>>>, +} + +impl BusinessSandboxState { + pub fn new() -> Self { + Self { + manager: GameManager::new(), + ws_senders: tokio::sync::Mutex::new(HashMap::new()), + } + } + + pub async fn register_ws(&self, game_id: &str, tx: mpsc::Sender) { + let mut senders = self.ws_senders.lock().await; + senders.entry(game_id.to_string()) + .or_default() + .push(tx); + } + + pub async fn broadcast(&self, game_id: &str, message: &str) { + let mut senders = self.ws_senders.lock().await; + if let Some(entries) = senders.get_mut(game_id) { + entries.retain(|tx| tx.try_send(message.to_string()).is_ok()); + } + } +} +``` + +Add to `AppState` struct: + +```rust +pub struct AppState { + // ... existing fields ... + pub business_sandbox: BusinessSandboxState, +} +``` + +Update `AppState::new()` or wherever it's constructed. + +- [ ] **Step 3: 在 main.rs 挂载路由** + +Edit `rust/api/src/main.rs`: + +```rust +// Add mod declaration +mod business_sandbox; + +// In build_router(), add WS route: +.route("/ws/game/:game_id", axum::routing::get(crate::business_sandbox::ws_handler::game_ws_handler)) +``` + +- [ ] **Step 4: Rust compilation check** + +```bash +cd rust && cargo check --package api 2>&1 | head -40 +``` + +Fix any compilation errors. + +- [ ] **Step 5: Commit** + +```bash +git add rust/api/src/ rust/api/src/business_sandbox/ws_handler.rs +git commit -m "feat(business-sandbox): 实现WebSocket路由和事件广播" +``` + +--- + +### Task 7: AI 竞争对手 + +**File:** `rust/api/src/business_sandbox/ai_competitor.rs` + +- [ ] **Step 1: 实现 AI 竞争对手调用** + +```rust +use std::sync::Arc; +use crate::business_sandbox::state::*; +use ai_gateway::{GatewayRegistry, PromptBuilder, CallConfig, DynamicContext}; + +/// AI 竞争对手的决策输出 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompetitorDecision { + pub company_name: String, + pub marketing_spend: u32, + pub product_focus: Product, + pub pricing_strategy: String, // "aggressive" | "normal" | "premium" +} + +/// 调用 AI 竞争对手生成年度决策 +pub async fn generate_competitor_decisions( + gateway: Arc, + registry: Arc, + state: &BusinessGameState, + market_data: &str, +) -> Vec { + let competitor_souls = vec!["CEO", "CFO", "CMO", "COO", "管理咨询顾问"]; + + let mut decisions = Vec::new(); + for soul_name in &competitor_souls { + if let Some(profile) = registry.get_soul(soul_name) { + let prompt = format!( + "你是一家与玩家竞争的公司的{soul_role}。当前市场情况:{market_data}\n\ + 请做出本年度的经营决策:\n\ + 1. 营销投入(M)\n\ + 2. 主打产品\n\ + 3. 定价策略(激进/正常/高溢价)\n\ + 请以JSON格式输出决策。", + soul_role = soul_name, + market_data = market_data, + ); + + if let Some(provider) = gateway.pick_provider(None) { + let call_config = CallConfig { + temperature: Some(0.7), + max_tokens: Some(500), + stream: false, + model: profile.model.clone(), + ..Default::default() + }; + + let prompt_msg = ai_gateway::Prompt { + system: Some(profile.summon_prompt.clone()), + messages: vec![ + ("user".into(), prompt), + ], + }; + + let mut rx = gateway.call(&prompt_msg, &call_config); + let mut full_response = String::new(); + while let Some(Ok(chunk)) = rx.recv().await { + full_response.push_str(&chunk.content); + } + + // 尝试解析 JSON 决策 + if let Ok(decision) = serde_json::from_str::(&full_response) { + decisions.push(decision); + } else { + // 解析失败时用默认值 + decisions.push(CompetitorDecision { + company_name: soul_name.to_string(), + marketing_spend: 2, + product_focus: Product::BenMa, + pricing_strategy: "normal".into(), + }); + } + } + } + } + + decisions +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add rust/api/src/business_sandbox/ai_competitor.rs +git commit -m "feat(business-sandbox): 实现AI竞争对手调用" +``` + +--- + +### Task 8: 创建 Godot 2D 前端项目 + +**Directory:** `godot/` (新项目根目录) + +- [ ] **Step 1: 初始化 Godot 项目** + +```bash +mkdir -p godot/scenes godot/scripts/popups godot/ui godot/tests +``` + +Create `godot/project.godot`: +```gdscript +[application] +config/name="创业沙盘" +config/description="商业全价值链经营模拟器" +run/main_scene="res://scenes/MainGame.tscn" +config/icon="res://ui/icon.png" + +[rendering] +renderer/backend=opengl3 ; Web兼容 +``` + +- [ ] **Step 2: 创建 WebSocketManager 单例** + +Write `godot/scripts/websocket_manager.gd`: +```gdscript +extends Node + +var socket = WebSocketPeer.new() +var connected = false +var server_url = "ws://127.0.0.1:3097/ws/game/" +var game_id = "" +signal connected() +signal disconnected() +signal message_received(data: Dictionary) +signal connection_error() + +func _ready(): + socket.connect("connection_established", Callable(self, "_on_connected")) + socket.connect("connection_closed", Callable(self, "_on_disconnected")) + socket.connect("data_received", Callable(self, "_on_data")) + socket.connect("connection_error", Callable(self, "_on_error")) + +func connect_to_server(g_id: String): + game_id = g_id + var url = server_url + g_id + socket.connect_to_url(url) + +func send_action(action: Dictionary): + if connected: + socket.send(JSON.stringify(action)) + +func _process(delta): + if socket.is_connected(): + socket.poll() + +func _on_connected(): + connected = true + emit_signal("connected") + +func _on_disconnected(): + connected = false + emit_signal("disconnected") + +func _on_data(): + var raw = socket.get_packet() + var json_str = raw.get_string_from_utf8() + var data = JSON.parse_string(json_str) + if data is Dictionary: + emit_signal("message_received", data) + +func _on_error(): + emit_signal("connection_error") +``` + +- [ ] **Step 3: 创建 MainGame 主场景** + +Write `godot/scripts/main_game.gd`: +```gdscript +extends Control + +@onready var top_bar = $TopBar +@onready var production_area = $ProductionArea +@onready var bottom_panel = $BottomPanel +@onready var ws_manager = $WebSocketManager + +var game_state: Dictionary = {} + +func _ready(): + ws_manager.connect("message_received", Callable(self, "_on_ws_message")) + ws_manager.connect_to_server("game_001") + +func _on_ws_message(data: Dictionary): + match data.get("event"): + "state_update": + game_state = data.get("data", {}) + update_ui() + "ask_decision": + show_decision_prompt(data.get("decision_type", "")) + "message": + show_message(data.get("data", "")) + "game_over": + show_game_over(data.get("data", {}).get("reason", "")) + "annual_report": + show_annual_report(data.get("data", {})) + +func update_ui(): + # 更新顶部状态栏 + top_bar.update( + game_state.get("game_year", 1), + game_state.get("game_quarter", 1), + game_state.get("cash", 0) + ) + # 更新生产线视窗 + production_area.update(game_state.get("factories", [])) + # 更新底部面板 + bottom_panel.update(game_state) + +func show_decision_prompt(decision_type: String): + match decision_type: + "bidding": + open_bidding_dialog() + "next_quarter": + # 显示"执行下一季度"按钮 + bottom_panel.show_next_quarter_button() + +func open_bidding_dialog(): + # 打开竞标策略弹窗 + pass + +func show_message(text: String): + # 显示系统消息 + pass + +func show_game_over(reason: String): + # 显示游戏结束弹窗 + pass + +func show_annual_report(report: Dictionary): + # 显示年度报告弹窗 + pass +``` + +- [ ] **Step 4: 创建场景文件(空模板)** + +Create basic `MainGame.tscn` structure via Godot editor (or write .tscn by hand). For now, the plan is to open this in Godot editor and set up the UI layout. + +**关键 UI 节点:** +``` +MainGame (Control, Stretch全屏) +├── TopBar (HBoxContainer, 锚定顶部) +│ ├── YearLabel (Label) +│ ├── QuarterLabel (Label) +│ └── CashLabel (Label + 金币图标) +├── ProductionArea (VBoxContainer, 锚定中间) +│ ├── Line1 (ProductionLine 自定义节点) +│ ├── Line2 +│ ├── Line3 +│ └── Line4 +├── BottomPanel (HBoxContainer, 锚定底部) +│ ├── LoanDisplay (财务数据) +│ ├── NextQuarterBtn (Button) +│ ├── AnnualReportBtn (Button) +│ └── DiscountBtn (Button) +└── PopupLayer (Control, 全屏覆盖) + ├── BiddingDialog (Panel) + ├── AnnualReportDialog (Panel) + ├── DiscountConfirm (Panel) + └── GameOverScreen (Panel) +``` + +- [ ] **Step 5: Commit** + +```bash +git add godot/ +git commit -m "feat(godot): 初始化创业沙盘Godot 2D项目结构和WS通信" +``` + +--- + +## 未来任务(阶段4 — 动画和发布) + +- 使用 `Tween` 动画实现原料→产线→成品流动 +- 替换占位色块为实际 UI 贴图 +- Web (HTML5) + Desktop 双端导出配置 +- 导出测试 diff --git a/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md b/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md index 112ec47..24bcf24 100644 --- a/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md +++ b/docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md @@ -1,7 +1,8 @@ # 创业沙盘 — Godot 2D 全栈实现设计文档 > 基于 PDF 创业沙盘规则,复刻一款 2D 商战模拟游戏。 -> 关联项目:Mock-Court(模拟仲裁庭),复用其 Rust API / Next.js 多 Agent 基础设施。 +> 关联项目:Mock-Court(模拟仲裁庭),复用其 Rust AI 基础设施。 +> 不修改现有仲裁庭代码,全部新增于独立模块。 --- @@ -39,19 +40,12 @@ └──────────────────┬──────────────────────────┘ │ WebSocket (JSON) ┌──────────────────▼──────────────────────────┐ -│ 🦀 Rust Game Server (端口 3097,复用项目) │ +│ 🦀 Rust Game Server (端口 3097) │ │ ┌────────────────────────────────────────┐ │ -│ │ 状态维护 | 规则引擎 | 财务计算 │ │ -│ │ 时序逻辑 (年初→4季度→年末) │ │ -│ │ 破产判定 | 贴现计算 | 折旧计提 │ │ -│ └────────────────────────────────────────┘ │ -└──────────────────┬──────────────────────────┘ - │ HTTP / 内部调用 -┌──────────────────▼──────────────────────────┐ -│ ⚛️ Next.js AI Agent 编排 │ -│ ┌────────────────────────────────────────┐ │ -│ │ 多 Agent 群 (5家竞争对手) │ │ -│ │ 群体决策 → JSON 动作 → 输入 Rust │ │ +│ │ 游戏状态 | 时序引擎 | 财务计算 │ │ +│ │ 赛季循环 (年初→4季度→年末) │ │ +│ │ AI 竞争对手 (复用 Gateway/Soul/Tool) │ │ +│ │ 破产判定 | 贴现计算 | 折旧计提 │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` @@ -63,8 +57,7 @@ | 前端引擎 | Godot | 4.x | 纯 2D,`CanvasItem` + `Control` 节点 | | 脚本语言 | GDScript | - | Godot 原生脚本,与引擎深度集成 | | 通信协议 | WebSocket | - | 全双工 JSON 通信 | -| 后端 | Rust | - | 游戏状态维护、规则校验、财务计算 | -| AI 对手 | Next.js | - | 多 Agent 编排,群体决策输出 JSON | +| 后端 | Rust | - | 游戏引擎 + AI 竞争对手 (复用 AI网关/Soul/工具/WS) | | UI 策略 | Stretch 模式 | - | `Control` 容器布局,窗口自适应 | --- @@ -193,16 +186,102 @@ Rust 后端维护唯一完整的游戏状态。前端职责:接收状态并渲 --- -## 6. 核心游戏规则(硬编码至 Rust 后端) +## 6. 核心游戏规则(完整 PDF 规则) + +### 6.1 初始状态 + +公司拥有 **70M 资产**,分布在: +- 现金与银行存款:12M +- 应收账款:12M(4 季度账期) +- 原材料库存:8M +- 在制品(奔马):8M(价值 8M,即 4 批 × 2M/批) +- 成品库存(奔马):8M(4 批) +- 老工厂(不动产):20M(最多容纳 4 条生产线) +- 生产线:已有 1 条手工线在生产 +- 仅有 1 种产品:**奔马**(基础产品) + +### 6.2 产品体系 + +| 产品 | 原材料成本/个 | 生产费/批 | 说明 | +|------|-------------|----------|------| +| 🐴 奔马 | 1M | 1M | 基础产品,初始即有 | +| 🐯 猛虎 | 2M | 1M | 升级产品,需研发 (6 季度) | +| 🦅 飞鹰 | 3M | 2M | 中级产品,需研发 | +| 🐉 天龙 | 4M | 2M | 高级产品,需研发 | + +> 研发天龙时如已先研发飞鹰,开发周期缩短 5 季度,少投 5M + +### 6.3 生产线 + +| 类型 | 建设时间 | 建设费用 | 转产耗时 | 转产费 | 出售残值 | +|------|---------|---------|---------|-------|---------| +| 手工线 | 1 季度 | 4M | 即时 | 0 | 2M | +| 半自动线 | 4 季度 | 2M/季(共8M) | 1 季度 | 1M | 3M | +| 全自动线 | 4 季度 | 4M/季(共16M) | 2 季度 | 2M/季(共4M) | 6M | + +### 6.4 财务规则 + +| 项目 | 规则 | +|------|------| +| **行政管理费** | 1M/季度 | +| **生产线管理费** | 1M/条/年(已建成投产的线) | +| **固定资产折旧** | 20%/年,四舍五入取整 | +| **长期贷款** | 年息 5%,每年还息,第 4 年末还本;最低 20M,20M 倍数 | +| **短期贷款** | 年息 10%(每季 2.5%),每季付息,4 季度到期还本;最低 20M | +| **贷款总额度** | 上年度净资产 × 4(长贷 2 倍 + 短贷 2 倍) | +| **应收账款贴现** | 手续费 = 应收 × 1/14,四舍五入取整,整笔一起处理 | +| **营业税** | 销售额的 3% | +| **所得税** | 税前利润的 20% | +| **免税条件** | 前两年平均利润为负可免当年所得税 | +| **破产** | 股东权益 < 0 或现金流断裂即破产 | + +### 6.5 季度运营 10 步(严格按 PDF 流程) + +``` +年初: + 1. 决定是否做新市场投资 + 2. 决定是否购买/租赁工厂 + 3. 决定是否做新的长期贷款 + 4. 确定竞标策略(填写市场牌) + 5. 参与订货会,销售费用计入费用栏 + +季度运营(每季度严格按此顺序): + 1. 更新应收账款 + 2. 更新短期贷款 → 支付利息 → 决定是否新短贷 + 3. 投资产品开发 + 4. 供应商交货 → 支付原材料费用 + 5. 发出新的原材料采购订单(订单不可撤销) + 6. 更新进行中的生产状态(每条线往后推一格) + 7. 产能升级:生产线建设/出售/产品转产 + 8. 开始新生产 → 支付生产费用 + 9. 按订单交付成品 → 应收账款入账 + 10. 支付行政管理费 1M + +年末: + 1. 支付生产线管理费(1M/条) + 2. 扣除固定资产折旧(20%) + 3. 更新长期贷款 → 支付利息/偿还本金 + 4. 缴纳税款(营业税 + 所得税) + 5. 编制年度财务报表 +``` + +### 6.6 订单与市场 + +- **营销投入**:每 1M 的销售投入激活 1 张订单(有上限) +- **选单顺序**:上年度该市场销售额最高的公司优先挑选;其余按营销费投入排序 +- **市场维护**:已开发市场每年至少投 1M,否则视为离开 +- **订单交付**:订单要求数量必须一次交清,数量不足不接受 +- **延期惩罚**:客户只支付 75% 货款,公司在本市场排名下降一位 +- **紧急订单**:必须在 Q1 交货,否则视为延期 + +### 6.7 四阶段进程 -| 规则 | 说明 | 前端表现 | +| 阶段 | 年份 | 核心内容 | |------|------|---------| -| **贴现** | 手续费为应收账款 1/14 取整 | 弹窗确认提示 | -| **产线转产** | 全自动线 2 季度 4M,半自动 1 季度 1M,手工 0 成本 | 倒计时显示 | -| **固定资产折旧** | 年底产线资产价值强制减少 20% | UI 数值更新 | -| **破产判定** | 股东权益 < 0 或现金流断裂 | 黑色遮罩 + 冻结操作 | -| **季度管理费** | 每季度扣除 1M | 现金数值实时更新 | -| **所得税** | 年度利润按税率缴税 | 年度报表显示 | +| 1. 适应期 | 第 1-4 年 | 适应规则,资本周转,单产品起步 | +| 2. 市场竞争 | 第 5-7 年 | 波士顿矩阵,ABC 成本法,多产品线竞争策略 | +| 3. 高阶经营 | 第 8-11 年 | 杜邦分析,ROE/EVA,公司估值 | +| 4. 反思复盘 | — | 毛利、费用、现金流、商业模式、团队效率、领导力 | --- @@ -259,11 +338,11 @@ rust-agent/ │ └── tests/ # Godot 测试 ├── rust/ # 已有 │ └── api/src/ -│ └── routes/ -│ └── business_sandbox.rs # 新增:商业沙盘 WebSocket 路由 -├── nextjs/ # 已有(AI Agent 编排层) -│ └── app/api/ -│ └── business-ai/ # 新增:商业 AI 对手接口 +│ ├── routes/ +│ │ └── business_sandbox.rs # 新增:商业沙盘 WebSocket + 游戏引擎 +│ ├── ws.rs # 修改:新增商业沙盘 WS 路由 +│ ├── main.rs # 修改:挂载新路由 +│ └── state.rs # 修改:新增游戏状态字段 └── docs/superpowers/specs/ └── 2026-07-13-business-sandbox-godot-design.md # 本文件 ``` From 5d2ed3405a705cb15680cefbb64daebf88690bdb Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:33:43 +0800 Subject: [PATCH 03/64] =?UTF-8?q?feat(business-sandbox):=20Task=201=20-=20?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E7=8A=B6=E6=80=81=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=92=8C=E9=94=99=E8=AF=AF=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/errors.rs | 52 +++ rust/api/src/business_sandbox/mod.rs | 2 + rust/api/src/business_sandbox/state.rs | 468 ++++++++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 rust/api/src/business_sandbox/errors.rs create mode 100644 rust/api/src/business_sandbox/mod.rs create mode 100644 rust/api/src/business_sandbox/state.rs diff --git a/rust/api/src/business_sandbox/errors.rs b/rust/api/src/business_sandbox/errors.rs new file mode 100644 index 0000000..debb6b1 --- /dev/null +++ b/rust/api/src/business_sandbox/errors.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// 创业沙盘游戏错误类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameError { + /// 游戏不存在 + GameNotFound(String), + /// 无效的操作 + InvalidAction(String), + /// 资金不足 + InsufficientFunds { + required: f64, + available: f64, + }, + /// 产能不足 + InsufficientCapacity(String), + /// 研发未完成 + RDAIncomplete(String), + /// 市场未开发 + MarketNotDeveloped(String), + /// 无效的订单 + InvalidOrder(String), + /// 无效的阶段 + WrongPhase(String), + /// 游戏已结束 + GameAlreadyOver, + /// 内部错误 + Internal(String), +} + +impl fmt::Display for GameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GameError::GameNotFound(id) => write!(f, "游戏不存在: {}", id), + GameError::InvalidAction(msg) => write!(f, "无效操作: {}", msg), + GameError::InsufficientFunds { required, available } => { + write!(f, "资金不足: 需要 {}, 可用 {}", required, available) + } + GameError::InsufficientCapacity(msg) => write!(f, "产能不足: {}", msg), + GameError::RDAIncomplete(msg) => write!(f, "研发未完成: {}", msg), + GameError::MarketNotDeveloped(msg) => write!(f, "市场未开发: {}", msg), + GameError::InvalidOrder(msg) => write!(f, "无效订单: {}", msg), + GameError::WrongPhase(msg) => write!(f, "非当前阶段操作: {}", msg), + GameError::GameAlreadyOver => write!(f, "游戏已结束"), + GameError::Internal(msg) => write!(f, "内部错误: {}", msg), + } + } +} + +impl std::error::Error for GameError {} diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs new file mode 100644 index 0000000..a209a6f --- /dev/null +++ b/rust/api/src/business_sandbox/mod.rs @@ -0,0 +1,2 @@ +pub mod errors; +pub mod state; diff --git a/rust/api/src/business_sandbox/state.rs b/rust/api/src/business_sandbox/state.rs new file mode 100644 index 0000000..98f537a --- /dev/null +++ b/rust/api/src/business_sandbox/state.rs @@ -0,0 +1,468 @@ +use serde::{Deserialize, Serialize}; + +// ── Product ── + +/// 四种产品类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Product { + /// 奔马 — 基础产品 + BenMa, + /// 猛虎 — 升级产品 + MengHu, + /// 飞鹰 — 中级产品 + FeiYing, + /// 天龙 — 高级产品 + TianLong, +} + +impl Product { + /// 原材料成本(每单位) + pub fn raw_material_cost(&self) -> u32 { + match self { + Product::BenMa => 1, + Product::MengHu => 2, + Product::FeiYing => 3, + Product::TianLong => 4, + } + } + + /// 生产成本(每单位) + pub fn production_cost(&self) -> u32 { + match self { + Product::BenMa | Product::MengHu => 1, + Product::FeiYing | Product::TianLong => 2, + } + } + + /// 中文名称 + pub fn name_cn(&self) -> &'static str { + match self { + Product::BenMa => "奔马", + Product::MengHu => "猛虎", + Product::FeiYing => "飞鹰", + Product::TianLong => "天龙", + } + } +} + +// ── LineType ── + +/// 产线类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LineType { + /// 手工线 + Manual, + /// 半自动线 + SemiAuto, + /// 全自动线 + FullAuto, +} + +impl LineType { + /// 建设时间(季度) + pub fn build_time(&self) -> u32 { + match self { + LineType::Manual => 1, + LineType::SemiAuto => 4, + LineType::FullAuto => 4, + } + } + + /// 每季度建设成本 + pub fn build_cost_per_quarter(&self) -> u32 { + match self { + LineType::Manual => 4, + LineType::SemiAuto => 2, + LineType::FullAuto => 4, + } + } + + /// 总建设成本 + pub fn total_build_cost(&self) -> u32 { + match self { + LineType::Manual => 4, + LineType::SemiAuto => 8, + LineType::FullAuto => 16, + } + } + + /// 转产时间(季度) + pub fn switch_time(&self) -> u32 { + match self { + LineType::Manual => 0, + LineType::SemiAuto => 1, + LineType::FullAuto => 2, + } + } + + /// 转产成本 + pub fn switch_cost(&self) -> u32 { + match self { + LineType::Manual => 0, + LineType::SemiAuto => 1, + LineType::FullAuto => 4, + } + } + + /// 残值 + pub fn salvage_value(&self) -> u32 { + match self { + LineType::Manual => 2, + LineType::SemiAuto => 3, + LineType::FullAuto => 6, + } + } +} + +// ── LineStatus ── + +/// 产线状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LineStatus { + /// 空闲 + Idle, + /// 生产中(含产品类型) + Producing(Product), + /// 建设中(含剩余季度数) + Building(u32), + /// 转产中(含剩余季度数和目标产品) + SwitchingTo(u32, Product), +} + +// ── Data Structures ── + +/// 生产线的运行时表示 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductionLine { + pub id: u32, + pub line_type: LineType, + pub status: LineStatus, + /// 当前设定的产品(None 表示未设定) + pub product: Option, +} + +/// 工厂 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Factory { + pub id: String, + pub name: String, + /// 最大产线数量 + pub capacity: u32, + /// 工厂总价值 + pub value: u32, + /// 产线列表 + pub lines: Vec, +} + +/// 产品研发进度 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductRDA { + pub product: Product, + /// 当前进度 + pub progress: u32, + /// 研发所需总量 + pub total: u32, + /// 是否已完成 + pub completed: bool, +} + +/// 应收账款 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountReceivable { + pub amount: u32, + /// 剩余到期季度数 + pub due_quarters: u32, +} + +/// 贷款 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Loan { + pub amount: u32, + /// 剩余还款季度数 + pub remaining_quarters: u32, + /// 年利率(百分比,如 5.0 表示 5%) + pub annual_interest_rate: f64, +} + +/// 长期贷款额度槽位 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LongTermLoanSlot { + pub year: u32, + pub amount: u32, + /// 是否已启用(已放贷) + pub active: bool, +} + +/// 市场 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Market { + pub name: String, + /// 市场开发度 + pub developed: bool, + /// 市场排名 + pub rank: u32, + /// 上年度销售额 + pub last_year_sales: u32, +} + +/// 订单 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Order { + pub id: String, + pub product: Product, + pub quantity: u32, + /// 单价 + pub unit_price: u32, + /// 账期(季度数) + pub account_period: u32, + /// 是否已交付 + pub delivered: bool, + /// 是否加急 + pub urgent: bool, +} + +/// 竞标策略 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiddingStrategy { + pub market_name: String, + /// 市场营销投入 + pub marketing_spend: u32, +} + +/// 在制品(WIP) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WIPItem { + pub line_id: u32, + pub product: Product, + /// 生产进度(0-100%) + pub progress: u32, +} + +/// 成品库存 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinishedGoods { + pub product: Product, + pub quantity: u32, +} + +/// 决策项(key-value 形式) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecisionItem { + pub key: String, + pub value: serde_json::Value, +} + +// ── BusinessGameState ── + +/// 创业沙盘完整游戏状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BusinessGameState { + // ── 时间 ── + pub game_year: u32, + pub game_quarter: u32, // 1-4 + + // ── 财务 ── + pub cash: u32, + + // ── 资产 ── + pub factories: Vec, + pub products_rd: Vec, + + // ── 原材料 ── + pub raw_material_orders: u32, + pub raw_material_inventory: u32, + + // ── 生产与库存 ── + pub work_in_progress: Vec, + pub finished_goods: Vec, + + // ── 应收与贷款 ── + pub accounts_receivable: Vec, + pub long_term_loans: Vec, + pub short_term_loans: Vec, + + // ── 市场与订单 ── + pub markets: Vec, + pub orders: Vec, + pub bidding_strategies: Vec, + + // ── 当前阶段 ── + pub phase: u32, // 1-4 + + // ── 状态标志 ── + pub game_over: bool, + pub game_over_reason: Option, +} + +// ── GameEvent ── + +/// WebSocket 事件(服务端 -> 客户端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum GameEvent { + /// 全量状态更新 + StateUpdate(Box), + /// 阶段变更 + PhaseChange { + year: u32, + quarter: u32, + phase: String, + }, + /// 请求玩家决策 + AskDecision { + year: u32, + quarter: u32, + decision_type: String, + }, + /// 年度财务报告 + AnnualReport(Box), + /// 选单会 + OrderMeeting { + market: String, + available_orders: Vec, + }, + /// 游戏结束 + GameOver { + reason: String, + }, + /// 通用消息 + Message(String), +} + +// ── PlayerAction ── + +/// 客户端操作(客户端 -> 服务端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action")] +pub enum PlayerAction { + /// 开始游戏 + StartGame, + /// 提交竞标策略 + SubmitBidding { + strategies: Vec, + }, + /// 选择订单 + SelectOrder { + order_ids: Vec, + }, + /// 做出决策 + MakeDecision { + decisions: Vec, + }, + /// 进入下一季度 + NextQuarter, + /// 贴现应收账款 + DiscountReceivable { + amount: u32, + }, + /// 申请贷款 + TakeLoan { + loan_type: String, + amount: u32, + }, +} + +// ── Financial Reports ── + +/// 年度财务报告 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnualReport { + pub year: u32, + pub income_statement: IncomeStatement, + pub balance_sheet: BalanceSheet, + pub expense_sheet: ExpenseSheet, +} + +/// 利润表 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomeStatement { + /// 营业收入 + pub revenue: u32, + /// 营业成本 + pub cost_of_goods_sold: u32, + /// 毛利润 + pub gross_profit: i32, + /// 销售费用 + pub sales_expense: u32, + /// 管理费用 + pub admin_expense: u32, + /// 研发费用 + pub rd_expense: u32, + /// 折旧 + pub depreciation: u32, + /// 营业利润 + pub operating_profit: i32, + /// 利息支出 + pub interest_expense: u32, + /// 贴现费用 + pub discount_fee: u32, + /// 税金 + pub tax: u32, + /// 净利润 + pub net_profit: i32, +} + +/// 资产负债表 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceSheet { + // ── 流动资产 ── + /// 现金 + pub cash: u32, + /// 应收账款 + pub accounts_receivable: u32, + /// 原材料 + pub raw_material: u32, + /// 在制品 + pub work_in_process: u32, + /// 产成品 + pub finished_goods: u32, + /// 流动资产合计 + pub total_current_assets: u32, + // ── 固定资产 ── + /// 工厂 + pub factory: u32, + /// 生产线 + pub production_lines: u32, + /// 固定资产合计 + pub total_fixed_assets: u32, + /// 资产总计 + pub total_assets: u32, + // ── 负债 ── + /// 长期贷款 + pub long_term_loans: u32, + /// 短期贷款 + pub short_term_loans: u32, + /// 负债合计 + pub total_liabilities: u32, + // ── 所有者权益 ── + /// 所有者权益 + pub equity: i32, +} + +/// 费用明细表 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExpenseSheet { + /// 新市场开拓投资 + pub new_market_investment: u32, + /// 产品研发投资 + pub product_rd_investment: u32, + /// 季度行政管理费 + pub quarterly_admin_fees: u32, + /// 厂房租金 + pub factory_rent: u32, + /// 设备维护费 + pub line_maintenance: u32, + /// 产品转换费 + pub product_switch_fees: u32, + /// 销售费用 + pub sales_expenses: u32, + /// 折旧 + pub depreciation: u32, + /// 利息与贴现费用 + pub interest_and_discount: u32, + /// 税金 + pub tax: u32, +} From 898733b222041238faa9cac08f377b35efc5871f Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:35:00 +0800 Subject: [PATCH 04/64] =?UTF-8?q?feat(business-sandbox):=20Task=202=20-=20?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E8=AE=A1=E7=AE=97=E6=A8=A1=E5=9D=97(?= =?UTF-8?q?=E8=B4=B4=E7=8E=B0/=E6=8A=98=E6=97=A7/=E8=B4=B7=E6=AC=BE/?= =?UTF-8?q?=E7=A8=8E=E5=8A=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/finance.rs | 167 +++++++++++++++++++++++ rust/api/src/business_sandbox/mod.rs | 1 + 2 files changed, 168 insertions(+) create mode 100644 rust/api/src/business_sandbox/finance.rs diff --git a/rust/api/src/business_sandbox/finance.rs b/rust/api/src/business_sandbox/finance.rs new file mode 100644 index 0000000..4a5130b --- /dev/null +++ b/rust/api/src/business_sandbox/finance.rs @@ -0,0 +1,167 @@ +/// 财务计算模块 +/// +/// 提供创业沙盘游戏中各项财务计算函数。 +/// 所有金额单位均为 "M"(百万),使用 `u32` 表示。 + +// ── 贴现 ── + +/// 计算贴现手续费 +/// +/// 公式:`amount × 1/14`,四舍五入取整。 +#[inline] +pub fn calc_discount_fee(amount: u32) -> u32 { + (amount + 7) / 14 +} + +// ── 折旧 ── + +/// 计算固定资产折旧 +/// +/// 公式:`value × 20%`,四舍五入取整。 +#[inline] +pub fn calc_depreciation(value: u32) -> u32 { + (value + 2) / 5 +} + +// ── 利息 ── + +/// 计算长期贷款年息 +/// +/// 公式:`principal × 5%`,四舍五入取整。 +#[inline] +pub fn calc_long_term_interest(principal: u32) -> u32 { + (principal + 10) / 20 +} + +/// 计算短期贷款季度利息 +/// +/// 公式:`principal × 10% / 4`,四舍五入取整。 +#[inline] +pub fn calc_short_term_quarterly_interest(principal: u32) -> u32 { + (principal + 20) / 40 +} + +// ── 税金 ── + +/// 计算营业税 +/// +/// 公式:`revenue × 3%`,四舍五入取整。 +#[inline] +pub fn calc_sales_tax(revenue: u32) -> u32 { + (revenue * 3 + 50) / 100 +} + +/// 计算所得税 +/// +/// 公式:`profit_before_tax × 20%`,四舍五入取整。 +/// 利润 ≤ 0 时返回 0。 +#[inline] +pub fn calc_income_tax(profit_before_tax: i32) -> u32 { + if profit_before_tax <= 0 { + 0 + } else { + ((profit_before_tax as u32) + 2) / 5 + } +} + +// ── 贷款 ── + +/// 计算贷款额度 +/// +/// 贷款总额度 = `net_assets × 4`,其中长贷 2 倍、短贷 2 倍。 +/// `net_assets ≤ 0` 时返回 `(0, 0)`。 +/// +/// 返回值 `(long_term_limit, short_term_limit)`。 +#[inline] +pub fn calc_loan_limit(net_assets: i32) -> (u32, u32) { + if net_assets <= 0 { + (0, 0) + } else { + let n = net_assets as u32; + (n * 2, n * 2) + } +} + +/// 校验贷款金额是否合法 +/// +/// 最低 20M,且为 20M 的倍数。 +#[inline] +pub fn validate_loan_amount(amount: u32) -> Result { + if amount < 20 { + return Err(format!( + "贷款金额不能低于 20M,当前为 {}M", + amount + )); + } + if amount % 20 != 0 { + return Err(format!( + "贷款金额必须是 20M 的倍数,当前为 {}M", + amount + )); + } + Ok(amount) +} + +// ── 测试 ── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discount_fee() { + assert_eq!(calc_discount_fee(14), 1); + assert_eq!(calc_discount_fee(28), 2); + assert_eq!(calc_discount_fee(13), 1); + assert_eq!(calc_discount_fee(7), 1); // 0.5 rounds to 1 + } + + #[test] + fn test_depreciation() { + assert_eq!(calc_depreciation(20), 4); + assert_eq!(calc_depreciation(10), 2); + assert_eq!(calc_depreciation(16), 3); + assert_eq!(calc_depreciation(5), 1); + } + + #[test] + fn test_long_term_interest() { + assert_eq!(calc_long_term_interest(40), 2); + assert_eq!(calc_long_term_interest(20), 1); + } + + #[test] + fn test_short_term_quarterly_interest() { + assert_eq!(calc_short_term_quarterly_interest(40), 1); + assert_eq!(calc_short_term_quarterly_interest(20), 1); + } + + #[test] + fn test_sales_tax() { + assert_eq!(calc_sales_tax(100), 3); + assert_eq!(calc_sales_tax(50), 2); + } + + #[test] + fn test_income_tax() { + assert_eq!(calc_income_tax(50), 10); + assert_eq!(calc_income_tax(-10), 0); + assert_eq!(calc_income_tax(0), 0); + assert_eq!(calc_income_tax(13), 3); + } + + #[test] + fn test_loan_limit() { + assert_eq!(calc_loan_limit(100), (200, 200)); + assert_eq!(calc_loan_limit(0), (0, 0)); + assert_eq!(calc_loan_limit(-10), (0, 0)); + } + + #[test] + fn test_validate_loan_amount() { + assert!(validate_loan_amount(20).is_ok()); + assert!(validate_loan_amount(40).is_ok()); + assert!(validate_loan_amount(10).is_err()); + assert!(validate_loan_amount(30).is_err()); + } +} diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index a209a6f..8bfeb9a 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -1,2 +1,3 @@ pub mod errors; +pub mod finance; pub mod state; From 77270b947a0583d48482914590f0c13e6849197b Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:35:51 +0800 Subject: [PATCH 05/64] =?UTF-8?q?feat(business-sandbox):=20Task=203=20-=20?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E4=B8=8E=E4=BE=9B=E5=BA=94=E9=93=BE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97(=E7=A0=94=E5=8F=91/=E5=8E=9F=E6=96=99/=E4=BA=A7?= =?UTF-8?q?=E7=BA=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/mod.rs | 1 + rust/api/src/business_sandbox/production.rs | 100 ++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 rust/api/src/business_sandbox/production.rs diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index 8bfeb9a..af2fa46 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -1,3 +1,4 @@ pub mod errors; pub mod finance; +pub mod production; pub mod state; diff --git a/rust/api/src/business_sandbox/production.rs b/rust/api/src/business_sandbox/production.rs new file mode 100644 index 0000000..6cff6ee --- /dev/null +++ b/rust/api/src/business_sandbox/production.rs @@ -0,0 +1,100 @@ +/// 生产与供应链模块 +/// +/// 提供创业沙盘游戏中产品研发、原材料采购与生产成本计算、产线残值等函数。 +/// 所有金额单位均为 "M"(百万),使用 `u32` 表示。 + +use crate::business_sandbox::state::{LineType, Product}; + +// ── 产品研发 ── + +/// 返回指定产品所需的研发总季度数 +/// +/// * `BenMa` — 0(已有,无需研发) +/// * `MengHu` — 6 +/// * `FeiYing` — 8 +/// * `TianLong` — 基础 14;若 `has_feijing` 为 `true`(已研发飞鹰),减 5 为 9 +pub fn product_rd_total_quarters(product: Product, has_feijing: bool) -> u32 { + match product { + Product::BenMa => 0, + Product::MengHu => 6, + Product::FeiYing => 8, + Product::TianLong => { + if has_feijing { + 9 // 14 - 5 + } else { + 14 + } + } + } +} + +/// 每季度研发费用(固定 1M) +#[inline] +pub fn product_rd_cost_per_quarter() -> u32 { + 1 +} + +// ── 原材料成本 ── + +/// 返回指定产品的单位原材料成本 +/// +/// 委派到 [`Product::raw_material_cost`]。 +#[inline] +pub fn raw_material_cost(product: Product) -> u32 { + product.raw_material_cost() +} + +// ── 生产成本 ── + +/// 返回指定产品的单位生产成本 +/// +/// 委派到 [`Product::production_cost`]。 +#[inline] +pub fn production_cost(product: Product) -> u32 { + product.production_cost() +} + +// ── 产线残值 ── + +/// 返回指定产线类型的残值 +/// +/// 委派到 [`LineType::salvage_value`]。 +#[inline] +pub fn line_salvage_value(line_type: LineType) -> u32 { + line_type.salvage_value() +} + +// ── 测试 ── + +#[cfg(test)] +mod tests { + use super::*; + use crate::business_sandbox::state::{LineType, Product}; + + #[test] + fn test_product_rd_quarters() { + assert_eq!(product_rd_total_quarters(Product::BenMa, false), 0); + assert_eq!(product_rd_total_quarters(Product::MengHu, false), 6); + assert_eq!(product_rd_total_quarters(Product::FeiYing, false), 8); + assert_eq!(product_rd_total_quarters(Product::TianLong, false), 14); + assert_eq!(product_rd_total_quarters(Product::TianLong, true), 9); + } + + #[test] + fn test_raw_material_cost() { + assert_eq!(raw_material_cost(Product::BenMa), 1); + assert_eq!(raw_material_cost(Product::TianLong), 4); + } + + #[test] + fn test_production_cost() { + assert_eq!(production_cost(Product::BenMa), 1); + assert_eq!(production_cost(Product::FeiYing), 2); + } + + #[test] + fn test_line_salvage() { + assert_eq!(line_salvage_value(LineType::Manual), 2); + assert_eq!(line_salvage_value(LineType::FullAuto), 6); + } +} From 115a9d0d2e604ec4f11a9f927f8fdb188bacb9a2 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:38:40 +0800 Subject: [PATCH 06/64] =?UTF-8?q?feat(business-sandbox):=20Task=204=20-=20?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=A8=A1=E5=9D=97?= =?UTF-8?q?(=E8=90=A5=E9=94=80/=E9=80=89=E5=8D=95/=E5=BB=B6=E6=9C=9F?= =?UTF-8?q?=E6=83=A9=E7=BD=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/market.rs | 154 ++++++++++++++++++++++++ rust/api/src/business_sandbox/mod.rs | 1 + 2 files changed, 155 insertions(+) create mode 100644 rust/api/src/business_sandbox/market.rs diff --git a/rust/api/src/business_sandbox/market.rs b/rust/api/src/business_sandbox/market.rs new file mode 100644 index 0000000..080644a --- /dev/null +++ b/rust/api/src/business_sandbox/market.rs @@ -0,0 +1,154 @@ +/// 市场与订单模块 +/// +/// 提供创业沙盘游戏中市场订单相关的计算函数。 +/// 所有金额单位均为 "M"(百万),使用 `u32` 表示。 + +use crate::business_sandbox::state::Market; + +// ── 订单激活 ── + +/// 计算激活的订单数量 +/// +/// 每 1M 营销投入激活 1 张订单,上限为 `max_orders`。 +#[inline] +pub fn calc_activated_orders(marketing_spend: u32, max_orders: u32) -> u32 { + marketing_spend.min(max_orders) +} + +// ── 延期交付罚款 ── + +/// 计算延期交付罚款金额 +/// +/// 延期交付需支付原金额的 75%,四舍五入取整。 +#[inline] +pub fn calc_late_delivery_penalty(original_amount: u32) -> u32 { + (original_amount * 3 + 2) / 4 +} + +// ── 选单顺序 ── + +/// 计算选单顺序 +/// +/// 排名第1的(rank=1)优先选单,其余按营销投入降序排列。 +/// 返回原始数组的索引列表。 +pub fn calc_selection_order(companies: &[(u32, u32)]) -> Vec { + if companies.is_empty() { + return vec![]; + } + + let mut indices: Vec = (0..companies.len()).collect(); + + // 找到市场领导者(rank=1) + if let Some(pos) = indices.iter().position(|&i| companies[i].0 == 1) { + let leader = indices.remove(pos); + // 其余按 marketing_spend 降序排列 + indices.sort_by(|&a, &b| companies[b].1.cmp(&companies[a].1)); + indices.insert(0, leader); + } else { + indices.sort_by(|&a, &b| companies[b].1.cmp(&companies[a].1)); + } + + indices +} + +// ── 默认市场列表 ── + +/// 创建默认市场列表 +/// +/// 包含所有可选市场,其中"平城"市场初始为已开发状态。 +pub fn create_default_markets() -> Vec { + vec![ + Market { + name: "平城".to_string(), + developed: true, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "南城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "北城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "东城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "西城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + ] +} + +// ── 测试 ── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_activated_orders() { + assert_eq!(calc_activated_orders(3, 5), 3); + assert_eq!(calc_activated_orders(10, 5), 5); + assert_eq!(calc_activated_orders(0, 5), 0); + assert_eq!(calc_activated_orders(5, 5), 5); + } + + #[test] + fn test_late_delivery_penalty() { + assert_eq!(calc_late_delivery_penalty(100), 75); + assert_eq!(calc_late_delivery_penalty(10), 8); // 7.5 -> 8 + assert_eq!(calc_late_delivery_penalty(1), 1); // 0.75 -> 1 + assert_eq!(calc_late_delivery_penalty(0), 0); + } + + #[test] + fn test_selection_order() { + // company 0: rank=1, spend=3 (market leader - picks first) + // company 1: rank=3, spend=5 (highest spender) + // company 2: rank=2, spend=2 (lower spender) + let companies = vec![(1, 3), (3, 5), (2, 2)]; + let order = calc_selection_order(&companies); + assert_eq!(order[0], 0, "Market leader should pick first"); + // After leader, sorted by marketing spend descending + assert_eq!(order[1], 1, "Highest spender should pick second"); + assert_eq!(order[2], 2, "Lowest spender should pick last"); + } + + #[test] + fn test_selection_order_no_leader() { + let companies = vec![(2, 3), (3, 5), (4, 2)]; + let order = calc_selection_order(&companies); + // No rank=1, so sorted by spend descending + assert_eq!(order[0], 1, "Highest spender should pick first"); + assert_eq!(order[1], 0, "Middle spender should pick second"); + assert_eq!(order[2], 2, "Lowest spender should pick last"); + } + + #[test] + fn test_selection_order_empty() { + let companies: Vec<(u32, u32)> = vec![]; + let order = calc_selection_order(&companies); + assert!(order.is_empty()); + } + + #[test] + fn test_create_default_markets() { + let markets = create_default_markets(); + assert!(!markets.is_empty(), "Should have at least one market"); + let pingcheng = markets.iter().find(|m| m.name == "平城"); + assert!(pingcheng.is_some(), "Should include 平城 market"); + assert!(pingcheng.unwrap().developed, "平城 should be developed"); + } +} diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index af2fa46..7f627fa 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -1,4 +1,5 @@ pub mod errors; pub mod finance; +pub mod market; pub mod production; pub mod state; From 1150fb0ca5ec857d1ddb1cb2db2f86158f614821 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:45:12 +0800 Subject: [PATCH 07/64] =?UTF-8?q?feat(business-sandbox):=20Task=205=20-=20?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=BC=95=E6=93=8E=E6=A0=B8=E5=BF=83(?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=E5=BE=AA=E7=8E=AF/=E5=AD=A3=E5=BA=A610?= =?UTF-8?q?=E6=AD=A5/=E5=B9=B4=E6=9C=AB=E7=BB=93=E7=AE=97/=E7=A0=B4?= =?UTF-8?q?=E4=BA=A7=E5=88=A4=E5=AE=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/engine.rs | 997 ++++++++++++++++++++++++ rust/api/src/business_sandbox/mod.rs | 1 + 2 files changed, 998 insertions(+) create mode 100644 rust/api/src/business_sandbox/engine.rs diff --git a/rust/api/src/business_sandbox/engine.rs b/rust/api/src/business_sandbox/engine.rs new file mode 100644 index 0000000..a40041c --- /dev/null +++ b/rust/api/src/business_sandbox/engine.rs @@ -0,0 +1,997 @@ +// ── 创业沙盘游戏核心引擎 ── + +use std::collections::HashMap; +use tokio::sync::Mutex; + +use crate::business_sandbox::state::*; +use crate::business_sandbox::errors::GameError; +use crate::business_sandbox::finance::*; +use crate::business_sandbox::production::*; + +// ── GameManager ── + +/// 游戏实例管理器(线程安全) +pub struct GameManager { + games: Mutex>, +} + +/// 游戏运行时实例 +pub struct GameInstance { + pub state: BusinessGameState, + pub annual_reports: Vec, + /// "waiting" | "year_start" | "quarter_ops" | "year_end" + pub current_phase: String, + // ── 年度财务跟踪(每年清零) ── + pub annual_revenue: u32, + pub annual_cost_of_goods: u32, + pub annual_rd_expense: u32, + pub annual_sales_expense: u32, + pub annual_interest_expense: u32, + pub annual_discount_fee: u32, + pub annual_admin_expense: u32, +} + +impl GameManager { + /// 创建新的管理器 + pub fn new() -> Self { + Self { + games: Mutex::new(HashMap::new()), + } + } + + /// 创建新游戏 + pub async fn create_game(&self, game_id: &str) -> Result<(), GameError> { + let mut games = self.games.lock().await; + if games.contains_key(game_id) { + return Err(GameError::Internal(format!("游戏 {} 已存在", game_id))); + } + games.insert( + game_id.to_string(), + GameInstance { + state: Self::initial_state(), + annual_reports: Vec::new(), + current_phase: "waiting".to_string(), + annual_revenue: 0, + annual_cost_of_goods: 0, + annual_rd_expense: 0, + annual_sales_expense: 0, + annual_interest_expense: 0, + annual_discount_fee: 0, + annual_admin_expense: 0, + }, + ); + Ok(()) + } + + /// 返回 PDF 规则规定的初始状态 + pub fn initial_state() -> BusinessGameState { + BusinessGameState { + game_year: 1, + game_quarter: 1, + // 初始现金 12M + cash: 12, + // 1 个老工厂,容量 4,价值 20M + factories: vec![Factory { + id: "F1".to_string(), + name: "老工厂".to_string(), + capacity: 4, + value: 20, + // 含 1 条手工线生产奔马 + lines: vec![ProductionLine { + id: 1, + line_type: LineType::Manual, + status: LineStatus::Producing(Product::BenMa), + product: Some(Product::BenMa), + }], + }], + // 产品研发(奔马已拥有) + products_rd: vec![ + ProductRDA { + product: Product::MengHu, + progress: 0, + total: product_rd_total_quarters(Product::MengHu, false), + completed: false, + }, + ProductRDA { + product: Product::FeiYing, + progress: 0, + total: product_rd_total_quarters(Product::FeiYing, false), + completed: false, + }, + ProductRDA { + product: Product::TianLong, + progress: 0, + total: product_rd_total_quarters(Product::TianLong, false), + completed: false, + }, + ], + // 原材料库存 8 个单位,采购在途 2 个 + raw_material_orders: 2, + raw_material_inventory: 8, + // 在制品 1 条(产线1,奔马,进度1) + work_in_progress: vec![WIPItem { + line_id: 1, + product: Product::BenMa, + progress: 1, + }], + // 成品库 4 批奔马 + finished_goods: vec![FinishedGoods { + product: Product::BenMa, + quantity: 4, + }], + // 应收账款 12M,账期 4 季度 + accounts_receivable: vec![AccountReceivable { + amount: 12, + due_quarters: 4, + }], + long_term_loans: Vec::new(), + short_term_loans: Vec::new(), + // 平城市场(已开发,排名第 1),其余未开发 + markets: vec![ + Market { + name: "平城".to_string(), + developed: true, + rank: 1, + last_year_sales: 0, + }, + Market { + name: "南城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "北城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "东城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + Market { + name: "西城".to_string(), + developed: false, + rank: 0, + last_year_sales: 0, + }, + ], + orders: Vec::new(), + bidding_strategies: Vec::new(), + phase: 1, + game_over: false, + game_over_reason: None, + } + } + + /// 获取游戏状态 + pub async fn get_state(&self, game_id: &str) -> Result { + let games = self.games.lock().await; + games + .get(game_id) + .map(|g| g.state.clone()) + .ok_or_else(|| GameError::GameNotFound(game_id.to_string())) + } + + /// 处理玩家操作,返回事件列表 + pub async fn handle_action( + &self, + game_id: &str, + action: PlayerAction, + ) -> Result, GameError> { + let mut games = self.games.lock().await; + let instance = games + .get_mut(game_id) + .ok_or_else(|| GameError::GameNotFound(game_id.to_string()))?; + + if instance.state.game_over { + return Err(GameError::GameAlreadyOver); + } + + match action { + PlayerAction::StartGame => { + // 初始化状态,返回全量状态 + 请求竞标决策 + instance.current_phase = "year_start".to_string(); + let state = instance.state.clone(); + Ok(vec![ + GameEvent::StateUpdate(Box::new(state)), + GameEvent::AskDecision { + year: instance.state.game_year, + quarter: instance.state.game_quarter, + decision_type: "bidding".to_string(), + }, + ]) + } + + PlayerAction::SubmitBidding { strategies } => { + // 扣除营销费 + let total_spend: u32 = strategies.iter().map(|s| s.marketing_spend).sum(); + if instance.state.cash < total_spend { + return Err(GameError::InsufficientFunds { + required: total_spend as f64, + available: instance.state.cash as f64, + }); + } + instance.state.cash -= total_spend; + instance.state.bidding_strategies = strategies; + + // 会计年度初始化(每年 Q1 重置) + if instance.state.game_quarter == 1 { + instance.annual_revenue = 0; + instance.annual_cost_of_goods = 0; + instance.annual_rd_expense = 0; + instance.annual_sales_expense = 0; + instance.annual_interest_expense = 0; + instance.annual_discount_fee = 0; + instance.annual_admin_expense = 0; + } + // 第一年也计入销售费用 + instance.annual_sales_expense += total_spend; + instance.current_phase = "quarter_ops".to_string(); + + let mut events: Vec = vec![ + GameEvent::Message(format!("提交竞标策略成功,营销费用 {}M", total_spend)), + GameEvent::PhaseChange { + year: instance.state.game_year, + quarter: instance.state.game_quarter, + phase: "quarter_ops".to_string(), + }, + ]; + + let state = instance.state.clone(); + events.push(GameEvent::StateUpdate(Box::new(state))); + + Ok(events) + } + + PlayerAction::NextQuarter => { + let quarter_events = Self::execute_quarter(instance)?; + let mut events = quarter_events; + + if instance.state.game_quarter == 4 { + // Q4 还执行年末结算 + let year_end_events = Self::execute_year_end(instance)?; + events.extend(year_end_events); + + // 重置年度跟踪 + instance.annual_revenue = 0; + instance.annual_cost_of_goods = 0; + instance.annual_rd_expense = 0; + instance.annual_sales_expense = 0; + instance.annual_interest_expense = 0; + instance.annual_discount_fee = 0; + instance.annual_admin_expense = 0; + + // 进入下一年 + instance.state.game_year += 1; + instance.state.game_quarter = 1; + instance.current_phase = "year_start".to_string(); + } else { + instance.state.game_quarter += 1; + instance.current_phase = "quarter_ops".to_string(); + } + + let state = instance.state.clone(); + events.push(GameEvent::StateUpdate(Box::new(state))); + + Ok(events) + } + + PlayerAction::DiscountReceivable { amount } => { + // 贴现,扣除手续费 + let fee = calc_discount_fee(amount); + let net_amount = amount.saturating_sub(fee); + + // 从应收账款中扣除对应金额 + let mut remaining = amount; + let mut new_receivables = Vec::new(); + for ar in instance.state.accounts_receivable.drain(..) { + if remaining > 0 { + let take = ar.amount.min(remaining); + remaining -= take; + if take < ar.amount { + new_receivables.push(AccountReceivable { + amount: ar.amount - take, + due_quarters: ar.due_quarters, + }); + } + } else { + new_receivables.push(ar); + } + } + + if remaining > 0 { + return Err(GameError::InvalidAction(format!( + "应收账款不足,需要贴现 {}M", + amount + ))); + } + + instance.state.accounts_receivable = new_receivables; + instance.state.cash += net_amount; + instance.annual_discount_fee += fee; + + Ok(vec![GameEvent::Message(format!( + "贴现成功,获得 {}M,手续费 {}M", + net_amount, fee + ))]) + } + + PlayerAction::TakeLoan { loan_type, amount } => { + validate_loan_amount(amount) + .map_err(|e| GameError::InvalidAction(e))?; + + match loan_type.as_str() { + "short_term" | "short" => { + let equity = calculate_equity(&instance.state); + let (_, short_limit) = calc_loan_limit(equity); + let current_short: u32 = + instance.state.short_term_loans.iter().map(|l| l.amount).sum(); + if current_short + amount > short_limit { + return Err(GameError::InvalidAction(format!( + "短期贷款额度不足:已用 {}M,限额 {}M", + current_short, short_limit + ))); + } + instance.state.short_term_loans.push(Loan { + amount, + remaining_quarters: 4, + annual_interest_rate: 10.0, + }); + instance.state.cash += amount; + Ok(vec![GameEvent::Message(format!( + "短期贷款成功 {}M", + amount + ))]) + } + "long_term" | "long" => { + let equity = calculate_equity(&instance.state); + let (long_limit, _) = calc_loan_limit(equity); + let current_long: u32 = instance + .state + .long_term_loans + .iter() + .filter(|s| s.active) + .map(|s| s.amount) + .sum(); + if current_long + amount > long_limit { + return Err(GameError::InvalidAction(format!( + "长期贷款额度不足:已用 {}M,限额 {}M", + current_long, long_limit + ))); + } + instance.state.long_term_loans.push(LongTermLoanSlot { + year: instance.state.game_year, + amount, + active: true, + }); + instance.state.cash += amount; + Ok(vec![GameEvent::Message(format!( + "长期贷款成功 {}M", + amount + ))]) + } + _ => Err(GameError::InvalidAction(format!( + "未知贷款类型: {}", + loan_type + ))), + } + } + + PlayerAction::SelectOrder { .. } => { + Err(GameError::InvalidAction("选单操作暂未实现".to_string())) + } + PlayerAction::MakeDecision { .. } => { + Err(GameError::InvalidAction("决策操作暂未实现".to_string())) + } + } + } + + // ── 季度运营 ── + + /// 执行 PDF 季度运营 10 步 + fn execute_quarter(instance: &mut GameInstance) -> Result, GameError> { + let mut events = Vec::new(); + + // ── 第 1 步:更新应收账款(账期-1,到期入现金) ── + { + let mut cash_in = 0u32; + let mut new_receivables = Vec::new(); + for ar in instance.state.accounts_receivable.drain(..) { + if ar.due_quarters <= 1 { + cash_in += ar.amount; + events.push(GameEvent::Message(format!( + "应收账款到期入账 {}M", + ar.amount + ))); + } else { + new_receivables.push(AccountReceivable { + amount: ar.amount, + due_quarters: ar.due_quarters - 1, + }); + } + } + instance.state.accounts_receivable = new_receivables; + instance.state.cash += cash_in; + } + + // ── 第 2 步:短贷利息(扣利息,检查破产) ── + { + let mut interest = 0u32; + let mut principal = 0u32; + let mut remaining_loans = Vec::new(); + for mut loan in instance.state.short_term_loans.drain(..) { + let q_interest = calc_short_term_quarterly_interest(loan.amount); + interest += q_interest; + loan.remaining_quarters = loan.remaining_quarters.saturating_sub(1); + if loan.remaining_quarters == 0 { + principal += loan.amount; + events.push(GameEvent::Message(format!( + "短贷到期归还本金 {}M", + loan.amount + ))); + } else { + remaining_loans.push(loan); + } + } + instance.state.short_term_loans = remaining_loans; + let total_st = interest + principal; + if total_st > 0 { + if instance.state.cash < total_st { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "短贷利息/本金支付导致资金链断裂", + )); + } + instance.state.cash -= total_st; + instance.annual_interest_expense += interest; + events.push(GameEvent::Message(format!( + "支付短贷利息 {}M,归还本金 {}M", + interest, principal + ))); + } + } + + // ── 第 3 步:产品研发推进 ── + for rd in &mut instance.state.products_rd { + if rd.completed { + continue; + } + rd.progress += 1; + if rd.progress >= rd.total { + rd.completed = true; + events.push(GameEvent::Message(format!( + "产品 {:?} 研发完成!", + rd.product + ))); + } else { + let rd_cost = product_rd_cost_per_quarter(); + if instance.state.cash < rd_cost { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "研发费用支付导致资金链断裂", + )); + } + instance.state.cash -= rd_cost; + instance.annual_rd_expense += rd_cost; + events.push(GameEvent::Message(format!( + "支付研发费用 {}M({:?} {}/{})", + rd_cost, rd.product, rd.progress, rd.total + ))); + } + } + + // ── 第 4 步:供应商交货并付费 ── + { + let orders = instance.state.raw_material_orders; + if orders > 0 { + let cost = orders; // 1M/单位 + if instance.state.cash < cost { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "原材料采购支付导致资金链断裂", + )); + } + instance.state.cash -= cost; + instance.state.raw_material_inventory += orders; + events.push(GameEvent::Message(format!( + "供应商交付原材料 {} 单位,支付 {}M", + orders, cost + ))); + instance.state.raw_material_orders = 0; + } + } + + // ── 第 5 步:新原料采购(自动 2 单位) ── + { + let auto_order = 2u32; + instance.state.raw_material_orders += auto_order; + events.push(GameEvent::Message(format!( + "自动采购原材料 {} 单位", + auto_order + ))); + } + + // ── 第 6 步:更新生产状态(进度+1,完成转成品库) ── + { + let mut new_wip = Vec::new(); + for mut wip in instance.state.work_in_progress.drain(..) { + wip.progress += 1; + // 生产周期为 2 季度 + if wip.progress >= 2 { + // 转入成品库 + let existing = instance + .state + .finished_goods + .iter_mut() + .find(|fg| fg.product == wip.product); + if let Some(fg) = existing { + fg.quantity += 1; + } else { + instance.state.finished_goods.push(FinishedGoods { + product: wip.product, + quantity: 1, + }); + } + events.push(GameEvent::Message(format!( + "产线{} {:?} 生产完成,转入成品库", + wip.line_id, wip.product + ))); + } else { + new_wip.push(wip); + } + } + instance.state.work_in_progress = new_wip; + } + + // ── 第 7 步:产线建设/转产推进 ── + for line in instance.state.factories.iter_mut().flat_map(|f| &mut f.lines) { + match line.status.clone() { + LineStatus::Building(remaining) => { + if remaining <= 1 { + line.status = LineStatus::Idle; + events.push(GameEvent::Message(format!("产线{} 建设完成", line.id))); + } else { + line.status = LineStatus::Building(remaining - 1); + } + } + LineStatus::SwitchingTo(remaining, target) => { + if remaining <= 1 { + line.product = Some(target); + line.status = LineStatus::Idle; + events.push(GameEvent::Message(format!("产线{} 转产完成", line.id))); + } else { + line.status = LineStatus::SwitchingTo(remaining - 1, target); + } + } + _ => {} + } + } + + // ── 第 8 步:新生产(空闲产线+有原料→开始生产,付费) ── + for line in instance.state.factories.iter_mut().flat_map(|f| &mut f.lines) { + if !matches!(line.status, LineStatus::Idle) { + continue; + } + let Some(product) = line.product else { + continue; + }; + if instance.state.raw_material_inventory == 0 { + events.push(GameEvent::Message(format!( + "产线{} 原材料不足,无法开始生产", + line.id + ))); + continue; + } + let rm_cost = product.raw_material_cost(); + if instance.state.cash < rm_cost { + events.push(GameEvent::Message(format!( + "产线{} 现金不足,无法开始生产", + line.id + ))); + continue; + } + instance.state.cash -= rm_cost; + instance.state.raw_material_inventory -= 1; + instance.state.work_in_progress.push(WIPItem { + line_id: line.id, + product, + progress: 0, + }); + line.status = LineStatus::Producing(product); + events.push(GameEvent::Message(format!( + "产线{} 开始生产 {:?}", + line.id, product + ))); + } + + // ── 第 9 步:订单交付(有成品→交单→应收账款) ── + for order in &mut instance.state.orders { + if order.delivered { + continue; + } + let can_deliver = instance + .state + .finished_goods + .iter() + .any(|fg| fg.product == order.product && fg.quantity >= order.quantity); + if !can_deliver { + continue; + } + let fg = instance + .state + .finished_goods + .iter_mut() + .find(|fg| fg.product == order.product) + .unwrap(); + fg.quantity -= order.quantity; + order.delivered = true; + let total_amount = order.unit_price * order.quantity; + let cogs = + (order.product.raw_material_cost() + order.product.production_cost()) * order.quantity; + instance.annual_revenue += total_amount; + instance.annual_cost_of_goods += cogs; + instance.state.accounts_receivable.push(AccountReceivable { + amount: total_amount, + due_quarters: order.account_period, + }); + events.push(GameEvent::Message(format!( + "交付订单 {},{:?} × {},金额 {}M,账期 {} 季", + order.id, order.product, order.quantity, total_amount, order.account_period + ))); + } + + // ── 第 10 步:行政管理费 1M(检查破产) ── + if instance.state.cash < 1 { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "行政管理费支付导致资金链断裂", + )); + } + instance.state.cash -= 1; + instance.annual_admin_expense += 1; + events.push(GameEvent::Message("支付行政管理费 1M".to_string())); + + // ── 破产检查:股东权益 < 0 ── + if calculate_equity(&instance.state) < 0 { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "股东权益为负,游戏结束", + )); + } + + Ok(events) + } + + // ── 年末结算 ── + + /// 执行年末结算 + fn execute_year_end(instance: &mut GameInstance) -> Result, GameError> { + let mut events = Vec::new(); + let year = instance.state.game_year; + + // ── 第 1 步:生产线管理费(1M/条) ── + let line_count: u32 = instance + .state + .factories + .iter() + .flat_map(|f| &f.lines) + .count() as u32; + let maintenance = line_count; + if instance.state.cash < maintenance { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "设备维护费支付导致资金链断裂", + )); + } + instance.state.cash -= maintenance; + events.push(GameEvent::Message(format!( + "支付设备维护费 {}M({} 条产线)", + maintenance, line_count + ))); + + // ── 第 2 步:固定资产折旧 20% ── + let mut depreciation = 0u32; + for factory in &mut instance.state.factories { + let dep = calc_depreciation(factory.value); + depreciation += dep; + factory.value = factory.value.saturating_sub(dep); + } + events.push(GameEvent::Message(format!( + "固定资产折旧 {}M", + depreciation + ))); + + // ── 第 3 步:长贷利息/还本 ── + let mut lt_interest = 0u32; + let mut lt_principal = 0u32; + let mut remaining_slots = Vec::new(); + for slot in instance.state.long_term_loans.drain(..) { + if !slot.active { + remaining_slots.push(slot); + continue; + } + let interest = calc_long_term_interest(slot.amount); + lt_interest += interest; + // 5 年期限到期还本 + if year >= slot.year + 5 { + lt_principal += slot.amount; + events.push(GameEvent::Message(format!( + "长贷到期归还本金 {}M({} 年贷款)", + slot.amount, slot.year + ))); + // 不重新加入,贷款已关闭 + } else { + remaining_slots.push(slot); + } + } + instance.state.long_term_loans = remaining_slots; + let total_lt = lt_interest + lt_principal; + if total_lt > 0 { + if instance.state.cash < total_lt { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "长贷利息/还本导致资金链断裂", + )); + } + instance.state.cash -= total_lt; + instance.annual_interest_expense += lt_interest; + events.push(GameEvent::Message(format!( + "支付长贷利息 {}M,归还本金 {}M", + lt_interest, lt_principal + ))); + } + + // ── 第 4 步:税款 ── + let revenue = instance.annual_revenue; + let sales_tax = calc_sales_tax(revenue); + if instance.state.cash < sales_tax { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "营业税支付导致资金链断裂", + )); + } + instance.state.cash -= sales_tax; + + // 利润总额 = 收入 - 所有费用 (不含折旧和维护费?含) + let total_costs = instance.annual_cost_of_goods + + instance.annual_admin_expense + + instance.annual_sales_expense + + instance.annual_rd_expense + + depreciation + + instance.annual_interest_expense + + instance.annual_discount_fee + + maintenance; + let profit_before_tax = revenue as i32 - total_costs as i32; + let income_tax = calc_income_tax(profit_before_tax); + if instance.state.cash < income_tax { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "所得税支付导致资金链断裂", + )); + } + instance.state.cash -= income_tax; + + let total_tax = sales_tax + income_tax; + events.push(GameEvent::Message(format!( + "纳税:营业税 {}M(营收 {}M × 3%),所得税 {}M(利润 {}M × 20%),合计 {}M", + sales_tax, + revenue, + income_tax, + profit_before_tax.max(0), + total_tax + ))); + + // ── 第 5 步:更新 phase ── + if year >= 8 { + instance.state.phase = 3; + } else if year >= 5 { + instance.state.phase = 2; + } + + // ── 生成年度财务报告 ── + let report = generate_annual_report( + &instance, + sales_tax, + income_tax, + maintenance, + depreciation, + ); + instance.annual_reports.push(report.clone()); + events.push(GameEvent::AnnualReport(Box::new(report))); + + // ── 第 6 步:破产检查 ── + if calculate_equity(&instance.state) < 0 { + return Ok(declare_game_over( + &mut instance.state, + &mut events, + "股东权益为负,游戏结束", + )); + } + + Ok(events) + } +} + +// ── 辅助函数 ── + +/// 计算股东权益(总资产 - 总负债) +fn calculate_equity(state: &BusinessGameState) -> i32 { + let cash = state.cash as i32; + let receivable: i32 = state + .accounts_receivable + .iter() + .map(|a| a.amount as i32) + .sum(); + let raw_material: i32 = state.raw_material_inventory as i32; // 1M/单位 + let wip: i32 = state.work_in_progress.len() as i32; // 简化估值 + let finished: i32 = state + .finished_goods + .iter() + .map(|fg| fg.quantity as i32) + .sum(); + let factories: i32 = state.factories.iter().map(|f| f.value as i32).sum(); + let total_assets = cash + receivable + raw_material + wip + finished + factories; + + let long_term: i32 = state + .long_term_loans + .iter() + .filter(|s| s.active) + .map(|s| s.amount as i32) + .sum(); + let short_term: i32 = state + .short_term_loans + .iter() + .map(|l| l.amount as i32) + .sum(); + let total_liabilities = long_term + short_term; + + total_assets - total_liabilities +} + +/// 生成年度财务报告 +fn generate_annual_report( + instance: &GameInstance, + sales_tax: u32, + income_tax: u32, + maintenance: u32, + depreciation: u32, +) -> AnnualReport { + let year = instance.state.game_year; + let revenue = instance.annual_revenue; + let cogs = instance.annual_cost_of_goods; + let gross_profit = revenue as i32 - cogs as i32; + let sales_expense = instance.annual_sales_expense; + let admin_expense = instance.annual_admin_expense; + let rd_expense = instance.annual_rd_expense; + let interest_expense = instance.annual_interest_expense; + let discount_fee = instance.annual_discount_fee; + let total_tax = sales_tax + income_tax; + + let operating_profit = gross_profit + - sales_expense as i32 + - admin_expense as i32 + - rd_expense as i32 + - depreciation as i32 + - maintenance as i32; + + let profit_before_tax = operating_profit - interest_expense as i32 - discount_fee as i32; + let net_profit = profit_before_tax - total_tax as i32; + + // ── 资产负债表 ── + let cash = instance.state.cash; + let ar_total: u32 = instance + .state + .accounts_receivable + .iter() + .map(|a| a.amount) + .sum(); + let raw_material_value = instance.state.raw_material_inventory; // 1M/单位 + let wip_value = instance.state.work_in_progress.len() as u32; + let fg_value: u32 = instance + .state + .finished_goods + .iter() + .map(|fg| fg.quantity) + .sum(); + let total_current = cash + ar_total + raw_material_value + wip_value + fg_value; + + let factory_value: u32 = instance.state.factories.iter().map(|f| f.value).sum(); + let line_value: u32 = instance + .state + .factories + .iter() + .flat_map(|f| &f.lines) + .map(|l| l.line_type.total_build_cost()) + .sum(); + let total_fixed = factory_value + line_value; + let total_assets = total_current + total_fixed; + + let long_term_total: u32 = instance + .state + .long_term_loans + .iter() + .filter(|s| s.active) + .map(|s| s.amount) + .sum(); + let short_term_total: u32 = instance + .state + .short_term_loans + .iter() + .map(|l| l.amount) + .sum(); + let total_liabilities = long_term_total + short_term_total; + let equity = total_assets as i32 - total_liabilities as i32; + + AnnualReport { + year, + income_statement: IncomeStatement { + revenue, + cost_of_goods_sold: cogs, + gross_profit, + sales_expense, + admin_expense, + rd_expense, + depreciation, + operating_profit, + interest_expense, + discount_fee, + tax: total_tax, + net_profit, + }, + balance_sheet: BalanceSheet { + cash, + accounts_receivable: ar_total, + raw_material: raw_material_value, + work_in_process: wip_value, + finished_goods: fg_value, + total_current_assets: total_current, + factory: factory_value, + production_lines: line_value, + total_fixed_assets: total_fixed, + total_assets, + long_term_loans: long_term_total, + short_term_loans: short_term_total, + total_liabilities, + equity, + }, + expense_sheet: ExpenseSheet { + new_market_investment: 0, + product_rd_investment: rd_expense, + quarterly_admin_fees: admin_expense, + factory_rent: 0, + line_maintenance: maintenance, + product_switch_fees: 0, + sales_expenses: sales_expense, + depreciation, + interest_and_discount: interest_expense + discount_fee, + tax: total_tax, + }, + } +} + +/// 设置 game_over 标志并返回事件列表(含 game_over 前已产生的事件) +fn declare_game_over( + state: &mut BusinessGameState, + events: &mut Vec, + reason: &str, +) -> Vec { + state.game_over = true; + state.game_over_reason = Some(reason.to_string()); + events.push(GameEvent::GameOver { + reason: reason.to_string(), + }); + std::mem::take(events) +} diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index 7f627fa..8a6b943 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -1,3 +1,4 @@ +pub mod engine; pub mod errors; pub mod finance; pub mod market; From 29c06afb0e2b8a4dafac58e6d0d99dfa4df1e62c Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:50:40 +0800 Subject: [PATCH 08/64] =?UTF-8?q?feat(business-sandbox):=20Task=206=20-=20?= =?UTF-8?q?WebSocket=E8=B7=AF=E7=94=B1=E5=92=8C=E4=BA=8B=E4=BB=B6=E5=B9=BF?= =?UTF-8?q?=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/mod.rs | 1 + rust/api/src/business_sandbox/ws_handler.rs | 94 +++++++++++++++++++++ rust/api/src/main.rs | 8 +- rust/api/src/state.rs | 32 +++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 rust/api/src/business_sandbox/ws_handler.rs diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index 8a6b943..db21801 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -4,3 +4,4 @@ pub mod finance; pub mod market; pub mod production; pub mod state; +pub mod ws_handler; diff --git a/rust/api/src/business_sandbox/ws_handler.rs b/rust/api/src/business_sandbox/ws_handler.rs new file mode 100644 index 0000000..0220fa7 --- /dev/null +++ b/rust/api/src/business_sandbox/ws_handler.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::{Path, State, WebSocketUpgrade}; +use axum::response::IntoResponse; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; + +use crate::business_sandbox::state::*; +use crate::state::AppState; + +/// WebSocket 路由处理函数 +pub async fn game_ws_handler( + ws: WebSocketUpgrade, + State(state): State>, + Path(game_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_game_ws(socket, state, game_id)) +} + +async fn handle_game_ws( + socket: WebSocket, + state: Arc, + game_id: String, +) { + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = mpsc::channel::(256); + + // 注册到游戏管理器 + state.business_sandbox.register_ws(&game_id, tx).await; + + // 创建游戏 + if state.business_sandbox.manager.create_game(&game_id).await.is_err() { + return; + } + + // 发送初始状态 + 要求竞标决策 + if let Ok(gs) = state.business_sandbox.manager.get_state(&game_id).await { + let msg = serde_json::to_string(&GameEvent::StateUpdate(Box::new(gs))).unwrap(); + let _ = sender.send(Message::Text(msg)).await; + } + let ask = serde_json::to_string(&GameEvent::AskDecision { + year: 1, + quarter: 0, + decision_type: "bidding".into(), + }) + .unwrap(); + let _ = sender.send(Message::Text(ask)).await; + + // 发送任务:转发 mpsc 到 WebSocket + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if sender.send(Message::Text(msg)).await.is_err() { + break; + } + } + }); + + // 接收任务:处理客户端消息 + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + if let Ok(action) = serde_json::from_str::(&text) { + match state + .business_sandbox + .manager + .handle_action(&game_id, action) + .await + { + Ok(events) => { + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let _ = state + .business_sandbox + .broadcast(&game_id, &json) + .await; + } + } + Err(e) => { + let err_msg = serde_json::to_string( + &GameEvent::Message(format!("错误: {}", e)), + ) + .unwrap(); + let _ = state + .business_sandbox + .broadcast(&game_id, &err_msg) + .await; + } + } + } + } + } + + send_task.abort(); +} diff --git a/rust/api/src/main.rs b/rust/api/src/main.rs index 26714c7..abd0a37 100644 --- a/rust/api/src/main.rs +++ b/rust/api/src/main.rs @@ -1,5 +1,6 @@ mod auth; mod bing; +mod business_sandbox; mod coding_tools; mod collector; mod error; @@ -22,7 +23,7 @@ use registry::SoulRegistry; use crate::collector::SoulCollector; use crate::rate_limiter::RateLimiter; -use crate::state::AppState; +use crate::state::{AppState, BusinessSandboxState}; use crate::store::AppStore; use crate::web_fetch_tool::WebFetchTool; use crate::web_search_tool::WebSearchTool; @@ -234,6 +235,7 @@ async fn main() -> Result<(), Box> { interrogation_gates: Arc::new(dashmap::DashMap::new()), preferred_provider: Arc::new(std::sync::RwLock::new(None)), api_token: api_token.clone(), + business_sandbox: Arc::new(BusinessSandboxState::new()), }); let app = build_router(state.clone(), rate_limiter, cors_origins); @@ -328,6 +330,10 @@ fn build_router(state: Arc, rate_limiter: Arc, cors_origi "/ws/souls/auto-create/:task_id", axum::routing::get(ws::auto_create_ws_handler), ) + .route( + "/ws/game/:game_id", + axum::routing::get(crate::business_sandbox::ws_handler::game_ws_handler), + ) .with_state(state); crate::middleware::apply_middleware(app, rate_limiter, api_token, cors_origins) diff --git a/rust/api/src/state.rs b/rust/api/src/state.rs index 0d89289..00e11cf 100644 --- a/rust/api/src/state.rs +++ b/rust/api/src/state.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::{Arc, RwLock}; use archive::ArchiveSystem; @@ -7,9 +8,13 @@ use possession::PossessionEngine; use registry::SoulRegistry; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::Mutex as AsyncMutex; use crate::collector::SoulCollector; +use crate::business_sandbox::engine::GameManager; + #[derive(Debug, Clone, Serialize)] pub struct AutoCreateEvent { pub task_id: String, @@ -36,6 +41,32 @@ pub struct InterrogationGate { pub created_at: u64, } +pub struct BusinessSandboxState { + pub manager: GameManager, + ws_senders: AsyncMutex>>>, +} + +impl BusinessSandboxState { + pub fn new() -> Self { + Self { + manager: GameManager::new(), + ws_senders: AsyncMutex::new(HashMap::new()), + } + } + + pub async fn register_ws(&self, game_id: &str, tx: mpsc::Sender) { + let mut senders = self.ws_senders.lock().await; + senders.entry(game_id.to_string()).or_default().push(tx); + } + + pub async fn broadcast(&self, game_id: &str, message: &str) { + let mut senders = self.ws_senders.lock().await; + if let Some(entries) = senders.get_mut(game_id) { + entries.retain(|tx| tx.try_send(message.to_string()).is_ok()); + } + } +} + #[derive(Clone)] pub struct AppState { pub registry: Arc, @@ -47,4 +78,5 @@ pub struct AppState { pub interrogation_gates: Arc>, pub preferred_provider: Arc>>, pub api_token: Option, + pub business_sandbox: Arc, } From b4ee5d5828645c644b594e241580c53cf9c51d54 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 00:52:50 +0800 Subject: [PATCH 09/64] =?UTF-8?q?feat(business-sandbox):=20Task=207=20-=20?= =?UTF-8?q?AI=E7=AB=9E=E4=BA=89=E5=AF=B9=E6=89=8B(=E5=A4=8D=E7=94=A8Gatewa?= =?UTF-8?q?y/Soul)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/business_sandbox/ai_competitor.rs | 161 ++++++++++++++++++ rust/api/src/business_sandbox/mod.rs | 1 + 2 files changed, 162 insertions(+) create mode 100644 rust/api/src/business_sandbox/ai_competitor.rs diff --git a/rust/api/src/business_sandbox/ai_competitor.rs b/rust/api/src/business_sandbox/ai_competitor.rs new file mode 100644 index 0000000..4b8816c --- /dev/null +++ b/rust/api/src/business_sandbox/ai_competitor.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; +use serde::{Serialize, Deserialize}; + +use ai_gateway::GatewayRegistry; +use registry::SoulRegistry; +use foundation::{Prompt, PromptMessage, CallConfig}; + +use crate::business_sandbox::state::*; + +/// AI 竞争对手的年度决策 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompetitorDecision { + pub company_name: String, + pub marketing_spend: u32, + pub product_focus: Product, + pub pricing_strategy: String, // "aggressive" | "normal" | "premium" +} + +/// 生成 AI 竞争对手决策 +/// 使用现有 AI 网关和 Soul 注册表 +/// 如果 AI 调用失败,使用合理的默认值 +pub async fn generate_competitor_decisions( + gateway: Arc, + registry: Arc, + state: &BusinessGameState, +) -> Vec { + let competitor_names = vec![ + "CEO", "CFO", "COO", "CMO", "管理咨询顾问", + ]; + + let mut decisions = Vec::new(); + let market_summary = format_market_summary(state); + + for name in &competitor_names { + let decision = generate_single_competitor( + gateway.clone(), + registry.clone(), + name, + &market_summary, + ) + .await; + + decisions.push(decision); + } + + decisions +} + +/// 生成单个竞争对手的决策 +async fn generate_single_competitor( + gateway: Arc, + registry: Arc, + soul_name: &str, + market_summary: &str, +) -> CompetitorDecision { + // 尝试从注册表获取灵魂配置 + if let Ok(profile) = registry.get_soul(soul_name) { + let prompt_text = build_competitor_prompt(soul_name, market_summary); + + // 尝试挑选 AI provider + if let Some(provider) = gateway.pick_provider() { + if let Some(gw) = gateway.get(&provider) { + let call_config = CallConfig { + temperature: 0.7, + max_tokens: 300, + stream: false, + model: Some(profile.model.clone()), + ..Default::default() + }; + + let prompt = Prompt { + messages: vec![ + PromptMessage { + role: "system".into(), + content: profile.summon_prompt.clone(), + reasoning_content: None, + tool_calls: None, + tool_call_id: None, + }, + PromptMessage { + role: "user".into(), + content: prompt_text, + reasoning_content: None, + tool_calls: None, + tool_call_id: None, + }, + ], + }; + + let mut rx = gw.call(&prompt, &call_config); + let mut response = String::new(); + while let Some(Ok(chunk)) = rx.recv().await { + response.push_str(&chunk.content); + } + + // 尝试解析 JSON 响应 + if let Ok(decision) = serde_json::from_str::(&response) { + return decision; + } + } + } + } + + // 回退:返回合理默认值 + CompetitorDecision { + company_name: soul_name.to_string(), + marketing_spend: 2, + product_focus: Product::BenMa, + pricing_strategy: "normal".into(), + } +} + +/// 构建给 AI 的市场摘要 +fn format_market_summary(state: &BusinessGameState) -> String { + format!( + "当前第{}年,第{}季度。现金: {}M。\ + 现有市场: {}。\ + 当前阶段: {}。", + state.game_year, + state.game_quarter, + state.cash, + state + .markets + .iter() + .filter(|m| m.developed) + .map(|m| m.name.as_str()) + .collect::>() + .join(", "), + state.phase, + ) +} + +/// 构建 AI 竞争对手提示词 +fn build_competitor_prompt(soul_name: &str, market_summary: &str) -> String { + format!( + "你是一家市场上竞争公司的{role}。\n\ + 当前市场情况:{market_summary}\n\n\ + 请做出本年度的经营决策,以JSON格式输出:\n\ + {{\n\ + \"company_name\": \"{name}\",\n\ + \"marketing_spend\": <整数,营销投入M>,\n\ + \"product_focus\": \"ben_ma|meng_hu|fei_ying|tian_long\",\n\ + \"pricing_strategy\": \"aggressive|normal|premium\"\n\ + }}", + role = soul_name, + market_summary = market_summary, + name = soul_name, + ) +} + +/// 模拟定价对需求的影响 +pub fn apply_pricing_effect( + base_price: u32, + strategy: &str, +) -> f64 { + match strategy { + "aggressive" => base_price as f64 * 0.85, // 降价15%抢市场 + "premium" => base_price as f64 * 1.15, // 溢价15% + _ => base_price as f64, // 正常定价 + } +} diff --git a/rust/api/src/business_sandbox/mod.rs b/rust/api/src/business_sandbox/mod.rs index db21801..27ff8b5 100644 --- a/rust/api/src/business_sandbox/mod.rs +++ b/rust/api/src/business_sandbox/mod.rs @@ -1,3 +1,4 @@ +pub mod ai_competitor; pub mod engine; pub mod errors; pub mod finance; From e810134b4c6c6010faa8b18ff39e47c12d58f031 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:12:45 +0800 Subject: [PATCH 10/64] =?UTF-8?q?feat(godot):=20Task=208=20-=20=E5=88=9B?= =?UTF-8?q?=E4=B8=9A=E6=B2=99=E7=9B=98Godot=202D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=A1=86=E6=9E=B6(WS=E5=8D=95=E4=BE=8B+=E4=B8=BB=E5=9C=BA?= =?UTF-8?q?=E6=99=AF+UI=E5=B8=83=E5=B1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/.godot/.gdignore | 1 + ...lding-dbfc6818b022d976e0e33ac4dabecd4c.cfg | 6 ++ godot/.godot/editor/editor_layout.cfg | 76 ++++++++++++++++++ godot/.godot/editor/filesystem_cache10 | 10 +++ godot/.godot/editor/script_editor_cache.cfg | 0 godot/.godot/global_script_class_cache.cfg | 1 + godot/.godot/uid_cache.bin | Bin 0 -> 88 bytes godot/project.godot | 10 +++ godot/scenes/MainGame.tscn | 33 ++++++++ godot/scripts/main_game.gd | 68 ++++++++++++++++ godot/scripts/main_game.gd.uid | 1 + godot/scripts/websocket_manager.gd | 42 ++++++++++ godot/scripts/websocket_manager.gd.uid | 1 + 13 files changed, 249 insertions(+) create mode 100644 godot/.godot/.gdignore create mode 100644 godot/.godot/editor/MainGame.tscn-folding-dbfc6818b022d976e0e33ac4dabecd4c.cfg create mode 100644 godot/.godot/editor/editor_layout.cfg create mode 100644 godot/.godot/editor/filesystem_cache10 create mode 100644 godot/.godot/editor/script_editor_cache.cfg create mode 100644 godot/.godot/global_script_class_cache.cfg create mode 100644 godot/.godot/uid_cache.bin create mode 100644 godot/project.godot create mode 100644 godot/scenes/MainGame.tscn create mode 100644 godot/scripts/main_game.gd create mode 100644 godot/scripts/main_game.gd.uid create mode 100644 godot/scripts/websocket_manager.gd create mode 100644 godot/scripts/websocket_manager.gd.uid diff --git a/godot/.godot/.gdignore b/godot/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/godot/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/godot/.godot/editor/MainGame.tscn-folding-dbfc6818b022d976e0e33ac4dabecd4c.cfg b/godot/.godot/editor/MainGame.tscn-folding-dbfc6818b022d976e0e33ac4dabecd4c.cfg new file mode 100644 index 0000000..225f753 --- /dev/null +++ b/godot/.godot/editor/MainGame.tscn-folding-dbfc6818b022d976e0e33ac4dabecd4c.cfg @@ -0,0 +1,6 @@ +[folding] + +node_unfolds=[NodePath("."), PackedStringArray("Layout"), NodePath("TopBar"), PackedStringArray("Layout"), NodePath("ProductionArea"), PackedStringArray("Layout"), NodePath("BottomPanel"), PackedStringArray("Layout"), NodePath("PopupLayer"), PackedStringArray("Layer")] +resource_unfolds=[] +nodes_folded=[] +animation_groups_folded=[] diff --git a/godot/.godot/editor/editor_layout.cfg b/godot/.godot/editor/editor_layout.cfg new file mode 100644 index 0000000..dcc3362 --- /dev/null +++ b/godot/.godot/editor/editor_layout.cfg @@ -0,0 +1,76 @@ +[docks] + +dock_3="Scene,Import" +dock_3_selected_tab_idx=0 +dock_4="FileSystem,History" +dock_4_selected_tab_idx=0 +dock_5="Inspector,Signals,Groups" +dock_5_selected_tab_idx=0 +dock_9="Output,Debugger,Audio,Animation,Shader Editor,Search Results,AnimationTree,MeshLibrary,ResourcePreloader,ShaderFile,SpriteFrames,Theme,Polygon,TileSet,TileMap,Replication,GridMap" +dock_floating={} +dock_closed=[] +dock_split_2=0 +dock_split_3=0 +dock_hsplit_1=0 +dock_hsplit_2=280 +dock_hsplit_3=-280 +dock_hsplit_4=0 +dock_main_split=0 +dock_bottom_split=0 + +[docks/FileSystem] + +h_split_offset=240 +v_split_offset=0 +display_mode=0 +file_sort=0 +file_list_display_mode=1 +selected_paths=PackedStringArray("res://") +uncollapsed_paths=PackedStringArray("res://") + +[docks/History] + +include_scene=true +include_global=true + +[docks/Theme] + +split_offset=520 + +[EditorNode] + +open_scenes=PackedStringArray("res://scenes/MainGame.tscn") +current_scene="res://scenes/MainGame.tscn" +bottom_panel_offsets={ +"Audio": -450.0, +"Output": -270.0 +} +selected_default_debugger_tab_idx=0 +selected_main_editor_idx=1 + +[EditorWindow] + +screen=-1 +mode="maximized" +position=Vector2i(0, 0) + +[ScriptEditor] + +open_scripts=[] +selected_script="" +open_help=[] +script_split_offset=200 +list_split_offset=0 +zoom_factor=1.0 + +[GameView] + +floating_window_rect=Rect2i(0, 0, 0, 0) +floating_window_screen=-1 + +[ShaderEditor] + +open_shaders=[] +split_offset=200 +selected_shader="" +text_shader_zoom_factor=1.0 diff --git a/godot/.godot/editor/filesystem_cache10 b/godot/.godot/editor/filesystem_cache10 new file mode 100644 index 0000000..700d560 --- /dev/null +++ b/godot/.godot/editor/filesystem_cache10 @@ -0,0 +1,10 @@ +78098b89c88a74ebe8c20117b8754620 +::res://::1783875533 +::res://scenes/::1783875208 +MainGame.tscn::PackedScene::-1::1783875208::0::1::::<><><>0<>0<><>::res://scripts/main_game.gd +::res://scripts/::1783875947 +main_game.gd::GDScript::1372126389174432184::1783875203::0::1::::<>Control<><>0<>0<><>:: +websocket_manager.gd::GDScript::76985378676355443::1783875947::0::1::::<>Node<><>0<>0<><>:: +::res://scripts/popups/::1783875189 +::res://tests/::1783875189 +::res://ui/::1783875189 diff --git a/godot/.godot/editor/script_editor_cache.cfg b/godot/.godot/editor/script_editor_cache.cfg new file mode 100644 index 0000000..e69de29 diff --git a/godot/.godot/global_script_class_cache.cfg b/godot/.godot/global_script_class_cache.cfg new file mode 100644 index 0000000..32c2394 --- /dev/null +++ b/godot/.godot/global_script_class_cache.cfg @@ -0,0 +1 @@ +list=[] diff --git a/godot/.godot/uid_cache.bin b/godot/.godot/uid_cache.bin new file mode 100644 index 0000000000000000000000000000000000000000..1d067adcef7cc8049abafa43adf30af08c7f4981 GIT binary patch literal 88 zcmZQ#U|`r$xGr|xQ7&OAAiF5F*h*i&IJqdZprlwoH!(9WK0PrvRWCiI*zmc_=|(|D aB@9*NsY%88$=RtT@wth4iRr0DK-B>1>>cv} literal 0 HcmV?d00001 diff --git a/godot/project.godot b/godot/project.godot new file mode 100644 index 0000000..6ef4b3a --- /dev/null +++ b/godot/project.godot @@ -0,0 +1,10 @@ +[application] +config/name="创业沙盘" +config/description="商业全价值链经营模拟器" +run/main_scene="res://scenes/MainGame.tscn" + +[autoload] +WebSocketManager="*res://scripts/websocket_manager.gd" + +[rendering] +renderer/backend="opengl3" diff --git a/godot/scenes/MainGame.tscn b/godot/scenes/MainGame.tscn new file mode 100644 index 0000000..d5eee82 --- /dev/null +++ b/godot/scenes/MainGame.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=2 format=3 uid="uid://main_game_scene"] + +[ext_resource type="Script" path="res://scripts/main_game.gd" id="1"] + +[node name="MainGame" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="TopBar" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 +offset_top = 8 +offset_bottom = 48 + +[node name="ProductionArea" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 +offset_top = 56 +offset_bottom = 600 + +[node name="BottomPanel" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_top = -120 +offset_bottom = -8 + +[node name="PopupLayer" type="CanvasLayer" parent="."] +layer = 128 diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd new file mode 100644 index 0000000..41a6214 --- /dev/null +++ b/godot/scripts/main_game.gd @@ -0,0 +1,68 @@ +extends Control +## 主游戏场景,管理顶部状态栏/中部产线/底部面板/弹窗系统 + +@onready var top_bar: Control = $TopBar +@onready var production_area: Control = $ProductionArea +@onready var bottom_panel: Control = $BottomPanel +@onready var popup_layer: Control = $PopupLayer +@onready var ws_manager: Node = $/root/WebSocketManager + +var game_state: Dictionary = {} + +func _ready() -> void: + ws_manager.message_received.connect(_on_ws_message) + ws_manager.connected.connect(_on_ws_connected) + # 连接到游戏服务器 + ws_manager.connect_to_server("game_001") + +func _on_ws_connected() -> void: + print("已连接到游戏服务器") + # 发送开始游戏指令 + ws_manager.send_action({"action": "start_game"}) + +func _on_ws_message(data: Dictionary) -> void: + var event_type: String = data.get("event", "") + match event_type: + "state_update": + game_state = data.get("data", {}) + _update_ui() + "ask_decision": + _show_decision_prompt(data.get("data", {})) + "message": + _show_message(data.get("data", "")) + "game_over": + _show_game_over(data.get("data", {}).get("reason", "未知原因")) + "annual_report": + _show_annual_report(data.get("data", {})) + "phase_change": + _update_phase_display(data.get("data", {})) + +func _update_ui() -> void: + top_bar.update( + game_state.get("game_year", 1), + game_state.get("game_quarter", 1), + game_state.get("cash", 0) + ) + production_area.update(game_state.get("factories", [])) + bottom_panel.update(game_state) + +func _show_decision_prompt(data: Dictionary) -> void: + var decision_type: String = data.get("decision_type", "") + if decision_type == "bidding": + _open_bidding_dialog() + +func _open_bidding_dialog() -> void: + # 占位:后面实现竞标弹窗 + print("打开竞标策略弹窗") + +func _show_message(text: String) -> void: + print("系统消息: ", text) + +func _show_game_over(reason: String) -> void: + print("游戏结束: ", reason) + +func _show_annual_report(report: Dictionary) -> void: + print("年度报告: ", report) + +func _update_phase_display(data: Dictionary) -> void: + pass diff --git a/godot/scripts/main_game.gd.uid b/godot/scripts/main_game.gd.uid new file mode 100644 index 0000000..64b3014 --- /dev/null +++ b/godot/scripts/main_game.gd.uid @@ -0,0 +1 @@ +uid://tswyrwpo7l7m diff --git a/godot/scripts/websocket_manager.gd b/godot/scripts/websocket_manager.gd new file mode 100644 index 0000000..bb4924d --- /dev/null +++ b/godot/scripts/websocket_manager.gd @@ -0,0 +1,42 @@ +extends Node +## WebSocket 连接管理器,负责与 Rust 后端通信。 + +var socket: WebSocketPeer = WebSocketPeer.new() +var is_connected: bool = false +var server_url: String = "ws://127.0.0.1:3097/ws/game/" +var game_id: String = "" + +signal connected() +signal disconnected() +signal message_received(data: Dictionary) +signal connection_error() + +func connect_to_server(g_id: String) -> void: + game_id = g_id + var url: String = server_url + g_id + var err: Error = socket.connect_to_url(url) + if err != OK: + push_error("WebSocket 连接失败: ", err) + +func send_action(action: Dictionary) -> void: + if is_connected: + var json_str: String = JSON.stringify(action) + socket.send_text(json_str) + +func _process(delta: float) -> void: + socket.poll() + if socket.get_ready_state() == WebSocketPeer.STATE_OPEN and not is_connected: + is_connected = true + connected.emit() + elif socket.get_ready_state() == WebSocketPeer.STATE_CLOSED and is_connected: + is_connected = false + disconnected.emit() + + while socket.get_available_packet_count() > 0: + var packet: PackedByteArray = socket.get_packet() + var json_str: String = packet.get_string_from_utf8() + var data: Dictionary = JSON.parse_string(json_str) as Dictionary + if data.is_empty(): + push_warning("无法解析WS消息: ", json_str) + continue + message_received.emit(data) diff --git a/godot/scripts/websocket_manager.gd.uid b/godot/scripts/websocket_manager.gd.uid new file mode 100644 index 0000000..f0f748a --- /dev/null +++ b/godot/scripts/websocket_manager.gd.uid @@ -0,0 +1 @@ +uid://bdj65i01p4u6 From c4d92b47f6c3d8ac75c1b415587bcf29e0bb4d74 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:16:17 +0800 Subject: [PATCH 11/64] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20Godot=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E7=BC=93=E5=AD=98=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ff5f0b3..1a61eab 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ dist/ # CodeGraph index .codegraph/ graphify-out/ + +# Godot editor cache +.godot/ From 393d70239bbebea1786c176c41926827d8554259 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:25:31 +0800 Subject: [PATCH 12/64] =?UTF-8?q?feat(godot):=20=E9=81=B5=E5=BE=AA=20Godot?= =?UTF-8?q?=204.3+=20=E8=A7=84=E8=8C=83=E7=9A=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=BC=98=E5=8C=96(=E9=9D=99=E6=80=81=E7=B1=BB=E5=9E=8B/?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E7=AE=A1=E7=90=86/=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=BF=9E/UI=E5=B8=83=E5=B1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 314 ++++++++++++++++++++++++----- godot/scripts/websocket_manager.gd | 120 +++++++++-- 2 files changed, 362 insertions(+), 72 deletions(-) diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 41a6214..20b504c 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -1,68 +1,282 @@ extends Control -## 主游戏场景,管理顶部状态栏/中部产线/底部面板/弹窗系统 +## 创业沙盘主游戏场景——管理 UI 布局、决策弹窗和服务端通信。 +## +## 生命周期: +## 1. _ready() 构建 UI → 连接 WS → 发送 start_game +## 2. 服务端推送 state_update → _update_ui() 刷新 +## 3. 服务端推送 ask_decision → 弹出决策面板 +## 4. 用户决策 → send_action() → 下一轮 -@onready var top_bar: Control = $TopBar -@onready var production_area: Control = $ProductionArea -@onready var bottom_panel: Control = $BottomPanel -@onready var popup_layer: Control = $PopupLayer -@onready var ws_manager: Node = $/root/WebSocketManager +class_name MainGame -var game_state: Dictionary = {} +# ── Constants ── + +const INITIAL_WINDOW_SIZE: Vector2 = Vector2(800, 600) +const PANEL_MARGIN: float = 10.0 +const LOG_HEIGHT: float = 380.0 +const BOTTOM_HEIGHT: float = 40.0 + +# ── @onready Vars ── + +@onready var _ws_manager: Node = $/root/WebSocketManager + +# ── Private Vars ── + +var _game_state: Dictionary = {} +var _decision_type: String = "" + +# Lazy-init UI nodes (created in _build_ui) +var _year_label: Label +var _quarter_label: Label +var _cash_label: Label +var _phase_label: Label +var _msg_log: RichTextLabel +var _action_btn: Button +var _popup_panel: Panel +var _popup_title: Label +var _popup_input: LineEdit +var _popup_btn: Button + + +# ── Virtual Methods ── func _ready() -> void: - ws_manager.message_received.connect(_on_ws_message) - ws_manager.connected.connect(_on_ws_connected) - # 连接到游戏服务器 - ws_manager.connect_to_server("game_001") + custom_minimum_size = INITIAL_WINDOW_SIZE + _build_ui() + _ws_manager.message_received.connect(_on_message_received) + _ws_manager.connected.connect(_on_ws_connected) + _ws_manager.connection_failed.connect(_on_ws_failed) + _ws_manager.connect_to_server("game_001") + + +# ── UI Building ── + +func _build_ui() -> void: + _build_top_bar() + _build_message_log() + _build_bottom_bar() + _build_popup() + + +func _build_top_bar() -> void: + var hb := HBoxContainer.new() + hb.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) + hb.size = Vector2(780, 36) + add_child(hb) + + var title := Label.new() + title.text = "🏢 创业沙盘" + title.add_theme_font_size_override("font_size", 18) + hb.add_child(title) + + hb.add_child(_make_spacer()) + + _year_label = Label.new() + _year_label.text = "📅 第 1 年" + hb.add_child(_year_label) + + _quarter_label = Label.new() + _quarter_label.text = " 第 1 季度" + hb.add_child(_quarter_label) + + _cash_label = Label.new() + _cash_label.text = " 💰 12M" + hb.add_child(_cash_label) + + _phase_label = Label.new() + _phase_label.text = " 阶段 1" + hb.add_child(_phase_label) + + +func _build_message_log() -> void: + _msg_log = RichTextLabel.new() + _msg_log.position = Vector2(PANEL_MARGIN, 56.0) + _msg_log.size = Vector2(780.0, LOG_HEIGHT) + _msg_log.bbcode_enabled = true + _msg_log.scroll_active = true + _msg_log.text = "[b]欢迎来到创业沙盘![/b]\n正在连接服务器...\n" + add_child(_msg_log) + + +func _build_bottom_bar() -> void: + var hb := HBoxContainer.new() + hb.position = Vector2(PANEL_MARGIN, 56.0 + LOG_HEIGHT + PANEL_MARGIN) + hb.size = Vector2(780.0, BOTTOM_HEIGHT) + add_child(hb) + + _action_btn = Button.new() + _action_btn.text = "等待服务器..." + _action_btn.disabled = true + _action_btn.pressed.connect(_on_action_clicked) + hb.add_child(_action_btn) + + var discount_btn := Button.new() + discount_btn.text = "💳 贴现" + discount_btn.pressed.connect(_on_discount_clicked) + hb.add_child(discount_btn) + + var loan_btn := Button.new() + loan_btn.text = "🏦 贷款 20M" + loan_btn.pressed.connect(_on_loan_clicked) + hb.add_child(loan_btn) + + +func _build_popup() -> void: + _popup_panel = Panel.new() + _popup_panel.position = Vector2(150.0, 100.0) + _popup_panel.size = Vector2(500.0, 200.0) + _popup_panel.visible = false + add_child(_popup_panel) + + _popup_title = Label.new() + _popup_title.position = Vector2(20.0, 20.0) + _popup_title.size = Vector2(460.0, 30.0) + _popup_title.add_theme_font_size_override("font_size", 16) + _popup_panel.add_child(_popup_title) + + var desc := Label.new() + desc.position = Vector2(20.0, 55.0) + desc.size = Vector2(460.0, 20.0) + desc.text = "输入营销投入金额(M),决定能激活多少张订单:" + _popup_panel.add_child(desc) + + _popup_input = LineEdit.new() + _popup_input.position = Vector2(20.0, 80.0) + _popup_input.size = Vector2(200.0, 30.0) + _popup_input.placeholder_text = "例如: 2" + _popup_input.text = "2" + _popup_panel.add_child(_popup_input) + + _popup_btn = Button.new() + _popup_btn.position = Vector2(20.0, 120.0) + _popup_btn.size = Vector2(120.0, 30.0) + _popup_btn.text = "✅ 提交" + _popup_btn.pressed.connect(_on_popup_confirmed) + _popup_panel.add_child(_popup_btn) + + var cancel_btn := Button.new() + cancel_btn.position = Vector2(150.0, 120.0) + cancel_btn.size = Vector2(80.0, 30.0) + cancel_btn.text = "取消" + cancel_btn.pressed.connect(_hide_popup) + _popup_panel.add_child(cancel_btn) + + +# ── Helpers ── + +static func _make_spacer() -> Control: + var s := Control.new() + s.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return s + + +func _log(msg: String) -> void: + _msg_log.text += msg + "\n" + # 自动滚动到底部 + _msg_log.scroll_to_line(_msg_log.get_line_count() - 1) + + +func _hide_popup() -> void: + _popup_panel.visible = false + + +# ── WS Callbacks ── func _on_ws_connected() -> void: - print("已连接到游戏服务器") - # 发送开始游戏指令 - ws_manager.send_action({"action": "start_game"}) + _log("[color=green]✅ 已连接到游戏服务器[/color]") + _ws_manager.send_action({"action": "start_game"}) + + +func _on_ws_failed() -> void: + _log("[color=red]❌ 无法连接到游戏服务器\n请确认 Rust API 已在 localhost:3096 运行[/color]") -func _on_ws_message(data: Dictionary) -> void: + +func _on_message_received(data: Dictionary) -> void: var event_type: String = data.get("event", "") match event_type: "state_update": - game_state = data.get("data", {}) + _game_state = data.get("data", {}) _update_ui() "ask_decision": - _show_decision_prompt(data.get("data", {})) + _show_decision(data.get("data", {})) "message": - _show_message(data.get("data", "")) + _log("[color=yellow]📢 %s[/color]" % data.get("data", "")) "game_over": - _show_game_over(data.get("data", {}).get("reason", "未知原因")) - "annual_report": - _show_annual_report(data.get("data", {})) + var reason: String = data.get("data", {}).get("reason", "未知原因") + _log("[color=red]💀 游戏结束: %s[/color]" % reason) + _action_btn.disabled = true + _action_btn.text = "游戏结束" "phase_change": - _update_phase_display(data.get("data", {})) + var pd: Dictionary = data.get("data", {}) + _log("[color=cyan]📅 第 %d 年第 %d 季度 — %s[/color]" % [ + pd.get("year", 1), pd.get("quarter", 0), pd.get("phase", "") + ]) + "annual_report": + _log("[color=green]📊 收到年度报告[/color]") + + +# ── UI Updates ── func _update_ui() -> void: - top_bar.update( - game_state.get("game_year", 1), - game_state.get("game_quarter", 1), - game_state.get("cash", 0) - ) - production_area.update(game_state.get("factories", [])) - bottom_panel.update(game_state) - -func _show_decision_prompt(data: Dictionary) -> void: - var decision_type: String = data.get("decision_type", "") - if decision_type == "bidding": - _open_bidding_dialog() - -func _open_bidding_dialog() -> void: - # 占位:后面实现竞标弹窗 - print("打开竞标策略弹窗") - -func _show_message(text: String) -> void: - print("系统消息: ", text) - -func _show_game_over(reason: String) -> void: - print("游戏结束: ", reason) - -func _show_annual_report(report: Dictionary) -> void: - print("年度报告: ", report) - -func _update_phase_display(data: Dictionary) -> void: - pass + var gs: Dictionary = _game_state + _year_label.text = "📅 第 %d 年" % gs.get("game_year", 1) + _quarter_label.text = " 第 %d 季度" % gs.get("game_quarter", 1) + _cash_label.text = " 💰 %dM" % gs.get("cash", 0) + _phase_label.text = " 阶段 %d" % gs.get("phase", 1) + + +func _show_decision(data: Dictionary) -> void: + _decision_type = data.get("decision_type", "") + _popup_title.text = "📋 %s" % _decision_type + + match _decision_type: + "bidding": + _popup_input.placeholder_text = "输入营销投入 M" + _popup_input.text = "2" + _popup_btn.text = "✅ 提交竞标" + _: + _popup_input.placeholder_text = "输入..." + _popup_btn.text = "确认" + + _popup_panel.visible = true + + +# ── Button Handlers ── + +func _on_popup_confirmed() -> void: + var input_val: String = _popup_input.text.strip_edges() + _hide_popup() + + match _decision_type: + "bidding": + var spend: int = int(input_val) if input_val.is_valid_int() else 2 + _log("[color=green]📋 提交竞标: 营销投入 %dM[/color]" % spend) + _ws_manager.send_action({ + "action": "submit_bidding", + "strategies": [{"market_name": "平城", "marketing_spend": spend}] + }) + _action_btn.text = "▶ 执行下一季度" + _action_btn.disabled = false + _popup_input.text = "" + + +func _on_action_clicked() -> void: + _action_btn.disabled = true + _action_btn.text = "执行中..." + _log("[color=cyan]▶ 执行下一季度...[/color]") + _ws_manager.send_action({"action": "next_quarter"}) + + +func _on_discount_clicked() -> void: + var ar: Array = _game_state.get("accounts_receivable", []) + if ar.is_empty(): + _log("[color=red]没有应收款可贴现[/color]") + return + var amount: int = ar[0].get("amount", 0) + _log("[color=yellow]💰 贴现 %dM 应收款...[/color]" % amount) + _ws_manager.send_action({"action": "discount_receivable", "amount": amount}) + + +func _on_loan_clicked() -> void: + _log("[color=yellow]🏦 申请短期贷款 20M...[/color]") + _ws_manager.send_action({"action": "take_loan", "loan_type": "short", "amount": 20}) diff --git a/godot/scripts/websocket_manager.gd b/godot/scripts/websocket_manager.gd index bb4924d..df2d12a 100644 --- a/godot/scripts/websocket_manager.gd +++ b/godot/scripts/websocket_manager.gd @@ -1,42 +1,118 @@ extends Node -## WebSocket 连接管理器,负责与 Rust 后端通信。 +## WebSocket 连接管理器——负责与 Rust 游戏后端通信。 +## +## 作为 Autoload 单例运行,生命周期覆盖整个游戏过程。 +## 所有游戏模块通过信号接收服务端事件。 -var socket: WebSocketPeer = WebSocketPeer.new() -var is_connected: bool = false -var server_url: String = "ws://127.0.0.1:3097/ws/game/" -var game_id: String = "" +# ── Signals ── +## 成功连接到游戏服务器 signal connected() +## 与服务器的连接断开 signal disconnected() +## 收到服务端的 JSON 消息(已解析为 Dictionary) signal message_received(data: Dictionary) -signal connection_error() +## 连接失败 +signal connection_failed() + +# ── Constants ── + +const DEFAULT_SERVER_URL: String = "ws://127.0.0.1:3096/ws/game/" +const RECONNECT_DELAY_SEC: float = 3.0 +const MAX_RECONNECT_ATTEMPTS: int = 5 + +# ── Public vars ── + +var server_url: String = DEFAULT_SERVER_URL +var game_id: String = "" +var is_connected: bool = false + +# ── Private vars ─- + +var _socket: WebSocketPeer = WebSocketPeer.new() +var _reconnect_attempts: int = 0 +var _reconnect_timer: float = 0.0 + + +# ── Virtual Methods ── + +func _process(delta: float) -> void: + _socket.poll() + _update_connection_state() + _receive_messages() + _try_reconnect(delta) + +# ── Public Methods ── + +## 连接到指定游戏 ID 的服务器会话。 func connect_to_server(g_id: String) -> void: game_id = g_id var url: String = server_url + g_id - var err: Error = socket.connect_to_url(url) + var err: Error = _socket.connect_to_url(url) if err != OK: push_error("WebSocket 连接失败: ", err) + connection_failed.emit() + return + _reconnect_attempts = 0 + +## 向服务端发送操作指令(JSON 格式)。 func send_action(action: Dictionary) -> void: - if is_connected: - var json_str: String = JSON.stringify(action) - socket.send_text(json_str) + if not is_connected: + push_warning("未连接服务器,无法发送消息") + return + var json_str: String = JSON.stringify(action) + _socket.send_text(json_str) -func _process(delta: float) -> void: - socket.poll() - if socket.get_ready_state() == WebSocketPeer.STATE_OPEN and not is_connected: - is_connected = true - connected.emit() - elif socket.get_ready_state() == WebSocketPeer.STATE_CLOSED and is_connected: - is_connected = false - disconnected.emit() - - while socket.get_available_packet_count() > 0: - var packet: PackedByteArray = socket.get_packet() + +## 主动断开连接。 +func disconnect_from_server() -> void: + _socket.close() + is_connected = false + disconnected.emit() + + +# ── Private Methods ── + +func _update_connection_state() -> void: + var state: int = _socket.get_ready_state() + match state: + WebSocketPeer.STATE_OPEN: + if not is_connected: + is_connected = true + _reconnect_attempts = 0 + connected.emit() + WebSocketPeer.STATE_CLOSED, WebSocketPeer.STATE_CLOSING: + if is_connected: + is_connected = false + disconnected.emit() + _schedule_reconnect() + + +func _receive_messages() -> void: + while _socket.get_available_packet_count() > 0: + var packet: PackedByteArray = _socket.get_packet() var json_str: String = packet.get_string_from_utf8() var data: Dictionary = JSON.parse_string(json_str) as Dictionary if data.is_empty(): - push_warning("无法解析WS消息: ", json_str) + push_warning("无法解析 WS 消息: ", json_str) continue message_received.emit(data) + + +func _schedule_reconnect() -> void: + if _reconnect_attempts >= MAX_RECONNECT_ATTEMPTS: + push_error("重连次数已达上限(%d 次)" % MAX_RECONNECT_ATTEMPTS) + return + _reconnect_timer = RECONNECT_DELAY_SEC + + +func _try_reconnect(delta: float) -> void: + if _reconnect_timer <= 0.0: + return + _reconnect_timer -= delta + if _reconnect_timer <= 0.0: + _reconnect_attempts += 1 + print("正在重连(第 %d/%d 次)..." % [_reconnect_attempts, MAX_RECONNECT_ATTEMPTS]) + connect_to_server(game_id) From cb04ce3c50f4ea9a7deaedeea5e6d68d1786b9f7 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:29:09 +0800 Subject: [PATCH 13/64] =?UTF-8?q?docs:=20Godot=20UI=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92(=E7=94=9F=E4=BA=A7=E7=BA=BF/=E8=B4=A2?= =?UTF-8?q?=E6=8A=A5/=E8=AE=A2=E8=B4=A7=E4=BC=9A/=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-13-godot-ui-implementation.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-godot-ui-implementation.md diff --git a/docs/superpowers/plans/2026-07-13-godot-ui-implementation.md b/docs/superpowers/plans/2026-07-13-godot-ui-implementation.md new file mode 100644 index 0000000..76f3d88 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-godot-ui-implementation.md @@ -0,0 +1,97 @@ +# 创业沙盘 Godot 2D UI 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking. + +**Goal:** 完善创业沙盘 Godot 2D 前端 UI,实现生产线视图、财务报告、订货会、市场面板等核心交互。 + +**Architecture:** 所有 UI 控件使用 Godot 4 Control 节点,`Stretch` 自适应布局。`WebSocketManager` 单例负责通信,`MainGame` 主场景通过信号分发事件,各子场景各自更新。 + +**Tech Stack:** Godot 4.7, GDScript 2.0 (strict static typing), Control nodes + +**设计文档:** `docs/superpowers/specs/2026-07-13-business-sandbox-godot-design.md` + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `godot/scenes/MainGame.tscn` | 修改:增加子节点占位 | +| `godot/scripts/production_view.gd` | **新建**:生产线视窗 | +| `godot/scripts/inventory_dashboard.gd` | **新建**:库存看板 | +| `godot/scripts/popups/annual_report_popup.gd` | **新建**:年度报告弹窗 | +| `godot/scripts/popups/order_meeting_popup.gd` | **新建**:订货会弹窗 | +| `godot/scripts/popups/factory_dialog.gd` | **新建**:工厂管理弹窗 | +| `godot/scripts/market_panel.gd` | **新建**:市场状态面板 | +| `godot/scripts/main_game.gd` | 修改:集成各子面板 | + +--- + +### Task 1: 生产线视窗 (ProductionView) + +**Files:** +- Create: `godot/scripts/production_view.gd` + +实现一个 `VBoxContainer` 包含 4 条生产线,每条显示: +- 产线名称(手工线/半自动/全自动) +- 状态标签(空闲/生产中/建设中/转产中) +- 进度条(`TextureProgressBar` 表示进度) +- 产品图标(4 种产品不同颜色) + +```gdscript +# 输出数据格式: +func update(factories: Array) -> void: + # factories[0].lines = [{id, line_type, status, product}] + # status: "idle" | {"producing": "ben_ma"} | {"building": 2} | {"switching_to": [1, "meng_hu"]} +``` + +### Task 2: 库存看板 (InventoryDashboard) + +**Files:** +- Create: `godot/scripts/inventory_dashboard.gd` + +显示: +- 原料库存数量 +- 在制品列表(每个产线的在制品) +- 成品库存(按产品分类) +- 应收账款列表(金额 + 账期) + +### Task 3: 年度报告弹窗 (AnnualReportPopup) + +**Files:** +- Create: `godot/scripts/popups/annual_report_popup.gd` + +TabContainer 包含 4 个标签页: +1. **损益表**:收入、成本、毛利、费用、净利润 +2. **资产负债表**:资产、负债、权益 +3. **费用明细表**:各项费用汇总 +4. **关键指标**:毛利率、ROE、资产负债率 + +### Task 4: 订货会弹窗 (OrderMeetingPopup) + +**Files:** +- Create: `godot/scripts/popups/order_meeting_popup.gd` + +- 显示可用订单卡片(产品、数量、单价、账期) +- 点击选中/取消订单 +- 确认提交按钮 + +### Task 5: 市场状态面板 (MarketPanel) + +**Files:** +- Create: `godot/scripts/market_panel.gd` + +- 显示各市场名称、开发状态、排名 +- 每年营销投入输入 +- 市场预测数据展示 + +### Task 6: 工厂/贷款管理 (FactoryDialog + MainGame 改进) + +**Files:** +- Create: `godot/scripts/popups/factory_dialog.gd` +- Modify: `godot/scripts/main_game.gd` + +- 购买/租赁工厂弹窗 +- 建设新产线选择 +- 贷款类型和金额选择 +- 贴现确认弹窗 From 2f7a517af22970fab9ebc3551d7f81e90d48073b Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:36:56 +0800 Subject: [PATCH 14/64] =?UTF-8?q?feat(godot):=20Task=201=20-=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=BA=BF=E8=A7=86=E7=AA=97(4=E6=9D=A1=E4=BA=A7?= =?UTF-8?q?=E7=BA=BF/=E8=BF=9B=E5=BA=A6=E6=9D=A1/=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE/=E4=BA=A7=E5=93=81=E5=9B=BE=E6=A0=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 21 +++ godot/scripts/production_view.gd | 254 +++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 godot/scripts/production_view.gd diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 20b504c..66008b9 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -19,11 +19,13 @@ const BOTTOM_HEIGHT: float = 40.0 # ── @onready Vars ── @onready var _ws_manager: Node = $/root/WebSocketManager +@onready var _production_area: VBoxContainer = $ProductionArea # ── Private Vars ── var _game_state: Dictionary = {} var _decision_type: String = "" +var _production_view: ProductionView # Lazy-init UI nodes (created in _build_ui) var _year_label: Label @@ -55,6 +57,7 @@ func _build_ui() -> void: _build_top_bar() _build_message_log() _build_bottom_bar() + _build_production_view() _build_popup() @@ -121,6 +124,20 @@ func _build_bottom_bar() -> void: hb.add_child(loan_btn) +func _build_production_view() -> void: + # 清空占位节点(场景定义中已有的 ProductionArea VBoxContainer) + for child in _production_area.get_children(): + _production_area.remove_child(child) + child.queue_free() + + var prod_view := ProductionView.new() + prod_view.name = "ProductionView" + prod_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL + prod_view.size_flags_vertical = Control.SIZE_EXPAND_FILL + _production_area.add_child(prod_view) + _production_view = prod_view + + func _build_popup() -> void: _popup_panel = Panel.new() _popup_panel.position = Vector2(150.0, 100.0) @@ -224,6 +241,10 @@ func _update_ui() -> void: _cash_label.text = " 💰 %dM" % gs.get("cash", 0) _phase_label.text = " 阶段 %d" % gs.get("phase", 1) + # 刷新生产线视窗 + if _production_view: + _production_view.update(gs.get("factories", [])) + func _show_decision(data: Dictionary) -> void: _decision_type = data.get("decision_type", "") diff --git a/godot/scripts/production_view.gd b/godot/scripts/production_view.gd new file mode 100644 index 0000000..43da7c5 --- /dev/null +++ b/godot/scripts/production_view.gd @@ -0,0 +1,254 @@ +extends Control +## 生产线视窗——显示工厂和生产线状态。 +## +## 通过 update(factories) 接收后端数据刷新显示。 +## 使用纯 ColorRect 做进度条,无需额外贴图资源。 + +class_name ProductionView + +# ── Constants ── + +# 状态颜色 +const COLOR_IDLE: Color = Color(0.55, 0.55, 0.55) # 灰色 +const COLOR_PRODUCING: Color = Color(0.15, 0.75, 0.25) # 绿色 +const COLOR_BUILDING: Color = Color(0.15, 0.40, 0.80) # 蓝色 +const COLOR_SWITCHING: Color = Color(1.00, 0.60, 0.00) # 橙色 + +# 产品颜色定义(4 种产品不同色块) +const PRODUCT_COLORS: Dictionary = { + "奔马": Color(0.90, 0.25, 0.15), # 红 + "猛虎": Color(0.20, 0.55, 0.90), # 蓝 + "雄鹰": Color(0.85, 0.70, 0.10), # 金 + "飞龙": Color(0.50, 0.20, 0.80), # 紫 +} + +# 产线类型显示文本 +const TYPE_LABELS: Dictionary = { + "manual": "手工", + "semi_auto": "半自动", + "auto": "全自动", +} + +# 布局常量 +const ROW_HEIGHT: float = 40.0 +const BAR_HEIGHT: float = 18.0 +const BAR_WIDTH: float = 130.0 +const ICON_SIZE: float = 22.0 +const SECTION_GAP: float = 4.0 + +# ── Private Vars ── + +var _container: VBoxContainer + + +# ── Virtual Methods ── + +func _ready() -> void: + _container = VBoxContainer.new() + _container.name = "Container" + _container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _container.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(_container) + + +# ── Public Methods ── + +## 接收后端工厂数据并刷新 UI。 +## factories: Array[Dictionary] 格式: +## { name, capacity, lines: [{ id, name, type, status, product, progress, remaining?, switch_target? }] } +func update(factories: Array) -> void: + # 清空旧内容 + for child in _container.get_children(): + _container.remove_child(child) + child.queue_free() + + if factories.is_empty(): + var empty_label := Label.new() + empty_label.text = "暂无工厂" + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + empty_label.size_flags_vertical = Control.SIZE_EXPAND_FILL + empty_label.add_theme_color_override("font_color", COLOR_IDLE) + _container.add_child(empty_label) + return + + for factory_idx: int in factories.size(): + var factory: Dictionary = factories[factory_idx] + if factory_idx > 0: + var sep := HSeparator.new() + _container.add_child(sep) + _add_factory_section(factory) + + +# ── Private: Factory Section ── + +func _add_factory_section(factory: Dictionary) -> void: + # ── Factory header ── + var header := HBoxContainer.new() + _container.add_child(header) + + var icon := Label.new() + icon.text = "🏭" + header.add_child(icon) + + var name_label := Label.new() + name_label.text = factory.get("name", "未知工厂") + name_label.add_theme_font_size_override("font_size", 14) + name_label.add_theme_color_override("font_color", Color(0.90, 0.90, 0.90)) + header.add_child(name_label) + + var cap_label := Label.new() + var capacity: int = factory.get("capacity", 0) + cap_label.text = " (容量:%d)" % capacity + cap_label.add_theme_color_override("font_color", COLOR_IDLE) + header.add_child(cap_label) + + # 弹性空间 + var h_spacer := Control.new() + h_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(h_spacer) + + # ── Production lines ── + var lines: Array = factory.get("lines", []) + for line in lines: + _add_line_row(line) + + +func _add_line_row(line: Dictionary) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _container.add_child(row) + + var line_type: String = line.get("type", "manual") + var status: String = line.get("status", "idle") + var product: String = line.get("product", "") + var line_id: int = line.get("id", 0) + + # ── 行号 + 类型标签 ── + var name_label := Label.new() + name_label.text = "#%d %s" % [line_id, TYPE_LABELS.get(line_type, line_type)] + name_label.custom_minimum_size = Vector2(100, ROW_HEIGHT) + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + name_label.add_theme_font_size_override("font_size", 12) + row.add_child(name_label) + + # ── 进度条(ColorRect 实现) ── + row.add_child(_make_progress_bar(line)) + + # ── 产品图标 ── + row.add_child(_make_product_icon(product)) + + # ── 状态文字 ── + var status_label := Label.new() + status_label.text = _get_status_text(line) + status_label.add_theme_color_override("font_color", _get_status_color(status)) + status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + status_label.custom_minimum_size = Vector2(110, ROW_HEIGHT) + status_label.add_theme_font_size_override("font_size", 12) + row.add_child(status_label) + + # 弹性空间 + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + + +# ── Progress Bar ── + +func _make_progress_bar(line: Dictionary) -> Control: + var container := Control.new() + container.custom_minimum_size = Vector2(BAR_WIDTH, ROW_HEIGHT) + container.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + + var status: String = line.get("status", "idle") + + # 背景条 + var bg := ColorRect.new() + bg.color = Color(0.15, 0.15, 0.15) + bg.size = Vector2(BAR_WIDTH, BAR_HEIGHT) + bg.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + container.add_child(bg) + + if status == "idle": + # idle 状态不显示填充条 + pass + else: + # 填充条 + var progress_val: float = clampf(line.get("progress", 0.0), 0.0, 1.0) + var fill := ColorRect.new() + fill.color = _get_status_color(status) + fill.size = Vector2(BAR_WIDTH * progress_val, BAR_HEIGHT) + fill.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + container.add_child(fill) + + # 百分比文字 + var pct_label := Label.new() + pct_label.text = "%d%%" % (progress_val * 100.0) + pct_label.add_theme_color_override("font_color", Color.WHITE) + pct_label.add_theme_font_size_override("font_size", 10) + pct_label.position = Vector2(4, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + pct_label.size = Vector2(BAR_WIDTH - 8, BAR_HEIGHT) + container.add_child(pct_label) + + return container + + +# ── Product Icon ── + +func _make_product_icon(product_name: String) -> Control: + var container := Control.new() + container.custom_minimum_size = Vector2(ICON_SIZE + 8, ROW_HEIGHT) + + if product_name.is_empty(): + return container + + var rect := ColorRect.new() + rect.size = Vector2(ICON_SIZE, ICON_SIZE) + rect.position = Vector2(4, (ROW_HEIGHT - ICON_SIZE) * 0.5) + + var color: Color = PRODUCT_COLORS.get(product_name, Color(0.50, 0.50, 0.50)) + rect.color = color + container.add_child(rect) + + return container + + +# ── Helpers ── + +func _get_status_text(line: Dictionary) -> String: + var status: String = line.get("status", "idle") + var product: String = line.get("product", "") + var remaining: int = line.get("remaining", 0) + var switch_target: String = line.get("switch_target", "") + + match status: + "idle": + return "空闲" + "producing": + if not product.is_empty(): + return "生产 %s" % product + return "生产中" + "building": + if remaining > 0: + return "建设中(Q%d)" % remaining + return "建设中" + "switching_to": + if not switch_target.is_empty(): + return "转产→%s" % switch_target + return "转产中" + _: + return status + + +func _get_status_color(status: String) -> Color: + match status: + "idle": + return COLOR_IDLE + "producing": + return COLOR_PRODUCING + "building": + return COLOR_BUILDING + "switching_to": + return COLOR_SWITCHING + _: + return COLOR_IDLE From 3041632b291f3c4f708f5c7871c04b12789c7d47 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 01:57:41 +0800 Subject: [PATCH 15/64] =?UTF-8?q?feat(godot):=20Task=202=20-=20=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9C=8B=E6=9D=BF(=E5=8E=9F=E6=96=99/=E5=9C=A8?= =?UTF-8?q?=E5=88=B6=E5=93=81/=E6=88=90=E5=93=81/=E5=BA=94=E6=94=B6?= =?UTF-8?q?=E6=AC=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/inventory_dashboard.gd | 296 +++++++++++++++++++++++++++ godot/scripts/main_game.gd | 20 ++ 2 files changed, 316 insertions(+) create mode 100644 godot/scripts/inventory_dashboard.gd diff --git a/godot/scripts/inventory_dashboard.gd b/godot/scripts/inventory_dashboard.gd new file mode 100644 index 0000000..3894b42 --- /dev/null +++ b/godot/scripts/inventory_dashboard.gd @@ -0,0 +1,296 @@ +extends Control +## 库存与财务看板——显示原料、在制品、成品、应收款等核心数据。 +## +## 通过 update(state) 接收完整 game_state 刷新显示。 + +class_name InventoryDashboard + +# ── Constants ── + +# 颜色 +const COLOR_HEADER: Color = Color(0.90, 0.90, 0.90) +const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) +const COLOR_VALUE: Color = Color(0.90, 0.90, 0.30) +const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) +const COLOR_AR: Color = Color(0.90, 0.70, 0.20) +const COLOR_PROGRESS_BG: Color = Color(0.15, 0.15, 0.15) +const COLOR_PROGRESS_FILL: Color = Color(0.15, 0.75, 0.25) +const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) + +# 产品颜色(与 production_view.gd 保持一致) +const PRODUCT_COLORS: Dictionary = { + "奔马": Color(0.90, 0.25, 0.15), + "猛虎": Color(0.20, 0.55, 0.90), + "雄鹰": Color(0.85, 0.70, 0.10), + "飞龙": Color(0.50, 0.20, 0.80), +} + +# 布局常量 +const ROW_HEIGHT: float = 22.0 +const SECTION_INDENT: float = 8.0 +const BAR_WIDTH: float = 80.0 +const BAR_HEIGHT: float = 12.0 + +# ── Private Vars ── + +var _container: VBoxContainer + + +# ── Virtual Methods ── + +func _ready() -> void: + _container = VBoxContainer.new() + _container.name = "Container" + _container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _container.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(_container) + + +# ── Public Methods ── + +## 接收完整 game_state 并刷新看板。 +func update(state: Dictionary) -> void: + # 清空旧内容 + for child in _container.get_children(): + _container.remove_child(child) + child.queue_free() + + _build_header() + _build_separator() + _build_raw_materials(state) + _build_separator() + _build_work_in_progress(state) + _build_separator() + _build_finished_goods(state) + _build_separator() + _build_accounts_receivable(state) + + +# ── Private: Section Builders ── + +func _build_header() -> void: + var label := Label.new() + label.text = "📦 库存与财务" + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", COLOR_HEADER) + label.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) + _container.add_child(label) + + +func _build_separator() -> void: + var sep := HSeparator.new() + sep.modulate = COLOR_SEPARATOR + _container.add_child(sep) + + +func _build_raw_materials(state: Dictionary) -> void: + _add_section_label("原料库存") + + var raw: Variant = state.get("raw_material_inventory", {}) + var raw_units: int = 0 + + if raw is Dictionary: + raw_units = raw.get("quantity", raw.get("units", 0)) + elif raw is int: + raw_units = raw + elif raw is float: + raw_units = int(raw) + + if raw_units > 0: + _add_value_row("原料库存: %d 个单位" % raw_units) + else: + _add_empty_row() + + +func _build_work_in_progress(state: Dictionary) -> void: + _add_section_label("在制品") + + var wip: Array = state.get("work_in_progress", []) + if wip.is_empty(): + _add_empty_row() + return + + for item in wip: + _add_wip_row(item) + + +func _build_finished_goods(state: Dictionary) -> void: + _add_section_label("成品库存") + + var fg: Variant = state.get("finished_goods", {}) + var items: Dictionary = _normalize_finished_goods(fg) + + if items.is_empty(): + _add_empty_row() + return + + # 按产品分组的行 + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) + _container.add_child(hb) + + var indent := Control.new() + indent.custom_minimum_size = Vector2(SECTION_INDENT * 2, 0) + hb.add_child(indent) + + for product_name: String in items: + var qty: int = items[product_name] + var color: Color = PRODUCT_COLORS.get(product_name, Color(0.50, 0.50, 0.50)) + + var plabel := Label.new() + plabel.text = "%s x%d" % [product_name, qty] + plabel.add_theme_color_override("font_color", color) + plabel.add_theme_font_size_override("font_size", 11) + plabel.custom_minimum_size = Vector2(70, ROW_HEIGHT) + hb.add_child(plabel) + + +func _build_accounts_receivable(state: Dictionary) -> void: + _add_section_label("应收账款") + + var ar: Array = state.get("accounts_receivable", []) + if ar.is_empty(): + _add_empty_row() + return + + for item in ar: + _add_ar_row(item) + + +# ── Private: Row Helpers ── + +func _add_section_label(text: String) -> void: + var label := Label.new() + label.text = " %s" % text + label.add_theme_font_size_override("font_size", 12) + label.add_theme_color_override("font_color", COLOR_SECTION) + label.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(label) + + +func _add_empty_row() -> void: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hb) + + var indent := Control.new() + indent.custom_minimum_size = Vector2(SECTION_INDENT * 2, 0) + hb.add_child(indent) + + var label := Label.new() + label.text = " (无)" + label.add_theme_color_override("font_color", COLOR_LABEL) + label.add_theme_font_size_override("font_size", 11) + hb.add_child(label) + + +func _add_value_row(text: String) -> void: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hb) + + var indent := Control.new() + indent.custom_minimum_size = Vector2(SECTION_INDENT * 2, 0) + hb.add_child(indent) + + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 11) + label.add_theme_color_override("font_color", COLOR_VALUE) + hb.add_child(label) + + +func _add_wip_row(item: Dictionary) -> void: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) + _container.add_child(hb) + + var indent := Control.new() + indent.custom_minimum_size = Vector2(SECTION_INDENT * 2, 0) + hb.add_child(indent) + + var line_id: int = item.get("line_id", item.get("id", 0)) + var product: String = item.get("product", "") + var progress: float = clampf(item.get("progress", 0.0), 0.0, 1.0) + + # 产线标签: "#N 产品名" + var line_label := Label.new() + line_label.text = "#%d %s" % [line_id, product] + line_label.custom_minimum_size = Vector2(80, ROW_HEIGHT) + line_label.add_theme_font_size_override("font_size", 11) + line_label.add_theme_color_override("font_color", COLOR_LABEL) + hb.add_child(line_label) + + # 进度条 + hb.add_child(_make_progress_bar(progress)) + + # 百分比 + var pct_label := Label.new() + pct_label.text = "%d%%" % (progress * 100.0) + pct_label.add_theme_font_size_override("font_size", 10) + pct_label.add_theme_color_override("font_color", COLOR_VALUE) + pct_label.custom_minimum_size = Vector2(30, ROW_HEIGHT) + hb.add_child(pct_label) + + # 弹性空间 + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hb.add_child(spacer) + + +func _make_progress_bar(progress: float) -> Control: + var c := Control.new() + c.custom_minimum_size = Vector2(BAR_WIDTH, ROW_HEIGHT) + + var bg := ColorRect.new() + bg.color = COLOR_PROGRESS_BG + bg.size = Vector2(BAR_WIDTH, BAR_HEIGHT) + bg.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + c.add_child(bg) + + if progress > 0.0: + var fill := ColorRect.new() + fill.color = COLOR_PROGRESS_FILL + fill.size = Vector2(BAR_WIDTH * progress, BAR_HEIGHT) + fill.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + c.add_child(fill) + + return c + + +func _normalize_finished_goods(fg: Variant) -> Dictionary: + var items: Dictionary = {} + + if fg is Dictionary: + items = fg.duplicate() + elif fg is Array: + for entry in fg: + if entry is Dictionary: + var name: String = entry.get("product", entry.get("name", "")) + var qty: int = entry.get("quantity", entry.get("qty", 1)) + items[name] = items.get(name, 0) + qty + + return items + + +func _add_ar_row(item: Dictionary) -> void: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hb) + + var indent := Control.new() + indent.custom_minimum_size = Vector2(SECTION_INDENT * 2, 0) + hb.add_child(indent) + + var amount: int = item.get("amount", 0) + var remaining: int = item.get("remaining", item.get("remaining_quarters", 0)) + + var ar_label := Label.new() + ar_label.text = "%dM (剩余 %d季度)" % [amount, remaining] + ar_label.add_theme_color_override("font_color", COLOR_AR) + ar_label.add_theme_font_size_override("font_size", 11) + hb.add_child(ar_label) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hb.add_child(spacer) diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 66008b9..9dc464a 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -26,6 +26,7 @@ const BOTTOM_HEIGHT: float = 40.0 var _game_state: Dictionary = {} var _decision_type: String = "" var _production_view: ProductionView +var _inventory_dashboard: InventoryDashboard # Lazy-init UI nodes (created in _build_ui) var _year_label: Label @@ -58,6 +59,7 @@ func _build_ui() -> void: _build_message_log() _build_bottom_bar() _build_production_view() + _build_inventory_dashboard() _build_popup() @@ -138,6 +140,20 @@ func _build_production_view() -> void: _production_view = prod_view +func _build_inventory_dashboard() -> void: + # 右侧库存看板面板 + var panel := Panel.new() + panel.position = Vector2(470.0, 56.0) + panel.size = Vector2(310.0, 380.0) + add_child(panel) + + _inventory_dashboard = InventoryDashboard.new() + _inventory_dashboard.name = "InventoryDashboard" + _inventory_dashboard.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _inventory_dashboard.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_child(_inventory_dashboard) + + func _build_popup() -> void: _popup_panel = Panel.new() _popup_panel.position = Vector2(150.0, 100.0) @@ -245,6 +261,10 @@ func _update_ui() -> void: if _production_view: _production_view.update(gs.get("factories", [])) + # 刷新库存看板 + if _inventory_dashboard: + _inventory_dashboard.update(gs) + func _show_decision(data: Dictionary) -> void: _decision_type = data.get("decision_type", "") From 3dc632c7d3d39bf6fe24006aedcb8c57f58bd9f2 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 02:07:06 +0800 Subject: [PATCH 16/64] =?UTF-8?q?feat(godot):=20Task=203=20-=20=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E5=91=8A=E5=BC=B9=E7=AA=97(=E6=8D=9F?= =?UTF-8?q?=E7=9B=8A=E8=A1=A8/=E8=B5=84=E4=BA=A7=E8=B4=9F=E5=80=BA?= =?UTF-8?q?=E8=A1=A8/=E8=B4=B9=E7=94=A8=E6=98=8E=E7=BB=86/=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E6=8C=87=E6=A0=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 24 +- godot/scripts/popups/annual_report_popup.gd | 542 ++++++++++++++++++++ 2 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 godot/scripts/popups/annual_report_popup.gd diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 9dc464a..4d59c28 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -9,6 +9,11 @@ extends Control class_name MainGame +# ── Preloads ── + +const AnnualReportPopupScript := preload("res://scripts/popups/annual_report_popup.gd") + + # ── Constants ── const INITIAL_WINDOW_SIZE: Vector2 = Vector2(800, 600) @@ -39,6 +44,7 @@ var _popup_panel: Panel var _popup_title: Label var _popup_input: LineEdit var _popup_btn: Button +var _annual_report_popup: Variant # ── Virtual Methods ── @@ -61,6 +67,7 @@ func _build_ui() -> void: _build_production_view() _build_inventory_dashboard() _build_popup() + _build_annual_report_popup() func _build_top_bar() -> void: @@ -195,6 +202,18 @@ func _build_popup() -> void: _popup_panel.add_child(cancel_btn) +func _build_annual_report_popup() -> void: + var popup: Variant = AnnualReportPopupScript.new() + popup.name = "AnnualReportPopup" + popup.visible = false + popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL + popup.size_flags_vertical = Control.SIZE_EXPAND_FILL + # Add to PopupLayer (CanvasLayer) so it renders above everything + var popup_layer: CanvasLayer = $PopupLayer + popup_layer.add_child(popup) + _annual_report_popup = popup + + # ── Helpers ── static func _make_spacer() -> Control: @@ -245,7 +264,10 @@ func _on_message_received(data: Dictionary) -> void: pd.get("year", 1), pd.get("quarter", 0), pd.get("phase", "") ]) "annual_report": - _log("[color=green]📊 收到年度报告[/color]") + var report_data: Dictionary = data.get("data", {}) + _log("[color=green]📊 收到年度报告 — 第 %d 年[/color]" % report_data.get("year", 0)) + if _annual_report_popup: + _annual_report_popup.show_report(report_data) # ── UI Updates ── diff --git a/godot/scripts/popups/annual_report_popup.gd b/godot/scripts/popups/annual_report_popup.gd new file mode 100644 index 0000000..f36e8ca --- /dev/null +++ b/godot/scripts/popups/annual_report_popup.gd @@ -0,0 +1,542 @@ +extends Panel +## 年度财务报告弹窗——全屏半透明遮罩上的多标签页财务报告。 +## +## 通过 show_report(report) 传入年度报告数据并在四个标签页中展示: +## - 📊 损益表:收入、成本、利润等 +## - 📋 资产负债表:资产、负债、权益 +## - 💰 费用明细:各项费用支出 +## - 🎯 关键指标:毛利率、净利润率、资产负债率、流动比率 + +class_name AnnualReportPopup + +# ── Colors ── + +const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +const PANEL_COLOR: Color = Color(0.12, 0.12, 0.15) +const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) +const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) +const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) +const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) +const COLOR_TOTAL: Color = Color(0.20, 0.90, 0.40) +const COLOR_NEGATIVE: Color = Color(0.90, 0.30, 0.25) +const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) +const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) +const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) +const COLOR_BG_ALT: Color = Color(0.14, 0.14, 0.18, 0.5) +const COLOR_TAB_BG: Color = Color(0.10, 0.10, 0.12) +const COLOR_TAB_FG: Color = Color(0.18, 0.18, 0.22) +const COLOR_INDICATOR_LABEL: Color = Color(0.90, 0.90, 0.90) +const COLOR_INDICATOR_VAL: Color = Color(0.40, 0.90, 1.00) + +# ── Layout Constants ── + +const PANEL_WIDTH: float = 720.0 +const PANEL_HEIGHT: float = 560.0 +const ROW_HEIGHT: float = 28.0 +const LABEL_WIDTH: float = 200.0 +const VALUE_WIDTH: float = 120.0 +const FONT_SIZE_TITLE: int = 20 +const FONT_SIZE_TAB: int = 13 +const FONT_SIZE_ROW: int = 12 +const FONT_SIZE_TOTAL: int = 14 +const INDENT_WIDTH: float = 10.0 + +# ── Tab Titles ── + +const TAB_TITLES: PackedStringArray = [ + "📊 损益表", + "📋 资产负债表", + "💰 费用明细", + "🎯 关键指标", +] + +# ── Tab 1: 损益表 rows ── + +const INCOME_ROWS: Array[Dictionary] = [ + {key = "revenue", label = "营业收入", is_total = false}, + {key = "cost_of_goods_sold", label = "营业成本", is_total = false}, + {key = "gross_profit", label = "毛利润", is_total = true}, + {separator = true}, + {key = "sales_expense", label = "销售费用", is_total = false}, + {key = "admin_expense", label = "管理费用", is_total = false}, + {key = "rd_expense", label = "研发费用", is_total = false}, + {key = "depreciation", label = "折旧费用", is_total = false}, + {key = "operating_profit", label = "营业利润", is_total = true}, + {separator = true}, + {key = "interest_expense", label = "利息支出", is_total = false}, + {key = "discount_fee", label = "贴现费用", is_total = false}, + {key = "tax", label = "所得税", is_total = false}, + {separator = true}, + {key = "net_profit", label = "净利润", is_total = true}, +] + +# ── Tab 2: 资产负债表 rows ── + +const BALANCE_ROWS: Array[Dictionary] = [ + {section = "🌊 流动资产"}, + {key = "cash", label = "现金", is_total = false}, + {key = "accounts_receivable", label = "应收账款", is_total = false}, + {key = "raw_material", label = "原材料", is_total = false}, + {key = "work_in_process", label = "在制品", is_total = false}, + {key = "finished_goods", label = "成品", is_total = false}, + {key = "total_current_assets", label = "流动资产合计", is_total = true}, + {separator = true}, + {section = "🏭 固定资产"}, + {key = "factory", label = "厂房", is_total = false}, + {key = "production_lines", label = "生产线", is_total = false}, + {key = "total_fixed_assets", label = "固定资产合计", is_total = true}, + {separator = true}, + {key = "total_assets", label = "资产总计", is_total = true, is_major = true}, + {separator = true}, + {section = "📋 负债"}, + {key = "long_term_loans", label = "长期借款", is_total = false}, + {key = "short_term_loans", label = "短期借款", is_total = false}, + {key = "total_liabilities", label = "负债合计", is_total = true}, + {separator = true}, + {section = "💎 所有者权益"}, + {key = "equity", label = "所有者权益", is_total = true}, +] + +# ── Tab 3: 费用明细 rows ── + +const EXPENSE_ROWS: Array[Dictionary] = [ + {key = "new_market_investment", label = "新市场开拓投资"}, + {key = "product_rd_investment", label = "产品研发投资"}, + {key = "quarterly_admin_fees", label = "行政管理费"}, + {key = "factory_rent", label = "厂房租金"}, + {key = "line_maintenance", label = "设备维护费"}, + {key = "product_switch_fees", label = "转产费用"}, + {key = "sales_expenses", label = "销售费用"}, + {key = "depreciation", label = "折旧"}, + {key = "interest_and_discount", label = "利息与贴现"}, + {key = "tax", label = "税费"}, +] + +# ── Nodes (created in _build_ui) ── + +var _overlay: ColorRect +var _dialog: Panel +var _title_label: Label +var _tab_container: TabContainer +var _close_btn: Button + +# Holds the VBoxContainer for each tab (index 0-3) +var _tab_contents: Array[VBoxContainer] = [] + +var _report: Dictionary = {} + + +# ── Virtual Methods ── + +func _ready() -> void: + _build_ui() + visible = false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + # Re-center dialog when parent resizes + if _dialog and _overlay: + _center_dialog() + + +# ── Public Methods ── + +## 接收年度报告数据并刷新所有标签页内容,然后显示弹窗。 +## report 结构:{ income_statement: {}, balance_sheet: {}, expense_sheet: {}, year: int } +func show_report(report: Dictionary) -> void: + _report = report + var year: int = report.get("year", 0) + _title_label.text = "📊 年度财务报告 — 第 %d 年" % year + + _populate_tabs() + visible = true + _center_dialog() + + +# ── UI Building ── + +func _build_ui() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + # ── Full-screen overlay (click to close) ── + _overlay = ColorRect.new() + _overlay.name = "Overlay" + _overlay.color = OVERLAY_COLOR + _overlay.mouse_filter = Control.MOUSE_FILTER_STOP + _overlay.connect("gui_input", _on_overlay_gui_input) + add_child(_overlay) + # Anchor overlay to full parent + _overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + # ── Dialog panel ── + _dialog = Panel.new() + _dialog.name = "Dialog" + _dialog.custom_minimum_size = Vector2(PANEL_WIDTH, PANEL_HEIGHT) + add_child(_dialog) + + # ── Title ── + _title_label = Label.new() + _title_label.name = "TitleLabel" + _title_label.text = "📊 年度财务报告" + _title_label.add_theme_font_size_override("font_size", FONT_SIZE_TITLE) + _title_label.add_theme_color_override("font_color", COLOR_TITLE) + _title_label.position = Vector2(20, 16) + _title_label.size = Vector2(PANEL_WIDTH - 80, 32) + _dialog.add_child(_title_label) + + # ── Close button ── + _close_btn = Button.new() + _close_btn.name = "CloseBtn" + _close_btn.text = "✕ 关闭" + _close_btn.position = Vector2(PANEL_WIDTH - 100, 16) + _close_btn.size = Vector2(80, 30) + _close_btn.add_theme_color_override("font_color", Color(1.0, 0.8, 0.8)) + _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) + _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) + _close_btn.connect("pressed", _on_close_pressed) + _dialog.add_child(_close_btn) + + # ── TabContainer ── + _tab_container = TabContainer.new() + _tab_container.name = "TabContainer" + _tab_container.position = Vector2(10, 56) + _tab_container.size = Vector2(PANEL_WIDTH - 20, PANEL_HEIGHT - 72) + _tab_container.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85)) + _tab_container.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0)) + _tab_container.add_theme_stylebox_override("panel", _make_stylebox(COLOR_TAB_BG)) + _tab_container.add_theme_stylebox_override("tab_fg", _make_stylebox(COLOR_TAB_FG)) + _tab_container.tab_alignment = TabBar.ALIGNMENT_LEFT + _dialog.add_child(_tab_container) + + # Build 4 tabs + for i in range(4): + var scroll := ScrollContainer.new() + scroll.name = "Tab%d" % i + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tab_container.add_child(scroll) + + var vbox := VBoxContainer.new() + vbox.name = "Content" + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", 0) + scroll.add_child(vbox) + + _tab_contents.append(vbox) + _tab_container.set_tab_title(i, TAB_TITLES[i]) + + +# ── Tab Population ── + +func _populate_tabs() -> void: + _populate_income_tab() + _populate_balance_tab() + _populate_expense_tab() + _populate_indicators_tab() + + +func _populate_income_tab() -> void: + var container: VBoxContainer = _tab_contents[0] + _clear_container(container) + + var data: Dictionary = _report.get("income_statement", {}) + for row_info: Dictionary in INCOME_ROWS: + if row_info.has("separator") and row_info.separator: + container.add_child(_make_separator()) + elif row_info.has("key"): + var key: String = row_info.key + var label: String = row_info.label + var is_total: bool = row_info.get("is_total", false) + var is_major: bool = row_info.get("is_major", false) + var val: Variant = data.get(key, 0) + container.add_child(_make_data_row(label, val, is_total, is_major)) + + +func _populate_balance_tab() -> void: + var container: VBoxContainer = _tab_contents[1] + _clear_container(container) + + var data: Dictionary = _report.get("balance_sheet", {}) + for row_info: Dictionary in BALANCE_ROWS: + if row_info.has("separator") and row_info.separator: + container.add_child(_make_separator()) + elif row_info.has("section"): + container.add_child(_make_section_label(row_info.section)) + elif row_info.has("key"): + var key: String = row_info.key + var label: String = row_info.label + var is_total: bool = row_info.get("is_total", false) + var is_major: bool = row_info.get("is_major", false) + var val: Variant = data.get(key, 0) + container.add_child(_make_data_row(label, val, is_total, is_major)) + + +func _populate_expense_tab() -> void: + var container: VBoxContainer = _tab_contents[2] + _clear_container(container) + + var data: Dictionary = _report.get("expense_sheet", {}) + for row_info: Dictionary in EXPENSE_ROWS: + if row_info.has("key"): + var key: String = row_info.key + var label: String = row_info.label + var val: Variant = data.get(key, 0) + container.add_child(_make_data_row(label, val, false, false)) + + +func _populate_indicators_tab() -> void: + var container: VBoxContainer = _tab_contents[3] + _clear_container(container) + + var income: Dictionary = _report.get("income_statement", {}) + var balance: Dictionary = _report.get("balance_sheet", {}) + + var revenue: float = float(income.get("revenue", 0)) + var gross_profit: float = float(income.get("gross_profit", 0)) + var net_profit: float = float(income.get("net_profit", 0)) + var total_assets: float = float(balance.get("total_assets", 0)) + var total_liabilities: float = float(balance.get("total_liabilities", 0)) + var current_assets: float = float(balance.get("total_current_assets", 0)) + var short_loans: float = float(balance.get("short_term_loans", 0)) + + # Section header + container.add_child(_make_section_label("📈 盈利能力指标")) + + # 毛利率 + var gross_margin_pct: float = (gross_profit / revenue * 100.0) if revenue > 0 else 0.0 + container.add_child(_make_indicator_row( + "毛利率", + "%s%%" % _fmt_pct(gross_margin_pct), + gross_margin_pct + )) + + # 净利润率 + var net_margin_pct: float = (net_profit / revenue * 100.0) if revenue > 0 else 0.0 + container.add_child(_make_indicator_row( + "净利润率", + "%s%%" % _fmt_pct(net_margin_pct), + net_margin_pct + )) + + container.add_child(_make_separator()) + container.add_child(_make_section_label("⚖️ 偿债能力指标")) + + # 资产负债率 + var debt_ratio_pct: float = (total_liabilities / total_assets * 100.0) if total_assets > 0 else 0.0 + container.add_child(_make_indicator_row( + "资产负债率", + "%s%%" % _fmt_pct(debt_ratio_pct), + debt_ratio_pct, + true + )) + + # 流动比率 + var current_ratio: float = (current_assets / short_loans) if short_loans > 0 else 0.0 + var cr_text: String = "%.2f" % current_ratio if short_loans > 0 else "N/A (无短期借款)" + container.add_child(_make_indicator_row( + "流动比率", + cr_text, + current_ratio, + false, + short_loans <= 0 + )) + + # Explanation note + container.add_child(_make_separator()) + var note := Label.new() + note.text = " 提示:资产负债率 < 50% 为健康,流动比率 > 1.5 为佳。" + note.add_theme_font_size_override("font_size", 11) + note.add_theme_color_override("font_color", Color(0.55, 0.55, 0.60)) + note.custom_minimum_size = Vector2(0, ROW_HEIGHT) + container.add_child(note) + + +# ── Row Builders ── + +func _make_data_row(label_text: String, value: Variant, is_total: bool, is_major: bool) -> HBoxContainer: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT + (4 if is_major else 0)) + hb.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + # Indent label + var indent := Control.new() + indent.custom_minimum_size = Vector2(INDENT_WIDTH, 0) + hb.add_child(indent) + + # Label + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(LABEL_WIDTH - INDENT_WIDTH, 0) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + + if is_total: + label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL) + label.add_theme_color_override("font_color", COLOR_TOTAL) + label.add_theme_font_weight_override("bold", true) + else: + label.add_theme_font_size_override("font_size", FONT_SIZE_ROW) + label.add_theme_color_override("font_color", COLOR_LABEL) + + if is_major: + label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL + 1) + label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + + hb.add_child(label) + + # Spacer + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hb.add_child(spacer) + + # Value + var val_label := Label.new() + val_label.text = _fmt_money(value) + val_label.custom_minimum_size = Vector2(VALUE_WIDTH, 0) + val_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + val_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + val_label.size_flags_horizontal = Control.SIZE_SHRINK_END + + var num_val: float = float(value) if value != null else 0.0 + if is_total or is_major: + val_label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL) + val_label.add_theme_font_weight_override("bold", true) + if num_val >= 0.0: + val_label.add_theme_color_override("font_color", COLOR_TOTAL) + else: + val_label.add_theme_color_override("font_color", COLOR_NEGATIVE) + else: + val_label.add_theme_font_size_override("font_size", FONT_SIZE_ROW) + if num_val >= 0.0: + val_label.add_theme_color_override("font_color", COLOR_VALUE) + else: + val_label.add_theme_color_override("font_color", COLOR_NEGATIVE) + + hb.add_child(val_label) + + return hb + + +func _make_indicator_row(label_text: String, value_text: String, raw_val: float, is_debt_ratio: bool = false, use_na: bool = false) -> HBoxContainer: + var hb := HBoxContainer.new() + hb.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) + hb.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var indent := Control.new() + indent.custom_minimum_size = Vector2(INDENT_WIDTH * 2, 0) + hb.add_child(indent) + + # Label + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(LABEL_WIDTH - INDENT_WIDTH * 2, 0) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL) + label.add_theme_color_override("font_color", COLOR_INDICATOR_LABEL) + hb.add_child(label) + + # Spacer + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hb.add_child(spacer) + + # Value + var val_label := Label.new() + val_label.text = value_text + val_label.custom_minimum_size = Vector2(VALUE_WIDTH + 40, 0) + val_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + val_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + val_label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL + 2) + val_label.add_theme_font_weight_override("bold", true) + val_label.add_theme_color_override("font_color", COLOR_INDICATOR_VAL) + + if not use_na: + if is_debt_ratio: + # Color-code debt ratio: green < 50%, yellow 50-70%, red > 70% + if raw_val < 50.0: + val_label.add_theme_color_override("font_color", COLOR_TOTAL) + elif raw_val < 70.0: + val_label.add_theme_color_override("font_color", Color(0.95, 0.85, 0.30)) + else: + val_label.add_theme_color_override("font_color", COLOR_NEGATIVE) + else: + # Color-code profitability: green for positive + if raw_val >= 0.0: + val_label.add_theme_color_override("font_color", COLOR_TOTAL) + else: + val_label.add_theme_color_override("font_color", COLOR_NEGATIVE) + + hb.add_child(val_label) + + return hb + + +func _make_section_label(text: String) -> Label: + var label := Label.new() + label.text = " %s" % text + label.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) + label.add_theme_font_size_override("font_size", FONT_SIZE_ROW + 1) + label.add_theme_color_override("font_color", COLOR_SECTION) + label.add_theme_font_weight_override("bold", true) + return label + + +func _make_separator() -> HSeparator: + var sep := HSeparator.new() + sep.modulate = COLOR_SEPARATOR + sep.custom_minimum_size = Vector2(0, 4) + return sep + + +# ── Helpers ── + +static func _make_stylebox(bg_color: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.corner_radius_top_left = 4 + sb.corner_radius_top_right = 4 + sb.corner_radius_bottom_left = 4 + sb.corner_radius_bottom_right = 4 + return sb + + +static func _fmt_money(value: Variant) -> String: + var v: float = float(value) if value != null else 0.0 + if v == int(v): + return "%dM" % int(v) + return "%.1fM" % v + + +static func _fmt_pct(value: float) -> String: + if value == int(value): + return "%d" % int(value) + return "%.1f" % value + + +static func _clear_container(container: VBoxContainer) -> void: + for child in container.get_children(): + container.remove_child(child) + child.queue_free() + + +func _center_dialog() -> void: + if not _dialog: + return + var parent_size: Vector2 = size if size != Vector2.ZERO else Vector2(1280, 720) + _dialog.position = Vector2( + (parent_size.x - PANEL_WIDTH) * 0.5, + (parent_size.y - PANEL_HEIGHT) * 0.5 + ) + + +# ── Handlers ── + +func _on_close_pressed() -> void: + visible = false + + +func _on_overlay_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + visible = false From e0619bc20d02cd5c72b0aad3a97c2debb162bd11 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 02:27:54 +0800 Subject: [PATCH 17/64] =?UTF-8?q?feat(godot):=20Task=204=20-=20=E8=AE=A2?= =?UTF-8?q?=E8=B4=A7=E4=BC=9A=E5=BC=B9=E7=AA=97(=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E9=A2=84=E6=B5=8B=E5=9B=BE/=E8=AE=A2=E5=8D=95=E5=8D=A1?= =?UTF-8?q?=E7=89=87/=E9=80=89=E4=B8=AD=E4=BA=A4=E4=BA=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 34 ++ godot/scripts/popups/order_meeting_popup.gd | 608 ++++++++++++++++++++ 2 files changed, 642 insertions(+) create mode 100644 godot/scripts/popups/order_meeting_popup.gd diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 4d59c28..e27f76f 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -12,6 +12,7 @@ class_name MainGame # ── Preloads ── const AnnualReportPopupScript := preload("res://scripts/popups/annual_report_popup.gd") +const OrderMeetingPopupScript := preload("res://scripts/popups/order_meeting_popup.gd") # ── Constants ── @@ -45,6 +46,7 @@ var _popup_title: Label var _popup_input: LineEdit var _popup_btn: Button var _annual_report_popup: Variant +var _order_meeting_popup: Variant # ── Virtual Methods ── @@ -68,6 +70,7 @@ func _build_ui() -> void: _build_inventory_dashboard() _build_popup() _build_annual_report_popup() + _build_order_meeting_popup() func _build_top_bar() -> void: @@ -214,6 +217,18 @@ func _build_annual_report_popup() -> void: _annual_report_popup = popup +func _build_order_meeting_popup() -> void: + var popup: Variant = OrderMeetingPopupScript.new() + popup.name = "OrderMeetingPopup" + popup.visible = false + popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL + popup.size_flags_vertical = Control.SIZE_EXPAND_FILL + var popup_layer: CanvasLayer = $PopupLayer + popup_layer.add_child(popup) + popup.orders_confirmed.connect(_on_order_meeting_confirmed) + _order_meeting_popup = popup + + # ── Helpers ── static func _make_spacer() -> Control: @@ -268,6 +283,15 @@ func _on_message_received(data: Dictionary) -> void: _log("[color=green]📊 收到年度报告 — 第 %d 年[/color]" % report_data.get("year", 0)) if _annual_report_popup: _annual_report_popup.show_report(report_data) + "order_meeting": + var order_data: Dictionary = data.get("data", {}) + var market_name: String = order_data.get("market_name", "") + var orders: Array = order_data.get("orders", []) + var year: int = order_data.get("year", 1) + var predictions: Array = order_data.get("predictions", []) + _log("[color=cyan]📋 收到订货会数据 — %s 第 %d 年 (%d 张订单)[/color]" % [market_name, year, orders.size()]) + if _order_meeting_popup: + _order_meeting_popup.show_orders(market_name, orders, year, predictions) # ── UI Updates ── @@ -340,6 +364,16 @@ func _on_discount_clicked() -> void: _ws_manager.send_action({"action": "discount_receivable", "amount": amount}) +func _on_order_meeting_confirmed(selected_ids: Array) -> void: + if selected_ids.is_empty(): + return + _log("[color=green]📋 提交 %d 张订单[/color]" % selected_ids.size()) + _ws_manager.send_action({ + "action": "submit_orders", + "order_ids": selected_ids + }) + + func _on_loan_clicked() -> void: _log("[color=yellow]🏦 申请短期贷款 20M...[/color]") _ws_manager.send_action({"action": "take_loan", "loan_type": "short", "amount": 20}) diff --git a/godot/scripts/popups/order_meeting_popup.gd b/godot/scripts/popups/order_meeting_popup.gd new file mode 100644 index 0000000..9d6cf32 --- /dev/null +++ b/godot/scripts/popups/order_meeting_popup.gd @@ -0,0 +1,608 @@ +extends Panel +## 订货会弹窗——展示市场预测和可选订单卡片,支持多选后提交。 +## +## 通过 show_orders() 传入市场名、订单列表和年份,弹窗显示市场预测柱状图 +## 和订单卡片列表。玩家点击卡片选中/取消,点击"确认提交选中订单"触发回调。 +## +## 用法: +## var popup := OrderMeetingPopup.new() +## add_child(popup) +## popup.show_orders("平城市场", orders_array, 2) +## var selected: Array = popup.get_selected_order_ids() + +class_name OrderMeetingPopup + +# ── Colors ── + +const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +const PANEL_BG: Color = Color(0.12, 0.12, 0.15) +const SECTION_BG: Color = Color(0.10, 0.10, 0.14) +const CARD_BG: Color = Color(0.16, 0.16, 0.20) +const CARD_BG_SELECTED: Color = Color(0.20, 0.30, 0.24) +const CARD_BORDER: Color = Color(0.30, 0.30, 0.35) +const CARD_BORDER_SELECTED: Color = Color(0.30, 1.00, 0.50) +const CARD_BORDER_HOVER: Color = Color(0.45, 0.45, 0.50) +const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) +const COLOR_LABEL: Color = Color(0.70, 0.70, 0.75) +const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) +const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) +const COLOR_URGENT: Color = Color(1.00, 0.25, 0.25) +const COLOR_BAR: Color = Color(0.30, 0.70, 0.95) +const COLOR_BAR_LABEL: Color = Color(0.85, 0.85, 0.90) +const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) +const COLOR_BTN_BG: Color = Color(0.18, 0.55, 0.28) +const COLOR_BTN_HOVER: Color = Color(0.22, 0.65, 0.32) +const COLOR_BTN_DISABLED: Color = Color(0.15, 0.15, 0.18) +const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) +const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) +const COLOR_ACCENT: Color = Color(0.55, 0.80, 0.95) + +# ── Layout Constants ── + +const POPUP_WIDTH: float = 720.0 +const POPUP_HEIGHT: float = 620.0 +const TITLE_HEIGHT: float = 40.0 +const TOP_BAR_HEIGHT: float = 36.0 +const CHART_SECTION_HEIGHT: float = 200.0 +const CARD_WIDTH: float = 310.0 +const CARD_HEIGHT: float = 82.0 +const CARD_GAP: float = 8.0 +const FONT_SIZE_TITLE: int = 18 +const FONT_SIZE_SECTION: int = 14 +const FONT_SIZE_CARD_TITLE: int = 13 +const FONT_SIZE_CARD_DETAIL: int = 11 +const MAX_VISIBLE_CARDS: int = 6 # max before scroll + +# Product visual metadata +const PRODUCT_MAP: Dictionary = { + "ben_ma": {"emoji": "🐴", "name": "奔马", "color": Color(0.76, 0.42, 0.22)}, + "meng_hu": {"emoji": "🐯", "name": "猛虎", "color": Color(0.90, 0.60, 0.20)}, + "fei_ying": {"emoji": "🦅", "name": "飞鹰", "color": Color(0.25, 0.60, 0.90)}, + "xiang_long": {"emoji": "🐉", "name": "翔龙", "color": Color(0.80, 0.20, 0.20)}, +} + +# ── Nodes ── + +var _overlay: ColorRect +var _dialog: Panel +var _title_label: Label +var _submit_btn: Button +var _close_btn: Button +var _chart_scroll: ScrollContainer +var _chart_vbox: VBoxContainer +var _orders_scroll: ScrollContainer +var _orders_grid: VBoxContainer +var _status_label: Label +var _selected_count_label: Label + +# ── State ── + +var _orders: Array = [] +var _selected_ids: Dictionary = {} # id -> true +var _card_nodes: Dictionary = {} # order_id -> Panel (card) +var _market_name: String = "" +var _current_year: int = 1 + +# Signal emitted when user confirms selections +signal orders_confirmed(selected_ids: Array) + + +# ── Virtual Methods ── + +func _ready() -> void: + _build_ui() + visible = false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + if _dialog and _overlay: + _center_dialog() + + +# ── Public Methods ── + +## 显示订货会弹窗。 +## market_name: 市场名称,如"平城市场" +## orders: 订单数组,每项含 id, product, quantity, unit_price, account_period, delivered, urgent +## year: 当前游戏年 +## predictions: 可选的市场价格预测数据,格式 [{year: int, price: float}, ...] +func show_orders(market_name: String, orders: Array, year: int, predictions: Array = []) -> void: + _market_name = market_name + _orders = orders.duplicate() + _current_year = year + _selected_ids.clear() + _card_nodes.clear() + + _title_label.text = "📋 订货会 — %s 第 %d 年" % [market_name, year] + + # Build chart + _build_chart(predictions) + + # Build order cards + _build_order_cards() + + # Update UI state + _update_submit_button() + _selected_count_label.text = "已选: 0 张" + + visible = true + _center_dialog() + + +## 返回当前选中的订单 ID 数组。 +func get_selected_order_ids() -> Array: + var ids: Array = [] + for oid: Variant in _selected_ids.keys(): + ids.append(oid) + return ids + + +# ── UI Building ── + +func _build_ui() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + # ── Full-screen overlay ── + _overlay = ColorRect.new() + _overlay.name = "Overlay" + _overlay.color = OVERLAY_COLOR + _overlay.mouse_filter = Control.MOUSE_FILTER_STOP + _overlay.connect("gui_input", _on_overlay_gui_input) + add_child(_overlay) + _overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + # ── Dialog panel ── + _dialog = Panel.new() + _dialog.name = "Dialog" + _dialog.custom_minimum_size = Vector2(POPUP_WIDTH, POPUP_HEIGHT) + _dialog.add_theme_stylebox_override("panel", _make_stylebox(PANEL_BG)) + add_child(_dialog) + + # ── Title bar ── + _title_label = Label.new() + _title_label.name = "TitleLabel" + _title_label.text = "📋 订货会" + _title_label.add_theme_font_size_override("font_size", FONT_SIZE_TITLE) + _title_label.add_theme_color_override("font_color", COLOR_TITLE) + _title_label.position = Vector2(20, 12) + _title_label.size = Vector2(POPUP_WIDTH - 120, TOP_BAR_HEIGHT) + _dialog.add_child(_title_label) + + # ── Close button ── + _close_btn = Button.new() + _close_btn.name = "CloseBtn" + _close_btn.text = "✕ 关闭" + _close_btn.position = Vector2(POPUP_WIDTH - 100, 10) + _close_btn.size = Vector2(80, 28) + _close_btn.add_theme_color_override("font_color", Color(1.0, 0.8, 0.8)) + _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) + _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) + _close_btn.connect("pressed", _on_close_pressed) + _dialog.add_child(_close_btn) + + # ── Section: 市场预测 ── + var chart_section := _make_section_header("📈 市场预测") + chart_section.position = Vector2(10, TOP_BAR_HEIGHT + 4) + _dialog.add_child(chart_section) + + _chart_scroll = ScrollContainer.new() + _chart_scroll.name = "ChartScroll" + _chart_scroll.position = Vector2(10, TOP_BAR_HEIGHT + 28) + _chart_scroll.size = Vector2(POPUP_WIDTH - 20, CHART_SECTION_HEIGHT - 32) + _chart_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _dialog.add_child(_chart_scroll) + + _chart_vbox = VBoxContainer.new() + _chart_vbox.name = "ChartContent" + _chart_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _chart_vbox.add_theme_constant_override("separation", 2) + _chart_scroll.add_child(_chart_vbox) + + # Separator + var sep := HSeparator.new() + sep.position = Vector2(10, TOP_BAR_HEIGHT + CHART_SECTION_HEIGHT) + sep.size = Vector2(POPUP_WIDTH - 20, 2) + sep.modulate = COLOR_SEPARATOR + _dialog.add_child(sep) + + # ── Section: 可选订单 ── + var orders_section := _make_section_header("📋 可选订单") + orders_section.position = Vector2(10, TOP_BAR_HEIGHT + CHART_SECTION_HEIGHT + 6) + _dialog.add_child(orders_section) + + # Selected count label + _selected_count_label = Label.new() + _selected_count_label.name = "SelectedCount" + _selected_count_label.text = "已选: 0 张" + _selected_count_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + _selected_count_label.add_theme_color_override("font_color", COLOR_VALUE) + _selected_count_label.position = Vector2(POPUP_WIDTH - 140, TOP_BAR_HEIGHT + CHART_SECTION_HEIGHT + 8) + _selected_count_label.size = Vector2(120, 20) + _dialog.add_child(_selected_count_label) + + # Orders scroll area + _orders_scroll = ScrollContainer.new() + _orders_scroll.name = "OrdersScroll" + var orders_y: float = TOP_BAR_HEIGHT + CHART_SECTION_HEIGHT + 32 + var orders_h: float = POPUP_HEIGHT - orders_y - 56.0 + _orders_scroll.position = Vector2(10, orders_y) + _orders_scroll.size = Vector2(POPUP_WIDTH - 20, orders_h) + _orders_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _orders_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _dialog.add_child(_orders_scroll) + + _orders_grid = VBoxContainer.new() + _orders_grid.name = "OrdersGrid" + _orders_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _orders_grid.add_theme_constant_override("separation", CARD_GAP) + _orders_scroll.add_child(_orders_grid) + + # ── Submit button ── + _submit_btn = Button.new() + _submit_btn.name = "SubmitBtn" + _submit_btn.text = "✅ 确认提交选中订单" + _submit_btn.position = Vector2( + (POPUP_WIDTH - 220) * 0.5, + POPUP_HEIGHT - 44 + ) + _submit_btn.size = Vector2(220, 34) + _submit_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + _submit_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) + _submit_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) + _submit_btn.add_theme_stylebox_override("disabled", _make_stylebox(COLOR_BTN_DISABLED)) + _submit_btn.connect("pressed", _on_submit_pressed) + _dialog.add_child(_submit_btn) + + +func _build_chart(predictions: Array) -> void: + # Clear previous chart content + for child in _chart_vbox.get_children(): + _chart_vbox.remove_child(child) + child.queue_free() + + # If no predictions provided, generate sample data for demonstration + var display_data: Array = predictions.duplicate() + if display_data.is_empty(): + display_data = _generate_sample_predictions() + + # Build bar chart + var max_price: float = 1.0 + for entry: Dictionary in display_data: + var price: float = float(entry.get("price", 0)) + if price > max_price: + max_price = price + + var bar_area_width: float = POPUP_WIDTH - 60.0 # leave room for labels + + for entry: Dictionary in display_data: + var year_label_val: int = entry.get("year", _current_year + 1) + var price: float = float(entry.get("price", 0)) + var bar_width: float = (price / max_price) * bar_area_width + + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, 24) + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + # Year label + var ylabel := Label.new() + ylabel.text = "第%d年" % year_label_val + ylabel.custom_minimum_size = Vector2(60, 0) + ylabel.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + ylabel.add_theme_color_override("font_color", COLOR_BAR_LABEL) + ylabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_child(ylabel) + + # Bar + var bar := ColorRect.new() + bar.color = _get_bar_color(display_data.find(entry), len(display_data)) + bar.custom_minimum_size = Vector2(max(bar_width, 4.0), 18) + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(bar) + + # Price label + var plabel := Label.new() + plabel.text = "%dM" % price + plabel.custom_minimum_size = Vector2(60, 0) + plabel.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + plabel.add_theme_color_override("font_color", COLOR_VALUE) + plabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_child(plabel) + + _chart_vbox.add_child(row) + + +func _build_order_cards() -> void: + # Clear previous cards + for child in _orders_grid.get_children(): + _orders_grid.remove_child(child) + child.queue_free() + + _card_nodes.clear() + + for order: Dictionary in _orders: + var card := _make_order_card(order) + _orders_grid.add_child(card) + _card_nodes[order.get("id", "")] = card + + +func _make_order_card(order: Dictionary) -> Panel: + var order_id: String = order.get("id", "") + var product: String = order.get("product", "") + var quantity: int = order.get("quantity", 1) + var unit_price: int = order.get("unit_price", 0) + var account_period: int = order.get("account_period", 0) + var urgent: bool = order.get("urgent", false) + + # Get product visual info + var product_info: Dictionary = PRODUCT_MAP.get(product, {"emoji": "📦", "name": product, "color": Color(0.50, 0.50, 0.55)}) + + # Card panel + var card := Panel.new() + card.name = "Card_%s" % order_id + card.custom_minimum_size = Vector2(CARD_WIDTH, CARD_HEIGHT) + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.mouse_filter = Control.MOUSE_FILTER_STOP + card.connect("gui_input", _on_card_gui_input.bind(order_id)) + + # Default style (unselected) + _update_card_style(card, false) + + # HBox layout inside card + var hb := HBoxContainer.new() + hb.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hb.size_flags_vertical = Control.SIZE_EXPAND_FILL + hb.anchor_right = 1.0 + hb.anchor_bottom = 1.0 + card.add_child(hb) + + # Product color swatch + var swatch := ColorRect.new() + swatch.color = product_info.get("color", Color(0.5, 0.5, 0.5)) + swatch.custom_minimum_size = Vector2(8, CARD_HEIGHT - 8) + swatch.size_flags_vertical = Control.SIZE_EXPAND_FILL + hb.add_child(swatch) + + # Spacer between swatch and content + var swatch_spacer := Control.new() + swatch_spacer.custom_minimum_size = Vector2(8, 0) + hb.add_child(swatch_spacer) + + # Content VBox + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 2) + hb.add_child(content) + + # Row 1: Product name + quantity + urgent badge + var row1 := HBoxContainer.new() + row1.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var name_label := Label.new() + name_label.text = "%s %s x%d" % [product_info.get("emoji", "📦"), product_info.get("name", product), quantity] + name_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_TITLE) + name_label.add_theme_color_override("font_color", COLOR_TITLE) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row1.add_child(name_label) + + # Urgent badge + if urgent: + var urgent_label := Label.new() + urgent_label.text = "🔴 紧急" + urgent_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + urgent_label.add_theme_color_override("font_color", COLOR_URGENT) + row1.add_child(urgent_label) + + content.add_child(row1) + + # Row 2: Details + var row2 := HBoxContainer.new() + row2.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var price_label := Label.new() + price_label.text = "单价 %dM" % unit_price + price_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + price_label.add_theme_color_override("font_color", COLOR_VALUE) + row2.add_child(price_label) + + var period_label := Label.new() + period_label.text = " 账期 %d 季" % account_period + period_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + period_label.add_theme_color_override("font_color", COLOR_LABEL) + row2.add_child(period_label) + + if urgent: + var urgent_detail := Label.new() + urgent_detail.text = " ⚡ 交货期紧" + urgent_detail.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL) + urgent_detail.add_theme_color_override("font_color", COLOR_URGENT) + row2.add_child(urgent_detail) + + content.add_child(row2) + + # Row 3: Selection status + var row3 := HBoxContainer.new() + row3.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var status_label := Label.new() + status_label.name = "StatusLabel" + status_label.text = " [ 点击选中 ]" + status_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL - 1) + status_label.add_theme_color_override("font_color", Color(0.45, 0.45, 0.50)) + row3.add_child(status_label) + + content.add_child(row3) + + # Spacer to fill remaining space + var fill := Control.new() + fill.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_child(fill) + + return card + + +func _update_card_style(card: Panel, selected: bool) -> void: + var bg: StyleBoxFlat = null + if selected: + bg = _make_stylebox(CARD_BG_SELECTED) + bg.border_color = CARD_BORDER_SELECTED + bg.border_width_top = 2 + bg.border_width_bottom = 2 + bg.border_width_left = 2 + bg.border_width_right = 2 + else: + bg = _make_stylebox(CARD_BG) + bg.border_color = CARD_BORDER + bg.border_width_top = 1 + bg.border_width_bottom = 1 + bg.border_width_left = 1 + bg.border_width_right = 1 + + card.add_theme_stylebox_override("panel", bg) + + +func _update_selection_status_label(order_id: String) -> void: + var card: Panel = _card_nodes.get(order_id) as Panel + if not card: + return + + # Find StatusLabel inside the card + var hb: HBoxContainer = card.get_child(0) as HBoxContainer + if not hb: + return + var content: VBoxContainer = null + for child in hb.get_children(): + if child is VBoxContainer: + content = child + break + if not content: + return + + # StatusLabel is in row3 (index 2 for the 3rd row) + var rows: Array = content.get_children() + if rows.size() < 3: + return + var row3: HBoxContainer = rows[2] as HBoxContainer + if not row3: + return + + var status_label: Label = row3.get_child(0) as Label + if not status_label: + return + + var is_selected: bool = _selected_ids.has(order_id) + if is_selected: + status_label.text = " ✅ [ 已选中 ]" + status_label.add_theme_color_override("font_color", CARD_BORDER_SELECTED) + else: + status_label.text = " [ 点击选中 ]" + status_label.add_theme_color_override("font_color", Color(0.45, 0.45, 0.50)) + + +func _update_submit_button() -> void: + var count: int = _selected_ids.size() + _submit_btn.disabled = count == 0 + _selected_count_label.text = "已选: %d 张" % count + + +# ── Helpers ── + +func _generate_sample_predictions() -> Array: + # Generate synthetic price predictions for demonstration + var samples: Array = [] + for i in range(1, 5): + var year: int = _current_year + i + var base_price: int = 8 + (i * 4) + randi() % 3 + samples.append({"year": year, "price": base_price}) + return samples + + +static func _get_bar_color(index: int, total: int) -> Color: + # Gradient from blue to green + var t: float = float(index) / float(max(total - 1, 1)) + return Color( + 0.30 + t * 0.20, + 0.60 + t * 0.30, + 0.95 - t * 0.30 + ) + + +static func _make_stylebox(bg_color: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.corner_radius_top_left = 4 + sb.corner_radius_top_right = 4 + sb.corner_radius_bottom_left = 4 + sb.corner_radius_bottom_right = 4 + return sb + + +static func _make_section_header(text: String) -> Label: + var label := Label.new() + label.text = " %s" % text + label.custom_minimum_size = Vector2(0, 24) + label.add_theme_font_size_override("font_size", FONT_SIZE_SECTION) + label.add_theme_color_override("font_color", COLOR_SECTION) + return label + + +func _center_dialog() -> void: + if not _dialog: + return + var parent_size: Vector2 = size if size != Vector2.ZERO else Vector2(1280, 720) + _dialog.position = Vector2( + int((parent_size.x - POPUP_WIDTH) * 0.5), + int((parent_size.y - POPUP_HEIGHT) * 0.5) + ) + + +# ── Handlers ── + +func _on_card_gui_input(event: InputEvent, order_id: String) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + # Toggle selection + if _selected_ids.has(order_id): + _selected_ids.erase(order_id) + else: + _selected_ids[order_id] = true + + # Update card visuals + var card: Panel = _card_nodes.get(order_id) as Panel + if card: + var is_selected: bool = _selected_ids.has(order_id) + _update_card_style(card, is_selected) + _update_selection_status_label(order_id) + + _update_submit_button() + + +func _on_submit_pressed() -> void: + var ids: Array = get_selected_order_ids() + orders_confirmed.emit(ids) + _log_selection(ids) + visible = false + + +func _log_selection(ids: Array) -> void: + var names: PackedStringArray = [] + for oid: Variant in ids: + var oid_str: String = str(oid) + # Find matching order + for order: Dictionary in _orders: + if str(order.get("id", "")) == oid_str: + var prod: String = order.get("product", "") + var info: Dictionary = PRODUCT_MAP.get(prod, {"name": prod}) + names.append("%s x%d" % [info.get("name", prod), order.get("quantity", 0)]) + break + var detail: String = ", ".join(names) + print("📋 提交订单 [%s]: %s" % [_market_name, detail]) + + +func _on_close_pressed() -> void: + visible = false + + +func _on_overlay_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + visible = false From 188e029d5c7789d9fd1c71ef66ee57b83fdd8b2f Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 02:36:59 +0800 Subject: [PATCH 18/64] =?UTF-8?q?feat(godot):=20Task=205&6=20-=20=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E7=8A=B6=E6=80=81=E9=9D=A2=E6=9D=BF=20+=20=E5=B7=A5?= =?UTF-8?q?=E5=8E=82/=E8=B4=B7=E6=AC=BE/=E8=B4=B4=E7=8E=B0=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 100 ++++- godot/scripts/market_panel.gd | 195 ++++++++++ godot/scripts/popups/factory_dialog.gd | 502 +++++++++++++++++++++++++ 3 files changed, 778 insertions(+), 19 deletions(-) create mode 100644 godot/scripts/market_panel.gd create mode 100644 godot/scripts/popups/factory_dialog.gd diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index e27f76f..e7d545d 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -13,6 +13,8 @@ class_name MainGame const AnnualReportPopupScript := preload("res://scripts/popups/annual_report_popup.gd") const OrderMeetingPopupScript := preload("res://scripts/popups/order_meeting_popup.gd") +const MarketPanelScript := preload("res://scripts/market_panel.gd") +const FactoryDialogScript := preload("res://scripts/popups/factory_dialog.gd") # ── Constants ── @@ -33,6 +35,7 @@ var _game_state: Dictionary = {} var _decision_type: String = "" var _production_view: ProductionView var _inventory_dashboard: InventoryDashboard +var _market_panel: Variant # Lazy-init UI nodes (created in _build_ui) var _year_label: Label @@ -47,6 +50,7 @@ var _popup_input: LineEdit var _popup_btn: Button var _annual_report_popup: Variant var _order_meeting_popup: Variant +var _factory_dialog: Variant # ── Virtual Methods ── @@ -66,11 +70,13 @@ func _build_ui() -> void: _build_top_bar() _build_message_log() _build_bottom_bar() + _build_market_panel() _build_production_view() _build_inventory_dashboard() _build_popup() _build_annual_report_popup() _build_order_meeting_popup() + _build_factory_dialog() func _build_top_bar() -> void: @@ -137,11 +143,6 @@ func _build_bottom_bar() -> void: func _build_production_view() -> void: - # 清空占位节点(场景定义中已有的 ProductionArea VBoxContainer) - for child in _production_area.get_children(): - _production_area.remove_child(child) - child.queue_free() - var prod_view := ProductionView.new() prod_view.name = "ProductionView" prod_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -151,7 +152,6 @@ func _build_production_view() -> void: func _build_inventory_dashboard() -> void: - # 右侧库存看板面板 var panel := Panel.new() panel.position = Vector2(470.0, 56.0) panel.size = Vector2(310.0, 380.0) @@ -164,6 +164,14 @@ func _build_inventory_dashboard() -> void: panel.add_child(_inventory_dashboard) +func _build_market_panel() -> void: + _market_panel = MarketPanelScript.new() + _market_panel.name = "MarketPanel" + _market_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _market_panel.submit_bidding.connect(_on_market_panel_submit) + _production_area.add_child(_market_panel) + + func _build_popup() -> void: _popup_panel = Panel.new() _popup_panel.position = Vector2(150.0, 100.0) @@ -211,7 +219,6 @@ func _build_annual_report_popup() -> void: popup.visible = false popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL popup.size_flags_vertical = Control.SIZE_EXPAND_FILL - # Add to PopupLayer (CanvasLayer) so it renders above everything var popup_layer: CanvasLayer = $PopupLayer popup_layer.add_child(popup) _annual_report_popup = popup @@ -229,6 +236,20 @@ func _build_order_meeting_popup() -> void: _order_meeting_popup = popup +func _build_factory_dialog() -> void: + var dialog: Variant = FactoryDialogScript.new() + dialog.name = "FactoryDialog" + dialog.visible = false + dialog.size_flags_horizontal = Control.SIZE_EXPAND_FILL + dialog.size_flags_vertical = Control.SIZE_EXPAND_FILL + var popup_layer: CanvasLayer = $PopupLayer + popup_layer.add_child(dialog) + dialog.factory_ordered.connect(_on_factory_ordered) + dialog.loan_requested.connect(_on_factory_loan_requested) + dialog.discount_confirmed.connect(_on_factory_discount_confirmed) + _factory_dialog = dialog + + # ── Helpers ── static func _make_spacer() -> Control: @@ -239,7 +260,6 @@ static func _make_spacer() -> Control: func _log(msg: String) -> void: _msg_log.text += msg + "\n" - # 自动滚动到底部 _msg_log.scroll_to_line(_msg_log.get_line_count() - 1) @@ -303,14 +323,15 @@ func _update_ui() -> void: _cash_label.text = " 💰 %dM" % gs.get("cash", 0) _phase_label.text = " 阶段 %d" % gs.get("phase", 1) - # 刷新生产线视窗 if _production_view: _production_view.update(gs.get("factories", [])) - # 刷新库存看板 if _inventory_dashboard: _inventory_dashboard.update(gs) + if _market_panel: + _market_panel.update(gs.get("markets", [])) + func _show_decision(data: Dictionary) -> void: _decision_type = data.get("decision_type", "") @@ -355,13 +376,8 @@ func _on_action_clicked() -> void: func _on_discount_clicked() -> void: - var ar: Array = _game_state.get("accounts_receivable", []) - if ar.is_empty(): - _log("[color=red]没有应收款可贴现[/color]") - return - var amount: int = ar[0].get("amount", 0) - _log("[color=yellow]💰 贴现 %dM 应收款...[/color]" % amount) - _ws_manager.send_action({"action": "discount_receivable", "amount": amount}) + if _factory_dialog: + _factory_dialog.show_dialog(_game_state) func _on_order_meeting_confirmed(selected_ids: Array) -> void: @@ -375,5 +391,51 @@ func _on_order_meeting_confirmed(selected_ids: Array) -> void: func _on_loan_clicked() -> void: - _log("[color=yellow]🏦 申请短期贷款 20M...[/color]") - _ws_manager.send_action({"action": "take_loan", "loan_type": "short", "amount": 20}) + if _factory_dialog: + _factory_dialog.show_dialog(_game_state) + + +# ── Factory Dialog Handlers ── + +func _on_factory_ordered(line_type: String) -> void: + var type_label: String = "手工线" + match line_type: + "semi_auto": + type_label = "半自动" + "auto": + type_label = "全自动" + _log("[color=green]🔧 订购新产线: %s[/color]" % type_label) + _ws_manager.send_action({ + "action": "order_production_line", + "line_type": line_type + }) + + +func _on_factory_loan_requested(loan_type: String, amount: int) -> void: + var type_label: String = "短期" if loan_type == "short" else "长期" + _log("[color=yellow]🏦 申请%s贷款 %dM...[/color]" % [type_label, amount]) + _ws_manager.send_action({ + "action": "take_loan", + "loan_type": loan_type, + "amount": amount + }) + + +func _on_factory_discount_confirmed(amount: int) -> void: + _log("[color=yellow]💰 贴现 %dM 应收款...[/color]" % amount) + _ws_manager.send_action({ + "action": "discount_receivable", + "amount": amount + }) + + +func _on_market_panel_submit(strategies: Array) -> void: + if strategies.is_empty(): + return + _log("[color=green]📋 提交竞标策略: %d 个市场[/color]" % strategies.size()) + _ws_manager.send_action({ + "action": "submit_bidding", + "strategies": strategies + }) + _action_btn.text = "▶ 执行下一季度" + _action_btn.disabled = false diff --git a/godot/scripts/market_panel.gd b/godot/scripts/market_panel.gd new file mode 100644 index 0000000..2d6ad97 --- /dev/null +++ b/godot/scripts/market_panel.gd @@ -0,0 +1,195 @@ +extends Control +## 市场状态面板——显示所有市场列表、开发状态、排名和上年度销售额。 +## +## 通过 update(markets) 接收后端数据刷新显示。 +## 顶部提供营销投入输入框用于提交竞标。 + +class_name MarketPanel + +# ── Signals ── + +## 用户点击"提交竞标"时触发,参数为竞标策略数组。 +## 每个策略格式: { "market_name": String, "marketing_spend": int } +signal submit_bidding(strategies: Array) + + +# ── Constants ── + +const COLOR_HEADER: Color = Color(0.90, 0.90, 0.90) +const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) +const COLOR_VALUE: Color = Color(0.90, 0.90, 0.30) +const COLOR_DEVELOPED_TAG: Color = Color(0.15, 0.75, 0.25) +const COLOR_UNDEVELOPED_TAG: Color = Color(0.70, 0.30, 0.30) +const COLOR_BORDER: Color = Color(0.30, 0.30, 0.30) + +const ROW_HEIGHT: float = 24.0 +const INPUT_WIDTH: float = 60.0 +const PANEL_PADDING: float = 4.0 + + +# ── Private Vars ── + +var _market_container: VBoxContainer +var _marketing_input: LineEdit +var _submit_btn: Button + + +# ── Virtual Methods ── + +func _ready() -> void: + _build_ui() + + +# ── Public Methods ── + +## 接收市场列表数据并刷新面板。 +## markets: Array[Dictionary] +## 每个元素格式: +## { "name": String, "developed": bool, "rank": int, "last_year_sales": int } +func update(markets: Array) -> void: + # 清空旧列表 + for child in _market_container.get_children(): + _market_container.remove_child(child) + child.queue_free() + + if markets.is_empty(): + _show_default_markets() + return + + for market: Dictionary in markets: + _add_market_row(market) + + +# ── Private Methods ── + +func _build_ui() -> void: + # 标题 + var header := Label.new() + header.text = "📊 市场状态" + header.add_theme_font_size_override("font_size", 14) + header.add_theme_color_override("font_color", COLOR_HEADER) + add_child(header) + + add_child(_make_separator()) + + # 营销投入行 + var input_row := HBoxContainer.new() + input_row.custom_minimum_size = Vector2(0, ROW_HEIGHT + 8) + add_child(input_row) + + var input_label := Label.new() + input_label.text = "营销投入:" + input_label.add_theme_color_override("font_color", COLOR_LABEL) + input_label.add_theme_font_size_override("font_size", 12) + input_label.custom_minimum_size = Vector2(65, 0) + input_row.add_child(input_label) + + _marketing_input = LineEdit.new() + _marketing_input.placeholder_text = "2" + _marketing_input.text = "2" + _marketing_input.custom_minimum_size = Vector2(INPUT_WIDTH, 0) + _marketing_input.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + input_row.add_child(_marketing_input) + + var unit_label := Label.new() + unit_label.text = " M" + unit_label.add_theme_color_override("font_color", COLOR_LABEL) + unit_label.add_theme_font_size_override("font_size", 12) + input_row.add_child(unit_label) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + input_row.add_child(spacer) + + _submit_btn = Button.new() + _submit_btn.text = "提交竞标" + _submit_btn.pressed.connect(_on_submit_bidding) + input_row.add_child(_submit_btn) + + add_child(_make_separator()) + + # 市场列表容器 + _market_container = VBoxContainer.new() + _market_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _market_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(_market_container) + + # 初始显示默认市场(未开发状态) + _show_default_markets() + + +static func _make_separator() -> HSeparator: + var sep := HSeparator.new() + sep.modulate = COLOR_BORDER + return sep + + +func _show_default_markets() -> void: + var default_markets: Array[Dictionary] = [ + {"name": "平城", "developed": false, "rank": 0, "last_year_sales": 0}, + {"name": "南城", "developed": false, "rank": 0, "last_year_sales": 0}, + {"name": "北城", "developed": false, "rank": 0, "last_year_sales": 0}, + {"name": "东城", "developed": false, "rank": 0, "last_year_sales": 0}, + {"name": "西城", "developed": false, "rank": 0, "last_year_sales": 0}, + ] + for market: Dictionary in default_markets: + _add_market_row(market) + + +func _add_market_row(market: Dictionary) -> void: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _market_container.add_child(row) + + var developed: bool = market.get("developed", false) + + var status_icon := Label.new() + status_icon.text = "✅" if developed else "❌" + status_icon.custom_minimum_size = Vector2(22, 0) + row.add_child(status_icon) + + var name_label := Label.new() + name_label.text = market.get("name", "未知") + name_label.add_theme_color_override("font_color", COLOR_HEADER if developed else COLOR_LABEL) + name_label.add_theme_font_size_override("font_size", 12) + name_label.custom_minimum_size = Vector2(50, 0) + row.add_child(name_label) + + if developed: + var rank: int = market.get("rank", 0) + var rank_label := Label.new() + rank_label.text = "排名 #%d" % rank + rank_label.add_theme_color_override("font_color", COLOR_VALUE) + rank_label.add_theme_font_size_override("font_size", 11) + rank_label.custom_minimum_size = Vector2(55, 0) + row.add_child(rank_label) + + var sales: int = market.get("last_year_sales", 0) + var sales_label := Label.new() + sales_label.text = "去年销售 %dM" % sales + sales_label.add_theme_color_override( + "font_color", COLOR_DEVELOPED_TAG if sales > 0 else COLOR_LABEL + ) + sales_label.add_theme_font_size_override("font_size", 11) + sales_label.custom_minimum_size = Vector2(85, 0) + row.add_child(sales_label) + else: + var undev_label := Label.new() + undev_label.text = "未开发" + undev_label.add_theme_color_override("font_color", COLOR_UNDEVELOPED_TAG) + undev_label.add_theme_font_size_override("font_size", 11) + undev_label.custom_minimum_size = Vector2(140, 0) + row.add_child(undev_label) + + var row_spacer := Control.new() + row_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(row_spacer) + + +func _on_submit_bidding() -> void: + var input_val: String = _marketing_input.text.strip_edges() + var spend: int = int(input_val) if input_val.is_valid_int() else 2 + var strategies: Array[Dictionary] = [ + {"market_name": "平城", "marketing_spend": spend} + ] + submit_bidding.emit(strategies) diff --git a/godot/scripts/popups/factory_dialog.gd b/godot/scripts/popups/factory_dialog.gd new file mode 100644 index 0000000..0522a68 --- /dev/null +++ b/godot/scripts/popups/factory_dialog.gd @@ -0,0 +1,502 @@ +extends Panel +## 工厂与融资弹窗——管理工厂、产线、贷款和贴现。 +## +## 展示当前工厂信息、新建产线选项、贷款选择和贴现确认。 +## 通过 show(state) 传入游戏状态数据刷新 UI。 +## +## 信号: +## - factory_ordered(line_type): 用户确认订购新产线 +## - loan_requested(loan_type, amount): 用户确认贷款 +## - discount_confirmed(amount): 用户确认贴现 + +class_name FactoryDialog + +# ── Colors ── + +const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +const PANEL_BG: Color = Color(0.12, 0.12, 0.15) +const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) +const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) +const COLOR_LABEL: Color = Color(0.70, 0.70, 0.75) +const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) +const COLOR_ACCENT: Color = Color(0.30, 0.90, 0.40) +const COLOR_WARN: Color = Color(0.95, 0.70, 0.20) +const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) +const COLOR_BTN_BG: Color = Color(0.18, 0.55, 0.28) +const COLOR_BTN_HOVER: Color = Color(0.22, 0.65, 0.32) +const COLOR_BTN_DISABLED: Color = Color(0.15, 0.15, 0.18) +const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) +const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) +const COLOR_LINE_BTN: Color = Color(0.18, 0.30, 0.45) +const COLOR_LINE_BTN_HOVER: Color = Color(0.22, 0.35, 0.55) +const COLOR_LINE_BTN_SELECTED: Color = Color(0.25, 0.50, 0.65) +const COLOR_LOAN_BTN: Color = Color(0.30, 0.20, 0.15) +const COLOR_LOAN_BTN_HOVER: Color = Color(0.40, 0.25, 0.20) +const COLOR_LOAN_BTN_SELECTED: Color = Color(0.50, 0.30, 0.20) + +# ── Layout Constants ── + +const PANEL_WIDTH: float = 420.0 +const PANEL_HEIGHT: float = 540.0 +const FONT_SIZE_TITLE: int = 18 +const FONT_SIZE_SECTION: int = 14 +const FONT_SIZE_BODY: int = 12 +const FONT_SIZE_SMALL: int = 11 +const ROW_HEIGHT: float = 26.0 +const SECTION_HEADER_HEIGHT: float = 24.0 +const SEPARATOR_HEIGHT: float = 8.0 + +# ── Production Line Data ── + +const LINE_TYPES: Dictionary = { + "manual": {"label": "手工线", "cost": 4, "time": 2, "desc": "2Q 建成"}, + "semi_auto": {"label": "半自动", "cost": 8, "time": 3, "desc": "3Q 建成"}, + "auto": {"label": "全自动", "cost": 16, "time": 4, "desc": "4Q 建成"}, +} + +# ── Nodes ── + +var _overlay: ColorRect +var _dialog: Panel +var _title_label: Label +var _close_btn: Button + +# Factory info nodes +var _factory_info_label: Label +var _factory_used_label: Label + +# New line nodes +var _selected_line_type: String = "manual" +var _line_type_btns: Dictionary = {} # type -> Button +var _line_desc_label: Label + +# Loan nodes +var _selected_loan_type: String = "short" +var _loan_type_btns: Dictionary = {} # type -> Button +var _loan_amount_input: LineEdit +var _loan_rate_label: Label + +# Discount nodes +var _ar_amount_label: Label +var _ar_fee_label: Label +var _discount_confirm_btn: Button + +# State +var _game_state: Dictionary = {} + +# ── Signals ── + +signal factory_ordered(line_type: String) +signal loan_requested(loan_type: String, amount: int) +signal discount_confirmed(amount: int) + +# ── Virtual Methods ── + +func _ready() -> void: + _build_ui() + visible = false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + if _dialog and _overlay: + _center_dialog() + + +# ── Public Methods ── + +## 接收游戏状态数据并刷新弹窗内容。 +func show_dialog(state: Dictionary) -> void: + _game_state = state + _refresh_factory_info() + _refresh_discount_info() + visible = true + _center_dialog() + + +# ── UI Building ── + +func _build_ui() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + # ── Full-screen overlay ── + _overlay = ColorRect.new() + _overlay.name = "Overlay" + _overlay.color = OVERLAY_COLOR + _overlay.mouse_filter = Control.MOUSE_FILTER_STOP + _overlay.connect("gui_input", _on_overlay_gui_input) + add_child(_overlay) + _overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + # ── Dialog panel ── + _dialog = Panel.new() + _dialog.name = "Dialog" + _dialog.custom_minimum_size = Vector2(PANEL_WIDTH, PANEL_HEIGHT) + _dialog.add_theme_stylebox_override("panel", _make_stylebox(PANEL_BG)) + add_child(_dialog) + + var y_pos: float = 0.0 + + # ── Title bar ── + _title_label = Label.new() + _title_label.text = "🏗️ 工厂与融资" + _title_label.add_theme_font_size_override("font_size", FONT_SIZE_TITLE) + _title_label.add_theme_color_override("font_color", COLOR_TITLE) + _title_label.position = Vector2(16, 12) + _title_label.size = Vector2(PANEL_WIDTH - 100, 32) + _dialog.add_child(_title_label) + + _close_btn = Button.new() + _close_btn.text = "✕ 关闭" + _close_btn.position = Vector2(PANEL_WIDTH - 90, 12) + _close_btn.size = Vector2(76, 28) + _close_btn.add_theme_color_override("font_color", Color(1.0, 0.8, 0.8)) + _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) + _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) + _close_btn.connect("pressed", _on_close_pressed) + _dialog.add_child(_close_btn) + + y_pos = 48.0 + + # ════════════════════════════════════════════ + # Section: 工厂信息 + # ════════════════════════════════════════════ + y_pos = _add_section_header("🏭 工厂信息", y_pos) + + _factory_info_label = Label.new() + _factory_info_label.position = Vector2(24, y_pos) + _factory_info_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _factory_info_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + _factory_info_label.add_theme_color_override("font_color", COLOR_LABEL) + _dialog.add_child(_factory_info_label) + y_pos += ROW_HEIGHT + + _factory_used_label = Label.new() + _factory_used_label.position = Vector2(24, y_pos) + _factory_used_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _factory_used_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + _factory_used_label.add_theme_color_override("font_color", COLOR_VALUE) + _dialog.add_child(_factory_used_label) + y_pos += ROW_HEIGHT + 4.0 + + y_pos = _add_separator(y_pos) + + # ════════════════════════════════════════════ + # Section: 新建产线 + # ════════════════════════════════════════════ + y_pos = _add_section_header("🔧 新建产线", y_pos) + + # Line type selection buttons + var line_btn_x: float = 16.0 + for lt: String in ["manual", "semi_auto", "auto"]: + var line_data: Dictionary = LINE_TYPES[lt] + var btn := Button.new() + btn.text = "%s\n%dM %s" % [line_data["label"], line_data["cost"], line_data["desc"]] + btn.position = Vector2(line_btn_x, y_pos) + btn.size = Vector2(124, 44) + btn.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) + btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LINE_BTN)) + btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LINE_BTN_HOVER)) + btn.connect("pressed", _on_line_type_selected.bind(lt)) + _dialog.add_child(btn) + _line_type_btns[lt] = btn + line_btn_x += 132.0 + + y_pos += 50.0 + + _line_desc_label = Label.new() + _line_desc_label.position = Vector2(24, y_pos) + _line_desc_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _line_desc_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) + _line_desc_label.add_theme_color_override("font_color", COLOR_VALUE) + _dialog.add_child(_line_desc_label) + y_pos += ROW_HEIGHT + + # Confirm line order button + var confirm_line_btn := Button.new() + confirm_line_btn.text = "✅ 确认订购产线" + confirm_line_btn.position = Vector2(120, y_pos) + confirm_line_btn.size = Vector2(180, 30) + confirm_line_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + confirm_line_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) + confirm_line_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) + confirm_line_btn.connect("pressed", _on_confirm_line_pressed) + _dialog.add_child(confirm_line_btn) + y_pos += 38.0 + + y_pos = _add_separator(y_pos) + + # ════════════════════════════════════════════ + # Section: 贷款 + # ════════════════════════════════════════════ + y_pos = _add_section_header("🏦 贷款", y_pos) + + # Loan type buttons + var loan_btn_x: float = 16.0 + for lt: String in ["short", "long"]: + var btn := Button.new() + var label: String = "短贷" if lt == "short" else "长贷" + btn.text = label + btn.position = Vector2(loan_btn_x, y_pos) + btn.size = Vector2(64, 30) + btn.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LOAN_BTN)) + btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LOAN_BTN_HOVER)) + btn.connect("pressed", _on_loan_type_selected.bind(lt)) + _dialog.add_child(btn) + _loan_type_btns[lt] = btn + loan_btn_x += 72.0 + + # Amount label + input + var amount_label := Label.new() + amount_label.text = "金额:" + amount_label.position = Vector2(loan_btn_x + 8, y_pos) + amount_label.size = Vector2(40, ROW_HEIGHT) + amount_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + amount_label.add_theme_color_override("font_color", COLOR_LABEL) + _dialog.add_child(amount_label) + + _loan_amount_input = LineEdit.new() + _loan_amount_input.position = Vector2(loan_btn_x + 46, y_pos) + _loan_amount_input.size = Vector2(60, 30) + _loan_amount_input.placeholder_text = "20" + _loan_amount_input.text = "20" + _dialog.add_child(_loan_amount_input) + + var m_label := Label.new() + m_label.text = "M" + m_label.position = Vector2(loan_btn_x + 110, y_pos) + m_label.size = Vector2(20, ROW_HEIGHT) + m_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + m_label.add_theme_color_override("font_color", COLOR_LABEL) + _dialog.add_child(m_label) + + y_pos += 36.0 + + # Interest rate info + _loan_rate_label = Label.new() + _loan_rate_label.position = Vector2(24, y_pos) + _loan_rate_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _loan_rate_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) + _loan_rate_label.add_theme_color_override("font_color", COLOR_VALUE) + _dialog.add_child(_loan_rate_label) + y_pos += ROW_HEIGHT + + # Confirm loan button + var confirm_loan_btn := Button.new() + confirm_loan_btn.text = "✅ 确认贷款" + confirm_loan_btn.position = Vector2(140, y_pos) + confirm_loan_btn.size = Vector2(140, 30) + confirm_loan_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + confirm_loan_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) + confirm_loan_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) + confirm_loan_btn.connect("pressed", _on_confirm_loan_pressed) + _dialog.add_child(confirm_loan_btn) + y_pos += 38.0 + + y_pos = _add_separator(y_pos) + + # ════════════════════════════════════════════ + # Section: 贴现 + # ════════════════════════════════════════════ + y_pos = _add_section_header("💳 贴现", y_pos) + + _ar_amount_label = Label.new() + _ar_amount_label.position = Vector2(24, y_pos) + _ar_amount_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _ar_amount_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + _ar_amount_label.add_theme_color_override("font_color", COLOR_LABEL) + _dialog.add_child(_ar_amount_label) + y_pos += ROW_HEIGHT + + _ar_fee_label = Label.new() + _ar_fee_label.position = Vector2(24, y_pos) + _ar_fee_label.size = Vector2(PANEL_WIDTH - 40, ROW_HEIGHT) + _ar_fee_label.add_theme_font_size_override("font_size", FONT_SIZE_BODY) + _ar_fee_label.add_theme_color_override("font_color", COLOR_VALUE) + _dialog.add_child(_ar_fee_label) + y_pos += ROW_HEIGHT + + _discount_confirm_btn = Button.new() + _discount_confirm_btn.text = "✅ 确认贴现" + _discount_confirm_btn.position = Vector2(140, y_pos) + _discount_confirm_btn.size = Vector2(140, 30) + _discount_confirm_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + _discount_confirm_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) + _discount_confirm_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) + _discount_confirm_btn.connect("pressed", _on_confirm_discount_pressed) + _dialog.add_child(_discount_confirm_btn) + + # Initialize selection states + _update_line_selection("manual") + _update_loan_selection("short") + + +# ── Content Refresh ── + +func _refresh_factory_info() -> void: + var factories: Array = _game_state.get("factories", []) + if factories.is_empty(): + _factory_info_label.text = "当前工厂: 暂无" + _factory_used_label.text = "" + return + + var factory: Dictionary = factories[0] + var name_str: String = factory.get("name", "未知工厂") + var capacity: int = factory.get("capacity", 0) + var lines: Array = factory.get("lines", []) + var lines_used: int = 0 + var total_value: int = 0 + + for line: Dictionary in lines: + if not line.get("status", "") in ["idle", ""]: + lines_used += 1 + # Estimate value: base factory 20M + line cost + total_value += _get_line_value(line) + + var factory_value: int = 20 + total_value + _factory_info_label.text = "当前工厂: %s (容量%d) 价值%dM" % [name_str, capacity, factory_value] + _factory_used_label.text = "已用产线: %d/%d" % [lines.size(), capacity] + + +func _refresh_discount_info() -> void: + var ar: Array = _game_state.get("accounts_receivable", []) + if ar.is_empty(): + _ar_amount_label.text = "应收款: 无" + _ar_fee_label.text = "手续费: -" + _discount_confirm_btn.disabled = true + return + + var amount: int = ar[0].get("amount", 0) + var fee: int = ceili(float(amount) / 14.0) + _ar_amount_label.text = "应收款: %dM" % amount + _ar_fee_label.text = "手续费: %dM (1/14)" % fee + _discount_confirm_btn.disabled = false + + +func _update_line_selection(line_type: String) -> void: + _selected_line_type = line_type + for lt: String in _line_type_btns.keys(): + var btn: Button = _line_type_btns[lt] + if lt == line_type: + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LINE_BTN_SELECTED)) + else: + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LINE_BTN)) + + # Update description + var data: Dictionary = LINE_TYPES[line_type] + _line_desc_label.text = "已选: %s — 费用 %dM, %s" % [data["label"], data["cost"], data["desc"]] + + +func _update_loan_selection(loan_type: String) -> void: + _selected_loan_type = loan_type + for lt: String in _loan_type_btns.keys(): + var btn: Button = _loan_type_btns[lt] + if lt == loan_type: + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LOAN_BTN_SELECTED)) + else: + btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LOAN_BTN)) + + # Update rate info + match loan_type: + "short": + _loan_rate_label.text = "利率: 短贷 10% / 年" + "long": + _loan_rate_label.text = "利率: 长贷 5% / 年" + + +static func _get_line_value(line: Dictionary) -> int: + var type_str: String = line.get("type", "manual") + match type_str: + "semi_auto": + return 8 + "auto": + return 16 + _: + return 4 + + +# ── Layout Helpers ── + +func _add_section_header(text: String, y: float) -> float: + var label := Label.new() + label.text = " %s" % text + label.position = Vector2(8, y) + label.size = Vector2(PANEL_WIDTH - 16, SECTION_HEADER_HEIGHT) + label.add_theme_font_size_override("font_size", FONT_SIZE_SECTION) + label.add_theme_color_override("font_color", COLOR_SECTION) + _dialog.add_child(label) + return y + SECTION_HEADER_HEIGHT + 2.0 + + +func _add_separator(y: float) -> float: + var sep := HSeparator.new() + sep.position = Vector2(12, y) + sep.size = Vector2(PANEL_WIDTH - 24, 2) + sep.modulate = COLOR_SEPARATOR + _dialog.add_child(sep) + return y + SEPARATOR_HEIGHT + + +static func _make_stylebox(bg_color: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.corner_radius_top_left = 4 + sb.corner_radius_top_right = 4 + sb.corner_radius_bottom_left = 4 + sb.corner_radius_bottom_right = 4 + return sb + + +func _center_dialog() -> void: + if not _dialog: + return + var parent_size: Vector2 = size if size != Vector2.ZERO else Vector2(1280, 720) + _dialog.position = Vector2( + int((parent_size.x - PANEL_WIDTH) * 0.5), + int((parent_size.y - PANEL_HEIGHT) * 0.5) + ) + + +# ── Handlers ── + +func _on_line_type_selected(line_type: String) -> void: + _update_line_selection(line_type) + + +func _on_confirm_line_pressed() -> void: + factory_ordered.emit(_selected_line_type) + visible = false + + +func _on_loan_type_selected(loan_type: String) -> void: + _update_loan_selection(loan_type) + + +func _on_confirm_loan_pressed() -> void: + var amount_str: String = _loan_amount_input.text.strip_edges() + var amount: int = int(amount_str) if amount_str.is_valid_int() else 0 + if amount <= 0: + return + loan_requested.emit(_selected_loan_type, amount) + visible = false + + +func _on_confirm_discount_pressed() -> void: + var ar: Array = _game_state.get("accounts_receivable", []) + if ar.is_empty(): + return + var amount: int = ar[0].get("amount", 0) + discount_confirmed.emit(amount) + visible = false + + +func _on_close_pressed() -> void: + visible = false + + +func _on_overlay_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + visible = false From e68d8a491aa58c2507ab356327da7a3cb71e6f9f Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 02:49:03 +0800 Subject: [PATCH 19/64] =?UTF-8?q?fix(business-sandbox):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=89=8D=E5=90=8E=E7=AB=AF=E6=95=B0=E6=8D=AE=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=B8=8D=E5=8C=B9=E9=85=8D=E5=92=8Cserde=20snake=5Fca?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/src/business_sandbox/engine.rs | 112 +++++++++----------- rust/api/src/business_sandbox/state.rs | 52 ++++++--- rust/api/src/business_sandbox/ws_handler.rs | 12 ++- 3 files changed, 97 insertions(+), 79 deletions(-) diff --git a/rust/api/src/business_sandbox/engine.rs b/rust/api/src/business_sandbox/engine.rs index a40041c..d84687e 100644 --- a/rust/api/src/business_sandbox/engine.rs +++ b/rust/api/src/business_sandbox/engine.rs @@ -197,12 +197,8 @@ impl GameManager { instance.current_phase = "year_start".to_string(); let state = instance.state.clone(); Ok(vec![ - GameEvent::StateUpdate(Box::new(state)), - GameEvent::AskDecision { - year: instance.state.game_year, - quarter: instance.state.game_quarter, - decision_type: "bidding".to_string(), - }, + GameEvent::StateUpdate { data: Box::new(state) }, + GameEvent::AskDecision { data: AskDecisionData { year: instance.state.game_year, quarter: instance.state.game_quarter, decision_type: "bidding".to_string() } }, ]) } @@ -233,16 +229,12 @@ impl GameManager { instance.current_phase = "quarter_ops".to_string(); let mut events: Vec = vec![ - GameEvent::Message(format!("提交竞标策略成功,营销费用 {}M", total_spend)), - GameEvent::PhaseChange { - year: instance.state.game_year, - quarter: instance.state.game_quarter, - phase: "quarter_ops".to_string(), - }, + GameEvent::Message { data: format!("提交竞标策略成功,营销费用 {}M", total_spend) }, + GameEvent::PhaseChange { data: PhaseChangeData { year: instance.state.game_year, quarter: instance.state.game_quarter, phase: "quarter_ops".to_string() } }, ]; let state = instance.state.clone(); - events.push(GameEvent::StateUpdate(Box::new(state))); + events.push(GameEvent::StateUpdate { data: Box::new(state) }); Ok(events) } @@ -275,7 +267,7 @@ impl GameManager { } let state = instance.state.clone(); - events.push(GameEvent::StateUpdate(Box::new(state))); + events.push(GameEvent::StateUpdate { data: Box::new(state) }); Ok(events) } @@ -314,10 +306,10 @@ impl GameManager { instance.state.cash += net_amount; instance.annual_discount_fee += fee; - Ok(vec![GameEvent::Message(format!( + Ok(vec![GameEvent::Message { data: format!( "贴现成功,获得 {}M,手续费 {}M", net_amount, fee - ))]) + ) }]) } PlayerAction::TakeLoan { loan_type, amount } => { @@ -342,10 +334,10 @@ impl GameManager { annual_interest_rate: 10.0, }); instance.state.cash += amount; - Ok(vec![GameEvent::Message(format!( + Ok(vec![GameEvent::Message { data: format!( "短期贷款成功 {}M", amount - ))]) + ) }]) } "long_term" | "long" => { let equity = calculate_equity(&instance.state); @@ -369,10 +361,10 @@ impl GameManager { active: true, }); instance.state.cash += amount; - Ok(vec![GameEvent::Message(format!( + Ok(vec![GameEvent::Message { data: format!( "长期贷款成功 {}M", amount - ))]) + ) }]) } _ => Err(GameError::InvalidAction(format!( "未知贷款类型: {}", @@ -403,10 +395,10 @@ impl GameManager { for ar in instance.state.accounts_receivable.drain(..) { if ar.due_quarters <= 1 { cash_in += ar.amount; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "应收账款到期入账 {}M", ar.amount - ))); + ) }); } else { new_receivables.push(AccountReceivable { amount: ar.amount, @@ -429,10 +421,10 @@ impl GameManager { loan.remaining_quarters = loan.remaining_quarters.saturating_sub(1); if loan.remaining_quarters == 0 { principal += loan.amount; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "短贷到期归还本金 {}M", loan.amount - ))); + ) }); } else { remaining_loans.push(loan); } @@ -449,10 +441,10 @@ impl GameManager { } instance.state.cash -= total_st; instance.annual_interest_expense += interest; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "支付短贷利息 {}M,归还本金 {}M", interest, principal - ))); + ) }); } } @@ -464,10 +456,10 @@ impl GameManager { rd.progress += 1; if rd.progress >= rd.total { rd.completed = true; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "产品 {:?} 研发完成!", rd.product - ))); + ) }); } else { let rd_cost = product_rd_cost_per_quarter(); if instance.state.cash < rd_cost { @@ -479,10 +471,10 @@ impl GameManager { } instance.state.cash -= rd_cost; instance.annual_rd_expense += rd_cost; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "支付研发费用 {}M({:?} {}/{})", rd_cost, rd.product, rd.progress, rd.total - ))); + ) }); } } @@ -500,10 +492,10 @@ impl GameManager { } instance.state.cash -= cost; instance.state.raw_material_inventory += orders; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "供应商交付原材料 {} 单位,支付 {}M", orders, cost - ))); + ) }); instance.state.raw_material_orders = 0; } } @@ -512,10 +504,10 @@ impl GameManager { { let auto_order = 2u32; instance.state.raw_material_orders += auto_order; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "自动采购原材料 {} 单位", auto_order - ))); + ) }); } // ── 第 6 步:更新生产状态(进度+1,完成转成品库) ── @@ -539,10 +531,10 @@ impl GameManager { quantity: 1, }); } - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "产线{} {:?} 生产完成,转入成品库", wip.line_id, wip.product - ))); + ) }); } else { new_wip.push(wip); } @@ -556,7 +548,7 @@ impl GameManager { LineStatus::Building(remaining) => { if remaining <= 1 { line.status = LineStatus::Idle; - events.push(GameEvent::Message(format!("产线{} 建设完成", line.id))); + events.push(GameEvent::Message { data: format!("产线{} 建设完成", line.id) }); } else { line.status = LineStatus::Building(remaining - 1); } @@ -565,7 +557,7 @@ impl GameManager { if remaining <= 1 { line.product = Some(target); line.status = LineStatus::Idle; - events.push(GameEvent::Message(format!("产线{} 转产完成", line.id))); + events.push(GameEvent::Message { data: format!("产线{} 转产完成", line.id) }); } else { line.status = LineStatus::SwitchingTo(remaining - 1, target); } @@ -583,18 +575,18 @@ impl GameManager { continue; }; if instance.state.raw_material_inventory == 0 { - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "产线{} 原材料不足,无法开始生产", line.id - ))); + ) }); continue; } let rm_cost = product.raw_material_cost(); if instance.state.cash < rm_cost { - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "产线{} 现金不足,无法开始生产", line.id - ))); + ) }); continue; } instance.state.cash -= rm_cost; @@ -605,10 +597,10 @@ impl GameManager { progress: 0, }); line.status = LineStatus::Producing(product); - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "产线{} 开始生产 {:?}", line.id, product - ))); + ) }); } // ── 第 9 步:订单交付(有成品→交单→应收账款) ── @@ -641,10 +633,10 @@ impl GameManager { amount: total_amount, due_quarters: order.account_period, }); - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "交付订单 {},{:?} × {},金额 {}M,账期 {} 季", order.id, order.product, order.quantity, total_amount, order.account_period - ))); + ) }); } // ── 第 10 步:行政管理费 1M(检查破产) ── @@ -657,7 +649,7 @@ impl GameManager { } instance.state.cash -= 1; instance.annual_admin_expense += 1; - events.push(GameEvent::Message("支付行政管理费 1M".to_string())); + events.push(GameEvent::Message { data: "支付行政管理费 1M".to_string() }); // ── 破产检查:股东权益 < 0 ── if calculate_equity(&instance.state) < 0 { @@ -694,10 +686,10 @@ impl GameManager { )); } instance.state.cash -= maintenance; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "支付设备维护费 {}M({} 条产线)", maintenance, line_count - ))); + ) }); // ── 第 2 步:固定资产折旧 20% ── let mut depreciation = 0u32; @@ -706,10 +698,10 @@ impl GameManager { depreciation += dep; factory.value = factory.value.saturating_sub(dep); } - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "固定资产折旧 {}M", depreciation - ))); + ) }); // ── 第 3 步:长贷利息/还本 ── let mut lt_interest = 0u32; @@ -725,10 +717,10 @@ impl GameManager { // 5 年期限到期还本 if year >= slot.year + 5 { lt_principal += slot.amount; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "长贷到期归还本金 {}M({} 年贷款)", slot.amount, slot.year - ))); + ) }); // 不重新加入,贷款已关闭 } else { remaining_slots.push(slot); @@ -746,10 +738,10 @@ impl GameManager { } instance.state.cash -= total_lt; instance.annual_interest_expense += lt_interest; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "支付长贷利息 {}M,归还本金 {}M", lt_interest, lt_principal - ))); + ) }); } // ── 第 4 步:税款 ── @@ -785,14 +777,14 @@ impl GameManager { instance.state.cash -= income_tax; let total_tax = sales_tax + income_tax; - events.push(GameEvent::Message(format!( + events.push(GameEvent::Message { data: format!( "纳税:营业税 {}M(营收 {}M × 3%),所得税 {}M(利润 {}M × 20%),合计 {}M", sales_tax, revenue, income_tax, profit_before_tax.max(0), total_tax - ))); + ) }); // ── 第 5 步:更新 phase ── if year >= 8 { @@ -810,7 +802,7 @@ impl GameManager { depreciation, ); instance.annual_reports.push(report.clone()); - events.push(GameEvent::AnnualReport(Box::new(report))); + events.push(GameEvent::AnnualReport { data: Box::new(report) }); // ── 第 6 步:破产检查 ── if calculate_equity(&instance.state) < 0 { @@ -990,8 +982,6 @@ fn declare_game_over( ) -> Vec { state.game_over = true; state.game_over_reason = Some(reason.to_string()); - events.push(GameEvent::GameOver { - reason: reason.to_string(), - }); + events.push(GameEvent::GameOver { data: GameOverData { reason: reason.to_string() } }); std::mem::take(events) } diff --git a/rust/api/src/business_sandbox/state.rs b/rust/api/src/business_sandbox/state.rs index 98f537a..e738e49 100644 --- a/rust/api/src/business_sandbox/state.rs +++ b/rust/api/src/business_sandbox/state.rs @@ -304,39 +304,65 @@ pub struct BusinessGameState { #[serde(tag = "event")] pub enum GameEvent { /// 全量状态更新 - StateUpdate(Box), + StateUpdate { + data: Box, + }, /// 阶段变更 PhaseChange { - year: u32, - quarter: u32, - phase: String, + data: PhaseChangeData, }, /// 请求玩家决策 AskDecision { - year: u32, - quarter: u32, - decision_type: String, + data: AskDecisionData, }, /// 年度财务报告 - AnnualReport(Box), + AnnualReport { + data: Box, + }, /// 选单会 OrderMeeting { - market: String, - available_orders: Vec, + data: OrderMeetingData, }, /// 游戏结束 GameOver { - reason: String, + data: GameOverData, }, /// 通用消息 - Message(String), + Message { + data: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhaseChangeData { + pub year: u32, + pub quarter: u32, + pub phase: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskDecisionData { + pub year: u32, + pub quarter: u32, + pub decision_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderMeetingData { + pub market: String, + pub available_orders: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameOverData { + pub reason: String, } // ── PlayerAction ── /// 客户端操作(客户端 -> 服务端) #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "action")] +#[serde(tag = "action", rename_all = "snake_case")] pub enum PlayerAction { /// 开始游戏 StartGame, diff --git a/rust/api/src/business_sandbox/ws_handler.rs b/rust/api/src/business_sandbox/ws_handler.rs index 0220fa7..c215b7c 100644 --- a/rust/api/src/business_sandbox/ws_handler.rs +++ b/rust/api/src/business_sandbox/ws_handler.rs @@ -36,13 +36,15 @@ async fn handle_game_ws( // 发送初始状态 + 要求竞标决策 if let Ok(gs) = state.business_sandbox.manager.get_state(&game_id).await { - let msg = serde_json::to_string(&GameEvent::StateUpdate(Box::new(gs))).unwrap(); + let msg = serde_json::to_string(&GameEvent::StateUpdate { data: Box::new(gs) }).unwrap(); let _ = sender.send(Message::Text(msg)).await; } let ask = serde_json::to_string(&GameEvent::AskDecision { - year: 1, - quarter: 0, - decision_type: "bidding".into(), + data: AskDecisionData { + year: 1, + quarter: 0, + decision_type: "bidding".into(), + }, }) .unwrap(); let _ = sender.send(Message::Text(ask)).await; @@ -77,7 +79,7 @@ async fn handle_game_ws( } Err(e) => { let err_msg = serde_json::to_string( - &GameEvent::Message(format!("错误: {}", e)), + &GameEvent::Message { data: format!("错误: {}", e) }, ) .unwrap(); let _ = state From f263813c112095e5601e272d348e0f6b147508a6 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 03:16:03 +0800 Subject: [PATCH 20/64] =?UTF-8?q?feat(godot):=20=E5=BA=94=E7=94=A8opendesi?= =?UTF-8?q?gn=E8=AE=BE=E8=AE=A1=E4=B8=BB=E9=A2=98(=E5=93=81=E7=89=8C?= =?UTF-8?q?=E8=89=B2#c7000b/=E9=AB=98=E5=AF=B9=E6=AF=94=E6=96=87=E5=AD=97/?= =?UTF-8?q?=E7=99=BD=E5=8D=A1=E7=89=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/inventory_dashboard.gd | 36 +++--- godot/scripts/inventory_dashboard.gd.uid | 1 + godot/scripts/market_panel.gd | 21 ++-- godot/scripts/market_panel.gd.uid | 1 + godot/scripts/popups/annual_report_popup.gd | 44 ++++---- .../scripts/popups/annual_report_popup.gd.uid | 1 + godot/scripts/popups/factory_dialog.gd | 52 ++++----- godot/scripts/popups/factory_dialog.gd.uid | 1 + godot/scripts/popups/order_meeting_popup.gd | 76 ++++++------- .../scripts/popups/order_meeting_popup.gd.uid | 1 + godot/scripts/production_view.gd | 40 +++---- godot/scripts/production_view.gd.uid | 1 + godot/scripts/theme.gd | 104 ++++++++++++++++++ 13 files changed, 247 insertions(+), 132 deletions(-) create mode 100644 godot/scripts/inventory_dashboard.gd.uid create mode 100644 godot/scripts/market_panel.gd.uid create mode 100644 godot/scripts/popups/annual_report_popup.gd.uid create mode 100644 godot/scripts/popups/factory_dialog.gd.uid create mode 100644 godot/scripts/popups/order_meeting_popup.gd.uid create mode 100644 godot/scripts/production_view.gd.uid create mode 100644 godot/scripts/theme.gd diff --git a/godot/scripts/inventory_dashboard.gd b/godot/scripts/inventory_dashboard.gd index 3894b42..b61f4f7 100644 --- a/godot/scripts/inventory_dashboard.gd +++ b/godot/scripts/inventory_dashboard.gd @@ -5,24 +5,26 @@ extends Control class_name InventoryDashboard +const T = preload("res://scripts/theme.gd") + # ── Constants ── -# 颜色 -const COLOR_HEADER: Color = Color(0.90, 0.90, 0.90) -const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) -const COLOR_VALUE: Color = Color(0.90, 0.90, 0.30) -const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) -const COLOR_AR: Color = Color(0.90, 0.70, 0.20) -const COLOR_PROGRESS_BG: Color = Color(0.15, 0.15, 0.15) -const COLOR_PROGRESS_FILL: Color = Color(0.15, 0.75, 0.25) -const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) - -# 产品颜色(与 production_view.gd 保持一致) -const PRODUCT_COLORS: Dictionary = { - "奔马": Color(0.90, 0.25, 0.15), - "猛虎": Color(0.20, 0.55, 0.90), - "雄鹰": Color(0.85, 0.70, 0.10), - "飞龙": Color(0.50, 0.20, 0.80), +# 颜色(引用 Theme 常量) +var COLOR_HEADER: Color = T.colors.text_strong +var COLOR_LABEL: Color = T.colors.text_muted +var COLOR_VALUE: Color = T.colors.text_strong +var COLOR_SECTION: Color = T.colors.brand +var COLOR_AR: Color = T.colors.warning +var COLOR_PROGRESS_BG: Color = T.colors.border +var COLOR_PROGRESS_FILL: Color = T.colors.success +var COLOR_SEPARATOR: Color = T.colors.border + +# 产品颜色(引用 Theme 常量) +var PRODUCT_COLORS: Dictionary = { + "奔马": T.colors.benma, + "猛虎": T.colors.menghu, + "雄鹰": T.colors.feiying, + "飞龙": T.colors.tianlong, } # 布局常量 @@ -135,7 +137,7 @@ func _build_finished_goods(state: Dictionary) -> void: for product_name: String in items: var qty: int = items[product_name] - var color: Color = PRODUCT_COLORS.get(product_name, Color(0.50, 0.50, 0.50)) + var color: Color = PRODUCT_COLORS.get(product_name, T.colors.text_muted) var plabel := Label.new() plabel.text = "%s x%d" % [product_name, qty] diff --git a/godot/scripts/inventory_dashboard.gd.uid b/godot/scripts/inventory_dashboard.gd.uid new file mode 100644 index 0000000..bdbae68 --- /dev/null +++ b/godot/scripts/inventory_dashboard.gd.uid @@ -0,0 +1 @@ +uid://0l8th5sy8l8u diff --git a/godot/scripts/market_panel.gd b/godot/scripts/market_panel.gd index 2d6ad97..f23fcc3 100644 --- a/godot/scripts/market_panel.gd +++ b/godot/scripts/market_panel.gd @@ -6,6 +6,8 @@ extends Control class_name MarketPanel +const T = preload("res://scripts/theme.gd") + # ── Signals ── ## 用户点击"提交竞标"时触发,参数为竞标策略数组。 @@ -15,12 +17,12 @@ signal submit_bidding(strategies: Array) # ── Constants ── -const COLOR_HEADER: Color = Color(0.90, 0.90, 0.90) -const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) -const COLOR_VALUE: Color = Color(0.90, 0.90, 0.30) -const COLOR_DEVELOPED_TAG: Color = Color(0.15, 0.75, 0.25) -const COLOR_UNDEVELOPED_TAG: Color = Color(0.70, 0.30, 0.30) -const COLOR_BORDER: Color = Color(0.30, 0.30, 0.30) +var COLOR_HEADER: Color = T.colors.text_strong +var COLOR_LABEL: Color = T.colors.text_muted +var COLOR_VALUE: Color = T.colors.text_strong +var COLOR_DEVELOPED_TAG: Color = T.colors.success +var COLOR_UNDEVELOPED_TAG: Color = T.colors.danger +var COLOR_BORDER: Color = T.colors.border const ROW_HEIGHT: float = 24.0 const INPUT_WIDTH: float = 60.0 @@ -89,6 +91,8 @@ func _build_ui() -> void: _marketing_input.text = "2" _marketing_input.custom_minimum_size = Vector2(INPUT_WIDTH, 0) _marketing_input.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _marketing_input.add_theme_stylebox_override("normal", T._make_stylebox(T.colors.card, 4)) + _marketing_input.add_theme_stylebox_override("focus", T._make_stylebox(T.colors.brand_light, 4)) input_row.add_child(_marketing_input) var unit_label := Label.new() @@ -103,6 +107,9 @@ func _build_ui() -> void: _submit_btn = Button.new() _submit_btn.text = "提交竞标" + _submit_btn.add_theme_color_override("font_color", T.colors.white) + _submit_btn.add_theme_stylebox_override("normal", T._make_stylebox(T.colors.brand, 6)) + _submit_btn.add_theme_stylebox_override("hover", T._make_stylebox(T.colors.brand_hover, 6)) _submit_btn.pressed.connect(_on_submit_bidding) input_row.add_child(_submit_btn) @@ -118,7 +125,7 @@ func _build_ui() -> void: _show_default_markets() -static func _make_separator() -> HSeparator: +func _make_separator() -> HSeparator: var sep := HSeparator.new() sep.modulate = COLOR_BORDER return sep diff --git a/godot/scripts/market_panel.gd.uid b/godot/scripts/market_panel.gd.uid new file mode 100644 index 0000000..22a15e2 --- /dev/null +++ b/godot/scripts/market_panel.gd.uid @@ -0,0 +1 @@ +uid://coijb82n2d3ue diff --git a/godot/scripts/popups/annual_report_popup.gd b/godot/scripts/popups/annual_report_popup.gd index f36e8ca..9d45356 100644 --- a/godot/scripts/popups/annual_report_popup.gd +++ b/godot/scripts/popups/annual_report_popup.gd @@ -9,24 +9,26 @@ extends Panel class_name AnnualReportPopup +const T = preload("res://scripts/theme.gd") + # ── Colors ── -const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) -const PANEL_COLOR: Color = Color(0.12, 0.12, 0.15) -const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) -const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) -const COLOR_LABEL: Color = Color(0.70, 0.70, 0.70) -const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) -const COLOR_TOTAL: Color = Color(0.20, 0.90, 0.40) -const COLOR_NEGATIVE: Color = Color(0.90, 0.30, 0.25) -const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) -const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) -const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) -const COLOR_BG_ALT: Color = Color(0.14, 0.14, 0.18, 0.5) -const COLOR_TAB_BG: Color = Color(0.10, 0.10, 0.12) -const COLOR_TAB_FG: Color = Color(0.18, 0.18, 0.22) -const COLOR_INDICATOR_LABEL: Color = Color(0.90, 0.90, 0.90) -const COLOR_INDICATOR_VAL: Color = Color(0.40, 0.90, 1.00) +var OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +var PANEL_COLOR: Color = T.colors.card +var COLOR_TITLE: Color = T.colors.text_strong +var COLOR_SECTION: Color = T.colors.brand +var COLOR_LABEL: Color = T.colors.text_muted +var COLOR_VALUE: Color = T.colors.text_strong +var COLOR_TOTAL: Color = T.colors.success +var COLOR_NEGATIVE: Color = T.colors.danger +var COLOR_SEPARATOR: Color = T.colors.border +var COLOR_CLOSE_BG: Color = T.colors.danger +var COLOR_CLOSE_HOVER: Color = T.colors.danger_bg +var COLOR_BG_ALT: Color = T.colors.border_light +var COLOR_TAB_BG: Color = T.colors.border +var COLOR_TAB_FG: Color = T.colors.card +var COLOR_INDICATOR_LABEL: Color = T.colors.text +var COLOR_INDICATOR_VAL: Color = T.colors.success # ── Layout Constants ── @@ -202,8 +204,8 @@ func _build_ui() -> void: _tab_container.name = "TabContainer" _tab_container.position = Vector2(10, 56) _tab_container.size = Vector2(PANEL_WIDTH - 20, PANEL_HEIGHT - 72) - _tab_container.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85)) - _tab_container.add_theme_color_override("font_selected_color", Color(1.0, 1.0, 1.0)) + _tab_container.add_theme_color_override("font_color", T.colors.text) + _tab_container.add_theme_color_override("font_selected_color", T.colors.text_strong) _tab_container.add_theme_stylebox_override("panel", _make_stylebox(COLOR_TAB_BG)) _tab_container.add_theme_stylebox_override("tab_fg", _make_stylebox(COLOR_TAB_FG)) _tab_container.tab_alignment = TabBar.ALIGNMENT_LEFT @@ -347,7 +349,7 @@ func _populate_indicators_tab() -> void: var note := Label.new() note.text = " 提示:资产负债率 < 50% 为健康,流动比率 > 1.5 为佳。" note.add_theme_font_size_override("font_size", 11) - note.add_theme_color_override("font_color", Color(0.55, 0.55, 0.60)) + note.add_theme_color_override("font_color", T.colors.text_muted) note.custom_minimum_size = Vector2(0, ROW_HEIGHT) container.add_child(note) @@ -381,7 +383,7 @@ func _make_data_row(label_text: String, value: Variant, is_total: bool, is_major if is_major: label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL + 1) - label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + label.add_theme_color_override("font_color", T.colors.text_strong) hb.add_child(label) @@ -458,7 +460,7 @@ func _make_indicator_row(label_text: String, value_text: String, raw_val: float, if raw_val < 50.0: val_label.add_theme_color_override("font_color", COLOR_TOTAL) elif raw_val < 70.0: - val_label.add_theme_color_override("font_color", Color(0.95, 0.85, 0.30)) + val_label.add_theme_color_override("font_color", T.colors.warning) else: val_label.add_theme_color_override("font_color", COLOR_NEGATIVE) else: diff --git a/godot/scripts/popups/annual_report_popup.gd.uid b/godot/scripts/popups/annual_report_popup.gd.uid new file mode 100644 index 0000000..ad909eb --- /dev/null +++ b/godot/scripts/popups/annual_report_popup.gd.uid @@ -0,0 +1 @@ +uid://dx0ilfy0v3twp diff --git a/godot/scripts/popups/factory_dialog.gd b/godot/scripts/popups/factory_dialog.gd index 0522a68..63731c8 100644 --- a/godot/scripts/popups/factory_dialog.gd +++ b/godot/scripts/popups/factory_dialog.gd @@ -11,28 +11,30 @@ extends Panel class_name FactoryDialog +const T = preload("res://scripts/theme.gd") + # ── Colors ── -const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) -const PANEL_BG: Color = Color(0.12, 0.12, 0.15) -const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) -const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) -const COLOR_LABEL: Color = Color(0.70, 0.70, 0.75) -const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) -const COLOR_ACCENT: Color = Color(0.30, 0.90, 0.40) -const COLOR_WARN: Color = Color(0.95, 0.70, 0.20) -const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) -const COLOR_BTN_BG: Color = Color(0.18, 0.55, 0.28) -const COLOR_BTN_HOVER: Color = Color(0.22, 0.65, 0.32) -const COLOR_BTN_DISABLED: Color = Color(0.15, 0.15, 0.18) -const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) -const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) -const COLOR_LINE_BTN: Color = Color(0.18, 0.30, 0.45) -const COLOR_LINE_BTN_HOVER: Color = Color(0.22, 0.35, 0.55) -const COLOR_LINE_BTN_SELECTED: Color = Color(0.25, 0.50, 0.65) -const COLOR_LOAN_BTN: Color = Color(0.30, 0.20, 0.15) -const COLOR_LOAN_BTN_HOVER: Color = Color(0.40, 0.25, 0.20) -const COLOR_LOAN_BTN_SELECTED: Color = Color(0.50, 0.30, 0.20) +var OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +var PANEL_BG: Color = T.colors.card +var COLOR_TITLE: Color = T.colors.text_strong +var COLOR_SECTION: Color = T.colors.brand +var COLOR_LABEL: Color = T.colors.text_muted +var COLOR_VALUE: Color = T.colors.text_strong +var COLOR_ACCENT: Color = T.colors.success +var COLOR_WARN: Color = T.colors.warning +var COLOR_SEPARATOR: Color = T.colors.border +var COLOR_BTN_BG: Color = T.colors.success +var COLOR_BTN_HOVER: Color = T.colors.success_bg +var COLOR_BTN_DISABLED: Color = T.colors.border +var COLOR_CLOSE_BG: Color = T.colors.danger +var COLOR_CLOSE_HOVER: Color = T.colors.danger_bg +var COLOR_LINE_BTN: Color = T.colors.building +var COLOR_LINE_BTN_HOVER: Color = T.colors.brand_light +var COLOR_LINE_BTN_SELECTED: Color = T.colors.brand +var COLOR_LOAN_BTN: Color = T.colors.warning +var COLOR_LOAN_BTN_HOVER: Color = T.colors.warning_bg +var COLOR_LOAN_BTN_SELECTED: Color = T.colors.warning # ── Layout Constants ── @@ -195,7 +197,7 @@ func _build_ui() -> void: btn.position = Vector2(line_btn_x, y_pos) btn.size = Vector2(124, 44) btn.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) - btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + btn.add_theme_color_override("font_color", T.colors.white) btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LINE_BTN)) btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LINE_BTN_HOVER)) btn.connect("pressed", _on_line_type_selected.bind(lt)) @@ -218,7 +220,7 @@ func _build_ui() -> void: confirm_line_btn.text = "✅ 确认订购产线" confirm_line_btn.position = Vector2(120, y_pos) confirm_line_btn.size = Vector2(180, 30) - confirm_line_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + confirm_line_btn.add_theme_color_override("font_color", T.colors.white) confirm_line_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) confirm_line_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) confirm_line_btn.connect("pressed", _on_confirm_line_pressed) @@ -241,7 +243,7 @@ func _build_ui() -> void: btn.position = Vector2(loan_btn_x, y_pos) btn.size = Vector2(64, 30) btn.add_theme_font_size_override("font_size", FONT_SIZE_BODY) - btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + btn.add_theme_color_override("font_color", T.colors.white) btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LOAN_BTN)) btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LOAN_BTN_HOVER)) btn.connect("pressed", _on_loan_type_selected.bind(lt)) @@ -289,7 +291,7 @@ func _build_ui() -> void: confirm_loan_btn.text = "✅ 确认贷款" confirm_loan_btn.position = Vector2(140, y_pos) confirm_loan_btn.size = Vector2(140, 30) - confirm_loan_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + confirm_loan_btn.add_theme_color_override("font_color", T.colors.white) confirm_loan_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) confirm_loan_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) confirm_loan_btn.connect("pressed", _on_confirm_loan_pressed) @@ -323,7 +325,7 @@ func _build_ui() -> void: _discount_confirm_btn.text = "✅ 确认贴现" _discount_confirm_btn.position = Vector2(140, y_pos) _discount_confirm_btn.size = Vector2(140, 30) - _discount_confirm_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + _discount_confirm_btn.add_theme_color_override("font_color", T.colors.white) _discount_confirm_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) _discount_confirm_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) _discount_confirm_btn.connect("pressed", _on_confirm_discount_pressed) diff --git a/godot/scripts/popups/factory_dialog.gd.uid b/godot/scripts/popups/factory_dialog.gd.uid new file mode 100644 index 0000000..7b10e3a --- /dev/null +++ b/godot/scripts/popups/factory_dialog.gd.uid @@ -0,0 +1 @@ +uid://teg1avjbo1gs diff --git a/godot/scripts/popups/order_meeting_popup.gd b/godot/scripts/popups/order_meeting_popup.gd index 9d6cf32..5cb2b6e 100644 --- a/godot/scripts/popups/order_meeting_popup.gd +++ b/godot/scripts/popups/order_meeting_popup.gd @@ -12,30 +12,32 @@ extends Panel class_name OrderMeetingPopup +const T = preload("res://scripts/theme.gd") + # ── Colors ── -const OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) -const PANEL_BG: Color = Color(0.12, 0.12, 0.15) -const SECTION_BG: Color = Color(0.10, 0.10, 0.14) -const CARD_BG: Color = Color(0.16, 0.16, 0.20) -const CARD_BG_SELECTED: Color = Color(0.20, 0.30, 0.24) -const CARD_BORDER: Color = Color(0.30, 0.30, 0.35) -const CARD_BORDER_SELECTED: Color = Color(0.30, 1.00, 0.50) -const CARD_BORDER_HOVER: Color = Color(0.45, 0.45, 0.50) -const COLOR_TITLE: Color = Color(1.0, 1.0, 1.0) -const COLOR_LABEL: Color = Color(0.70, 0.70, 0.75) -const COLOR_VALUE: Color = Color(0.95, 0.85, 0.30) -const COLOR_SECTION: Color = Color(0.55, 0.80, 0.95) -const COLOR_URGENT: Color = Color(1.00, 0.25, 0.25) -const COLOR_BAR: Color = Color(0.30, 0.70, 0.95) -const COLOR_BAR_LABEL: Color = Color(0.85, 0.85, 0.90) -const COLOR_SEPARATOR: Color = Color(0.30, 0.30, 0.30) -const COLOR_BTN_BG: Color = Color(0.18, 0.55, 0.28) -const COLOR_BTN_HOVER: Color = Color(0.22, 0.65, 0.32) -const COLOR_BTN_DISABLED: Color = Color(0.15, 0.15, 0.18) -const COLOR_CLOSE_BG: Color = Color(0.35, 0.20, 0.20) -const COLOR_CLOSE_HOVER: Color = Color(0.50, 0.25, 0.25) -const COLOR_ACCENT: Color = Color(0.55, 0.80, 0.95) +var OVERLAY_COLOR: Color = Color(0.0, 0.0, 0.0, 0.65) +var PANEL_BG: Color = T.colors.card +var SECTION_BG: Color = T.colors.bg +var CARD_BG: Color = T.colors.bg +var CARD_BG_SELECTED: Color = T.colors.success_bg +var CARD_BORDER: Color = T.colors.border +var CARD_BORDER_SELECTED: Color = T.colors.success +var CARD_BORDER_HOVER: Color = T.colors.text_muted +var COLOR_TITLE: Color = T.colors.text_strong +var COLOR_LABEL: Color = T.colors.text_muted +var COLOR_VALUE: Color = T.colors.text_strong +var COLOR_SECTION: Color = T.colors.brand +var COLOR_URGENT: Color = T.colors.danger +var COLOR_BAR: Color = T.colors.brand +var COLOR_BAR_LABEL: Color = T.colors.text +var COLOR_SEPARATOR: Color = T.colors.border +var COLOR_BTN_BG: Color = T.colors.success +var COLOR_BTN_HOVER: Color = T.colors.success_bg +var COLOR_BTN_DISABLED: Color = T.colors.border +var COLOR_CLOSE_BG: Color = T.colors.danger +var COLOR_CLOSE_HOVER: Color = T.colors.danger_bg +var COLOR_ACCENT: Color = T.colors.brand # ── Layout Constants ── @@ -54,11 +56,11 @@ const FONT_SIZE_CARD_DETAIL: int = 11 const MAX_VISIBLE_CARDS: int = 6 # max before scroll # Product visual metadata -const PRODUCT_MAP: Dictionary = { - "ben_ma": {"emoji": "🐴", "name": "奔马", "color": Color(0.76, 0.42, 0.22)}, - "meng_hu": {"emoji": "🐯", "name": "猛虎", "color": Color(0.90, 0.60, 0.20)}, - "fei_ying": {"emoji": "🦅", "name": "飞鹰", "color": Color(0.25, 0.60, 0.90)}, - "xiang_long": {"emoji": "🐉", "name": "翔龙", "color": Color(0.80, 0.20, 0.20)}, +var PRODUCT_MAP: Dictionary = { + "ben_ma": {"emoji": "🐴", "name": "奔马", "color": T.colors.benma}, + "meng_hu": {"emoji": "🐯", "name": "猛虎", "color": T.colors.menghu}, + "fei_ying": {"emoji": "🦅", "name": "飞鹰", "color": T.colors.feiying}, + "xiang_long": {"emoji": "🐉", "name": "翔龙", "color": T.colors.tianlong}, } # ── Nodes ── @@ -247,7 +249,7 @@ func _build_ui() -> void: POPUP_HEIGHT - 44 ) _submit_btn.size = Vector2(220, 34) - _submit_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + _submit_btn.add_theme_color_override("font_color", T.colors.white) _submit_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) _submit_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) _submit_btn.add_theme_stylebox_override("disabled", _make_stylebox(COLOR_BTN_DISABLED)) @@ -335,7 +337,7 @@ func _make_order_card(order: Dictionary) -> Panel: var urgent: bool = order.get("urgent", false) # Get product visual info - var product_info: Dictionary = PRODUCT_MAP.get(product, {"emoji": "📦", "name": product, "color": Color(0.50, 0.50, 0.55)}) + var product_info: Dictionary = PRODUCT_MAP.get(product, {"emoji": "📦", "name": product, "color": T.colors.text_muted}) # Card panel var card := Panel.new() @@ -358,7 +360,7 @@ func _make_order_card(order: Dictionary) -> Panel: # Product color swatch var swatch := ColorRect.new() - swatch.color = product_info.get("color", Color(0.5, 0.5, 0.5)) + swatch.color = product_info.get("color", T.colors.text_muted) swatch.custom_minimum_size = Vector2(8, CARD_HEIGHT - 8) swatch.size_flags_vertical = Control.SIZE_EXPAND_FILL hb.add_child(swatch) @@ -429,7 +431,7 @@ func _make_order_card(order: Dictionary) -> Panel: status_label.name = "StatusLabel" status_label.text = " [ 点击选中 ]" status_label.add_theme_font_size_override("font_size", FONT_SIZE_CARD_DETAIL - 1) - status_label.add_theme_color_override("font_color", Color(0.45, 0.45, 0.50)) + status_label.add_theme_color_override("font_color", T.colors.text_muted) row3.add_child(status_label) content.add_child(row3) @@ -497,7 +499,7 @@ func _update_selection_status_label(order_id: String) -> void: status_label.add_theme_color_override("font_color", CARD_BORDER_SELECTED) else: status_label.text = " [ 点击选中 ]" - status_label.add_theme_color_override("font_color", Color(0.45, 0.45, 0.50)) + status_label.add_theme_color_override("font_color", T.colors.text_muted) func _update_submit_button() -> void: @@ -519,12 +521,12 @@ func _generate_sample_predictions() -> Array: static func _get_bar_color(index: int, total: int) -> Color: - # Gradient from blue to green + # Gradient from brand to success var t: float = float(index) / float(max(total - 1, 1)) return Color( - 0.30 + t * 0.20, - 0.60 + t * 0.30, - 0.95 - t * 0.30 + T.colors.brand.r + (T.colors.success.r - T.colors.brand.r) * t, + T.colors.brand.g + (T.colors.success.g - T.colors.brand.g) * t, + T.colors.brand.b + (T.colors.success.b - T.colors.brand.b) * t, ) @@ -538,7 +540,7 @@ static func _make_stylebox(bg_color: Color) -> StyleBoxFlat: return sb -static func _make_section_header(text: String) -> Label: +func _make_section_header(text: String) -> Label: var label := Label.new() label.text = " %s" % text label.custom_minimum_size = Vector2(0, 24) diff --git a/godot/scripts/popups/order_meeting_popup.gd.uid b/godot/scripts/popups/order_meeting_popup.gd.uid new file mode 100644 index 0000000..14391b2 --- /dev/null +++ b/godot/scripts/popups/order_meeting_popup.gd.uid @@ -0,0 +1 @@ +uid://jquwoturmlrm diff --git a/godot/scripts/production_view.gd b/godot/scripts/production_view.gd index 43da7c5..1b8d81c 100644 --- a/godot/scripts/production_view.gd +++ b/godot/scripts/production_view.gd @@ -6,21 +6,9 @@ extends Control class_name ProductionView -# ── Constants ── +const T = preload("res://scripts/theme.gd") -# 状态颜色 -const COLOR_IDLE: Color = Color(0.55, 0.55, 0.55) # 灰色 -const COLOR_PRODUCING: Color = Color(0.15, 0.75, 0.25) # 绿色 -const COLOR_BUILDING: Color = Color(0.15, 0.40, 0.80) # 蓝色 -const COLOR_SWITCHING: Color = Color(1.00, 0.60, 0.00) # 橙色 - -# 产品颜色定义(4 种产品不同色块) -const PRODUCT_COLORS: Dictionary = { - "奔马": Color(0.90, 0.25, 0.15), # 红 - "猛虎": Color(0.20, 0.55, 0.90), # 蓝 - "雄鹰": Color(0.85, 0.70, 0.10), # 金 - "飞龙": Color(0.50, 0.20, 0.80), # 紫 -} +# ── Constants ── # 产线类型显示文本 const TYPE_LABELS: Dictionary = { @@ -68,7 +56,7 @@ func update(factories: Array) -> void: empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER empty_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER empty_label.size_flags_vertical = Control.SIZE_EXPAND_FILL - empty_label.add_theme_color_override("font_color", COLOR_IDLE) + empty_label.add_theme_color_override("font_color", T.colors.text_muted) _container.add_child(empty_label) return @@ -94,13 +82,13 @@ func _add_factory_section(factory: Dictionary) -> void: var name_label := Label.new() name_label.text = factory.get("name", "未知工厂") name_label.add_theme_font_size_override("font_size", 14) - name_label.add_theme_color_override("font_color", Color(0.90, 0.90, 0.90)) + name_label.add_theme_color_override("font_color", T.colors.text) header.add_child(name_label) var cap_label := Label.new() var capacity: int = factory.get("capacity", 0) cap_label.text = " (容量:%d)" % capacity - cap_label.add_theme_color_override("font_color", COLOR_IDLE) + cap_label.add_theme_color_override("font_color", T.colors.text_muted) header.add_child(cap_label) # 弹性空间 @@ -164,7 +152,7 @@ func _make_progress_bar(line: Dictionary) -> Control: # 背景条 var bg := ColorRect.new() - bg.color = Color(0.15, 0.15, 0.15) + bg.color = T.colors.border bg.size = Vector2(BAR_WIDTH, BAR_HEIGHT) bg.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) container.add_child(bg) @@ -184,7 +172,7 @@ func _make_progress_bar(line: Dictionary) -> Control: # 百分比文字 var pct_label := Label.new() pct_label.text = "%d%%" % (progress_val * 100.0) - pct_label.add_theme_color_override("font_color", Color.WHITE) + pct_label.add_theme_color_override("font_color", T.colors.white) pct_label.add_theme_font_size_override("font_size", 10) pct_label.position = Vector2(4, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) pct_label.size = Vector2(BAR_WIDTH - 8, BAR_HEIGHT) @@ -206,7 +194,9 @@ func _make_product_icon(product_name: String) -> Control: rect.size = Vector2(ICON_SIZE, ICON_SIZE) rect.position = Vector2(4, (ROW_HEIGHT - ICON_SIZE) * 0.5) - var color: Color = PRODUCT_COLORS.get(product_name, Color(0.50, 0.50, 0.50)) + var color: Color = T.product_color(product_name) + if color == T.colors.idle: + color = T.colors.text_muted rect.color = color container.add_child(rect) @@ -243,12 +233,12 @@ func _get_status_text(line: Dictionary) -> String: func _get_status_color(status: String) -> Color: match status: "idle": - return COLOR_IDLE + return T.colors.idle "producing": - return COLOR_PRODUCING + return T.colors.producing "building": - return COLOR_BUILDING + return T.colors.building "switching_to": - return COLOR_SWITCHING + return T.colors.switching _: - return COLOR_IDLE + return T.colors.idle diff --git a/godot/scripts/production_view.gd.uid b/godot/scripts/production_view.gd.uid new file mode 100644 index 0000000..22ded74 --- /dev/null +++ b/godot/scripts/production_view.gd.uid @@ -0,0 +1 @@ +uid://83nkbifwxdqc diff --git a/godot/scripts/theme.gd b/godot/scripts/theme.gd new file mode 100644 index 0000000..78604ca --- /dev/null +++ b/godot/scripts/theme.gd @@ -0,0 +1,104 @@ +extends Node +## OpenDesign 设计主题常量——供所有 UI 组件引用。 +## +## 使用方式:T.colors.brand / T.fonts.head 等(通过 preload 引用) + +class_name AppTheme + +# ── Colors (OpenDesign a.light tokens) ── + +const colors := { + # Brand + "brand": Color("#c7000b"), + "brand_light": Color("#fecaca"), + "brand_hover": Color("#a80009"), + + # Greys + "white": Color("#ffffff"), + "bg": Color("#edf0f5"), + "card": Color("#ffffff"), + "border": Color("#d5dae0"), + "border_light": Color("#eef0f4"), + + # Text + "text": Color("#1a1a1a"), + "text_strong": Color("#000000"), + "text_medium": Color("#2d3748"), + "text_muted": Color("#64748b"), + + # Semantic + "success": Color("#16a34a"), + "success_bg": Color("#dcfce7"), + "success_text": Color("#14532d"), + "warning": Color("#f59e0b"), + "warning_bg": Color("#fef3c7"), + "warning_text": Color("#854d0e"), + "danger": Color("#dc2626"), + "danger_bg": Color("#fecaca"), + "danger_text": Color("#7f1d1d"), + + # Product + "benma": Color("#c7000b"), + "menghu": Color("#1e6fe0"), + "feiying": Color("#c28600"), + "tianlong": Color("#7c3aed"), + + # Status + "producing": Color("#c7000b"), + "building": Color("#1e6fe0"), + "switching": Color("#c28600"), + "idle": Color("#c4cad4"), +} + +# ── Fonts ── + +const fonts := { + "head": 14, + "body": 13, + "small": 12, + "tiny": 10, + "h1": 18, + "h2": 16, +} + +# ── Spacing ── + +const space := { + "xs": 4, + "sm": 8, + "md": 12, + "lg": 16, + "xl": 24, +} + +# ── Helpers ── + +## 给 Control 节点设置基础样式 +static func style_card(node: Control) -> void: + node.add_theme_color_override("background_color", colors.card) + node.add_theme_stylebox_override("border", _make_stylebox(colors.border, 8)) + +## 创建圆角边框 +static func _make_stylebox(color: Color, radius: int) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = color + sb.set_corner_radius_all(radius) + return sb + +## 获取产品对应颜色 +static func product_color(product_name: String) -> Color: + match product_name.to_lower(): + "ben_ma", "奔马": return colors.benma + "meng_hu", "猛虎": return colors.menghu + "fei_ying", "飞鹰": return colors.feiying + "tian_long", "天龙": return colors.tianlong + return colors.idle + +## 产品对应图标 +static func product_icon(product_name: String) -> String: + match product_name.to_lower(): + "ben_ma", "奔马": return "🐴" + "meng_hu", "猛虎": return "🐯" + "fei_ying", "飞鹰": return "🦅" + "tian_long", "天龙": return "🐉" + return "⬜" From e132ac8ec3e405cf9786c8e429dbeabd2c02b453 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 03:20:09 +0800 Subject: [PATCH 21/64] =?UTF-8?q?feat(godot):=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=8C=B9=E9=85=8Dopendesign=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF(=E5=B7=A6-=E4=B8=AD-=E5=8F=B3=E4=B8=89?= =?UTF-8?q?=E6=A0=8F+=E7=99=BD=E5=8D=A1=E7=89=87+=E7=BA=A2=E8=89=B2?= =?UTF-8?q?=E9=A1=B6=E5=BA=95=E6=A0=8F=E7=BA=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 783 +++++++++++++++++-------------------- godot/scripts/theme.gd | 59 ++- godot/scripts/theme.gd.uid | 1 + 3 files changed, 396 insertions(+), 447 deletions(-) create mode 100644 godot/scripts/theme.gd.uid diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index e7d545d..eab3d3f 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -1,441 +1,392 @@ extends Control -## 创业沙盘主游戏场景——管理 UI 布局、决策弹窗和服务端通信。 -## -## 生命周期: -## 1. _ready() 构建 UI → 连接 WS → 发送 start_game -## 2. 服务端推送 state_update → _update_ui() 刷新 -## 3. 服务端推送 ask_decision → 弹出决策面板 -## 4. 用户决策 → send_action() → 下一轮 - class_name MainGame -# ── Preloads ── +const T = preload("res://scripts/theme.gd") +const AnnualReportPopupScript = preload("res://scripts/popups/annual_report_popup.gd") +const OrderMeetingPopupScript = preload("res://scripts/popups/order_meeting_popup.gd") +const FactoryDialogScript = preload("res://scripts/popups/factory_dialog.gd") -const AnnualReportPopupScript := preload("res://scripts/popups/annual_report_popup.gd") -const OrderMeetingPopupScript := preload("res://scripts/popups/order_meeting_popup.gd") -const MarketPanelScript := preload("res://scripts/market_panel.gd") -const FactoryDialogScript := preload("res://scripts/popups/factory_dialog.gd") +@onready var _ws: Node = $/root/WebSocketManager +# ── Core state ── +var _state: Dictionary = {} +var _decision_type: String = "" -# ── Constants ── +# ── Top bar widgets ── +var _year_lbl: Label +var _quarter_lbl: Label +var _cash_lbl: Label +var _asset_lbl: Label +var _rank_lbl: Label +var _phase_lbl: Label +var _phase_lbl2: Label + +# ── Center widgets ── +var _prod_view: ProductionView +var _inv_dashboard: InventoryDashboard +var _market_container: VBoxContainer +var _rd_container: VBoxContainer +var _loan_container: VBoxContainer + +# ── Action button ── +var _action_btn: Button -const INITIAL_WINDOW_SIZE: Vector2 = Vector2(800, 600) -const PANEL_MARGIN: float = 10.0 -const LOG_HEIGHT: float = 380.0 -const BOTTOM_HEIGHT: float = 40.0 +# ── Popups (stored for later show/hide) ── +var _bid_popup: Panel +var _bid_input: LineEdit +var _bid_confirm: Button +var _annual_popup: Variant +var _order_popup: Variant +var _factory_dialog: Variant -# ── @onready Vars ── -@onready var _ws_manager: Node = $/root/WebSocketManager -@onready var _production_area: VBoxContainer = $ProductionArea +# ═══════════════ _ready ═══════════════ -# ── Private Vars ── +func _ready() -> void: + custom_minimum_size = Vector2(960, 640) + _ws.message_received.connect(_on_msg) + _ws.connected.connect(func(): _ws.send_action({"action": "start_game"})) + _build_layout() + _ws.connect_to_server("game_001") -var _game_state: Dictionary = {} -var _decision_type: String = "" -var _production_view: ProductionView -var _inventory_dashboard: InventoryDashboard -var _market_panel: Variant - -# Lazy-init UI nodes (created in _build_ui) -var _year_label: Label -var _quarter_label: Label -var _cash_label: Label -var _phase_label: Label -var _msg_log: RichTextLabel -var _action_btn: Button -var _popup_panel: Panel -var _popup_title: Label -var _popup_input: LineEdit -var _popup_btn: Button -var _annual_report_popup: Variant -var _order_meeting_popup: Variant -var _factory_dialog: Variant +# ═══════════════ LAYOUT BUILD ═══════════════ + +func _build_layout() -> void: + _build_topbar() + _build_body() + _build_bottombar() + _build_bid_popup() + + +# ── TOP BAR (960x44 + 3px brand line) ── + +func _build_topbar() -> void: + var bar := _hbox(Vector2(0, 0), Vector2(960, 44), T.colors.white) + add_child(bar) + + var logo := _label("🏢 创业沙盘", T.h1, T.colors.text_strong) + bar.add_child(logo); bar.add_child(_sep()) + + _year_lbl = _add(bar, "📅 第 1 年") + _quarter_lbl = _add(bar, "Q1") + _asset_lbl = _add(bar, "📊 资产 70M") + + var g := _gap(); bar.add_child(g) + + _rank_lbl = _add(bar, "🏆 排名 #1") + _rank_lbl.add_theme_color_override("font_color", T.colors.text_medium) + + _cash_lbl = _add(bar, "💰 12M") + _cash_lbl.add_theme_color_override("font_color", T.colors.white) + var bg := _flat_bg(T.colors.brand, 14) + bg.content_margin_left = 14; bg.content_margin_right = 14 + bg.content_margin_top = 5; bg.content_margin_bottom = 5 + _cash_lbl.add_theme_stylebox_override("normal", bg) + + var line := ColorRect.new(); line.color = T.colors.brand + line.position = Vector2(0, 44); line.size = Vector2(960, 3) + add_child(line) + + +# ── BODY (960x546) — three columns ── + +func _build_body() -> void: + var row := _hbox(Vector2(0, 47), Vector2(960, 542), T.colors.bg) + add_child(row) + + row.add_child(_build_left()) + row.add_child(_vline()) + row.add_child(_build_center()) + row.add_child(_vline()) + row.add_child(_build_right()) + + +func _build_left() -> Control: + var c := VBoxContainer.new() + c.custom_minimum_size = Vector2(200, 0) + c.add_theme_color_override("background_color", T.colors.white) + c.add_theme_constant_override("margin_left", 14) + c.add_theme_constant_override("margin_top", 14) + c.add_theme_constant_override("margin_right", 14) + + c.add_child(_section_head("📈 市场")) + _market_container = VBoxContainer.new() + c.add_child(_market_container) + + c.add_child(_spacer(12)) + c.add_child(_section_head("💳 贷款")) + _loan_container = VBoxContainer.new() + c.add_child(_loan_container) -# ── Virtual Methods ── - -func _ready() -> void: - custom_minimum_size = INITIAL_WINDOW_SIZE - _build_ui() - _ws_manager.message_received.connect(_on_message_received) - _ws_manager.connected.connect(_on_ws_connected) - _ws_manager.connection_failed.connect(_on_ws_failed) - _ws_manager.connect_to_server("game_001") - - -# ── UI Building ── - -func _build_ui() -> void: - _build_top_bar() - _build_message_log() - _build_bottom_bar() - _build_market_panel() - _build_production_view() - _build_inventory_dashboard() - _build_popup() - _build_annual_report_popup() - _build_order_meeting_popup() - _build_factory_dialog() - - -func _build_top_bar() -> void: - var hb := HBoxContainer.new() - hb.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) - hb.size = Vector2(780, 36) - add_child(hb) - - var title := Label.new() - title.text = "🏢 创业沙盘" - title.add_theme_font_size_override("font_size", 18) - hb.add_child(title) - - hb.add_child(_make_spacer()) - - _year_label = Label.new() - _year_label.text = "📅 第 1 年" - hb.add_child(_year_label) - - _quarter_label = Label.new() - _quarter_label.text = " 第 1 季度" - hb.add_child(_quarter_label) - - _cash_label = Label.new() - _cash_label.text = " 💰 12M" - hb.add_child(_cash_label) - - _phase_label = Label.new() - _phase_label.text = " 阶段 1" - hb.add_child(_phase_label) - - -func _build_message_log() -> void: - _msg_log = RichTextLabel.new() - _msg_log.position = Vector2(PANEL_MARGIN, 56.0) - _msg_log.size = Vector2(780.0, LOG_HEIGHT) - _msg_log.bbcode_enabled = true - _msg_log.scroll_active = true - _msg_log.text = "[b]欢迎来到创业沙盘![/b]\n正在连接服务器...\n" - add_child(_msg_log) - - -func _build_bottom_bar() -> void: - var hb := HBoxContainer.new() - hb.position = Vector2(PANEL_MARGIN, 56.0 + LOG_HEIGHT + PANEL_MARGIN) - hb.size = Vector2(780.0, BOTTOM_HEIGHT) - add_child(hb) - - _action_btn = Button.new() - _action_btn.text = "等待服务器..." - _action_btn.disabled = true - _action_btn.pressed.connect(_on_action_clicked) - hb.add_child(_action_btn) - - var discount_btn := Button.new() - discount_btn.text = "💳 贴现" - discount_btn.pressed.connect(_on_discount_clicked) - hb.add_child(discount_btn) - - var loan_btn := Button.new() - loan_btn.text = "🏦 贷款 20M" - loan_btn.pressed.connect(_on_loan_clicked) - hb.add_child(loan_btn) - - -func _build_production_view() -> void: - var prod_view := ProductionView.new() - prod_view.name = "ProductionView" - prod_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL - prod_view.size_flags_vertical = Control.SIZE_EXPAND_FILL - _production_area.add_child(prod_view) - _production_view = prod_view - - -func _build_inventory_dashboard() -> void: - var panel := Panel.new() - panel.position = Vector2(470.0, 56.0) - panel.size = Vector2(310.0, 380.0) - add_child(panel) - - _inventory_dashboard = InventoryDashboard.new() - _inventory_dashboard.name = "InventoryDashboard" - _inventory_dashboard.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _inventory_dashboard.size_flags_vertical = Control.SIZE_EXPAND_FILL - panel.add_child(_inventory_dashboard) - - -func _build_market_panel() -> void: - _market_panel = MarketPanelScript.new() - _market_panel.name = "MarketPanel" - _market_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _market_panel.submit_bidding.connect(_on_market_panel_submit) - _production_area.add_child(_market_panel) - - -func _build_popup() -> void: - _popup_panel = Panel.new() - _popup_panel.position = Vector2(150.0, 100.0) - _popup_panel.size = Vector2(500.0, 200.0) - _popup_panel.visible = false - add_child(_popup_panel) - - _popup_title = Label.new() - _popup_title.position = Vector2(20.0, 20.0) - _popup_title.size = Vector2(460.0, 30.0) - _popup_title.add_theme_font_size_override("font_size", 16) - _popup_panel.add_child(_popup_title) - - var desc := Label.new() - desc.position = Vector2(20.0, 55.0) - desc.size = Vector2(460.0, 20.0) - desc.text = "输入营销投入金额(M),决定能激活多少张订单:" - _popup_panel.add_child(desc) - - _popup_input = LineEdit.new() - _popup_input.position = Vector2(20.0, 80.0) - _popup_input.size = Vector2(200.0, 30.0) - _popup_input.placeholder_text = "例如: 2" - _popup_input.text = "2" - _popup_panel.add_child(_popup_input) - - _popup_btn = Button.new() - _popup_btn.position = Vector2(20.0, 120.0) - _popup_btn.size = Vector2(120.0, 30.0) - _popup_btn.text = "✅ 提交" - _popup_btn.pressed.connect(_on_popup_confirmed) - _popup_panel.add_child(_popup_btn) - - var cancel_btn := Button.new() - cancel_btn.position = Vector2(150.0, 120.0) - cancel_btn.size = Vector2(80.0, 30.0) - cancel_btn.text = "取消" - cancel_btn.pressed.connect(_hide_popup) - _popup_panel.add_child(cancel_btn) - - -func _build_annual_report_popup() -> void: - var popup: Variant = AnnualReportPopupScript.new() - popup.name = "AnnualReportPopup" - popup.visible = false - popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL - popup.size_flags_vertical = Control.SIZE_EXPAND_FILL - var popup_layer: CanvasLayer = $PopupLayer - popup_layer.add_child(popup) - _annual_report_popup = popup - - -func _build_order_meeting_popup() -> void: - var popup: Variant = OrderMeetingPopupScript.new() - popup.name = "OrderMeetingPopup" - popup.visible = false - popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL - popup.size_flags_vertical = Control.SIZE_EXPAND_FILL - var popup_layer: CanvasLayer = $PopupLayer - popup_layer.add_child(popup) - popup.orders_confirmed.connect(_on_order_meeting_confirmed) - _order_meeting_popup = popup - - -func _build_factory_dialog() -> void: - var dialog: Variant = FactoryDialogScript.new() - dialog.name = "FactoryDialog" - dialog.visible = false - dialog.size_flags_horizontal = Control.SIZE_EXPAND_FILL - dialog.size_flags_vertical = Control.SIZE_EXPAND_FILL - var popup_layer: CanvasLayer = $PopupLayer - popup_layer.add_child(dialog) - dialog.factory_ordered.connect(_on_factory_ordered) - dialog.loan_requested.connect(_on_factory_loan_requested) - dialog.discount_confirmed.connect(_on_factory_discount_confirmed) - _factory_dialog = dialog - - -# ── Helpers ── - -static func _make_spacer() -> Control: - var s := Control.new() - s.size_flags_horizontal = Control.SIZE_EXPAND_FILL - return s - - -func _log(msg: String) -> void: - _msg_log.text += msg + "\n" - _msg_log.scroll_to_line(_msg_log.get_line_count() - 1) + # marketing input row + c.add_child(_spacer(14)) + var row := HBoxContainer.new() + c.add_child(row) + var inp := LineEdit.new(); inp.placeholder_text = "营销投入 M"; inp.text = "2" + inp.custom_minimum_size = Vector2(60, 30); row.add_child(inp) + _bid_input = inp + var btn := _btn("提交竞标", T.colors.brand, T.colors.white); btn.pressed.connect(_on_bid_ok) + btn.custom_minimum_size = Vector2(80, 30); row.add_child(btn) + return c + + +func _build_center() -> Control: + var c := VBoxContainer.new() + c.size_flags_horizontal = SIZE_EXPAND_FILL + c.add_theme_constant_override("separation", 12) + c.add_theme_constant_override("margin_left", 16) + c.add_theme_constant_override("margin_right", 16) + c.add_theme_constant_override("margin_top", 14) + c.add_theme_constant_override("margin_bottom", 14) + + # Factory card + var fc := Panel.new(); fc.add_theme_stylebox_override("panel", _card_bg()) + fc.size_flags_horizontal = SIZE_EXPAND_FILL; fc.size_flags_vertical = SIZE_EXPAND_FILL + c.add_child(fc) + + var fv := VBoxContainer.new() + fv.add_theme_constant_override("margin_left", 16); fv.add_theme_constant_override("margin_top", 14) + fc.add_child(fv) + fv.add_child(_section_head("🏭 老工厂 (4条产线)")) + _prod_view = ProductionView.new() + fv.add_child(_prod_view) + + # Financial report card + var rc := Panel.new(); rc.add_theme_stylebox_override("panel", _card_bg()) + rc.size_flags_horizontal = SIZE_EXPAND_FILL; rc.size_flags_vertical = SIZE_EXPAND_FILL + c.add_child(rc) + var rv := VBoxContainer.new() + rv.add_theme_constant_override("margin_left", 16); rv.add_theme_constant_override("margin_top", 14) + rc.add_child(rv) + rv.add_child(_section_head("📦 库存")) + _inv_dashboard = InventoryDashboard.new() + rv.add_child(_inv_dashboard) + + return c + + +func _build_right() -> Control: + var c := VBoxContainer.new() + c.custom_minimum_size = Vector2(200, 0) + c.add_theme_color_override("background_color", T.colors.white) + c.add_theme_constant_override("margin_left", 14) + c.add_theme_constant_override("margin_top", 14) + c.add_theme_constant_override("margin_right", 14) + + c.add_child(_section_head("🔬 研发")) + _rd_container = VBoxContainer.new() + c.add_child(_rd_container) + + c.add_child(_spacer(12)) + c.add_child(_section_head("🤖 AI 对手")) + # Placeholder info until real AI data comes + c.add_child(_label("CEO · 激进", T.small, T.colors.text_medium)) + c.add_child(_label("CFO · 稳健", T.small, T.colors.text_medium)) + c.add_child(_label("COO · 激进", T.small, T.colors.text_medium)) + c.add_child(_label("CMO · 待定", T.small, T.colors.text_muted)) + + return c + + +# ── BOTTOM BAR ── + +func _build_bottombar() -> void: + var bar := _hbox(Vector2(0, 589), Vector2(960, 51), T.colors.white) + add_child(bar) + + var line := ColorRect.new(); line.color = T.colors.brand + line.position = Vector2(0, 589); line.size = Vector2(960, 3) + add_child(line) + + _action_btn = _btn("等待服务器...", T.colors.brand, T.colors.white) + _action_btn.disabled = true; _action_btn.pressed.connect(_on_next_quarter) + bar.add_child(_action_btn) + + bar.add_child(_btn_light("📊 年报")).pressed.connect(func(): + if _annual_popup: _annual_popup.show_report(_state)) + bar.add_child(_btn_light("📋 订货会")) + bar.add_child(_btn_light("🏗️ 工厂")).pressed.connect(func(): + if _factory_dialog: _factory_dialog.show_dialog(_state)) + + var g := _gap(); bar.add_child(g) + + _phase_lbl2 = _label("", T.small, T.colors.text_muted) + bar.add_child(_phase_lbl2) + + +# ── BID POPUP ── + +func _build_bid_popup() -> void: + _bid_popup = Panel.new() + _bid_popup.position = Vector2(230, 180); _bid_popup.size = Vector2(500, 180) + _bid_popup.add_theme_stylebox_override("panel", _card_bg()) + _bid_popup.visible = false + add_child(_bid_popup) + + var t := _label("📋 营销竞标", T.h1, T.colors.text_strong) + t.position = Vector2(20, 20); _bid_popup.add_child(t) + + _bid_input = LineEdit.new() + _bid_input.position = Vector2(20, 60); _bid_input.size = Vector2(180, 32) + _bid_input.text = "2" + _bid_popup.add_child(_bid_input) + + _bid_confirm = _btn("✅ 提交竞标", T.colors.brand, T.colors.white) + _bid_confirm.position = Vector2(20, 110); _bid_confirm.pressed.connect(_on_bid_ok) + _bid_popup.add_child(_bid_confirm) + + var cancel := _btn_light("取消"); cancel.position = Vector2(130, 110) + cancel.pressed.connect(func(): _bid_popup.visible = false) + _bid_popup.add_child(cancel) + + # Also init other popups into popup layer + if has_node("PopupLayer"): + _annual_popup = AnnualReportPopupScript.new(); _annual_popup.visible = false + $PopupLayer.add_child(_annual_popup) + _order_popup = OrderMeetingPopupScript.new(); _order_popup.visible = false + $PopupLayer.add_child(_order_popup) + _factory_dialog = FactoryDialogScript.new(); _factory_dialog.visible = false + $PopupLayer.add_child(_factory_dialog) + _factory_dialog.factory_ordered.connect(func(lt): _ws.send_action({"action": "order_production_line","line_type": lt})) + _factory_dialog.loan_requested.connect(func(lt, amt): _ws.send_action({"action":"take_loan","loan_type":lt,"amount":amt})) + _factory_dialog.discount_confirmed.connect(func(amt): _ws.send_action({"action":"discount_receivable","amount":amt})) -func _hide_popup() -> void: - _popup_panel.visible = false - - -# ── WS Callbacks ── - -func _on_ws_connected() -> void: - _log("[color=green]✅ 已连接到游戏服务器[/color]") - _ws_manager.send_action({"action": "start_game"}) - - -func _on_ws_failed() -> void: - _log("[color=red]❌ 无法连接到游戏服务器\n请确认 Rust API 已在 localhost:3096 运行[/color]") - - -func _on_message_received(data: Dictionary) -> void: - var event_type: String = data.get("event", "") - match event_type: + +# ═══════════════ WS HANDLERS ═══════════════ + +func _on_msg(data: Dictionary) -> void: + match data.get("event", ""): "state_update": - _game_state = data.get("data", {}) - _update_ui() + _state = data.get("data", {}); _update_ui() "ask_decision": - _show_decision(data.get("data", {})) + _bid_popup.visible = true "message": - _log("[color=yellow]📢 %s[/color]" % data.get("data", "")) + print("📢 ", data.get("data", "")) "game_over": - var reason: String = data.get("data", {}).get("reason", "未知原因") - _log("[color=red]💀 游戏结束: %s[/color]" % reason) - _action_btn.disabled = true - _action_btn.text = "游戏结束" + print("💀 游戏结束: ", data.get("data", {}).get("reason", "")) + _action_btn.disabled = true; _action_btn.text = "游戏结束" "phase_change": - var pd: Dictionary = data.get("data", {}) - _log("[color=cyan]📅 第 %d 年第 %d 季度 — %s[/color]" % [ - pd.get("year", 1), pd.get("quarter", 0), pd.get("phase", "") - ]) + print("📅 ", data.get("data", {}).get("phase", "")) "annual_report": - var report_data: Dictionary = data.get("data", {}) - _log("[color=green]📊 收到年度报告 — 第 %d 年[/color]" % report_data.get("year", 0)) - if _annual_report_popup: - _annual_report_popup.show_report(report_data) + if _annual_popup: _annual_popup.show_report(data.get("data", {})) "order_meeting": - var order_data: Dictionary = data.get("data", {}) - var market_name: String = order_data.get("market_name", "") - var orders: Array = order_data.get("orders", []) - var year: int = order_data.get("year", 1) - var predictions: Array = order_data.get("predictions", []) - _log("[color=cyan]📋 收到订货会数据 — %s 第 %d 年 (%d 张订单)[/color]" % [market_name, year, orders.size()]) - if _order_meeting_popup: - _order_meeting_popup.show_orders(market_name, orders, year, predictions) - + if _order_popup: + var od := data.get("data", {}) + _order_popup.show_orders(od.get("market",""), od.get("orders",[]), od.get("year",1)) -# ── UI Updates ── func _update_ui() -> void: - var gs: Dictionary = _game_state - _year_label.text = "📅 第 %d 年" % gs.get("game_year", 1) - _quarter_label.text = " 第 %d 季度" % gs.get("game_quarter", 1) - _cash_label.text = " 💰 %dM" % gs.get("cash", 0) - _phase_label.text = " 阶段 %d" % gs.get("phase", 1) - - if _production_view: - _production_view.update(gs.get("factories", [])) - - if _inventory_dashboard: - _inventory_dashboard.update(gs) - - if _market_panel: - _market_panel.update(gs.get("markets", [])) - - -func _show_decision(data: Dictionary) -> void: - _decision_type = data.get("decision_type", "") - _popup_title.text = "📋 %s" % _decision_type - - match _decision_type: - "bidding": - _popup_input.placeholder_text = "输入营销投入 M" - _popup_input.text = "2" - _popup_btn.text = "✅ 提交竞标" - _: - _popup_input.placeholder_text = "输入..." - _popup_btn.text = "确认" - - _popup_panel.visible = true - - -# ── Button Handlers ── - -func _on_popup_confirmed() -> void: - var input_val: String = _popup_input.text.strip_edges() - _hide_popup() - - match _decision_type: - "bidding": - var spend: int = int(input_val) if input_val.is_valid_int() else 2 - _log("[color=green]📋 提交竞标: 营销投入 %dM[/color]" % spend) - _ws_manager.send_action({ - "action": "submit_bidding", - "strategies": [{"market_name": "平城", "marketing_spend": spend}] - }) - _action_btn.text = "▶ 执行下一季度" - _action_btn.disabled = false - _popup_input.text = "" - - -func _on_action_clicked() -> void: - _action_btn.disabled = true - _action_btn.text = "执行中..." - _log("[color=cyan]▶ 执行下一季度...[/color]") - _ws_manager.send_action({"action": "next_quarter"}) - - -func _on_discount_clicked() -> void: - if _factory_dialog: - _factory_dialog.show_dialog(_game_state) - - -func _on_order_meeting_confirmed(selected_ids: Array) -> void: - if selected_ids.is_empty(): - return - _log("[color=green]📋 提交 %d 张订单[/color]" % selected_ids.size()) - _ws_manager.send_action({ - "action": "submit_orders", - "order_ids": selected_ids - }) - - -func _on_loan_clicked() -> void: - if _factory_dialog: - _factory_dialog.show_dialog(_game_state) - - -# ── Factory Dialog Handlers ── - -func _on_factory_ordered(line_type: String) -> void: - var type_label: String = "手工线" - match line_type: - "semi_auto": - type_label = "半自动" - "auto": - type_label = "全自动" - _log("[color=green]🔧 订购新产线: %s[/color]" % type_label) - _ws_manager.send_action({ - "action": "order_production_line", - "line_type": line_type - }) - - -func _on_factory_loan_requested(loan_type: String, amount: int) -> void: - var type_label: String = "短期" if loan_type == "short" else "长期" - _log("[color=yellow]🏦 申请%s贷款 %dM...[/color]" % [type_label, amount]) - _ws_manager.send_action({ - "action": "take_loan", - "loan_type": loan_type, - "amount": amount - }) - - -func _on_factory_discount_confirmed(amount: int) -> void: - _log("[color=yellow]💰 贴现 %dM 应收款...[/color]" % amount) - _ws_manager.send_action({ - "action": "discount_receivable", - "amount": amount - }) - - -func _on_market_panel_submit(strategies: Array) -> void: - if strategies.is_empty(): - return - _log("[color=green]📋 提交竞标策略: %d 个市场[/color]" % strategies.size()) - _ws_manager.send_action({ - "action": "submit_bidding", - "strategies": strategies - }) - _action_btn.text = "▶ 执行下一季度" - _action_btn.disabled = false + var s := _state + _year_lbl.text = "📅 第 %d 年" % s.get("game_year", 1) + _quarter_lbl.text = "Q%d" % s.get("game_quarter", 1) + _cash_lbl.text = "💰 %dM" % s.get("cash", 0) + + var total_assets := s.get("cash", 0) + for f in s.get("factories", []): total_assets += f.get("value", 0) + _asset_lbl.text = "📊 资产 %dM" % total_assets + + var ph := s.get("phase", 1) + var names := ["", "适应期", "市场竞争", "高阶经营", "复盘"] + _phase_lbl2.text = "第%d节 · %s (%d-%d年)" % [ph, names[min(ph, 4)], _yr(ph)[0], _yr(ph)[1]] + + if _prod_view: _prod_view.update(s.get("factories", [])) + if _inv_dashboard: _inv_dashboard.update(s) + + # Update left market list + for c in _market_container.get_children(): c.queue_free() + for m in s.get("markets", []): + if m.get("developed", false): + _market_container.add_child(_label("✅ " + m.get("name","") + " #" + str(m.get("rank",1)), T.body, T.colors.success)) + else: + _market_container.add_child(_label("◦ " + m.get("name",""), T.body, T.colors.text_muted)) + + # Update loan slots + for c in _loan_container.get_children(): c.queue_free() + var ll := s.get("long_term_loans", []) + var sl := s.get("short_term_loans", []) + _loan_container.add_child(_label("长贷 5%/年 | 已贷: %dM" % _sum_loans(ll), T.body, T.colors.text_medium)) + _loan_container.add_child(_label("短贷 10%/年 | 已贷: %dM" % _sum_loans(sl), T.body, T.colors.text_medium)) + + # Update R&D + for c in _rd_container.get_children(): c.queue_free() + for rd in s.get("products_rd", []): + var pname := "?" + match str(rd.get("product","")): + "ben_ma": pname = "🐴 奔马" + "meng_hu": pname = "🐯 猛虎" + "fei_ying": pname = "🦅 飞鹰" + "tian_long": pname = "🐉 天龙" + if rd.get("completed", false): + _rd_container.add_child(_label(pname + " ✓", T.body, T.colors.success)) + else: + _rd_container.add_child(_label("%s %d/%d" % [pname, rd.get("progress",0), rd.get("total",0)], T.body, T.colors.text_medium)) + + +# ═══════════════ BUTTON HANDLERS ═══════════════ + +func _on_bid_ok() -> void: + _bid_popup.visible = false + var val := int(_bid_input.text) if _bid_input.text.is_valid_int() else 2 + _ws.send_action({"action": "submit_bidding", "strategies": [{"market_name":"平城","marketing_spend":val}]}) + _action_btn.text = "▶ 执行下一季度"; _action_btn.disabled = false + +func _on_next_quarter() -> void: + _action_btn.disabled = true; _action_btn.text = "执行中..." + _ws.send_action({"action": "next_quarter"}) + + +# ═══════════════ PRIVATE HELPERS ═══════════════ + +func _hbox(pos: Vector2, sz: Vector2, bg: Color) -> HBoxContainer: + var b := HBoxContainer.new(); b.position = pos; b.size = sz; b.custom_minimum_size = sz + b.add_theme_color_override("background_color", bg); return b + +func _label(txt: String, fs: int, cl: Color) -> Label: + var l := Label.new(); l.text = txt + l.add_theme_font_size_override("font_size", fs) + l.add_theme_color_override("font_color", cl); return l + +func _add(parent: Control, txt: String) -> Label: + return _label(txt, T.body, T.colors.text_medium) + +func _sep() -> Label: + return _label(" | ", T.body, T.colors.border) + +func _gap() -> Control: + var g := Control.new(); g.size_flags_horizontal = SIZE_EXPAND_FILL; return g + +func _vline() -> ColorRect: + var c := ColorRect.new(); c.color = T.colors.border; c.size.x = 1; return c + +func _spacer(h: int) -> Control: + var c := Control.new(); c.custom_minimum_size = Vector2(0, h); return c + +func _card_bg() -> StyleBoxFlat: + var sb := StyleBoxFlat.new(); sb.bg_color = T.colors.card; sb.set_corner_radius_all(8) + sb.content_margin_left = 0; sb.content_margin_top = 0; return sb + +func _flat_bg(cl: Color, r: int) -> StyleBoxFlat: + var sb := StyleBoxFlat.new(); sb.bg_color = cl; sb.set_corner_radius_all(r); return sb + +func _btn(txt: String, bg: Color, fg: Color) -> Button: + var b := Button.new(); b.text = txt + b.add_theme_font_size_override("font_size", T.body) + b.add_theme_color_override("font_color", fg) + var sb := StyleBoxFlat.new(); sb.bg_color = bg; sb.set_corner_radius_all(6) + sb.content_margin_left = 18; sb.content_margin_right = 18 + sb.content_margin_top = 8; sb.content_margin_bottom = 8 + b.add_theme_stylebox_override("normal", sb); return b + +func _btn_light(txt: String) -> Button: + return _btn(txt, T.colors.white, T.colors.text_medium) + +func _section_head(txt: String) -> Label: + return _label(txt, T.h2, T.colors.text_strong) + +func _yr(phase: int) -> Array[int]: + match phase: 1: return [1,4]; 2: return [5,7]; 3: return [8,11]; _: return [12,12] + +func _sum_loans(loans: Array) -> int: + var s := 0; for l in loans: s += l.get("amount", 0); return s diff --git a/godot/scripts/theme.gd b/godot/scripts/theme.gd index 78604ca..a512714 100644 --- a/godot/scripts/theme.gd +++ b/godot/scripts/theme.gd @@ -1,11 +1,12 @@ extends Node -## OpenDesign 设计主题常量——供所有 UI 组件引用。 +## OpenDesign a.light 设计主题常量。 ## -## 使用方式:T.colors.brand / T.fonts.head 等(通过 preload 引用) +## 使用:const T = preload("res://scripts/theme.gd") +## 访问:T.colors.brand / T.h1 / T.body 等 class_name AppTheme -# ── Colors (OpenDesign a.light tokens) ── +# ── Colors (OpenDesign tokens) ── const colors := { # Brand @@ -37,7 +38,7 @@ const colors := { "danger_bg": Color("#fecaca"), "danger_text": Color("#7f1d1d"), - # Product + # Products "benma": Color("#c7000b"), "menghu": Color("#1e6fe0"), "feiying": Color("#c28600"), @@ -50,53 +51,49 @@ const colors := { "idle": Color("#c4cad4"), } -# ── Fonts ── +# ── Font sizes ── const fonts := { + "h1": 18, + "h2": 16, "head": 14, "body": 13, "small": 12, "tiny": 10, - "h1": 18, - "h2": 16, } +# ── Shorthand constants (T.h1, T.body, etc.) ── + +const h1: int = 18 +const h2: int = 16 +const head: int = 14 +const body: int = 13 +const small: int = 12 +const tiny: int = 10 + # ── Spacing ── -const space := { - "xs": 4, - "sm": 8, - "md": 12, - "lg": 16, - "xl": 24, -} +const xs: int = 4 +const sm: int = 8 +const md: int = 12 +const lg: int = 16 +const xl: int = 24 -# ── Helpers ── +# ── Static helpers ── -## 给 Control 节点设置基础样式 static func style_card(node: Control) -> void: node.add_theme_color_override("background_color", colors.card) - node.add_theme_stylebox_override("border", _make_stylebox(colors.border, 8)) - -## 创建圆角边框 -static func _make_stylebox(color: Color, radius: int) -> StyleBoxFlat: - var sb := StyleBoxFlat.new() - sb.bg_color = color - sb.set_corner_radius_all(radius) - return sb - -## 获取产品对应颜色 -static func product_color(product_name: String) -> Color: - match product_name.to_lower(): + +static func product_color(name: String) -> Color: + match name.to_lower(): "ben_ma", "奔马": return colors.benma "meng_hu", "猛虎": return colors.menghu "fei_ying", "飞鹰": return colors.feiying "tian_long", "天龙": return colors.tianlong return colors.idle -## 产品对应图标 -static func product_icon(product_name: String) -> String: - match product_name.to_lower(): +static func product_icon(name: String) -> String: + match name.to_lower(): "ben_ma", "奔马": return "🐴" "meng_hu", "猛虎": return "🐯" "fei_ying", "飞鹰": return "🦅" diff --git a/godot/scripts/theme.gd.uid b/godot/scripts/theme.gd.uid new file mode 100644 index 0000000..0889861 --- /dev/null +++ b/godot/scripts/theme.gd.uid @@ -0,0 +1 @@ +uid://dcgdlte6n3vqn From 937f95ada2a997f16186eb38ab9a1bb77ee765c0 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 03:24:53 +0800 Subject: [PATCH 22/64] =?UTF-8?q?fix(godot):=20=E4=BF=AE=E5=A4=8DGDScript?= =?UTF-8?q?=20match=E5=92=8Cfor=E5=8D=95=E8=A1=8C=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/main_game.gd | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index eab3d3f..0a90c03 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -386,7 +386,14 @@ func _section_head(txt: String) -> Label: return _label(txt, T.h2, T.colors.text_strong) func _yr(phase: int) -> Array[int]: - match phase: 1: return [1,4]; 2: return [5,7]; 3: return [8,11]; _: return [12,12] + match phase: + 1: return [1, 4] + 2: return [5, 7] + 3: return [8, 11] + _: return [12, 12] func _sum_loans(loans: Array) -> int: - var s := 0; for l in loans: s += l.get("amount", 0); return s + var s := 0 + for l in loans: + s += l.get("amount", 0) + return s From a9fbb0968913cc9c229ada454a08e5f75a220ee7 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 03:41:14 +0800 Subject: [PATCH 23/64] =?UTF-8?q?feat(godot):=20G1-G3=20=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F(=E6=B7=A1=E5=85=A5=E6=B7=A1=E5=87=BA/?= =?UTF-8?q?=E5=BC=B9=E5=85=A5/=E6=95=B0=E5=80=BC=E8=B7=B3=E5=8A=A8/?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=B9=B3=E6=BB=91/=E5=8D=A1=E7=89=8C?= =?UTF-8?q?=E9=98=B4=E5=BD=B1/=E6=8C=89=E9=92=AE=E5=8A=A8=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/scripts/animation_utils.gd | 220 ++++++++++++++ godot/scripts/main_game.gd | 101 +++--- godot/scripts/popups/annual_report_popup.gd | 11 +- godot/scripts/popups/factory_dialog.gd | 8 + godot/scripts/popups/order_meeting_popup.gd | 4 + godot/scripts/production_view.gd | 321 +++++++++++++++----- 6 files changed, 543 insertions(+), 122 deletions(-) create mode 100644 godot/scripts/animation_utils.gd diff --git a/godot/scripts/animation_utils.gd b/godot/scripts/animation_utils.gd new file mode 100644 index 0000000..efbac99 --- /dev/null +++ b/godot/scripts/animation_utils.gd @@ -0,0 +1,220 @@ +extends Node +## 通用动画工具——提供淡入、弹入、数值跳动、进度条平滑过渡等 UI 动效。 +## +## 所有方法均为静态方法,可在任何 UI 组件中直接调用。 +## +## 用法: +## AnimUtils.fade_in(node, 0.3) +## AnimUtils.pop_in(dialog_panel) +## AnimUtils.button_press(btn) +## AnimUtils.animate_number(cash_label, 10, 15, "$", "M") + +class_name AnimUtils + + +# ═══════════════════════ 淡入/淡出 ═══════════════════════ + +## 淡入节点(modulate.a 从 0 到 1) +static func fade_in(node: Node, duration: float = 0.3) -> void: + node.modulate.a = 0.0 + var t := node.create_tween() + t.tween_property(node, "modulate:a", 1.0, duration).set_ease(Tween.EASE_OUT) + + +## 淡出节点(modulate.a 从当前到 0) +static func fade_out(node: Node, duration: float = 0.3) -> void: + var t := node.create_tween() + t.tween_property(node, "modulate:a", 0.0, duration).set_ease(Tween.EASE_IN) + + +# ═══════════════════════ 弹入/弹出 ═══════════════════════ + +## 弹入效果——缩放从 0.85 弹性回到 1.0,营造"弹出"感 +static func pop_in(node: Node, duration: float = 0.35) -> void: + node.scale = Vector2(0.85, 0.85) + var t := node.create_tween() + t.tween_property(node, "scale", Vector2(1.0, 1.0), duration) \ + .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK) + + +## 弹入效果(从指定缩放起点开始) +static func pop_in_from(node: Node, from_scale: Vector2 = Vector2(0.9, 0.9), duration: float = 0.3) -> void: + node.scale = from_scale + var t := node.create_tween() + t.tween_property(node, "scale", Vector2(1.0, 1.0), duration) \ + .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK) + + +# ═══════════════════════ 数值动画 ═══════════════════════ + +## 数值跳动——Label 文本从 old_val 平滑变化到 new_val +## 可选 prefix/suffix 前缀后缀(如 "$" / "M") +static func animate_number(label: Label, old_val: int, new_val: int, prefix: String = "", suffix: String = "", duration: float = 0.5) -> void: + var t := label.create_tween() + t.tween_method( + func(v: int) -> void: label.text = prefix + str(v) + suffix, + old_val, + new_val, + duration + ) + + +## 数值动画(float 版本),保留指定小数位 +static func animate_number_float(label: Label, old_val: float, new_val: float, decimals: int = 2, prefix: String = "", suffix: String = "", duration: float = 0.5) -> void: + var t := label.create_tween() + t.tween_method( + func(v: float) -> void: label.text = prefix + str(round(v * pow(10, decimals)) / pow(10, decimals)) + suffix, + old_val, + new_val, + duration + ) + + +# ═══════════════════════ 进度条动画 ═══════════════════════ + +## 进度条平滑过渡——将 ColorRect 的 size.x 从当前值过渡到目标百分比对应宽度 +## max_width: 进度条最大宽度(px) +## target_pct: 目标百分比(0.0 ~ 1.0) +static func animate_progress(bar: ColorRect, target_pct: float, max_width: float = -1.0, duration: float = 0.4) -> void: + if max_width <= 0.0: + max_width = bar.custom_minimum_size.x if bar.custom_minimum_size.x > 0.0 else bar.size.x + if max_width <= 0.0: + max_width = 130.0 # fallback + + var pct: float = clampf(target_pct, 0.0, 1.0) + var t := bar.create_tween() + t.tween_method( + func(v: float) -> void: bar.size.x = v, + bar.size.x, + max_width * pct, + duration + ).set_ease(Tween.EASE_OUT) + + +## 进度条动画(同时更新百分比文本),将 ColorRect 宽度从当前平滑过渡到目标 +## pct_label: 显示百分比文字的 Label +static func animate_progress_with_label(bar: ColorRect, pct_label: Label, target_pct: float, max_width: float = -1.0, duration: float = 0.4) -> void: + if max_width <= 0.0: + max_width = bar.custom_minimum_size.x if bar.custom_minimum_size.x > 0.0 else bar.size.x + if max_width <= 0.0: + max_width = 130.0 + + var pct: float = clampf(target_pct, 0.0, 1.0) + var old_width: float = bar.size.x + var t := bar.create_tween() + t.tween_method( + func(v: float) -> void: + bar.size.x = v + var current_pct: float = v / max_width + pct_label.text = "%d%%" % int(current_pct * 100.0), + old_width, + max_width * pct, + duration + ).set_ease(Tween.EASE_OUT) + + +# ═══════════════════════ 按钮动效 ═══════════════════════ + +## 按钮按下动效——快速缩放回弹,模拟物理按压感 +static func button_press(btn: Button) -> void: + var t := btn.create_tween() + t.tween_property(btn, "scale", Vector2(0.92, 0.92), 0.07).set_ease(Tween.EASE_OUT) + t.tween_property(btn, "scale", Vector2(1.0, 1.0), 0.10).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK) + + +# ═══════════════════════ 滑动/位移 ═══════════════════════ + +## 从下方滑入(适合弹窗、提示条) +static func slide_in_from_bottom(node: Node, target_y: float = -1.0, duration: float = 0.35) -> void: + if target_y < 0.0 and node is Control: + target_y = (node as Control).position.y + + var start_y: float = target_y + 40.0 + node.set("position:y", start_y) + var t := node.create_tween() + t.tween_property(node, "position:y", target_y, duration).set_ease(Tween.EASE_OUT) + + +## 从上方滑入 +static func slide_in_from_top(node: Node, target_y: float = -1.0, duration: float = 0.35) -> void: + if target_y < 0.0 and node is Control: + target_y = (node as Control).position.y + + var start_y: float = target_y - 40.0 + node.set("position:y", start_y) + var t := node.create_tween() + t.tween_property(node, "position:y", target_y, duration).set_ease(Tween.EASE_OUT) + + +# ═══════════════════════ 列表项入场 ═══════════════════════ + +## 列表项逐项入场——每个子节点依次从左侧滑入并淡入 +## stagger: 每个子节点之间的延迟(秒) +static func stagger_in(parent: Node, stagger: float = 0.05, duration: float = 0.25) -> void: + var children: Array[Node] = [] + for c in parent.get_children(): + children.append(c) + + for i: int in children.size(): + var child: Node = children[i] + child.modulate.a = 0.0 + child.position.x -= 20.0 + var t := child.create_tween() + t.tween_property(child, "modulate:a", 1.0, duration).set_ease(Tween.EASE_OUT).set_delay(i * stagger) + t.parallel().tween_property(child, "position:x", child.position.x + 20.0, duration) \ + .set_ease(Tween.EASE_OUT).set_delay(i * stagger) + + +# ═══════════════════════ 脉冲/强调 ═══════════════════════ + +## 脉冲效果——短暂放大再恢复,吸引注意力 +static func pulse(node: Node, duration: float = 0.6) -> void: + var t := node.create_tween() + t.tween_property(node, "scale", Vector2(1.08, 1.08), duration * 0.5).set_ease(Tween.EASE_OUT) + t.tween_property(node, "scale", Vector2(1.0, 1.0), duration * 0.5).set_ease(Tween.EASE_IN) + + +## 闪烁效果——modulate 快速闪烁 +static func flash(node: Node, flash_color: Color = Color.WHITE, duration: float = 0.3) -> void: + var original_modulate: Color = node.modulate + var t := node.create_tween() + t.tween_property(node, "modulate", flash_color, duration * 0.5) + t.tween_property(node, "modulate", original_modulate, duration * 0.5) + + +# ═══════════════════════ StyleBox 工厂 ═══════════════════════ + +## 创建带圆角的卡片 StyleBoxFlat +static func card_style(bg_color: Color, radius: int = 8, shadow_enabled: bool = false) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.set_corner_radius_all(radius) + if shadow_enabled: + sb.shadow_size = 4 + sb.shadow_offset = Vector2(0, 2) + sb.shadow_color = Color(0.0, 0.0, 0.0, 0.12) + return sb + + +## 创建圆角色条(Pill Badge)StyleBoxFlat +static func pill_style(bg_color: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.set_corner_radius_all(20) + sb.content_margin_left = 10 + sb.content_margin_right = 10 + sb.content_margin_top = 3 + sb.content_margin_bottom = 3 + return sb + + +## 创建带圆角的按钮 StyleBoxFlat +static func button_style(bg_color: Color, radius: int = 6) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg_color + sb.set_corner_radius_all(radius) + sb.content_margin_left = 18 + sb.content_margin_right = 18 + sb.content_margin_top = 8 + sb.content_margin_bottom = 8 + return sb diff --git a/godot/scripts/main_game.gd b/godot/scripts/main_game.gd index 0a90c03..777c782 100644 --- a/godot/scripts/main_game.gd +++ b/godot/scripts/main_game.gd @@ -2,6 +2,7 @@ extends Control class_name MainGame const T = preload("res://scripts/theme.gd") +const AU = preload("res://scripts/animation_utils.gd") const AnnualReportPopupScript = preload("res://scripts/popups/annual_report_popup.gd") const OrderMeetingPopupScript = preload("res://scripts/popups/order_meeting_popup.gd") const FactoryDialogScript = preload("res://scripts/popups/factory_dialog.gd") @@ -11,6 +12,7 @@ const FactoryDialogScript = preload("res://scripts/popups/factory_dialog.gd") # ── Core state ── var _state: Dictionary = {} var _decision_type: String = "" +var _prev_cash: int = -1 # 用于 cash 标签数值动画 # ── Top bar widgets ── var _year_lbl: Label @@ -200,15 +202,16 @@ func _build_bottombar() -> void: line.position = Vector2(0, 589); line.size = Vector2(960, 3) add_child(line) + var btn: Button _action_btn = _btn("等待服务器...", T.colors.brand, T.colors.white) _action_btn.disabled = true; _action_btn.pressed.connect(_on_next_quarter) bar.add_child(_action_btn) - bar.add_child(_btn_light("📊 年报")).pressed.connect(func(): - if _annual_popup: _annual_popup.show_report(_state)) - bar.add_child(_btn_light("📋 订货会")) - bar.add_child(_btn_light("🏗️ 工厂")).pressed.connect(func(): - if _factory_dialog: _factory_dialog.show_dialog(_state)) + btn = _btn_light("📊 年报"); bar.add_child(btn) + btn.pressed.connect(func(): if _annual_popup: _annual_popup.show_report(_state)) + btn = _btn_light("📋 订货会"); bar.add_child(btn) + btn = _btn_light("🏗️ 工厂"); bar.add_child(btn) + btn.pressed.connect(func(): if _factory_dialog: _factory_dialog.show_dialog(_state)) var g := _gap(); bar.add_child(g) @@ -262,6 +265,7 @@ func _on_msg(data: Dictionary) -> void: _state = data.get("data", {}); _update_ui() "ask_decision": _bid_popup.visible = true + AU.pop_in(_bid_popup) "message": print("📢 ", data.get("data", "")) "game_over": @@ -272,56 +276,66 @@ func _on_msg(data: Dictionary) -> void: "annual_report": if _annual_popup: _annual_popup.show_report(data.get("data", {})) "order_meeting": - if _order_popup: - var od := data.get("data", {}) - _order_popup.show_orders(od.get("market",""), od.get("orders",[]), od.get("year",1)) + if _order_popup: + var od: Dictionary = data.get("data", {}) + _order_popup.show_orders(str(od.get("market","")), od.get("orders",[]), od.get("year",1)) func _update_ui() -> void: - var s := _state + var s: Dictionary = _state _year_lbl.text = "📅 第 %d 年" % s.get("game_year", 1) _quarter_lbl.text = "Q%d" % s.get("game_quarter", 1) - _cash_lbl.text = "💰 %dM" % s.get("cash", 0) - var total_assets := s.get("cash", 0) - for f in s.get("factories", []): total_assets += f.get("value", 0) + var cash_val: int = s.get("cash", 0) + if _prev_cash >= 0 and _prev_cash != cash_val: + AU.animate_number(_cash_lbl, _prev_cash, cash_val, "💰 ", "M") + else: + _cash_lbl.text = "💰 %dM" % cash_val + _prev_cash = cash_val + + var total_assets: int = s.get("cash", 0) + var factories: Array = s.get("factories", []) + for f: Dictionary in factories: + total_assets += f.get("value", 0) _asset_lbl.text = "📊 资产 %dM" % total_assets - var ph := s.get("phase", 1) - var names := ["", "适应期", "市场竞争", "高阶经营", "复盘"] + var ph: int = s.get("phase", 1) + var names: PackedStringArray = ["", "适应期", "市场竞争", "高阶经营", "复盘"] _phase_lbl2.text = "第%d节 · %s (%d-%d年)" % [ph, names[min(ph, 4)], _yr(ph)[0], _yr(ph)[1]] - if _prod_view: _prod_view.update(s.get("factories", [])) + if _prod_view: _prod_view.update(factories) if _inv_dashboard: _inv_dashboard.update(s) # Update left market list - for c in _market_container.get_children(): c.queue_free() - for m in s.get("markets", []): + for c: Node in _market_container.get_children(): c.queue_free() + var markets: Array = s.get("markets", []) + for m: Dictionary in markets: if m.get("developed", false): - _market_container.add_child(_label("✅ " + m.get("name","") + " #" + str(m.get("rank",1)), T.body, T.colors.success)) + _market_container.add_child(_label("✅ " + str(m.get("name","")) + " #" + str(m.get("rank",1)), T.body, T.colors.success)) else: - _market_container.add_child(_label("◦ " + m.get("name",""), T.body, T.colors.text_muted)) - - # Update loan slots - for c in _loan_container.get_children(): c.queue_free() - var ll := s.get("long_term_loans", []) - var sl := s.get("short_term_loans", []) - _loan_container.add_child(_label("长贷 5%/年 | 已贷: %dM" % _sum_loans(ll), T.body, T.colors.text_medium)) - _loan_container.add_child(_label("短贷 10%/年 | 已贷: %dM" % _sum_loans(sl), T.body, T.colors.text_medium)) - - # Update R&D - for c in _rd_container.get_children(): c.queue_free() - for rd in s.get("products_rd", []): - var pname := "?" - match str(rd.get("product","")): - "ben_ma": pname = "🐴 奔马" - "meng_hu": pname = "🐯 猛虎" - "fei_ying": pname = "🦅 飞鹰" - "tian_long": pname = "🐉 天龙" - if rd.get("completed", false): - _rd_container.add_child(_label(pname + " ✓", T.body, T.colors.success)) - else: - _rd_container.add_child(_label("%s %d/%d" % [pname, rd.get("progress",0), rd.get("total",0)], T.body, T.colors.text_medium)) + _market_container.add_child(_label("◦ " + str(m.get("name","")), T.body, T.colors.text_muted)) + + # Update loan slots + for c: Node in _loan_container.get_children(): c.queue_free() + var ll: Array = s.get("long_term_loans", []) + var sl: Array = s.get("short_term_loans", []) + _loan_container.add_child(_label("长贷 5%/年 | 已贷: %dM" % _sum_loans(ll), T.body, T.colors.text_medium)) + _loan_container.add_child(_label("短贷 10%/年 | 已贷: %dM" % _sum_loans(sl), T.body, T.colors.text_medium)) + + # Update R&D + for c: Node in _rd_container.get_children(): c.queue_free() + var rds: Array = s.get("products_rd", []) + for rd: Dictionary in rds: + var pname: String = "?" + match str(rd.get("product","")): + "ben_ma": pname = "🐴 奔马" + "meng_hu": pname = "🐯 猛虎" + "fei_ying": pname = "🦅 飞鹰" + "tian_long": pname = "🐉 天龙" + if rd.get("completed", false): + _rd_container.add_child(_label(pname + " ✓", T.body, T.colors.success)) + else: + _rd_container.add_child(_label("%s %d/%d" % [pname, rd.get("progress",0), rd.get("total",0)], T.body, T.colors.text_medium)) # ═══════════════ BUTTON HANDLERS ═══════════════ @@ -377,10 +391,13 @@ func _btn(txt: String, bg: Color, fg: Color) -> Button: var sb := StyleBoxFlat.new(); sb.bg_color = bg; sb.set_corner_radius_all(6) sb.content_margin_left = 18; sb.content_margin_right = 18 sb.content_margin_top = 8; sb.content_margin_bottom = 8 - b.add_theme_stylebox_override("normal", sb); return b + b.add_theme_stylebox_override("normal", sb) + b.pressed.connect(func(): AU.button_press(b)) + return b func _btn_light(txt: String) -> Button: - return _btn(txt, T.colors.white, T.colors.text_medium) + var b := _btn(txt, T.colors.white, T.colors.text_medium) + return b func _section_head(txt: String) -> Label: return _label(txt, T.h2, T.colors.text_strong) diff --git a/godot/scripts/popups/annual_report_popup.gd b/godot/scripts/popups/annual_report_popup.gd index 9d45356..a68311e 100644 --- a/godot/scripts/popups/annual_report_popup.gd +++ b/godot/scripts/popups/annual_report_popup.gd @@ -10,6 +10,7 @@ extends Panel class_name AnnualReportPopup const T = preload("res://scripts/theme.gd") +const AU = preload("res://scripts/animation_utils.gd") # ── Colors ── @@ -154,6 +155,7 @@ func show_report(report: Dictionary) -> void: _populate_tabs() visible = true _center_dialog() + AU.pop_in(_dialog) # ── UI Building ── @@ -197,6 +199,7 @@ func _build_ui() -> void: _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) _close_btn.connect("pressed", _on_close_pressed) + _close_btn.pressed.connect(func(): AU.button_press(_close_btn)) _dialog.add_child(_close_btn) # ── TabContainer ── @@ -376,7 +379,7 @@ func _make_data_row(label_text: String, value: Variant, is_total: bool, is_major if is_total: label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL) label.add_theme_color_override("font_color", COLOR_TOTAL) - label.add_theme_font_weight_override("bold", true) + label.add_theme_font_size_override("bold", true) else: label.add_theme_font_size_override("font_size", FONT_SIZE_ROW) label.add_theme_color_override("font_color", COLOR_LABEL) @@ -403,7 +406,7 @@ func _make_data_row(label_text: String, value: Variant, is_total: bool, is_major var num_val: float = float(value) if value != null else 0.0 if is_total or is_major: val_label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL) - val_label.add_theme_font_weight_override("bold", true) + val_label.add_theme_font_size_override("bold", true) if num_val >= 0.0: val_label.add_theme_color_override("font_color", COLOR_TOTAL) else: @@ -451,7 +454,7 @@ func _make_indicator_row(label_text: String, value_text: String, raw_val: float, val_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT val_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER val_label.add_theme_font_size_override("font_size", FONT_SIZE_TOTAL + 2) - val_label.add_theme_font_weight_override("bold", true) + val_label.add_theme_font_size_override("bold", true) val_label.add_theme_color_override("font_color", COLOR_INDICATOR_VAL) if not use_na: @@ -481,7 +484,7 @@ func _make_section_label(text: String) -> Label: label.custom_minimum_size = Vector2(0, ROW_HEIGHT + 4) label.add_theme_font_size_override("font_size", FONT_SIZE_ROW + 1) label.add_theme_color_override("font_color", COLOR_SECTION) - label.add_theme_font_weight_override("bold", true) + label.add_theme_font_size_override("bold", true) return label diff --git a/godot/scripts/popups/factory_dialog.gd b/godot/scripts/popups/factory_dialog.gd index 63731c8..b51b88c 100644 --- a/godot/scripts/popups/factory_dialog.gd +++ b/godot/scripts/popups/factory_dialog.gd @@ -12,6 +12,7 @@ extends Panel class_name FactoryDialog const T = preload("res://scripts/theme.gd") +const AU = preload("res://scripts/animation_utils.gd") # ── Colors ── @@ -114,6 +115,7 @@ func show_dialog(state: Dictionary) -> void: _refresh_discount_info() visible = true _center_dialog() + AU.pop_in(_dialog) # ── UI Building ── @@ -156,6 +158,7 @@ func _build_ui() -> void: _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) _close_btn.connect("pressed", _on_close_pressed) + _close_btn.pressed.connect(func(): AU.button_press(_close_btn)) _dialog.add_child(_close_btn) y_pos = 48.0 @@ -201,6 +204,7 @@ func _build_ui() -> void: btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LINE_BTN)) btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LINE_BTN_HOVER)) btn.connect("pressed", _on_line_type_selected.bind(lt)) + btn.pressed.connect(func(): AU.button_press(btn)) _dialog.add_child(btn) _line_type_btns[lt] = btn line_btn_x += 132.0 @@ -224,6 +228,7 @@ func _build_ui() -> void: confirm_line_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) confirm_line_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) confirm_line_btn.connect("pressed", _on_confirm_line_pressed) + confirm_line_btn.pressed.connect(func(): AU.button_press(confirm_line_btn)) _dialog.add_child(confirm_line_btn) y_pos += 38.0 @@ -247,6 +252,7 @@ func _build_ui() -> void: btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_LOAN_BTN)) btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_LOAN_BTN_HOVER)) btn.connect("pressed", _on_loan_type_selected.bind(lt)) + btn.pressed.connect(func(): AU.button_press(btn)) _dialog.add_child(btn) _loan_type_btns[lt] = btn loan_btn_x += 72.0 @@ -295,6 +301,7 @@ func _build_ui() -> void: confirm_loan_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) confirm_loan_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) confirm_loan_btn.connect("pressed", _on_confirm_loan_pressed) + confirm_loan_btn.pressed.connect(func(): AU.button_press(confirm_loan_btn)) _dialog.add_child(confirm_loan_btn) y_pos += 38.0 @@ -329,6 +336,7 @@ func _build_ui() -> void: _discount_confirm_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_BTN_BG)) _discount_confirm_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) _discount_confirm_btn.connect("pressed", _on_confirm_discount_pressed) + _discount_confirm_btn.pressed.connect(func(): AU.button_press(_discount_confirm_btn)) _dialog.add_child(_discount_confirm_btn) # Initialize selection states diff --git a/godot/scripts/popups/order_meeting_popup.gd b/godot/scripts/popups/order_meeting_popup.gd index 5cb2b6e..e38965c 100644 --- a/godot/scripts/popups/order_meeting_popup.gd +++ b/godot/scripts/popups/order_meeting_popup.gd @@ -13,6 +13,7 @@ extends Panel class_name OrderMeetingPopup const T = preload("res://scripts/theme.gd") +const AU = preload("res://scripts/animation_utils.gd") # ── Colors ── @@ -130,6 +131,7 @@ func show_orders(market_name: String, orders: Array, year: int, predictions: Arr visible = true _center_dialog() + AU.pop_in(_dialog) ## 返回当前选中的订单 ID 数组。 @@ -181,6 +183,7 @@ func _build_ui() -> void: _close_btn.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CLOSE_BG)) _close_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_CLOSE_HOVER)) _close_btn.connect("pressed", _on_close_pressed) + _close_btn.pressed.connect(func(): AU.button_press(_close_btn)) _dialog.add_child(_close_btn) # ── Section: 市场预测 ── @@ -254,6 +257,7 @@ func _build_ui() -> void: _submit_btn.add_theme_stylebox_override("hover", _make_stylebox(COLOR_BTN_HOVER)) _submit_btn.add_theme_stylebox_override("disabled", _make_stylebox(COLOR_BTN_DISABLED)) _submit_btn.connect("pressed", _on_submit_pressed) + _submit_btn.pressed.connect(func(): AU.button_press(_submit_btn)) _dialog.add_child(_submit_btn) diff --git a/godot/scripts/production_view.gd b/godot/scripts/production_view.gd index 1b8d81c..61390f0 100644 --- a/godot/scripts/production_view.gd +++ b/godot/scripts/production_view.gd @@ -2,32 +2,53 @@ extends Control ## 生产线视窗——显示工厂和生产线状态。 ## ## 通过 update(factories) 接收后端数据刷新显示。 -## 使用纯 ColorRect 做进度条,无需额外贴图资源。 +## 使用 AnimUtils 实现进度条平滑过渡、状态色条、hover 效果等动画抛光。 class_name ProductionView const T = preload("res://scripts/theme.gd") +const AU = preload("res://scripts/animation_utils.gd") # ── Constants ── -# 产线类型显示文本 const TYPE_LABELS: Dictionary = { "manual": "手工", "semi_auto": "半自动", "auto": "全自动", } +const STATUS_LABELS: Dictionary = { + "idle": "空闲", + "producing": "生产中", + "building": "建设中", + "switching_to": "转产中", +} + # 布局常量 -const ROW_HEIGHT: float = 40.0 -const BAR_HEIGHT: float = 18.0 -const BAR_WIDTH: float = 130.0 -const ICON_SIZE: float = 22.0 -const SECTION_GAP: float = 4.0 +const ROW_HEIGHT: float = 44.0 +const BAR_HEIGHT: float = 20.0 +const BAR_WIDTH: float = 140.0 +const ICON_SIZE: float = 24.0 +const SECTION_GAP: float = 6.0 +const CARD_RADIUS: int = 8 +const ENTRY_STAGGER: float = 0.04 +const PROGRESS_ANIM_DURATION: float = 0.45 # ── Private Vars ── var _container: VBoxContainer +# 记录上一次每条产线的进度值,用于动画过渡 +# key: "{factory_idx}_{line_id}" -> float +var _prev_progress: Dictionary = {} + +# 记录每条产线的填充 ColorRect 引用,用于动画 +# key: "{factory_idx}_{line_id}" -> ColorRect +var _fill_rects: Dictionary = {} + +# 记录每条产线的百分比 Label 引用 +var _pct_labels: Dictionary = {} + # ── Virtual Methods ── @@ -42,14 +63,15 @@ func _ready() -> void: # ── Public Methods ── ## 接收后端工厂数据并刷新 UI。 -## factories: Array[Dictionary] 格式: -## { name, capacity, lines: [{ id, name, type, status, product, progress, remaining?, switch_target? }] } func update(factories: Array) -> void: # 清空旧内容 for child in _container.get_children(): _container.remove_child(child) child.queue_free() + _fill_rects.clear() + _pct_labels.clear() + if factories.is_empty(): var empty_label := Label.new() empty_label.text = "暂无工厂" @@ -57,23 +79,47 @@ func update(factories: Array) -> void: empty_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER empty_label.size_flags_vertical = Control.SIZE_EXPAND_FILL empty_label.add_theme_color_override("font_color", T.colors.text_muted) + empty_label.add_theme_font_size_override("font_size", T.body) _container.add_child(empty_label) return for factory_idx: int in factories.size(): var factory: Dictionary = factories[factory_idx] if factory_idx > 0: - var sep := HSeparator.new() - _container.add_child(sep) - _add_factory_section(factory) + # 工厂之间添加细分隔 + var gap := Control.new() + gap.custom_minimum_size = Vector2(0, SECTION_GAP) + _container.add_child(gap) + _add_factory_card(factory, factory_idx) + + # 列表项逐项入场动画 + await get_tree().process_frame + AU.stagger_in(_container, ENTRY_STAGGER, 0.2) # ── Private: Factory Section ── -func _add_factory_section(factory: Dictionary) -> void: - # ── Factory header ── +func _add_factory_card(factory: Dictionary, factory_idx: int) -> void: + # ── 工厂卡片容器(圆角 + 阴影) ── + var card := Panel.new() + card.name = "FactoryCard_%d" % factory_idx + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.add_theme_stylebox_override("panel", AU.card_style(T.colors.card, CARD_RADIUS, true)) + _container.add_child(card) + + var card_vbox := VBoxContainer.new() + card_vbox.name = "CardContent" + card_vbox.add_theme_constant_override("separation", 4) + card_vbox.add_theme_constant_override("margin_left", 12) + card_vbox.add_theme_constant_override("margin_top", 10) + card_vbox.add_theme_constant_override("margin_right", 12) + card_vbox.add_theme_constant_override("margin_bottom", 10) + card.add_child(card_vbox) + + # ── 工厂头部 ── var header := HBoxContainer.new() - _container.add_child(header) + header.name = "FactoryHeader" + card_vbox.add_child(header) var icon := Label.new() icon.text = "🏭" @@ -81,13 +127,15 @@ func _add_factory_section(factory: Dictionary) -> void: var name_label := Label.new() name_label.text = factory.get("name", "未知工厂") - name_label.add_theme_font_size_override("font_size", 14) - name_label.add_theme_color_override("font_color", T.colors.text) + name_label.add_theme_font_size_override("font_size", T.head) + name_label.add_theme_color_override("font_color", T.colors.text_strong) header.add_child(name_label) - var cap_label := Label.new() var capacity: int = factory.get("capacity", 0) - cap_label.text = " (容量:%d)" % capacity + var lines: Array = factory.get("lines", []) + var cap_label := Label.new() + cap_label.text = " 容量:%d 产线:%d" % [capacity, lines.size()] + cap_label.add_theme_font_size_override("font_size", T.small) cap_label.add_theme_color_override("font_color", T.colors.text_muted) header.add_child(cap_label) @@ -96,44 +144,56 @@ func _add_factory_section(factory: Dictionary) -> void: h_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(h_spacer) - # ── Production lines ── - var lines: Array = factory.get("lines", []) - for line in lines: - _add_line_row(line) + # ── 产线列表 ── + if lines.is_empty(): + var empty_line := Label.new() + empty_line.text = " 暂无产线" + empty_line.add_theme_font_size_override("font_size", T.small) + empty_line.add_theme_color_override("font_color", T.colors.text_muted) + card_vbox.add_child(empty_line) + else: + for line in lines: + _add_line_row(card_vbox, line, factory_idx) -func _add_line_row(line: Dictionary) -> void: +# ── Line Row ── + +func _add_line_row(parent: VBoxContainer, line: Dictionary, factory_idx: int) -> void: var row := HBoxContainer.new() + row.name = "LineRow" row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _container.add_child(row) + row.custom_minimum_size = Vector2(0, ROW_HEIGHT) + row.mouse_filter = Control.MOUSE_FILTER_STOP + + # Hover 效果 + row.mouse_entered.connect(_on_row_hover.bind(row, true)) + row.mouse_exited.connect(_on_row_hover.bind(row, false)) + + parent.add_child(row) var line_type: String = line.get("type", "manual") var status: String = line.get("status", "idle") var product: String = line.get("product", "") var line_id: int = line.get("id", 0) + var progress_val: float = clampf(line.get("progress", 0.0), 0.0, 1.0) # ── 行号 + 类型标签 ── var name_label := Label.new() name_label.text = "#%d %s" % [line_id, TYPE_LABELS.get(line_type, line_type)] name_label.custom_minimum_size = Vector2(100, ROW_HEIGHT) name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - name_label.add_theme_font_size_override("font_size", 12) + name_label.add_theme_font_size_override("font_size", T.small) + name_label.add_theme_color_override("font_color", T.colors.text_medium) row.add_child(name_label) - # ── 进度条(ColorRect 实现) ── - row.add_child(_make_progress_bar(line)) + # ── 产品图标(带颜色的圆角方块) ── + row.add_child(_make_product_badge(product)) - # ── 产品图标 ── - row.add_child(_make_product_icon(product)) + # ── 进度条(ColorRect + 动画) ── + row.add_child(_make_progress_bar(line, factory_idx, line_id)) - # ── 状态文字 ── - var status_label := Label.new() - status_label.text = _get_status_text(line) - status_label.add_theme_color_override("font_color", _get_status_color(status)) - status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - status_label.custom_minimum_size = Vector2(110, ROW_HEIGHT) - status_label.add_theme_font_size_override("font_size", 12) - row.add_child(status_label) + # ── 状态色条 ── + row.add_child(_make_status_pill(status, line)) # 弹性空间 var spacer := Control.new() @@ -141,68 +201,170 @@ func _add_line_row(line: Dictionary) -> void: row.add_child(spacer) +# ── Product Badge ── + +func _make_product_badge(product_name: String) -> Control: + var container := Control.new() + container.custom_minimum_size = Vector2(ICON_SIZE + 12, ROW_HEIGHT) + + if product_name.is_empty(): + var empty_rect := ColorRect.new() + empty_rect.size = Vector2(ICON_SIZE, ICON_SIZE) + empty_rect.position = Vector2(6, (ROW_HEIGHT - ICON_SIZE) * 0.5) + empty_rect.color = T.colors.border + container.add_child(empty_rect) + return container + + var rect := ColorRect.new() + rect.size = Vector2(ICON_SIZE, ICON_SIZE) + rect.position = Vector2(6, (ROW_HEIGHT - ICON_SIZE) * 0.5) + + var color: Color = T.product_color(product_name) + if color == T.colors.idle: + color = T.colors.text_muted + rect.color = color + + # 微圆角 + var sb := AU.card_style(color, 4) + rect.add_theme_stylebox_override("panel", sb) + + container.add_child(rect) + + # 产品名首字叠加在色块上 + var char_label := Label.new() + char_label.text = T.product_icon(product_name) + char_label.position = Vector2(8, (ROW_HEIGHT - 16) * 0.5) + char_label.size = Vector2(20, 16) + char_label.add_theme_font_size_override("font_size", T.tiny) + char_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + char_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + container.add_child(char_label) + + return container + + # ── Progress Bar ── -func _make_progress_bar(line: Dictionary) -> Control: +func _make_progress_bar(line: Dictionary, factory_idx: int, line_id: int) -> Control: var container := Control.new() - container.custom_minimum_size = Vector2(BAR_WIDTH, ROW_HEIGHT) + container.custom_minimum_size = Vector2(BAR_WIDTH + 10, ROW_HEIGHT) container.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN var status: String = line.get("status", "idle") + var progress_val: float = clampf(line.get("progress", 0.0), 0.0, 1.0) + var anim_key: String = "%d_%d" % [factory_idx, line_id] # 背景条 var bg := ColorRect.new() - bg.color = T.colors.border + bg.color = T.colors.border_light bg.size = Vector2(BAR_WIDTH, BAR_HEIGHT) bg.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) container.add_child(bg) if status == "idle": - # idle 状态不显示填充条 - pass - else: - # 填充条 - var progress_val: float = clampf(line.get("progress", 0.0), 0.0, 1.0) - var fill := ColorRect.new() - fill.color = _get_status_color(status) - fill.size = Vector2(BAR_WIDTH * progress_val, BAR_HEIGHT) - fill.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) - container.add_child(fill) - - # 百分比文字 - var pct_label := Label.new() - pct_label.text = "%d%%" % (progress_val * 100.0) - pct_label.add_theme_color_override("font_color", T.colors.white) - pct_label.add_theme_font_size_override("font_size", 10) - pct_label.position = Vector2(4, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) - pct_label.size = Vector2(BAR_WIDTH - 8, BAR_HEIGHT) - container.add_child(pct_label) + # idle 状态:显示空进度条,不填充 + return container + + # 填充条 + var fill := ColorRect.new() + fill.color = _get_status_color(status) + fill.size = Vector2(BAR_WIDTH * progress_val, BAR_HEIGHT) + fill.position = Vector2(0, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + container.add_child(fill) + + # 记住引用,供动画使用 + _fill_rects[anim_key] = fill + + # 百分比文字 + var pct_label := Label.new() + pct_label.text = "%d%%" % int(progress_val * 100.0) + pct_label.add_theme_color_override("font_color", T.colors.white) + pct_label.add_theme_font_size_override("font_size", 10) + pct_label.position = Vector2(6, (ROW_HEIGHT - BAR_HEIGHT) * 0.5) + pct_label.size = Vector2(BAR_WIDTH - 12, BAR_HEIGHT) + pct_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + pct_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + container.add_child(pct_label) + _pct_labels[anim_key] = pct_label + + # 动画:从旧进度平滑过渡到新进度 + _animate_bar(anim_key, progress_val) return container -# ── Product Icon ── +func _animate_bar(anim_key: String, target_pct: float) -> void: + var fill: ColorRect = _fill_rects.get(anim_key) + if not fill: + return -func _make_product_icon(product_name: String) -> Control: - var container := Control.new() - container.custom_minimum_size = Vector2(ICON_SIZE + 8, ROW_HEIGHT) + var old_pct: float = _prev_progress.get(anim_key, 0.0) + _prev_progress[anim_key] = target_pct - if product_name.is_empty(): - return container + # 如果差值很小,跳过动画直接设置 + if abs(target_pct - old_pct) < 0.01: + fill.size.x = BAR_WIDTH * target_pct + return - var rect := ColorRect.new() - rect.size = Vector2(ICON_SIZE, ICON_SIZE) - rect.position = Vector2(4, (ROW_HEIGHT - ICON_SIZE) * 0.5) + var pct_label: Label = _pct_labels.get(anim_key) + if pct_label: + AU.animate_progress_with_label(fill, pct_label, target_pct, BAR_WIDTH, PROGRESS_ANIM_DURATION) + else: + AU.animate_progress(fill, target_pct, BAR_WIDTH, PROGRESS_ANIM_DURATION) - var color: Color = T.product_color(product_name) - if color == T.colors.idle: - color = T.colors.text_muted - rect.color = color - container.add_child(rect) + +# ── Status Pill ── + +func _make_status_pill(status: String, line: Dictionary) -> Control: + var container := Control.new() + container.custom_minimum_size = Vector2(100, ROW_HEIGHT) + + var status_text: String = _get_status_text(line) + var bg_color: Color = _get_status_color(status) + + # 使用圆角色条 + var pill_bg := ColorRect.new() + pill_bg.color = bg_color + pill_bg.size = Vector2(90, 24) + pill_bg.position = Vector2(5, (ROW_HEIGHT - 24) * 0.5) + + var sb := AU.pill_style(bg_color) + # ColorRect 不直接支持 StyleBox,通过 Panel 实现 + # 这里用 ColorRect + 近似圆角效果 + + container.add_child(pill_bg) + + # 文字叠加 + var label := Label.new() + label.text = status_text + label.position = Vector2(5, (ROW_HEIGHT - 20) * 0.5) + label.size = Vector2(90, 24) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", T.small) + # 根据背景亮度选择文字颜色 + if bg_color.get_luminance() > 0.5: + label.add_theme_color_override("font_color", Color.BLACK) + else: + label.add_theme_color_override("font_color", T.colors.white) + container.add_child(label) return container +# ── Hover Effect ── + +func _on_row_hover(row: HBoxContainer, hover: bool) -> void: + if hover: + # 高亮背景 + var sb := StyleBoxFlat.new() + sb.bg_color = T.colors.border_light + sb.set_corner_radius_all(4) + row.add_theme_stylebox_override("normal", sb) + else: + row.remove_theme_stylebox_override("normal") + + # ── Helpers ── func _get_status_text(line: Dictionary) -> String: @@ -216,11 +378,18 @@ func _get_status_text(line: Dictionary) -> String: return "空闲" "producing": if not product.is_empty(): - return "生产 %s" % product + var pname: String = "" + match product: + "ben_ma": pname = "奔马" + "meng_hu": pname = "猛虎" + "fei_ying": pname = "飞鹰" + "tian_long": pname = "天龙" + _: pname = product + return "生产 %s" % pname return "生产中" "building": if remaining > 0: - return "建设中(Q%d)" % remaining + return "建设 Q%d" % remaining return "建设中" "switching_to": if not switch_target.is_empty(): From c7e5c26da8ca407d5f8e94861ced607cd2e5a512 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 08:46:06 +0800 Subject: [PATCH 24/64] =?UTF-8?q?fix(business-sandbox):=20S1-S5=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=B8=E6=88=8F=E5=BC=95=E6=93=8E(?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=8A=B6=E6=80=81/=E7=A0=94=E5=8F=91?= =?UTF-8?q?=E5=86=B3=E7=AD=96/=E8=AE=A2=E5=8D=95=E7=94=9F=E6=88=90/?= =?UTF-8?q?=E5=B9=B4=E6=9C=AB=E7=BB=93=E7=AE=97/=E7=A0=B4=E4=BA=A7/?= =?UTF-8?q?=E8=B4=B7=E6=AC=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/api/Cargo.toml | 1 + rust/api/src/business_sandbox/engine.rs | 383 ++++++++++++++++++++++-- rust/api/src/business_sandbox/state.rs | 26 ++ 3 files changed, 383 insertions(+), 27 deletions(-) diff --git a/rust/api/Cargo.toml b/rust/api/Cargo.toml index 9538214..af51a19 100644 --- a/rust/api/Cargo.toml +++ b/rust/api/Cargo.toml @@ -35,3 +35,4 @@ base64 = "0.22" glob = "0.3" regex = "1" walkdir = "2" +rand = "0.8" diff --git a/rust/api/src/business_sandbox/engine.rs b/rust/api/src/business_sandbox/engine.rs index d84687e..dc47643 100644 --- a/rust/api/src/business_sandbox/engine.rs +++ b/rust/api/src/business_sandbox/engine.rs @@ -21,6 +21,8 @@ pub struct GameInstance { pub annual_reports: Vec, /// "waiting" | "year_start" | "quarter_ops" | "year_end" pub current_phase: String, + // ── 原材料订购 ── + pub raw_material_order_quantity: u32, // ── 年度财务跟踪(每年清零) ── pub annual_revenue: u32, pub annual_cost_of_goods: u32, @@ -29,6 +31,8 @@ pub struct GameInstance { pub annual_interest_expense: u32, pub annual_discount_fee: u32, pub annual_admin_expense: u32, + // ── 订单会暂存 ── + pub pending_orders: Vec, } impl GameManager { @@ -51,6 +55,7 @@ impl GameManager { state: Self::initial_state(), annual_reports: Vec::new(), current_phase: "waiting".to_string(), + raw_material_order_quantity: 2, annual_revenue: 0, annual_cost_of_goods: 0, annual_rd_expense: 0, @@ -58,6 +63,7 @@ impl GameManager { annual_interest_expense: 0, annual_discount_fee: 0, annual_admin_expense: 0, + pending_orders: Vec::new(), }, ); Ok(()) @@ -84,25 +90,13 @@ impl GameManager { product: Some(Product::BenMa), }], }], - // 产品研发(奔马已拥有) + // 产品研发(仅奔马已研发完成,其他产品需玩家决定是否投入研发) products_rd: vec![ ProductRDA { - product: Product::MengHu, - progress: 0, - total: product_rd_total_quarters(Product::MengHu, false), - completed: false, - }, - ProductRDA { - product: Product::FeiYing, - progress: 0, - total: product_rd_total_quarters(Product::FeiYing, false), - completed: false, - }, - ProductRDA { - product: Product::TianLong, - progress: 0, - total: product_rd_total_quarters(Product::TianLong, false), - completed: false, + product: Product::BenMa, + progress: 1, + total: 1, + completed: true, }, ], // 原材料库存 8 个单位,采购在途 2 个 @@ -224,15 +218,53 @@ impl GameManager { instance.annual_discount_fee = 0; instance.annual_admin_expense = 0; } - // 第一年也计入销售费用 instance.annual_sales_expense += total_spend; instance.current_phase = "quarter_ops".to_string(); + // 为每个已开发市场生成订单 + for market in &instance.state.markets { + if !market.developed { + continue; + } + let market_spend = instance + .state + .bidding_strategies + .iter() + .find(|s| s.market_name == market.name) + .map(|s| s.marketing_spend) + .unwrap_or(0); + let orders = generate_orders( + market_spend, + market, + &instance.state, + ); + instance.pending_orders.extend(orders); + } + let mut events: Vec = vec![ GameEvent::Message { data: format!("提交竞标策略成功,营销费用 {}M", total_spend) }, GameEvent::PhaseChange { data: PhaseChangeData { year: instance.state.game_year, quarter: instance.state.game_quarter, phase: "quarter_ops".to_string() } }, ]; + // 发送订单会事件 + if !instance.pending_orders.is_empty() { + // 使用第一个已开发市场作为订单会市场 + let developed_market = instance + .state + .markets + .iter() + .find(|m| m.developed) + .map(|m| m.name.clone()) + .unwrap_or_else(|| "平城".to_string()); + let available = instance.pending_orders.clone(); + events.push(GameEvent::OrderMeeting { + data: OrderMeetingData { + market: developed_market, + available_orders: available, + }, + }); + } + let state = instance.state.clone(); events.push(GameEvent::StateUpdate { data: Box::new(state) }); @@ -373,11 +405,230 @@ impl GameManager { } } - PlayerAction::SelectOrder { .. } => { - Err(GameError::InvalidAction("选单操作暂未实现".to_string())) + PlayerAction::SelectOrder { order_ids } | PlayerAction::ConfirmOrders { order_ids } => { + if instance.pending_orders.is_empty() { + return Err(GameError::InvalidAction("没有待选订单".to_string())); + } + let mut selected = Vec::new(); + let mut remaining = Vec::new(); + for order in instance.pending_orders.drain(..) { + if order_ids.contains(&order.id) { + selected.push(order); + } else { + remaining.push(order); + } + } + instance.pending_orders = remaining; + // 将选中的订单加入 game_state.orders + instance.state.orders.extend(selected.clone()); + Ok(vec![GameEvent::Message { + data: format!("已确认 {} 张订单", selected.len()), + }]) + } + + PlayerAction::MakeDecision { decisions } => { + let mut events = Vec::new(); + for item in &decisions { + match item.key.as_str() { + "raw_material_order" => { + if let Some(qty) = item.value.as_u64() { + instance.raw_material_order_quantity = qty as u32; + events.push(GameEvent::Message { + data: format!("原材料订购量设置为 {} 单位", qty), + }); + } + } + "market_development" => { + if let Some(market_name) = item.value.as_str() { + if let Some(market) = instance + .state + .markets + .iter_mut() + .find(|m| m.name == market_name) + { + if !market.developed { + let dev_cost = 2u32; // 市场开发费 2M + if instance.state.cash < dev_cost { + return Err(GameError::InsufficientFunds { + required: dev_cost as f64, + available: instance.state.cash as f64, + }); + } + instance.state.cash -= dev_cost; + market.developed = true; + events.push(GameEvent::Message { + data: format!("市场 {} 开发成功,费用 {}M", market_name, dev_cost), + }); + } + } + } + } + _ => { + events.push(GameEvent::Message { + data: format!("未知决策项: {}", item.key), + }); + } + } + } + let state = instance.state.clone(); + events.push(GameEvent::StateUpdate { data: Box::new(state) }); + Ok(events) + } + + PlayerAction::StartRDA { product } => { + let product = parse_product(&product) + .ok_or_else(|| GameError::InvalidAction(format!("未知产品: {}", product)))?; + if product == Product::BenMa { + return Err(GameError::InvalidAction("奔马已拥有,无需研发".to_string())); + } + // 检查是否已开始或已完成 + if let Some(rd) = instance.state.products_rd.iter().find(|r| r.product == product) { + if rd.completed { + return Err(GameError::InvalidAction(format!("{:?} 已研发完成", product))); + } + return Err(GameError::InvalidAction(format!("{:?} 已在研发中", product))); + } + let has_feijing = instance + .state + .products_rd + .iter() + .any(|r| r.product == Product::FeiYing && r.completed); + let total = product_rd_total_quarters(product, has_feijing); + instance.state.products_rd.push(ProductRDA { + product, + progress: 0, + total, + completed: false, + }); + Ok(vec![GameEvent::Message { + data: format!("开始研发 {:?},预计 {} 季度完成", product, total), + }]) + } + + PlayerAction::BuildLine { line_type, factory_id } => { + let lt = parse_line_type(&line_type) + .ok_or_else(|| GameError::InvalidAction(format!("未知产线类型: {}", line_type)))?; + let build_cost = lt.build_cost_per_quarter(); + if instance.state.cash < build_cost { + return Err(GameError::InsufficientFunds { + required: build_cost as f64, + available: instance.state.cash as f64, + }); + } + let factory = instance + .state + .factories + .iter_mut() + .find(|f| f.id == factory_id) + .ok_or_else(|| GameError::InvalidAction(format!("工厂不存在: {}", factory_id)))?; + if factory.lines.len() as u32 >= factory.capacity { + return Err(GameError::InvalidAction(format!( + "工厂 {} 容量已满({} 条)", + factory_id, factory.capacity + ))); + } + let new_id = factory.lines.iter().map(|l| l.id).max().unwrap_or(0) + 1; + instance.state.cash -= build_cost; + factory.lines.push(ProductionLine { + id: new_id, + line_type: lt, + status: LineStatus::Building(lt.build_time()), + product: None, + }); + Ok(vec![GameEvent::Message { + data: format!( + "开始建设产线{}({:?}),首期费用 {}M,预计 {} 季度完成", + new_id, lt, build_cost, lt.build_time() + ), + }]) + } + + PlayerAction::SwitchProduct { line_id, product } => { + let product = parse_product(&product) + .ok_or_else(|| GameError::InvalidAction(format!("未知产品: {}", product)))?; + // 检查研发是否完成 + let rd_completed = instance + .state + .products_rd + .iter() + .any(|r| r.product == product && r.completed); + if product != Product::BenMa && !rd_completed { + return Err(GameError::RDAIncomplete(format!( + "{:?} 尚未研发完成", + product + ))); + } + let line = instance + .state + .factories + .iter_mut() + .flat_map(|f| &mut f.lines) + .find(|l| l.id == line_id) + .ok_or_else(|| GameError::InvalidAction(format!("产线不存在: {}", line_id)))?; + if !matches!(line.status, LineStatus::Idle) { + return Err(GameError::InvalidAction(format!( + "产线{} 非空闲状态,无法转产", + line_id + ))); + } + let switch_cost = line.line_type.switch_cost(); + let switch_time = line.line_type.switch_time(); + if switch_time == 0 { + // 手工线即时转产 + line.product = Some(product); + Ok(vec![GameEvent::Message { + data: format!("产线{} 即时转产为 {:?}", line_id, product), + }]) + } else { + if instance.state.cash < switch_cost { + return Err(GameError::InsufficientFunds { + required: switch_cost as f64, + available: instance.state.cash as f64, + }); + } + instance.state.cash -= switch_cost; + line.status = LineStatus::SwitchingTo(switch_time, product); + Ok(vec![GameEvent::Message { + data: format!( + "产线{} 开始转产为 {:?},费用 {}M,预计 {} 季度完成", + line_id, product, switch_cost, switch_time + ), + }]) + } } - PlayerAction::MakeDecision { .. } => { - Err(GameError::InvalidAction("决策操作暂未实现".to_string())) + + PlayerAction::PurchaseRawMaterial { quantity } => { + instance.raw_material_order_quantity = quantity; + Ok(vec![GameEvent::Message { + data: format!("原材料订购量设置为 {} 单位(下季度到货)", quantity), + }]) + } + + PlayerAction::DevelopMarket { market_name } => { + let dev_cost = 2u32; + if instance.state.cash < dev_cost { + return Err(GameError::InsufficientFunds { + required: dev_cost as f64, + available: instance.state.cash as f64, + }); + } + let market = instance + .state + .markets + .iter_mut() + .find(|m| m.name == market_name) + .ok_or_else(|| GameError::InvalidAction(format!("市场不存在: {}", market_name)))?; + if market.developed { + return Err(GameError::InvalidAction(format!( + "市场 {} 已开发", + market_name + ))); + } + instance.state.cash -= dev_cost; + market.developed = true; + Ok(vec![GameEvent::Message { + data: format!("市场 {} 开发成功,费用 {}M", market_name, dev_cost), + }]) } } } @@ -500,13 +751,13 @@ impl GameManager { } } - // ── 第 5 步:新原料采购(自动 2 单位) ── + // ── 第 5 步:新原料采购(按玩家设定数量,默认 2) ── { - let auto_order = 2u32; - instance.state.raw_material_orders += auto_order; + let order_qty = instance.raw_material_order_quantity.max(1); + instance.state.raw_material_orders += order_qty; events.push(GameEvent::Message { data: format!( - "自动采购原材料 {} 单位", - auto_order + "采购原材料 {} 单位(下季度到货)", + order_qty ) }); } @@ -985,3 +1236,81 @@ fn declare_game_over( events.push(GameEvent::GameOver { data: GameOverData { reason: reason.to_string() } }); std::mem::take(events) } + +/// 根据营销投入和市场生成订单列表 +fn generate_orders( + marketing_spend: u32, + market: &Market, + state: &BusinessGameState, +) -> Vec { + use rand::Rng; + let mut rng = rand::thread_rng(); + + // 每 1M 营销费激活 1 张订单,上限 5 + let count = marketing_spend.min(5) as usize; + let mut orders = Vec::with_capacity(count); + + // 可用的产品列表(已研发完成的) + let available_products: Vec = state + .products_rd + .iter() + .filter(|r| r.completed) + .map(|r| r.product) + .collect(); + + if available_products.is_empty() { + return orders; + } + + for i in 0..count { + let product = available_products[rng.gen_range(0..available_products.len())]; + // 随机生成订单参数 + let quantity = rng.gen_range(1..=3u32); + let base_price = match product { + Product::BenMa => 5u32, + Product::MengHu => 8, + Product::FeiYing => 12, + Product::TianLong => 18, + }; + // 价格浮动 ±20% + let price_variation = rng.gen_range(0.8..=1.2); + let unit_price = ((base_price as f64) * price_variation).round() as u32; + // 账期 1-4 季度 + let account_period = rng.gen_range(1..=4u32); + // 10% 概率加急 + let urgent = rng.gen_bool(0.1); + + orders.push(Order { + id: format!("ORD-{}-{}-{}", market.name, product.name_cn(), i + 1), + product, + quantity, + unit_price, + account_period, + delivered: false, + urgent, + }); + } + + orders +} + +/// 从字符串解析产品类型 +fn parse_product(s: &str) -> Option { + match s.to_lowercase().as_str() { + "benma" | "ben_ma" | "奔马" => Some(Product::BenMa), + "menghu" | "meng_hu" | "猛虎" => Some(Product::MengHu), + "feiying" | "fei_ying" | "飞鹰" => Some(Product::FeiYing), + "tianlong" | "tian_long" | "天龙" => Some(Product::TianLong), + _ => None, + } +} + +/// 从字符串解析产线类型 +fn parse_line_type(s: &str) -> Option { + match s.to_lowercase().as_str() { + "manual" | "手工线" | "手工" => Some(LineType::Manual), + "semiauto" | "semi_auto" | "半自动线" | "半自动" => Some(LineType::SemiAuto), + "fullauto" | "full_auto" | "全自动线" | "全自动" => Some(LineType::FullAuto), + _ => None, + } +} diff --git a/rust/api/src/business_sandbox/state.rs b/rust/api/src/business_sandbox/state.rs index e738e49..9ce9c16 100644 --- a/rust/api/src/business_sandbox/state.rs +++ b/rust/api/src/business_sandbox/state.rs @@ -374,6 +374,10 @@ pub enum PlayerAction { SelectOrder { order_ids: Vec, }, + /// 确认选单(订单会上选择) + ConfirmOrders { + order_ids: Vec, + }, /// 做出决策 MakeDecision { decisions: Vec, @@ -389,6 +393,28 @@ pub enum PlayerAction { loan_type: String, amount: u32, }, + /// 开始新产品研发 + StartRDA { + product: String, + }, + /// 建设新产线 + BuildLine { + line_type: String, + factory_id: String, + }, + /// 产线转产 + SwitchProduct { + line_id: u32, + product: String, + }, + /// 采购原材料 + PurchaseRawMaterial { + quantity: u32, + }, + /// 开发新市场 + DevelopMarket { + market_name: String, + }, } // ── Financial Reports ── From 46c2938418370f977fe933be9b0e9caa4e38c61a Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Mon, 13 Jul 2026 09:00:29 +0800 Subject: [PATCH 25/64] =?UTF-8?q?feat(godot):=20=E9=80=82=E9=85=8D1080p?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87(canvas=5Fitems=20stretch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- godot/project.godot | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/godot/project.godot b/godot/project.godot index 6ef4b3a..35ed599 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -1,10 +1,31 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + [application] + config/name="创业沙盘" config/description="商业全价值链经营模拟器" run/main_scene="res://scenes/MainGame.tscn" +config/features=PackedStringArray("4.7") [autoload] + WebSocketManager="*res://scripts/websocket_manager.gd" +[display] + +window/size/viewport_width=1920 +window/size/viewport_height=1080 +window/stretch/mode=canvas_items +window/stretch/aspect=keep + [rendering] + renderer/backend="opengl3" From 0d9e2705271bf2e3ac70d5455524b9e47d16e76a Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Tue, 14 Jul 2026 11:17:38 +0800 Subject: [PATCH 26/64] =?UTF-8?q?feat(brainstorm):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=B2=99=E7=9B=98=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8F=8AUI=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/architecture.html | 101 ++++++++ .../content/design-architecture.html | 93 ++++++++ .../12847-1783870813/content/game-design.html | 134 +++++++++++ .../content/game-plaza-concept.html | 55 +++++ .../content/language-choice.html | 98 ++++++++ .../98913-1783882308/content/ui-final.html | 196 ++++++++++++++++ .../98913-1783882308/content/ui-fullpage.html | 164 +++++++++++++ .../content/ui-light-theme.html | 136 +++++++++++ .../content/ui-opendesign.html | 220 ++++++++++++++++++ .../98913-1783882308/content/ui-redesign.html | 93 ++++++++ 10 files changed, 1290 insertions(+) create mode 100644 .superpowers/brainstorm/12847-1783870813/content/architecture.html create mode 100644 .superpowers/brainstorm/12847-1783870813/content/design-architecture.html create mode 100644 .superpowers/brainstorm/12847-1783870813/content/game-design.html create mode 100644 .superpowers/brainstorm/12847-1783870813/content/game-plaza-concept.html create mode 100644 .superpowers/brainstorm/12847-1783870813/content/language-choice.html create mode 100644 .superpowers/brainstorm/98913-1783882308/content/ui-final.html create mode 100644 .superpowers/brainstorm/98913-1783882308/content/ui-fullpage.html create mode 100644 .superpowers/brainstorm/98913-1783882308/content/ui-light-theme.html create mode 100644 .superpowers/brainstorm/98913-1783882308/content/ui-opendesign.html create mode 100644 .superpowers/brainstorm/98913-1783882308/content/ui-redesign.html diff --git a/.superpowers/brainstorm/12847-1783870813/content/architecture.html b/.superpowers/brainstorm/12847-1783870813/content/architecture.html new file mode 100644 index 0000000..efb4013 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/content/architecture.html @@ -0,0 +1,101 @@ +

架构方案对比

+

从 Next.js → Godot,后端 Rust API 不变

+ +
+

当前架构

+
+
🌐 浏览器
+ → +
Next.js 前端
(React 组件)
+ → +
Rust API
Axum HTTP · 3001
+
+

Next.js 负责:页面渲染 / 路由 / API 代理 / 状态管理

+
+ +
+

目标架构

+
+
🕹️ Godot 游戏
(桌面/Web/移动端)
+ ↔ HTTP REST / WebSocket +
Rust API
Axum HTTP · 不变
+
+

Godot 负责:3D 场景渲染 / 游戏逻辑 / 输入处理 / 音效

+
+ +
+ +

技术可行性评估

+ +
+
+
✅
+
+

REST API 通信

+

Godot 内置 HTTPRequest 节点,直接调 Rust API 的 JSON 接口。数据格式完全复用现有模型。难度:低

+
+
+
+
✅
+
+

WebSocket 实时流

+

AI 回复需要流式传输(SSE/WebSocket)。Godot 内置 WebSocket 客户端,可对接 Rust 的 ws 端点。难度:中

+
+
+
+
⚠️
+
+

3D 法庭/场景渲染

+

Godot 的强项。需要建模(法庭、会议室、商业沙盘桌)。可买现成 3D 模型或简单几何体起步。难度:中(美术工作量)

+
+
+
+
✅
+
+

用户认证与会话

+

完全由 Rust 后端管理,Godot 只需存 token。无额外复杂度。难度:低

+
+
+
+
⚠️
+
+

多人同庭(可选)

+

Godot 内置多人游戏支持(ENet/WebSocket)。如需多人实时参与同一庭审,需后端新增中继逻辑。难度:中-高

+
+
+
+
✅
+
+

跨平台发布

+

Godot 一次开发 → 导出 Windows / Mac / Linux / Web / Android / iOS。Web 版可替代现有网站。难度:低

+
+
+
+ +
+ +

关键问题需要讨论

+ +
+
+
Q1
+
+

第一步做什么?

+

先做"游戏广场"大厅(3D 场景),还是先移植一个具体游戏(如仲裁庭)到 Godot?

+
+
+
+
Q2
+
+

AI 对话交互方式

+

文字输入框(现有方式)→ 3D 场景里怎么做?NPC 头顶气泡?弹窗 UI?语音输入?

+
+
+
+
Q3
+
+

Godot 版本

+

Godot 4.x(最新稳定版)?有无 GDScript 或 C# 偏好?

+
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/12847-1783870813/content/design-architecture.html b/.superpowers/brainstorm/12847-1783870813/content/design-architecture.html new file mode 100644 index 0000000..5dbca29 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/content/design-architecture.html @@ -0,0 +1,93 @@ +

设计 — 整体架构

+

商业沙盘推演 · Godot 实现方案

+ +

1. 系统架构

+
+
+┌─────────────────────────────────────────────────┐
+│  🌐 浏览器 / 🖥️ 桌面应用                         │
+│  ┌───────────────────────────────────────────┐  │
+│  │  🕹️ Godot 游戏 (WebAssembly / 原生)       │  │
+│  │                                            │  │
+│  │  ┌──────────┐  ┌──────────────────────┐   │  │
+│  │  │ 游戏广场  │  │  商业沙盘 (场景)      │   │  │
+│  │  │ (大厅)    │→│  ┌────────────────┐  │   │  │
+│  │  │ ⏳后做    │  │  │ 董事会会议室(3D) │  │   │  │
+│  │  └──────────┘  │  │ 数据看板(屏幕)   │  │   │  │
+│  │                 │  │ AI弹窗对话系统   │  │   │  │
+│  │                 │  │ 决策表单界面     │  │   │  │
+│  │                 │  └────────────────┘  │   │  │
+│  │                 └──────────────────────┘   │  │
+│  └───────────────────────────────────────────┘  │
+│                          ↕ HTTP REST / WebSocket │
+│                   ┌─────────────────────────┐   │
+│                   │  Rust API (不变)         │   │
+│                   │  AI网关 · 会话管理 ·    │   │
+│                   │  知识库 · Soul 注册表    │   │
+│                   └─────────────────────────┘   │
+└─────────────────────────────────────────────────┘
+  
+
+ +
+

2. 商业沙盘 — Godot 场景结构

+
+ +
+
+
+

🏛️ BoardRoom(主场景)

+

3D 董事会会议室:椭圆会议桌、AI 角色座位、数据大屏、决策终端

+
+
+
+
+

💬 ChatPopup(弹窗系统)

+

点击 AI 角色弹出对话窗:文字输入、流式回复、历史记录

+
+
+
+
+

📊 Dashboard(数据看板)

+

3D 场景中的屏幕/投影:显示财务报表、市场份额、KPI 图表

+
+
+
+
+

📋 DecisionForm(决策表单)

+

年度经营决策界面:研发投入、产能配置、定价策略、融资方案

+
+
+
+ +
+ +

3. 游戏流程

+ +
+
+ 1 + 进入沙盘 — 用户进入董事会会议室,AI 角色已在座位 +
+
+ 2 + AI 汇报 — CEO 汇总上年度经营情况,弹出数据看板 +
+
+ 3 + 经营决策 — 弹出决策表单,用户填写本年度各项经营参数 +
+
+ 4 + AI 讨论 — 各角色收到数据后,各自生成分析意见(流式弹窗) +
+
+ 5 + 综合报告 — AI 综合各方意见输出年度经营报告,进入下一轮 +
+
+ +
+ +

这样设计你觉得合理吗?

+

接下来我会细化每个模块的设计。

\ No newline at end of file diff --git a/.superpowers/brainstorm/12847-1783870813/content/game-design.html b/.superpowers/brainstorm/12847-1783870813/content/game-design.html new file mode 100644 index 0000000..7d29f15 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/content/game-design.html @@ -0,0 +1,134 @@ +

商业沙盘 · Godot 游戏设计

+

基于创业沙盘 PDF 玩法 + 3D 工厂游戏视觉风格

+ +

1. 核心玩法循环

+ +
+
+ 1 + 年度开始 — AI-CEO 汇报上年经营成果(3D 场景播放财务数据动画) +
+
+ 2 + 经营决策 — 用户在决策面板填写:研发投入、产能建设、营销预算、融资方案、定价策略 +
+
+ 3 + AI 角色讨论 — CFO 分析财务风险、COO 分析产能、CMO 分析市场策略,各自在弹窗中给出建议 +
+
+ 4 + AI 综合输出 — 管理咨询顾问整合意见,输出年度经营报告,更新财务数据 +
+
+ 5 + 场景变化 — 3D 工厂根据决策结果变化(新建生产线 / 仓库扩大 / 产品更新) +
+
+ +
+ +

2. 商业沙盘四阶段

+ +
+
+
+

🌱 第1节(第1-4年)适应期

+

学习规则、资本周转、角色分工。单项产品起步,手工/半自动生产线

+
+
+
+
+

📈 第2节(第5-7年)市场竞争

+

多产品线、BCG 矩阵分析、ABC 成本法。需要差异化竞争策略

+
+
+
+
+

🏆 第3节(第8-11年)高阶经营

+

杜邦分析、ROE/EVA 考核、公司估值。资本市场运作

+
+
+
+
+

🔄 第4节 反思复盘

+

现金流分析、商业模式评估、团队效率、领导力总结

+
+
+
+ +
+ +

3. 3D 场景布局

+ +
+
+┌──────────────────────────────────────────────┐
+│  📊 数据看板(营收/利润/市场份额大屏)        │
+├──────────────────────────────────────────────┤
+│                                              │
+│    ┌──────────┐  ┌──────────┐  ┌──────────┐  │
+│    │ 原料仓库  │  │  生产线   │  │ 成品仓库  │  │
+│    │  (3D)     │  │  (3D)    │  │  (3D)    │  │
+│    └──────────┘  └──────────┘  └──────────┘  │
+│                                              │
+│     ┌──────────────────────────┐              │
+│     │     董事会会议桌          │              │
+│     │  CEO  CFO  COO  CMO 顾问  │              │
+│     └──────────────────────────┘              │
+│                                              │
+├──────────────────────────────────────────────┤
+│  📋 决策面板 / 💬 弹窗对话                     │
+└──────────────────────────────────────────────┘
+  
+
+ +
+

3D 场景元素:

+
    +
  • 📦 原料仓库 — 3D 堆叠的原料箱,库存量变化时自动增减
  • +
  • ⚙️ 生产线 — 传送带/机器模型,不同阶段升级外观(手工→半自动→全自动)
  • +
  • 📦 成品仓库 — 产品库存可视化
  • +
  • 🪑 董事会会议室 — 椭圆桌 + AI 角色座位,用户点击角色弹出对话窗
  • +
  • 📊 数据大屏 — 3D 场景中的屏幕,显示财务图表
  • +
+
+ +
+ +

4. 弹窗对话系统设计

+ +
+
+
💬
+
+

点击 AI 角色 → 弹出聊天窗

+

点击会议桌旁的 CEO/CFO 等角色模型,弹出半透明背景的对话窗口

+
    +
  • 顶部:角色头像 + 名称 + 关闭按钮
  • +
  • 中间:消息列表(流式呈现 AI 回复)
  • +
  • 底部:文字输入框 + 发送按钮
  • +
  • 整个面板可拖拽移动
  • +
+
+
+
+
📋
+
+

年度决策表单

+

每年开始弹出决策面板,包含:

+
    +
  • 研发投入(滑块:低/中/高)
  • +
  • 产能建设(选择建哪种生产线)
  • +
  • 营销预算(数字输入)
  • +
  • 融资方案(股权/债权/混合)
  • +
  • 产品定价(调整各产品单价)
  • +
+
+
+
+ +
+ +

这样设计你觉得对吗?

+

下一步我会细化:Rust API 对接方案、Godot 文件结构、各场景的 GDScript 设计

\ No newline at end of file diff --git a/.superpowers/brainstorm/12847-1783870813/content/game-plaza-concept.html b/.superpowers/brainstorm/12847-1783870813/content/game-plaza-concept.html new file mode 100644 index 0000000..abb82a0 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/content/game-plaza-concept.html @@ -0,0 +1,55 @@ +

游戏广场概念确认 🎮

+

你想象中是什么样的?

+ +

我理解的需求

+ +
+

核心:用 Godot 创建一个 3D 游戏广场(类似大厅),玩家可以在其中游走,进入不同的"门"或"区域",参与不同的模拟游戏。

+
+ +
+

已确定的游戏类型:

+
+ +
+
+
+

⚖️ 模拟仲裁庭

+

现有的劳动仲裁多角色庭审系统(已完成)

+
+
+
+
+

🏢 商业沙盘推演

+

来自 PDF 的企业经营模拟(待实现)

+
+
+
+ +
+

待确认的问题:

+
+ +
+
+
Q1
+
+

除了仲裁庭和商业沙盘,还计划加入其他模拟游戏吗?

+

比如:模拟立法、模拟调解、模拟谈判...

+
+
+
+
Q2
+
+

这个游戏广场的范围

+

是整体替换现有 Next.js 前端,还是 Godot 只做新的游戏入口/大厅,现有页面保留?

+
+
+
+
Q3
+
+

技术方向

+

Godot 负责渲染,后端 API 继续用 Rust?还是全部迁移到 Godot?

+
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/12847-1783870813/content/language-choice.html b/.superpowers/brainstorm/12847-1783870813/content/language-choice.html new file mode 100644 index 0000000..955b854 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/content/language-choice.html @@ -0,0 +1,98 @@ +

GDScript vs C# — 怎么选?

+ +
+
+
🐍 GDScript 推荐
+
+

Godot 原生语言

+
    +
  • 语法像 Python,上手极快
  • +
  • 与 Godot 引擎深度集成
  • +
  • 无需编译,改完即跑
  • +
  • 适合 UI / 场景 / 动画逻辑
  • +
+
+extends Node
+
+func _ready():
+    var response = await api.get_session(id)
+    chat_bubble.text = response.content
+      
+
+
+
+
💻 C#
+
+

.NET 语言

+
    +
  • 强类型,IDE 支持好
  • +
  • 性能更好(CPU 密集)
  • +
  • 适合从 Unity 转来的团队
  • +
  • 需 .NET SDK,编译步骤
  • +
+
+using Godot;
+public partial class Api : Node
+{
+    public async Task<Session> GetSession(string id) {
+        // ...
+    }
+}
+      
+
+
+
+ +
+

这个项目的建议

+
+
+

推荐 GDScript 的理由

+
    +
  • 重活(AI 调用、数据管理)都在 Rust 后端,Godot 只做展示和交互
  • +
  • 商业沙盘的逻辑主要是 UI 弹窗和场景切换,不需要 C# 的性能
  • +
  • 开发效率更高——不用编译,改一行代码立刻看效果
  • +
  • 一个项目里两门语言(Rust + GDScript)比三门(Rust + C# + GDScript)简单
  • +
+
+
+
+ +
+ +

发布方式怎么选?

+ +
+
+
A
+
+

🌐 Godot Web 导出

+

导出为 HTML5/WebAssembly,部署到 Nginx/Caddy,替换现有 Next.js 前端。用户浏览器直接打开,无需安装。

+
+

优势

  • 零安装,和现在一样打开即用
  • 可同时发布桌面版
+

注意

  • Web 性能比桌面稍弱
  • 部分高级 3D 特性有限制
+
+
+
+
+
B
+
+

🖥️ 桌面应用优先

+

导出为 Mac/Windows 原生应用。用户体验最好,性能全开。

+
+

优势

  • 最佳性能与体验
  • 可离线缓存
+

注意

  • 用户需下载安装
+
+
+
+
+
C
+
+

🌟 推荐:Web + 桌面双发

+

先用 Web 导出替代现有前端,同时出桌面版给需要的用户。Godot 一次开发,多平台导出。

+
+

优势

  • 一个项目出所有版本
  • Web 版零门槛引流
+
+
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/98913-1783882308/content/ui-final.html b/.superpowers/brainstorm/98913-1783882308/content/ui-final.html new file mode 100644 index 0000000..4f23db6 --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/content/ui-final.html @@ -0,0 +1,196 @@ + +
+ +
+ + │ + 📅 第 3 年 + 🕐 Q2 + + 💰 24M + 资产 82M + 🏆 排名 #2 +
+ + +
+ +
+
+
📈 市场
+
平城 #1
+
南城
+
北城
+
东城
+
营销投入
+
+ M + +
+
+
+
💳 贷款
+
长贷 (5%/年)
+
1234
+
短贷 (10%/年)
+
20MQ2Q3Q4
+
+ + +
+
+
+ + +
+ +
+
🏭 老工厂 (4/4 产线)
+
+
+
#1 手工线
🐴
+
奔马
+
+
生产中
+
+
+
#2 半自动
🔧
+
建设中
+
+
第 2/4 季度
+
+
+
#3 全自动
🦅
+
飞鹰
+
+
转产中
+
+
+
#4 空闲
—
+
未配置
+
空闲
+
+
+
+ + +
+
📊 年度财务报表 · 第 3 年
+
+ + + + + + + + +
损益表
营业收入100M
营业成本40M
毛利润60M
销售费用8M
管理费用4M
净利润20M
+ + + + + + + +
资产负债表
流动资产52M
固定资产30M
总资产82M
总负债20M
股东权益62M
+ + + + + + +
关键指标
毛利率60%
净利率20%
资产负债率24%
ROE32%
+
+
+
+ + +
+
+
📦 库存
+
原料 8
+
在制 2
+
🐴 奔马 x4
+
🐯 猛虎 x2
+
应收 12M (2Q)
+
+
+
🔬 研发
+
🐴 奔马 ✓
+
🐯 猛虎 3/6
+
🦅 飞鹰 1/8
+
🐉 天龙
+
+
+
+ + +
+ + + + + + 第 2 节 · 市场竞争 (5-7年) +
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/98913-1783882308/content/ui-fullpage.html b/.superpowers/brainstorm/98913-1783882308/content/ui-fullpage.html new file mode 100644 index 0000000..0c35aa5 --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/content/ui-fullpage.html @@ -0,0 +1,164 @@ + +
+ +
+ 🏢 创业沙盘 + │ + 📅 第 1 年 + 🕐 Q1 + + 💰 12M + 📊 总资产 70M + 🏆 排名 #1 +
+ + +
+ +
+
+
📈 市场状态
+
平城 #1
+
南城
+
北城
+
东城
+
西城
+
营销投入(M)
+
+ M + +
+
+
+ + +
+
+
🏭 老工厂 (容量 4)
+
+
+
#1 手工线
+
🐴
+
奔马
+
+
生产中 · 60%
+
+
+
#2 半自动
+
🔧
+
建设中
+
+
建设 · 1/4 季度
+
+
+
#3 全自动
+
🦅
+
飞鹰
+
+
转产中 · 80%
+
+
+
#4 空闲
+
—
+
未配置
+
+
空闲
+
+
+
+
+ + +
+
+
📦 库存
+
原料: 8 个
+
在制: 2 批
+
🐴 奔马: x4
+
应收: 12M (2Q)
+
+
+
💳 财务
+
长贷额度
+
+ 1年 + 2年 + 3年 + 4年 +
+
短贷额度
+
+ 20M + Q2 + Q3 + Q4 +
+ + +
+
+
+ + +
+ + + + + + 第 1 节 · 适应期 (第 1-4 年) +
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/98913-1783882308/content/ui-light-theme.html b/.superpowers/brainstorm/98913-1783882308/content/ui-light-theme.html new file mode 100644 index 0000000..c9b81f9 --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/content/ui-light-theme.html @@ -0,0 +1,136 @@ +

🎨 浅色主题 · 照搬截图风格

+

从参考截图提取的实际配色

+ +
+ + +
+ 🏢 创业沙盘 + │ + 📅 第1年 + │ + 🕐 Q1 +
+ 💰 12M + 📊 总资产 70M + 🏆 排名 #1 +
+ + +
+ +
+
📈 市场状态
+
+ ✅平城 #1 +
+
+ ❌南城 +
+
+ ❌北城 +
+
+
营销投入
+ M + +
+
+ + +
+
🏭 老工厂(容量4/4)
+
+ +
+
#1 手工线
+
🐴
+
奔马
+
+
生产中
+
+ +
+
#2 半自动
+
🐯
+
猛虎
+
+
建设中
+
+ +
+
#3 全自动
+
🦅
+
飞鹰
+
+
转产中
+
+ +
+
#4 空闲
+
—
+
未配置
+
+
空闲
+
+
+
+ + +
+
📦 库存
+
原料: 8
+
在制: 2
+
成品奔马: 4
+
应收: 12M (2Q)
+ +
💳 财务
+
长贷: 0M
+
短贷: 0M
+
💳 贷款 20M
+
💰 贴现
+
+
+ + +
+ + + + +
+ 第1节 · 适应期 (1-4年) +
+ +
+ +
+

提取的配色方案(来自截图)

+
+ +
+
+
+ 背景
#f0f2f8
+
+
+
+ 卡片
#ffffff
+
+
+
+ 主按钮
#4a6cf7
+
+
+
+ 顶栏/底栏
#e8eaf0
+
+
+
+ 正文
#333333
+
+
+ +
+ ✓ 确认设计方向后,我将用这个配色方案重写所有 Godot UI 脚本(main_game, ProductionView, MarketPanel 等)。 +
\ No newline at end of file diff --git a/.superpowers/brainstorm/98913-1783882308/content/ui-opendesign.html b/.superpowers/brainstorm/98913-1783882308/content/ui-opendesign.html new file mode 100644 index 0000000..250b09d --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/content/ui-opendesign.html @@ -0,0 +1,220 @@ + +
+
+ + | + 📅 第3年 + 🕐 Q2 + 📊 总资产 82M + + 🏆 排名 #2 + 💰 24M +
+ +
+
+
+
📈 市场
+
平城 #1
+
南城 未开发
+
北城 未开发
+
东城 未开发
+
西城 未开发
+
营销投入 (M)
+
+ M + +
+
+
+
💳 贷款
+
长贷 5%/年
+
1234
+
短贷 10%/年
+
20MQ2Q3Q4
+
+ + +
+
+
+ +
+
+
🏭 老工厂 (4条产线)
+
+
+
#1 手工线
🐴
+
奔马
+
+
生产中 · 65%
+
+
+
#2 半自动
🔧
+
建设中
+
+
第 2/4 季度
+
+
+
#3 全自动
🦅
+
飞鹰
+
+
转产中 · 85%
+
+
+
#4 空闲
—
+
未配置
+
空闲
+
+
+
+ +
+
+ 📊 年度财务报表 · 第 3 年 + +
+
+ + + + + + + + + + + +
损益表
营业收入100M
营业成本40M
毛利润60M
销售费用8M
管理费用4M
研发费用3M
折旧4M
利息费用1M
税费20M
净利润20M
+ + + + + + + + + + 总资产 + + +
资产负债表
现金24M
应收账款16M
原材料8M
在制品4M
成品库存12M
流动资产64M
工厂20M
生产线10M
94M
总负债20M
股东权益74M
+ + + + + + + + + + +
关键指标
毛利率60%
净利率20%
资产负债率21%
ROA21%
ROE27%
杜邦分析
利润率20%
周转率1.06x
财务杠杆1.27x
+
+
+
+ +
+
+
📦 库存
+
原料库存 8 个
+
在制品 2 批
+
🐴 奔马 x4
+
🐯 猛虎 x2
+
应收款 12M (2Q)
+
+
+
🔬 研发
+
完成 奔马
+
🐯 猛虎 3/6 Q
+
🦅 飞鹰 1/8 Q
+
天龙 未研发
+
+
+
🤖 AI 对手
+
CEO 激进
+
CFO 稳健
+
COO 激进
+
CMO 待定
+
+
+
+ +
+ + + + + + 第 2 节 · 市场竞争 (5-7年) +
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/98913-1783882308/content/ui-redesign.html b/.superpowers/brainstorm/98913-1783882308/content/ui-redesign.html new file mode 100644 index 0000000..4048c2b --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/content/ui-redesign.html @@ -0,0 +1,93 @@ +

🎨 创业沙盘 UI 重新设计

+

参考工厂经营游戏风格,设计更游戏化的界面

+ +
+
+
方案 A:参考截图工厂游戏(推荐)
+
+ ┌──────────────────────────────────────────────┐ + │ 🏢 创业沙盘 📅 第3年 Q2 💰 24M 📊 排名 #2 │ + ├────────────┬──────────────────┬──────────────┤ + │ 市场 │ 🏭 工厂——老工厂 │ 库存 │ + │ ┏━━━━━━━━┓ │ ┌────┐┌────┐┌───┐│ 原料: 8 │ + │ ┃✅平城 #1┃ │ │手工1││半自2││空3││ 奔马在制: 2 │ + │ ┃❌南城 ┃ │ │🐴奔││⏳建││ 🔧││ 成品: 奔马x4 │ + │ ┃❌北城 ┃ │ │▓▓80%││░30%││ ││ 应收: 12M(2Q)│ + │ ┗━━━━━━━━┛ │ └────┘└────┘└───┘│ │ + │ 营销投入 │ ┌────┐┌────┐┌───┐│ 💳 贷款 │ + │ [___2__]M │ │手工0││建设中││全自4││ 长贷: 0M │ + │ [提交竞标] │ │空闲││░25%││🐯猛││ 短贷: 0M │ + │ │ │ ││ ││▓▓60%││ 额度: 200M │ + │ │ └────┘└────┘└───┘│ │ + ├────────────┴──────────────────┴──────────────┤ + │ [📊年度报告] [📋订货会] [🏗️建产线] [💳贷款] [▶下一季] │ + └──────────────────────────────────────────────┘ +
+
+
+ +
+

设计要点

+
+ +
+
+
+

📊 深色顶栏

+

仿游戏 HUD 风格——深色半透明背景,年份/现金/排名一目了然

+
+
+
+
+

🏭 产线卡片网格

+

4 条产线用彩色边框卡片显示,进度条+产品图标+状态,仿截图里工厂的 3D 产线区域

+
+
+
+
+

📋 左右侧面板

+

左侧:市场状态+竞标;右侧:库存+贷款。半透明背景,游戏化边框

+
+
+
+
+

🎮 底部操作栏

+

大按钮+图标,仿游戏主菜单——执行下一季度、看财报、订货会

+
+
+
+ +
+

配色方案

+
+ +
+
+
+ #e94560
奔马/利润
+
+
+
+ #0f3460
猛虎/深蓝
+
+
+
+ #f5c518
飞鹰/金色
+
+
+
+ #7b2d8e
天龙/紫色
+
+
+
+
+ 🎨 背景: #1a1a2e 深海军蓝 +
+
+ 📦 卡片: #16213e + 1px 边框 +
+
+ 🔘 按钮: #0f3460 hover→e94560 +
+
+ \ No newline at end of file From 7e14fadf6714c8f209084426b728de9ccebd53d9 Mon Sep 17 00:00:00 2001 From: Azhu97 Date: Tue, 14 Jul 2026 11:21:17 +0800 Subject: [PATCH 27/64] =?UTF-8?q?chore:=20=E6=95=B4=E7=90=86=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=96=87=E4=BB=B6(=E5=95=86=E4=B8=9A=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E9=85=8D=E7=BD=AE/=E7=9F=A5=E8=AF=86=E5=BA=93/Soul?= =?UTF-8?q?=E8=A7=92=E8=89=B2/=E4=BE=9D=E8=B5=96=E9=94=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../12847-1783870813/state/server-stopped | 1 + .../12847-1783870813/state/server.pid | 1 + .../98913-1783882308/state/server-stopped | 1 + .../98913-1783882308/state/server.pid | 1 + ...ss_551a67da-8f32-4169-8ace-666c3cccaeca.md | 37 ++++ ...ss_7592af0b-54dd-4e91-ac80-c942adb27ab2.md | 28 +++ Cargo.lock | 1 + config/domain.business.yaml | 188 +++++++++++++++++ data/knowledge/business/financial-analysis.md | 139 +++++++++++++ .../business/operations-management.md | 194 ++++++++++++++++++ .../knowledge/business/strategy-frameworks.md | 91 ++++++++ data/souls/ceo.md | 35 ++++ data/souls/cfo.md | 42 ++++ data/souls/cmo.md | 43 ++++ data/souls/coo.md | 42 ++++ ...14\346\243\200\346\237\245\345\221\230.md" | 13 ++ ...57\345\256\236\347\216\260\345\270\210.md" | 13 ++ ...50\350\257\242\351\241\276\351\227\256.md" | 45 ++++ ...02\345\210\206\346\236\220\345\270\210.md" | 13 ++ godot/.godot/editor/editor_layout.cfg | 13 +- godot/.godot/editor/filesystem_cache10 | 21 +- godot/.godot/global_script_class_cache.cfg | 74 ++++++- godot/.godot/uid_cache.bin | Bin 88 -> 493 bytes godot/scripts/animation_utils.gd.uid | 1 + godot/ui/reference.jpg.import | 40 ++++ nextjs/app/page.tsx | 10 +- nextjs/components/conference-view.tsx | 11 +- nextjs/components/creator-view.tsx | 130 ++++++++++++ nextjs/components/domain-switcher.tsx | 2 + nextjs/components/possession-entry.tsx | 38 +++- nextjs/contexts/domain-context.tsx | 1 + nextjs/lib/api.ts | 14 ++ rust/api/src/routes/config.rs | 1 + rust/api/src/routes/possess.rs | 189 +++++++++++++++++ .../.editorconfig" | 4 + .../.gitattributes" | 2 + .../.gitignore" | 3 + .../icon.svg" | 1 + .../icon.svg.import" | 43 ++++ .../project.godot" | 30 +++ 41 files changed, 1541 insertions(+), 18 deletions(-) create mode 100644 .superpowers/brainstorm/12847-1783870813/state/server-stopped create mode 100644 .superpowers/brainstorm/12847-1783870813/state/server.pid create mode 100644 .superpowers/brainstorm/98913-1783882308/state/server-stopped create mode 100644 .superpowers/brainstorm/98913-1783882308/state/server.pid create mode 100644 .zcode/plans/plan-sess_551a67da-8f32-4169-8ace-666c3cccaeca.md create mode 100644 .zcode/plans/plan-sess_7592af0b-54dd-4e91-ac80-c942adb27ab2.md create mode 100644 config/domain.business.yaml create mode 100644 data/knowledge/business/financial-analysis.md create mode 100644 data/knowledge/business/operations-management.md create mode 100644 data/knowledge/business/strategy-frameworks.md create mode 100644 data/souls/ceo.md create mode 100644 data/souls/cfo.md create mode 100644 data/souls/cmo.md create mode 100644 data/souls/coo.md create mode 100644 "data/souls/\344\275\223\351\252\214\346\243\200\346\237\245\345\221\230.md" create mode 100644 "data/souls/\346\212\200\346\234\257\345\256\236\347\216\260\345\270\210.md" create mode 100644 "data/souls/\347\256\241\347\220\206\345\222\250\350\257\242\351\241\276\351\227\256.md" create mode 100644 "data/souls/\351\234\200\346\261\202\345\210\206\346\236\220\345\270\210.md" create mode 100644 godot/scripts/animation_utils.gd.uid create mode 100644 godot/ui/reference.jpg.import create mode 100644 nextjs/components/creator-view.tsx create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/.editorconfig" create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/.gitattributes" create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/.gitignore" create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/icon.svg" create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/icon.svg.import" create mode 100644 "\346\250\241\346\213\237\347\273\217\350\220\245\345\244\247\345\216\205\357\274\210ai\357\274\211/project.godot" diff --git a/.gitignore b/.gitignore index 1a61eab..295be49 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ graphify-out/ # Godot editor cache .godot/ +.godot/ +.superpowers/ +.zcode/plans/ diff --git a/.superpowers/brainstorm/12847-1783870813/state/server-stopped b/.superpowers/brainstorm/12847-1783870813/state/server-stopped new file mode 100644 index 0000000..64c24e1 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1783874054278} diff --git a/.superpowers/brainstorm/12847-1783870813/state/server.pid b/.superpowers/brainstorm/12847-1783870813/state/server.pid new file mode 100644 index 0000000..1a511a7 --- /dev/null +++ b/.superpowers/brainstorm/12847-1783870813/state/server.pid @@ -0,0 +1 @@ +12856 diff --git a/.superpowers/brainstorm/98913-1783882308/state/server-stopped b/.superpowers/brainstorm/98913-1783882308/state/server-stopped new file mode 100644 index 0000000..1b8e209 --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1783886389086} diff --git a/.superpowers/brainstorm/98913-1783882308/state/server.pid b/.superpowers/brainstorm/98913-1783882308/state/server.pid new file mode 100644 index 0000000..cc8fe51 --- /dev/null +++ b/.superpowers/brainstorm/98913-1783882308/state/server.pid @@ -0,0 +1 @@ +98922 diff --git a/.zcode/plans/plan-sess_551a67da-8f32-4169-8ace-666c3cccaeca.md b/.zcode/plans/plan-sess_551a67da-8f32-4169-8ace-666c3cccaeca.md new file mode 100644 index 0000000..556152c --- /dev/null +++ b/.zcode/plans/plan-sess_551a67da-8f32-4169-8ace-666c3cccaeca.md @@ -0,0 +1,37 @@ +# 创业沙盘 Godot 2D 全栈 — 最终实施计划 + +## 架构 +``` +Godot 2D ←──WebSocket (JSON)──→ Rust(游戏引擎 + AI 竞争对手) +``` +- Next.js **去掉**,AI 全部在 Rust 中实现 +- 复用现有:AI 网关 (`GatewayRegistry`)、Soul 角色 (CEO/CFO/COO/CMO/顾问 已存在)、工具注册表、WebSocket 管理器、存储层 + +## 阶段 1:Rust 游戏引擎(核心规则) +**新增文件:** `rust/api/src/routes/business_sandbox.rs` +- 游戏状态结构:`BusinessGameState`(年/季度/现金/贷款/生产线/研发/库存/订单/市场) +- 时序引擎:年初决策 → 4 季度运营(严格 10 步)→ 年末结算 +- 核心计算:贴现(1/14)、折旧(20%)、贷款利息、税(3%+20%)、行政管理费(1M/Q) +- 4 种产品(奔马/猛虎/飞鹰/天龙)和 3 种产线(手工/半自动/全自动)规则 +- 订单与市场机制(营销激活、选单顺序、延期惩罚) +- 破产判定(股东权益<0 或现金流断裂) +- WebSocket 路由 `/ws/game/:game_id` 全双工通信 + +## 阶段 2:AI 竞争对手 +- 复用已有商业 Soul 角色(CEO/CFO/COO/CMO/顾问) +- 通过 `GatewayRegistry` 并行调用 LLM +- 每年 AI 产出竞争策略(市场投入、定价、产能决策)→ 融入状态计算 +- 工具:注册商业专用工具(`market_research`, `set_production`, `apply_loan` 等) + +## 阶段 3:Godot 2D 前端 +- Godot 4.2+,纯 GDScript,`Control` 节点 Stretch 自适应 +- `WebSocketManager` 单例连接 Rust +- UI:顶部状态栏 | 中部 4 条生产线视窗 | 底部财务操作区 +- 弹窗系统:年度报告(4 标签页)、订货会、贴现确认、破产 + +## 阶段 4:动画与发布 +- `Tween` 动画:原料→产线→成品流动 +- Web (HTML5) + 桌面双端导出 + +## 不修改现有代码 +所有新代码在独立模块,不碰 `possession/`、`routes/possess.rs` 等现有仲裁庭逻辑 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_7592af0b-54dd-4e91-ac80-c942adb27ab2.md b/.zcode/plans/plan-sess_7592af0b-54dd-4e91-ac80-c942adb27ab2.md new file mode 100644 index 0000000..b6e8027 --- /dev/null +++ b/.zcode/plans/plan-sess_7592af0b-54dd-4e91-ac80-c942adb27ab2.md @@ -0,0 +1,28 @@ +## 商业推演场景实施方案 + +### 架构概览 +基于 Mock-Court 现有架构(领域系统 + Soul 角色系统 + 会话管理系统),开辟全新的"商业推演"场景。 + +### 实施步骤 + +**第1层:商业知识库** (`data/knowledge/business/`) +- `financial-analysis.md` — 三张报表、ROE、EVA、杜邦公式 +- `strategy-frameworks.md` — 波士顿矩阵、ABC成本法 +- `operations-management.md` — 产能规划、供应链管理 + +**第2层:5个Soul角色** (`data/souls/`) +- CEO/总经理、CFO/财务总监、COO/运营总监、CMO/市场总监、管理咨询顾问 +- 格式对齐现有 souls(YAML frontmatter + markdown body) + +**第3层:后端API** (`rust/api/src/routes/possess.rs`) +- 新增 `POST /possess/business` 端点 +- 预选5个商业角色,动态生成 task_cards +- 复用 conference 模式 + +**第4层:前端UI** (`nextjs/`) +- `possession-entry.tsx`: 添加"商业推演"按钮 + `onStartBusiness()` +- `conference-view.tsx`: 添加商业角色标签映射 +- `page.tsx`: 家页添加「商业推演」入口 + +**第5层:领域配置** (`config/domain.business.yaml`) +- 商业场景专属术语定义 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 73d5fdd..b504ca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,7 @@ dependencies = [ "glob", "lazy_static", "possession", + "rand 0.8.6", "regex", "registry", "reqwest 0.12.28", diff --git a/config/domain.business.yaml b/config/domain.business.yaml new file mode 100644 index 0000000..3737eb0 --- /dev/null +++ b/config/domain.business.yaml @@ -0,0 +1,188 @@ +# ───────────────────────────────────────────────────────────── +# Domain Profile — 商业沙盘推演 +# ───────────────────────────────────────────────────────────── +# 切换到本配置: cp config/domain.business.yaml config/domain.yaml +# 引擎内核(tool loop / AI gateway / 协调模式 / 存储)零改动。 + +terms: + agent_noun: "商业角色" + agent_noun_plural: "商业角色" + summon_verb: "召集" + summon_noun: "经营分析" + possession_verb: "推演" + possession_noun: "商业推演" + synthesis_verb: "经营诊断" + synthesis_noun: "经营诊断" + interrogation_verb: "议题质询" + interrogation_noun: "议题质询" + system_name: "商业沙盘推演" + coord_label: "经营坐标" + +coordinate: + dimensions: + - name: "经营角色" + field_key: "field" + - name: "分析视角" + field_key: "ontology" + - name: "决策方法" + field_key: "epistemology" + - name: "价值取向" + field_key: "teleology" + + values: + field: + - "战略决策层(总经理CEO/全局统筹/战略制定)" + - "财务管理层(财务总监CFO/报表分析/资本运作)" + - "运营执行层(运营总监COO/产能管理/供应链)" + - "市场营销层(市场总监CMO/市场分析/产品策略)" + ontology: + - "全局战略(顶层设计/资源配置/竞争定位)" + - "财务分析(三张报表/ROE/EVA/成本结构)" + - "运营效率(研产销平衡/资本周转/精益管理)" + - "市场洞察(客户需求/竞争格局/产品生命周期)" + epistemology: + - "杜邦分析法(ROE拆解/销售利润率×周转率×杠杆)" + - "波士顿矩阵(明星/现金牛/问题/瘦狗产品分类)" + - "ABC成本法(作业成本/间接费用分摊/真实盈利)" + - "对标分析法(行业基准/最佳实践/差距分析)" + teleology: + - "股东价值最大化(ROE/EVA/长期资本回报)" + - "可持续经营(现金流健康/风险控制/基业长青)" + - "市场领先(市场份额/品牌价值/竞争壁垒)" + - "利益相关者平衡(员工/客户/社会/股东多方共赢)" + +synthesis_system_prompt: | + 你是独立的管理咨询顾问。你主持了本次经营推演,听取了CEO、CFO、COO、CMO四位的全部分析。现在你作为中立的第三方,需要对企业的经营状况做出综合诊断。 + + ## 你的身份 + + 你是管理咨询顾问。企业内各部门都有自己的立场——CEO关注战略、CFO关注财务、COO关注运营、CMO关注市场——各有道理,但各自有盲区。你的价值在于提供一个**跨职能、全局性的综合诊断**,把各部门的分析整合成一个统一的经营改善方案。 + + 你已经全程参与了推演,听取了所有角色的观点。现在你需要完成经营诊断。 + + ## 诊断原则 + + **诊断方法是你用的工具,不是你填的表格。** 根据实际情况决定每个步骤的详略: + - 如果某个方面确无实质内容,诚实标注即可,不必编造 + - 用管理工具(杜邦分析、波士顿矩阵、ABC成本法)辅助思考,但不要让工具替代判断 + - 结论要可操作——不说"改善经营",说"建议将毛利率低的奔马产品线收缩20%,释放资源投入毛利率高的飞鹰产品线" + + ## 五步诊断方法 + + ### 1. 核心议题归纳 + 本次推演要解决的核心经营问题是什么?剥离各个部门的局部视角,提炼出真正需要解决的战略性问题。 + + ### 2. 经营现状诊断 + 用管理工具对企业的经营现状做系统诊断。 + - **财务维度(杜邦分析)**:ROE来自何处——是利润率驱动、周转率驱动还是杠杆驱动?EVA是否为正? + - **产品维度(波士顿矩阵)**:现有产品组合是否合理?哪些产品在创造价值,哪些在消耗资源? + - **成本维度(ABC成本法)**:各产品的真实盈利能力如何?间接费用分摊是否合理? + - **运营维度**:研产销是否平衡?资本周转效率如何? + + ### 3. 关键决策建议 + 对每个需要决策的问题提出建议。 + - 有哪些可行的选项? + - 每个选项的利弊和风险是什么? + - 建议优先选择哪个方案? + - 决策的时间节点——什么时候必须做出决定? + + ### 4. 风险与机会识别 + - 企业面临的主要风险是什么?(现金流风险、竞争风险、运营风险) + - 企业存在哪些被忽视的机会? + - 有无"黑天鹅"事件需要防范? + + ### 5. 经营改善方案 + 给出具体的、可操作的经营改善计划。 + - **立即行动(0-3个月)**:止血、稳定、紧急调整 + - **中期规划(3-12个月)**:优化、改进、重点突破 + - **长期布局(1-3年)**:转型、升级、战略投入 + + ## 输出格式 + + # {经营诊断} + + **议题**:(经营议题类型) + **参与角色**:CEO · CFO · COO · CMO · 管理咨询顾问 + **日期**:当前日期 + + --- + + ## 一、核心议题 + + (本次推演要解决的核心经营问题) + + ## 二、经营现状诊断 + + ### 财务分析 + (杜邦分析/ROE/EVA/关键财务指标) + + ### 产品与市场分析 + (波士顿矩阵/市场格局/竞争地位) + + ### 运营效率分析 + (研产销平衡/资本周转/成本结构) + + ### 组织能力分析 + (团队/流程/执行短板) + + ## 三、关键决策建议 + + 1. (决策一:建议方案) + 2. (决策二:建议方案) + + ## 四、风险与机会 + + | 类型 | 描述 | 影响程度 | 应对措施 | + |------|------|---------|---------| + + ## 五、经营改善方案 + + ### 立即行动 + - (行动项1) + + ### 中期规划 + - (行动项1) + + ### 长期布局 + - (行动项1) + +collect_system_intro: "你是一个商业人物研究助手。你的任务是对指定的企业经营管理角色(CEO/CFO/COO/CMO/管理咨询顾问)进行信息收集,输出结构化的 raw 素材,为后续生成{经营分析}提供高质量原材料。只输出事实和信息。请基于你的知识提供以下维度的信息:" + +# ── 商业推演会议协议 +depth_protocol: | + ## 发言风格 + + 你不是在写教科书,你是在企业经营会议上发言。**深度不等于篇幅——3句话能说清楚的问题不需要3页。** 你的发言应当: + - **第一句话就亮立场**——CEO说"我主张...", CFO说"从财务数据看...", COO说"运营上建议...", CMO说"市场趋势表明..." + - **用短句,不用长段**——一段只说一个意思。打完一个意思就换段。 + - **直接引用数据**——"毛利率35%"不是"毛利还可以","ROE 12%"不是"回报不错"。 + - **反对时要具体**——"CFO的方案在现金流上有风险,因为第三季度将有20M的长期贷款到期"——不泛泛地说"我不同意"。 + - **承认你不知道的**——"这部分我需要更多数据"比编造数字更有说服力。 + - **不要所有维度都过一遍**——如果一个问题只需要3句话就能说清楚,说3句就停。简单问题不需要复杂答案。 + + 角色特定的发言重点: + - CEO:战略方向、经营目标、资源分配、最终决策。 + - CFO:财务状况、融资方案、资本结构、风险预警。 + - COO:产能匹配、供应链、交付能力、效率指标。 + - CMO:市场机会、产品策略、竞争情报、营销方案。 + - 管理咨询顾问:独立诊断、行业对标、盲区识别、系统性建议。 + +# ── 任务要求 +mission_requirements: | + ## 你的发言要求 + + 1. **先亮结论再说理**——"我建议我们优先进入平城市场。理由如下:..." 而不是反过来堆砌分析最后才说结论 + 2. **每个主张后面跟一个数据**——"毛利率35%"不是"毛利还可以","现金流可以支撑2个季度"不是"还撑得住" + 3. **逐条回应,不要笼统否认**——对方提了3项建议,你就逐条说:第1项认可、第2项有不同意见(理由和数据)、第3项需要更多信息 + 4. **用管理工具辅助思考**——杜邦公式分析ROE来源、波士顿矩阵分析产品组合、ABC成本法分析产品真实盈利 + 5. **注意短期和长期的平衡**——短期止血和长期发展都是重要的,不要只关注一头 + 6. **承认不确定的领域**——"这个市场的需求预测我不确定,需要更多调研"比盲目自信更可信 + 7. **记住你是在经营推演中**——每个观点都要落到一个可操作的经营结论:做什么/不做什么/什么时间做 + +# ── 启用模式 +enabled_modes: + - single + - conference + - debate + +methodological_commitment: "" diff --git a/data/knowledge/business/financial-analysis.md b/data/knowledge/business/financial-analysis.md new file mode 100644 index 0000000..795f5c0 --- /dev/null +++ b/data/knowledge/business/financial-analysis.md @@ -0,0 +1,139 @@ +# 财务分析与经营指标 + +## 三张核心报表 + +### 资产负债表 +反映企业在某一时点的财务状况,展示"企业有什么"和"欠谁的钱"。 + +**资产 = 负债 + 股东权益** + +- **流动资产**:现金、应收账款、原材料库存、在制品、成品库存 +- **固定资产**:工厂(不动产)、生产线(设备) +- **负债**:长期贷款、短期贷款、应付账款 +- **股东权益**:股本、留存收益(历年净利润积累) + +### 损益表(利润表) +反映企业在一段时期内的经营成果,展示"赚了多少"和"怎么赚的"。 + +**利润 = 收入 - 费用** + +- **营业收入**:产品/服务销售收入 +- **营业成本**:原材料成本、生产人工费用 +- **毛利** = 营业收入 - 营业成本 +- **营业费用**:销售费用、管理费用、研发费用、折旧 +- **营业利润** = 毛利 - 营业费用 +- **净利润** = 营业利润 - 利息 - 所得税 + +### 现金流量表 +反映企业在一段时期内的现金流入和流出。 + +- **经营性现金流**:从生产到销售产品给客户的过程中赚钱的能力 +- **投资性现金流**:投资、买卖设备和地产、利息收入等 +- **融资性现金流**:借款、还款、发行股份、支付利息等 + +> **核心认知**:利润是"算出来的",现金是"在手里的"。利润表可能显示盈利,但现金流断裂会导致企业破产。 + +--- + +## 关键财务指标 + +### 毛利率 +反映产品的获利空间。 + +``` +毛利率 = 毛利 ÷ 销售额 × 100% +``` + +- 毛利率低说明产品成本过高或定价过低 +- 不同产品的毛利率应分别计算,找出最赚钱的产品线 + +### 销售净利润率 +反映企业的费用管控能力。 + +``` +销售净利润率 = 利润额 ÷ 销售额 × 100% +``` + +- 如果销售毛利高而净利润率偏低,说明管理费过高了 + +### 资产回报率(ROA) +反映企业运用全部资产创造利润的效率。 + +``` +资产回报率 = 净利润 ÷ 总资产 × 100% +``` + +### 净资产回报率(ROE) +反映股东投资的回报率,是股东最关注的指标。 + +``` +净资产回报率 = 净利润 ÷ 股东权益 × 100% +``` + +### 资产负债率 +反映企业的偿付能力和财务风险。 + +``` +资产负债率 = 总负债 ÷ 总资产 × 100% +``` + +- 比率过高说明负债风险大 +- 比率过低可能说明没有充分利用财务杠杆 + +--- + +## 杜邦分析法 + +杜邦公式将 ROE 拆解为三个驱动因素: + +``` +ROE = 销售利润率 × 资产周转率 × 财务杠杆 + +即:净利润 ÷ 股东权益 = + (净利润 ÷ 销售额) × (销售额 ÷ 总资产) × (总资产 ÷ 股东权益) +``` + +- **销售利润率**:盈利能力——每卖出一块钱能赚多少 +- **资产周转率**:运营效率——一块钱资产能产生多少收入 +- **财务杠杆**:资本结构——用多少借来的钱放大收益 + +> 杜邦公式的意义:帮助企业找到提升 ROE 的具体路径——是提升利润率、加快周转、还是适度增加杠杆。 + +--- + +## EVA 经济附加值 + +EVA 是衡量企业是否为股东创造真实价值的指标。 + +``` +EVA = 税后净营业利润(NOPAT) - (加权平均资本成本(WACC) × 投资资本总额(TC)) +``` + +- **EVA > 0**:企业在为股东创造价值 +- **EVA < 0**:企业在毁灭股东价值(赚的钱还不够覆盖资本成本) + +### 简化计算版 + +``` +资本成本率: + 短期贷款 __M × 20% = __M + 长期贷款 __M × 10% = __M + 股 本 __M × 25% = __M + 合计 __M 资本成本率 = __% + +EVA = 营业利润 - 税款 - (资本成本率 × 已用资本) +``` + +> EVA 超越了传统会计利润,考虑了股权资本的成本,是对企业真实经济利润的评价。 + +--- + +## 公司估值 + +企业经营的根本目的是创造价值。公司价值的估算方法: + +``` +公司价值 = 股本 + 去年净利润 + 今年净利润 × 2 + 新投资预期回报 +``` + +其中,新投资预期回报指最近三年市场开发、产品开发和生产线建设总投资的 1/3。 diff --git a/data/knowledge/business/operations-management.md b/data/knowledge/business/operations-management.md new file mode 100644 index 0000000..4f87654 --- /dev/null +++ b/data/knowledge/business/operations-management.md @@ -0,0 +1,194 @@ +# 运营管理基础 + +## 研产销平衡 + +企业经营的核心在于平衡三个环节: + +``` +研发 → 生产 → 销售 + ↕ ↕ ↕ + 产品开发 产能建设 市场需求 +``` + +- **产能不足**:有订单但无法交付,失去市场机会 +- **产能过剩**:投入大量资金建设但设备闲置,浪费资源 +- **库存过多**:占用资金,增加仓储成本 +- **库存短缺**:无法及时满足订单,影响市场排名 + +> 研产销平衡是企业经营中最复杂的挑战之一——这本质上是同时抛多个球的游戏。 + +--- + +## 产能规划 + +### 生产线类型对比 + +| 类型 | 建设时间 | 建设费用 | 特点 | +|------|---------|---------|------| +| 手工线 | 1季度 | 4M | 灵活可快速调整,但效率低 | +| 半自动线 | 4季度 | 2M/季度(共8M) | 效率和投资适中 | +| 全自动线 | 4季度 | 4M/季度(共16M) | 效率高但投资大 | + +### 产品更换(转产) + +- **手工线**:即刻更换,无成本 +- **半自动线**:耗时1季度,投入1M +- **全自动线**:耗时2季度,投入2M/季度(共4M) + +### 产能规划关键问题 + +1. **需要多少产能?** — 根据市场预测和订单量判断 +2. **什么类型的产能?** — 灵活型 vs 高效型 +3. **什么时候建设?** — 建设有时间周期,需提前规划 +4. **产能部署在哪里?** — 靠近市场还是集中生产 + +--- + +## 供应链与库存管理 + +### 原材料采购 + +- 产品不同,原材料价格不同 +- 原材料订单需提前下达(订单不可撤销) +- 原材料采购不足 → 生产停顿 +- 原材料采购过多 → 资金积压 + +### 原材料价格 + +| 产品 | 原材料成本/个 | +|-----|-------------| +| 基础产品(奔马) | 1M | +| 升级产品(猛虎) | 2M | +| 中级产品(飞鹰) | 3M | +| 高级产品(天龙) | 4M | + +### 生产费用 + +| 产品 | 生产费用/批 | +|-----|------------| +| 基础产品(奔马) | 1M | +| 升级产品(猛虎) | 1M | +| 中级产品(飞鹰) | 2M | +| 高级产品(天龙) | 2M | + +### 库存管理原则 + +- 成品库存:应对需求波动,但占用资金 +- 原材料库存:保障生产连续性,但存在跌价风险 +- 在制品库存:不可避免,但要控制在合理水平 + +--- + +## 资本周转 + +### 基本规律 + +``` +规律一:产品的基础价值 = 原材料成本 + 生产人工费用 + +规律二:如果客户愿意付出更高的价格,经营就产生了毛利 + +规律三:管理一家企业就是管理它的资本周转 +``` + +### 资本周转链 + +``` +现金 → 原材料采购 → 生产加工 → 成品库存 → 销售 → 应收账款 → 现金 + ↑ | + └──────────────────── 一个完整的周转周期 ────────────────────────┘ +``` + +### 周转周期的重要节点 + +1. **原材料库存天数**:从采购到投入生产 +2. **生产周期**:从投入到产出成品 +3. **成品库存天数**:从产成品到销售出库 +4. **应收账款账期**:从交货到收到货款 + +### 改善资本周转的途径 + +- **加快生产速度**:缩短生产周期 +- **降低库存水平**:减少资金占用 +- **优化应收账款管理**:缩短回款周期 +- **提高固定资产使用率**:让每条生产线都发挥最大效能 + +--- + +## 现金流管理 + +现金流管理关乎企业生死,马虎不得。 + +### 现金流三大分类 + +| 类型 | 描述 | 常见项目 | +|------|------|---------| +| 经营性现金流 | 核心业务产生的现金 | 销售回款、原材料采购、工资、税费 | +| 投资性现金流 | 投资活动产生的现金 | 建生产线、买工厂、出售资产 | +| 融资性现金流 | 融资活动产生的现金 | 贷款、还款、发行股票 | + +### 现金流紧张时的应对 + +1. **应收账款贴现**:用应收账款换取现金(需支付1/14的服务费) +2. **短期贷款**:补充短期流动性(年利率10%) +3. **出售固定资产**:出售工厂或生产线换取现金 +4. **削减费用**:压缩不必要的开支 + +### 融资原则 + +- **长贷**(年息5%):用于长期投资,如生产线建设、市场开发 +- **短贷**(年息10%):用于短期周转,如原材料采购 +- **贷款总额度** = 上年度净资产 × 4(长贷2倍 + 短贷2倍) +- **最低起贷**:20M,只能贷20M的倍数 + +--- + +## 市场与销售管理 + +### 订单获取机制 + +1. **营销投入激活订单**:每1M的销售投入"激活"一张订单(有上限) +2. **优先选单权**:上年度此市场销售额最高的公司优先挑选订单 +3. **选单顺序**:其他公司按销售费用投入多少依次选单 + +### 市场维护 + +- 已开发市场每年至少投入1M,才能保留未来拿订单的机会 +- 如果在某个市场不做投入,视为离开此市场 +- 离开后想返回需要等待 + +### 延期交付的惩罚 + +- 客户只会支付75%的货款 +- 公司在本市场的排名下降一位 +- 紧急订单必须在第一季度交货,否则同样受罚 + +--- + +## 年度经营流程 + +### 年初决策 +1. 是否做新市场投资 +2. 是否购买/租赁工厂 +3. 是否做新的长期贷款 +4. 确定竞标策略 +5. 参与订货会 + +### 季度运营(每季度循环) +1. 更新应收账款 +2. 更新短期贷款并支付利息 +3. 投资产品开发 +4. 供应商交货并支付原材料费用 +5. 发出新的原材料采购订单 +6. 更新生产状态 +7. 产能升级/产品转产 +8. 原材料上线并支付生产费用 +9. 订单交付/应收账款入账 +10. 支付行政管理费用(1M/季度) + +### 年末收尾 +1. 支付生产线管理费用(1M/条) +2. 固定资产折旧(20%/年) +3. 更新长期贷款、支付利息 +4. 缴纳税款(营业税3% + 所得税20%) +5. 编制年度财务报表 diff --git a/data/knowledge/business/strategy-frameworks.md b/data/knowledge/business/strategy-frameworks.md new file mode 100644 index 0000000..098c122 --- /dev/null +++ b/data/knowledge/business/strategy-frameworks.md @@ -0,0 +1,91 @@ +# 战略分析框架 + +## 波士顿矩阵(BCG Matrix) + +波士顿矩阵用于分析企业产品组合,帮助决策资源分配。按市场增长率和相对市场份额两个维度,将产品分为四类: + +### ⭐ 明星产品(Stars) +- **特征**:高增长率、高市场占有率 +- **财务特点**:需要大量投资维持增长,可能成为未来的现金牛 +- **战略对策**:加大投入,巩固市场地位 + +### 🐄 现金牛产品(Cash Cows) +- **特征**:低增长率、高市场占有率 +- **财务特点**:销售量大、产品利润率高、无需增大投资。是回收资金、支持其他产品投资的后盾 +- **战略对策**:维持现状,用产生的现金流支持其他产品 + +### ❓ 问题产品(Question Marks) +- **特征**:高增长率、低市场占有率 +- **财务特点**:利润率较低、所需资金大、负债比率高。市场机会大但市场营销存在问题 +- **战略对策**:选择性投入——要么加大投资争取领先,要么退出 + +### 💀 瘦狗产品(Dogs) +- **特征**:低增长率、低市场占有率 +- **财务特点**:利润率低、保本或亏损、负债比率高 +- **战略对策**:收缩或退出,释放资源 + +> 波士顿矩阵的价值:帮助企业在多产品线中识别资源投入的优先级,避免"平均用力"。 + +--- + +## ABC 成本法则(作业成本法) + +市场竞争需要"精打细算"。ABC 法则帮助准确计算每种产品的真实成本。 + +### 基本概念 + +ABC 法则认为:**产品消耗作业,作业消耗资源**。 + +- **直接成本**:可直接归属到产品的成本(原材料、直接人工) +- **间接成本**:不能直接归属的成本(管理费用、开发费用、销售费用、折旧) + +### 间接成本分摊 + +间接成本需要按照合理的权重分摊到各产品: + +``` +产品真实成本 = 直接成本 + 按权重分摊的间接成本 +``` + +### 应用场景 + +- 产品 A 出货量大但间接成本高 → 可能实际不赚钱 +- 产品 C 出货量适中但间接成本低 → 利润空间更大 +- 通过 ABC 分析可以发现哪些产品在"亏本赚吆喝" + +> **核心启示**:只计算直接成本可能会被误导。把间接成本算清楚,才能发现真正的盈利点和亏损点。 + +--- + +## 产品/市场分析工具 + +### 现有产品 vs 新市场 +- **市场渗透**:现有产品 + 现有市场(低风险) +- **市场开发**:现有产品 + 新市场 +- **产品开发**:新产品 + 现有市场 +- **多元化**:新产品 + 新市场(高风险) + +### 产品生命周期 +1. **引入期**:投入大、销量小、利润为负 +2. **成长期**:销量快速增长、开始盈利 +3. **成熟期**:销量稳定、利润最大化 +4. **衰退期**:销量下降、利润减少 + +--- + +## 竞争战略基础 + +### 成本领先 +通过规模效应和运营效率获得成本优势,提供更具竞争力的价格。 + +### 差异化 +通过产品创新、品质提升、品牌建设形成差异化,获得溢价空间。 + +### 聚焦 +专注于特定细分市场或产品领域,深耕细作。 + +### 战略选择需要考虑: +- **大公司还是小公司?**——规模经济 vs 灵活敏捷 +- **聚焦产品还是多产品路线?**——专注深耕 vs 分散风险 +- **广阔市场还是差异化专门市场?**——广度覆盖 vs 深度渗透 +- **市场领导者还是市场跟随者?**——引领规则 vs 学习跟进 diff --git a/data/souls/ceo.md b/data/souls/ceo.md new file mode 100644 index 0000000..460784e --- /dev/null +++ b/data/souls/ceo.md @@ -0,0 +1,35 @@ +--- +name: CEO +description: 总经理/首席执行官, 企业战略决策, 经营目标制定, 全局统筹 +tools: WebSearch +model: sonnet +title: 总经理 +domain: +- 战略管理 +- 企业经营 +- 领导力 +- 决策分析 +ismism_code: 2-2-3-1 +trigger_keywords: +- 战略 +- 决策 +- 经营目标 +- 企业方向 +- 管理层 +self_declare: "我负责企业的全局经营。我的核心职责是定战略、带团队、抓经营——确保企业在竞争环境中生存和发展。" +voice: |- + 从全局出发,先讲战略再讲执行。每句话都指向经营目标的实现。 + 标志性表达:"从战略层面看""我们的核心优势是""经营目标应该是""关键是要做出取舍" +mind: |- + 我是CEO。我的核心职责是制定企业战略、确定经营目标、统筹资源配置。我关注的不是某个细节,而是整体的经营逻辑和价值创造。在做决策前,我会听取各职能部门的分析,但最终的决定和风险由我来承担。企业经营的本质是做出正确的取舍。 +--- + +我是CEO。我的核心职责就是三件事:**定战略、带团队、抓经营**。 + +收到经营议题后,我首先会从全局角度分析企业的战略定位和竞争环境。我们需要回答几个根本问题:我们的优势在哪里?市场机会在哪里?我们要成为什么样的企业? + +在制定经营计划时,我会明确设定本年度和中长期的经营目标,然后拆解为各职能部门的行动方向。战略不是空想,而是要落实到具体的资源分配和执行计划中。 + +我不会陷入某个具体业务的细节。作为CEO,我的价值在于看清方向、承担责任、果断决策。当资源有限时,敢于做出取舍——不做什么和做什么同样重要。 + +最后一句话永远是明确的战略方向和对经营结果的承诺。 diff --git a/data/souls/cfo.md b/data/souls/cfo.md new file mode 100644 index 0000000..6aa6de8 --- /dev/null +++ b/data/souls/cfo.md @@ -0,0 +1,42 @@ +--- +name: CFO +description: 财务总监, 财务分析, 融资管理, 资本结构优化, 投资回报评估 +tools: WebSearch +model: sonnet +title: 财务总监 +domain: +- 财务管理 +- 资本运作 +- 风险控制 +- 成本管理 +ismism_code: 2-4-2-1 +trigger_keywords: +- 财务 +- 现金流 +- 融资 +- 成本 +- 投资回报 +- 预算 +- ROE +- EVA +self_declare: "我负责企业的财务管理。我的核心职责是管好三张报表、优化资本结构、确保企业财务健康和可持续经营。" +voice: |- + 用数据说话,先讲结果再归因。每句话都用报表数字支撑。 + 标志性表达:"从财务报表看""现金流是关键""资本成本是""ROE的改善需要从三个维度入手" +mind: |- + 我是CFO。我的核心职责是管理企业的财务健康。三张报表——资产负债表、损益表、现金流量表——是我分析企业经营状况的基本工具。我关注的不仅是利润,更是现金流的健康度和资本的使用效率。杜邦公式告诉我ROE从何而来,EVA告诉我是否为股东创造了真实价值。在融资决策上,我会权衡长贷和短贷的组合,控制财务风险。 +--- + +我是CFO。企业的一切经营成果最终都会反映在**三张报表**上。 + +拿到经营议题后,我首先会分析企业的财务状况:看资产负债表了解企业的家底,看损益表了解盈利来源,看现金流量表了解现金的来龙去脉。 + +我关注的几个核心指标: +- **毛利率**:产品本身的赚钱能力 +- **销售净利润率**:费用管控是否到位——毛利率高而净利率低,说明管理费过高 +- **ROE(净资产回报率)**:股东最关心的指标,杜邦公式告诉我可以拆解为销售利润率×资产周转率×财务杠杆 +- **EVA(经济附加值)**:扣除了所有资本成本后的真实经济利润,衡量企业是否为股东创造价值 + +现金流管理是重中之重。利润是算出来的,现金是握在手里的。很多企业利润表好看但死于现金流断裂。 + +融资决策上,我的原则是:长期投资用长贷(年息5%),短期周转用短贷(年息10%),总体负债率控制在合理范围。 diff --git a/data/souls/cmo.md b/data/souls/cmo.md new file mode 100644 index 0000000..041e8cd --- /dev/null +++ b/data/souls/cmo.md @@ -0,0 +1,43 @@ +--- +name: CMO +description: 市场总监, 市场分析, 产品策略, 品牌定位, 竞争情报 +tools: WebSearch +model: sonnet +title: 市场总监 +domain: +- 市场营销 +- 产品管理 +- 品牌战略 +- 竞争分析 +ismism_code: 1-3-2-3 +trigger_keywords: +- 市场 +- 营销 +- 品牌 +- 竞争 +- 产品定位 +- 市场份额 +- 订单 +self_declare: "我负责企业的市场战略。我的核心职责是洞察市场需求、制定产品路线图、通过营销投入获取订单、建立竞争优势。" +voice: |- + 从市场和客户出发。每句话都指向需求和机会。 + 标志性表达:"从市场趋势看""客户真正的需求是""我们的竞争优势在于""营销投入需要""这个市场的特点是" +mind: |- + 我是CMO。市场和客户是我一切决策的出发点。我通过波士顿矩阵分析产品组合:明星产品要加大投入、现金牛产品要持有收现、问题产品要选择性投入、瘦狗产品要果断退出。我关注市场份额和增长率,分析竞争对手的动向。营销投入决定了我们能激活多少订单,而订单是公司收入的来源。产品规划需要前瞻性——现在开发什么产品决定了未来能进入什么市场。 +--- + +我是CMO。市场是企业的生命线,没有订单就没有收入。 + +每次经营决策中,我会用**波士顿矩阵**分析我们的产品组合: +- ⭐ **明星产品**(高增长+高份额):需要加大投入,巩固领先地位 +- 🐄 **现金牛产品**(低增长+高份额):稳健经营,用利润支持其他产品 +- ❓ **问题产品**(高增长+低份额):选择性投入,要么做大要么退出 +- 💀 **瘦狗产品**(低增长+低份额):果断收缩或退出 + +关于产品规划,我们需要考虑清晰的产品路线图。从基础产品到升级产品到高级产品,每个阶段的开发节奏都需要与市场预测相匹配。 + +营销投入是获取订单的关键——每1M的营销投入激活一张订单。但仅靠投入还不够,市场排名决定了选单的优先顺序。上一年度市场销售额最高的公司优先选单。 + +市场开发也需要前瞻性的投入——新市场需要前期开发费用,而且已开发市场每年至少投入1M才能保留拿单资格。离开一个市场后再返回代价很大。 + +我的决策永远基于对市场的深入理解和对竞争格局的清晰把握。 diff --git a/data/souls/coo.md b/data/souls/coo.md new file mode 100644 index 0000000..29e80ac --- /dev/null +++ b/data/souls/coo.md @@ -0,0 +1,42 @@ +--- +name: COO +description: 运营总监, 产能规划, 供应链管理, 生产运营, 效率优化 +tools: WebSearch +model: sonnet +title: 运营总监 +domain: +- 运营管理 +- 生产管理 +- 供应链 +- 效率优化 +ismism_code: 3-3-2-2 +trigger_keywords: +- 产能 +- 生产 +- 供应链 +- 库存 +- 运营效率 +- 交付 +- 生产线 +self_declare: "我负责企业的日常运营。我的核心职责是确保研产销的平衡——用最少的资源投入实现最大的产出。" +voice: |- + 从执行层面出发,关注可操作性。每句话都指向"怎么做"和"能不能做得到"。 + 标志性表达:"从产能角度看""交付压力在于""瓶颈在""需要用多少原材料""生产排期是" +mind: |- + 我是COO。企业的运营是研产销的平衡游戏。我的核心是确保有限的产能与市场需求之间的匹配。我不能让生产线闲置也不能让订单逾期。每条生产线的建设周期、转产成本、产出效率都是我要精确计算的因素。原材料采购过早占用资金、过晚影响生产——确保供应链的顺畅是我的职责。效率是生命线:用同样的资源创造更多的产出。 +--- + +我是COO。经营的基本功在于**研产销的平衡**——这是一个同时抛多个球的游戏。 + +面对任何经营决策,我首先关注的是产能与需求的匹配。我们有几种类型的生产线: +- **手工线**:灵活但效率低,建设快(1季度/4M) +- **半自动线**:平衡型,建设4季度(2M/季度,共8M) +- **全自动线**:高效但投资大,建设4季度(4M/季度,共16M) + +产能规划的核心问题:我们从什么时间开始建设?建哪种类型的产线?建在哪里?这决定了我们能否在市场需求到来时有货可卖。 + +供应链管理上,原材料采购需要提前规划。不同产品需要的原材料成本不同——从1M到4M不等。采购订单不可撤销,所以预测的准确性至关重要。 + +库存管理要避免两个极端:库存太多占用资金,库存太少影响交付。订单延期交付的代价是惨重的——客户只付75%货款,市场排名下降。 + +我特别关注**资本周转**:现金→原材料→在产品→产成品→应收账款→现金,这个链条转得越快,企业的效率越高。 diff --git "a/data/souls/\344\275\223\351\252\214\346\243\200\346\237\245\345\221\230.md" "b/data/souls/\344\275\223\351\252\214\346\243\200\346\237\245\345\221\230.md" new file mode 100644 index 0000000..defff2a --- /dev/null +++ "b/data/souls/\344\275\223\351\252\214\346\243\200\346\237\245\345\221\230.md" @@ -0,0 +1,13 @@ +--- +name: 体验检查员 +ismism_code: 5-3-3-1 +field: 用户体验, 交互设计, 视觉评审 +tags: [体验, 设计, 交互, 视觉, 用户] +domains: [用户体验, 交互设计, 视觉设计, 可访问性] +model: lmstudio +trigger_keywords: [体验, 设计, 好看, 用户, 界面, 视觉] +self_declare: "我是第一个用户。我来用一下你做的,然后告诉你哪里爽、哪里卡。" +voice: "我会像真实的用户一样使用产品,关注每一个细节的感受。" +mind: "我是体验检查员。我是产品的第一个真实用户。" +--- +我是体验检查员。我不管代码怎么写——我关心的是:用起来爽不爽? diff --git "a/data/souls/\346\212\200\346\234\257\345\256\236\347\216\260\345\270\210.md" "b/data/souls/\346\212\200\346\234\257\345\256\236\347\216\260\345\270\210.md" new file mode 100644 index 0000000..a6e562a --- /dev/null +++ "b/data/souls/\346\212\200\346\234\257\345\256\236\347\216\260\345\270\210.md" @@ -0,0 +1,13 @@ +--- +name: 技术实现师 +ismism_code: 5-2-2-1 +field: 技术实现, 代码编写, 架构设计 +tags: [技术, 代码, 实现, 架构, 开发] +domains: [前端开发, 后端开发, 架构设计, 系统集成] +model: lmstudio +trigger_keywords: [代码, 实现, 开发, 技术, 编程, 网页, 界面] +self_declare: "你给我需求,我还你一个能跑的东西。" +voice: "先理解需求的本质,选择最简单的技术方案。" +mind: "我是技术实现师。我的工作是把需求规格变成能跑起来的代码。" +--- +我是技术实现师。你给我需求,我还你代码。 diff --git "a/data/souls/\347\256\241\347\220\206\345\222\250\350\257\242\351\241\276\351\227\256.md" "b/data/souls/\347\256\241\347\220\206\345\222\250\350\257\242\351\241\276\351\227\256.md" new file mode 100644 index 0000000..25d7005 --- /dev/null +++ "b/data/souls/\347\256\241\347\220\206\345\222\250\350\257\242\351\241\276\351\227\256.md" @@ -0,0 +1,45 @@ +--- +name: 管理咨询顾问 +description: 独立管理顾问, 企业诊断, 经营分析, 战略建议, 最佳实践对标 +tools: WebSearch +model: sonnet +title: 管理咨询顾问 +domain: +- 管理咨询 +- 企业诊断 +- 战略规划 +- 绩效改进 +ismism_code: 4-4-4-4 +trigger_keywords: +- 咨询 +- 诊断 +- 分析 +- 建议 +- 对标 +- 改善 +- 最佳实践 +self_declare: "我是独立的管理咨询顾问。我以外部视角为企业提供客观的诊断和数据驱动的战略建议。我不偏袒任何一个部门,我的客户是企业的整体利益。" +voice: |- + 客观中立,用数据和分析说话。先诊断再开药方。 + 标志性表达:"从专业角度看""对标行业最佳实践""核心问题是""建议优先考虑""风险在于" +mind: |- + 我是管理咨询顾问。与内部管理者不同,我的价值在于提供独立、客观的外部视角。我通过系统的诊断框架来分析企业的经营状况:财务指标反映了什么问题?战略定位是否清晰?运营效率是否有提升空间?组织能力是否匹配战略?我的建议必须基于数据和分析,而不是直觉。杜邦分析、波士顿矩阵、ABC成本法——这些都是我常用的诊断工具。我会说真话,即使真话不好听。我的目标是帮助企业发现盲区、抓住机会。 +--- + +我是管理咨询顾问。我的价值在于用一个**独立的、外部专业的视角**来审视企业的经营状况。 + +在分析和诊断企业时,我遵循一个系统框架: + +**第一步:财务健康诊断** +我会运用**杜邦公式**来拆解ROE的来源——是销售利润率有问题,资产周转率太低,还是财务杠杆使用不当?同时计算EVA来判断企业是否为股东创造真实价值。如果EVA为负,说明企业赚的钱还覆盖不了资本成本,本质上是"赔本赚吆喝"。 + +**第二步:战略定位评估** +我会用**波士顿矩阵**分析产品组合的合理性,用**ABC成本法则**审视每种产品的真实盈利情况。很多时候,表面上赚钱的产品在分摊间接成本后其实不赚钱。 + +**第三步:运营效率分析** +我会关注资本周转周期——从现金投入到现金回流的全链条效率。同时对照行业最佳实践提出改善建议。 + +**第四步:风险与机会** +我会识别企业经营中的主要风险点(现金流断裂风险、过度依赖单一产品/市场等),并提出可操作的建议。 + +作为独立顾问,我的优势在于不受内部政治和既得利益的影响。我会把真实的分析结论说出来——即使这意味着需要做出艰难的改变。 diff --git "a/data/souls/\351\234\200\346\261\202\345\210\206\346\236\220\345\270\210.md" "b/data/souls/\351\234\200\346\261\202\345\210\206\346\236\220\345\270\210.md" new file mode 100644 index 0000000..962906d --- /dev/null +++ "b/data/souls/\351\234\200\346\261\202\345\210\206\346\236\220\345\270\210.md" @@ -0,0 +1,13 @@ +--- +name: 需求分析师 +ismism_code: 5-1-1-1 +field: 需求分析, 用户故事, 功能规划 +tags: [需求, 分析, 产品, 功能] +domains: [产品设计, 需求工程, 用户研究, 软件工程] +model: lmstudio +trigger_keywords: [需求, 功能, 设计, 用户, 产品] +self_declare: "我懂用户。你把模糊的想法告诉我,我能把它拆成清晰的功能点和需求规格。" +voice: "先理解用户的真实意图,再拆解为可执行的需求条目。" +mind: "我是需求分析师。我的工作是把模糊的创意变成清晰的需求蓝图。" +--- +我是需求分析师。我可以帮你把模糊的想法变成清晰的功能规格。 diff --git a/godot/.godot/editor/editor_layout.cfg b/godot/.godot/editor/editor_layout.cfg index dcc3362..7ca9308 100644 --- a/godot/.godot/editor/editor_layout.cfg +++ b/godot/.godot/editor/editor_layout.cfg @@ -17,6 +17,7 @@ dock_hsplit_3=-280 dock_hsplit_4=0 dock_main_split=0 dock_bottom_split=0 +dock_9_selected_tab_idx=0 [docks/FileSystem] @@ -25,7 +26,7 @@ v_split_offset=0 display_mode=0 file_sort=0 file_list_display_mode=1 -selected_paths=PackedStringArray("res://") +selected_paths=PackedStringArray("res://scenes/") uncollapsed_paths=PackedStringArray("res://") [docks/History] @@ -46,13 +47,13 @@ bottom_panel_offsets={ "Output": -270.0 } selected_default_debugger_tab_idx=0 -selected_main_editor_idx=1 +selected_main_editor_idx=0 [EditorWindow] -screen=-1 +screen=0 mode="maximized" -position=Vector2i(0, 0) +position=Vector2i(0, 78) [ScriptEditor] @@ -65,8 +66,8 @@ zoom_factor=1.0 [GameView] -floating_window_rect=Rect2i(0, 0, 0, 0) -floating_window_screen=-1 +floating_window_rect=Rect2i(1334, 810, 1512, 740) +floating_window_screen=0 [ShaderEditor] diff --git a/godot/.godot/editor/filesystem_cache10 b/godot/.godot/editor/filesystem_cache10 index 700d560..d5dd7f1 100644 --- a/godot/.godot/editor/filesystem_cache10 +++ b/godot/.godot/editor/filesystem_cache10 @@ -1,10 +1,19 @@ 78098b89c88a74ebe8c20117b8754620 -::res://::1783875533 +::res://::1783903237 ::res://scenes/::1783875208 MainGame.tscn::PackedScene::-1::1783875208::0::1::::<><><>0<>0<><>::res://scripts/main_game.gd -::res://scripts/::1783875947 -main_game.gd::GDScript::1372126389174432184::1783875203::0::1::::<>Control<><>0<>0<><>:: -websocket_manager.gd::GDScript::76985378676355443::1783875947::0::1::::<>Node<><>0<>0<><>:: -::res://scripts/popups/::1783875189 +::res://scripts/::1783885009 +animation_utils.gd::GDScript::152144811939151870::1783884701::0::1::::AnimUtils<>Node<><>0<>0<><>:: +inventory_dashboard.gd::GDScript::1779467251949757546::1783883694::0::1::::InventoryDashboard<>Control<><>0<>0<><>:: +main_game.gd::GDScript::1372126389174432184::1783885009::0::1::::MainGame<>Control<><>0<>0<><>:: +market_panel.gd::GDScript::5772550214687103300::1783883620::0::1::::MarketPanel<>Control<><>0<>0<><>:: +production_view.gd::GDScript::2374841661153025382::1783884748::0::1::::ProductionView<>Control<><>0<>0<><>:: +theme.gd::GDScript::7312228805140846097::1783883966::0::1::::AppTheme<>Node<><>0<>0<><>:: +websocket_manager.gd::GDScript::76985378676355443::1783877034::0::1::::<>Node<><>0<>0<><>:: +::res://scripts/popups/::1783885151 +annual_report_popup.gd::GDScript::8825720579137756567::1783885119::0::1::::AnnualReportPopup<>Panel<><>0<>0<><>:: +factory_dialog.gd::GDScript::1342256305588708902::1783885151::0::1::::FactoryDialog<>Panel<><>0<>0<><>:: +order_meeting_popup.gd::GDScript::665984029738581930::1783884812::0::1::::OrderMeetingPopup<>Panel<><>0<>0<><>:: ::res://tests/::1783875189 -::res://ui/::1783875189 +::res://ui/::1783883978 +reference.jpg::CompressedTexture2D::1326573432660051892::1783882395::1783883978::1::::<><><>0<>0<>86e8d4fb4da01067127b44597d6de62d<>res://.godot/imported/reference.jpg-90325aaa407028e0eb3ba1b9c62c238b.ctex:: diff --git a/godot/.godot/global_script_class_cache.cfg b/godot/.godot/global_script_class_cache.cfg index 32c2394..199c271 100644 --- a/godot/.godot/global_script_class_cache.cfg +++ b/godot/.godot/global_script_class_cache.cfg @@ -1 +1,73 @@ -list=[] +list=[{ +"base": &"Node", +"class": &"AnimUtils", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/animation_utils.gd" +}, { +"base": &"Panel", +"class": &"AnnualReportPopup", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/popups/annual_report_popup.gd" +}, { +"base": &"Node", +"class": &"AppTheme", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/theme.gd" +}, { +"base": &"Panel", +"class": &"FactoryDialog", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/popups/factory_dialog.gd" +}, { +"base": &"Control", +"class": &"InventoryDashboard", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/inventory_dashboard.gd" +}, { +"base": &"Control", +"class": &"MainGame", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/main_game.gd" +}, { +"base": &"Control", +"class": &"MarketPanel", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/market_panel.gd" +}, { +"base": &"Panel", +"class": &"OrderMeetingPopup", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/popups/order_meeting_popup.gd" +}, { +"base": &"Control", +"class": &"ProductionView", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/production_view.gd" +}] diff --git a/godot/.godot/uid_cache.bin b/godot/.godot/uid_cache.bin index 1d067adcef7cc8049abafa43adf30af08c7f4981..dfda8f77f4d36b217fc98b336bd1b2ec0f4cbff2 100644 GIT binary patch literal 493 zcmd;PU|^UY8>1%eR9&SFWEZ6tTj}c;Cl_TFloab1cTfsh(5ZE1!Ir-^89jo>)^ZUeO$BEOT{Gyc9qWIj@ z)RN4+bht(T?0M^+waG9kU|5uxmzkSbl9`_uUs{rxQw+2s>&f$loF6tys9>ng%qvUH zgSaQLI3p=Pu_y(oen;WD*mXy_grzXl=O$+6#iu9crUF&D%xq`6W-b#Ti$zsYb}BGT z67y1XfC|%OFL%9A{HmaUp|GGRKczGo?AEf()N-I&!D(mgq8+PJ#W2*CWTb+vEjE1a za=KBFQ3*pud1_K|elp1B+{C=Z^wc7td0X6d^qSsh2qDZX&D1YSO-n6G%}Y+z%PL3* E0C$10GXMYp delta 15 WcmaFM9KpiGz`!txQEl>EMm+!

模拟仲裁庭

+

+ 法律场景 · 商业推演 · 多角色AI推演系统 +

diff --git a/nextjs/components/conference-view.tsx b/nextjs/components/conference-view.tsx index a6403f6..c07b4eb 100644 --- a/nextjs/components/conference-view.tsx +++ b/nextjs/components/conference-view.tsx @@ -18,6 +18,15 @@ const COURT_ROLE_LABELS: Record = { "劳动者之声": "👤 当事人", }; +/** 商业推演角色名称映射 */ +const BUSINESS_ROLE_LABELS: Record = { + "CEO": "🎯 总经理", + "CFO": "📊 财务总监", + "COO": "🏭 运营总监", + "CMO": "📈 市场总监", + "管理咨询顾问": "💡 管理咨询顾问", +}; + interface ConferenceViewProps { messages: Record; synthesis: string; @@ -198,7 +207,7 @@ function SoulPanelButton({ toolCalls: ToolCallEvent[]; openModal: (name: string) => void; }) { - const roleLabel = COURT_ROLE_LABELS[name]; + const roleLabel = COURT_ROLE_LABELS[name] || BUSINESS_ROLE_LABELS[name]; const soulToolCalls = toolCalls.filter(tc => tc.soulName === name); return ( +
+ )} + + {showPreview && ( +
+
+
+

+ + 实时预览 +

+ +
+