Update campus behavior

This commit is contained in:
wjsjwr 2026-01-11 19:51:49 +08:00
parent 39682f14fe
commit fc5db6496f
45 changed files with 2716 additions and 19 deletions

View File

@ -171,5 +171,3 @@
* **策略性:** 不只是堆数值,而是在“做学术”和“搞政治”之间做平衡。
* **情感反馈:** 从初期的无助,到中期的纠结,再到后期的“屠龙少年终成恶龙”或“桃李满天下”的感动。
===

View File

@ -0,0 +1,179 @@
# 校园行为系统文档
本文档描述校园行为系统相关代码与配置,
覆盖 `scripts/Campus`、`scripts/Core`、`scripts/Models`
以及 `resources/definitions` 下的所有相关文件。
## 1. 系统概览
- 行为系统基于“优先级决策 + 有限状态机”。
- 决策优先级Critical(危机) -> AssignedTask(指令任务)
-> Needs(需求) -> Trait(特质偏好) -> Idle(闲置)。
- 行为数值由 `campus_behavior.json` 驱动,
动作效果按“每秒”变化。
- 所有行为通过 `GD.Print` 输出到控制台。
## 2. 代码文件说明
### 2.1 `scripts/Campus`
- `scripts/Campus/CampusBehaviorConfig.cs`
- 定义行为系统枚举与配置结构。
- 提供 `CampusBehaviorConfig` JSON 加载。
- 提供 `CampusLocationRegistry``CampusBehaviorWorld`
- 定义 `CampusTask``CampusAgentNeeds`
- `scripts/Campus/CampusBehaviorAgent.cs`
- 定义 `CampusAgentRuntime`、`CampusBehaviorIntent`、`CampusBehaviorContext`。
- 实现 Planner 与多级 Provider。
- 实现状态机Decision/Move/Action。
- `CampusBehaviorAgent` 负责每帧驱动与打断。
- `CampusTraitIds` 提供行为特质 Id 常量。
### 2.2 `scripts/Core`
- `scripts/Core/ContentCollectionResource.cs`
- 资源集合容器,可把多个定义资源打包到 `.tres`
- `scripts/Core/ContentRegistry.cs`
- 内容加载与合并入口,支持 Resource/JSON 源。
- `scripts/Core/ContentResources.cs`
- 内容资源接口:`IContentResource`/`IContentResourceCollection`。
- `scripts/Core/DisciplineDefinitionResource.cs`
- 学科定义 `.tres``DisciplineDefinition` 的映射。
- `scripts/Core/DomainEvents.cs`
- 领域事件:任务完成/失败、回合结束。
- `scripts/Core/EventBus.cs`
- 轻量事件总线 `DomainEventBus`
- `scripts/Core/GameController.cs`
- 回合阶段切换控制器。
- `scripts/Core/GameSession.cs`
- 会话入口,聚合 State/Content/Systems/Events/Localization。
- `CreateDefault()` 负责加载 `.tres` 与 JSON。
- `scripts/Core/GameSystems.cs`
- 系统集合Turn/Task/Economy/Synergy/Assignment。
- `scripts/Core/LocalizationService.cs`
- 多语言接口与 Godot 实现。
- `scripts/Core/ModManifest.cs`
- Mod 清单与包信息结构。
- `scripts/Core/Mvc.cs`
- MVC 接口与 `ModelView<TModel>` 基类。
- `scripts/Core/StatResolver.cs`
- 数值解析器(羁绊/学科/特质/装备叠加)。
### 2.3 `scripts/Models`
- `scripts/Models/CoreIds.cs`
- 核心内容 Id 常量(学科/羁绊/角色等)。
- `scripts/Models/DefinitionSupport.cs`
- 通用定义结构:`LocalizedText` 与 `DefinitionHeader`
- `scripts/Models/DisciplineDefinitions.cs`
- 学科定义 `DisciplineDefinition``DisciplineBuff`
- `scripts/Models/DomainEnums.cs`
- 统一枚举:`AttributeType`、`ResourceType`、`StatusType`。
- `scripts/Models/GameContentDatabase.cs`
- 定义数据汇总库(学科/羁绊/任务/装备等)。
- `scripts/Models/GameState.cs`
- 全局运行时状态:回合/经济/人员/任务/库存/羁绊/肉鸽。
- `scripts/Models/ItemDefinitions.cs`
- 装备/设施/消耗品定义与分类枚举。
- `scripts/Models/MentorModel.cs`
- 导师模型与资源组件(能量)。
- `scripts/Models/Modifiers.cs`
- 通用数值修饰结构(属性/状态/资源 + RuleIds
- `scripts/Models/PaperDefinitions.cs`
- 论文卡牌定义(等级与元数据)。
- `scripts/Models/PropertyValue.cs`
- 通用数值类型,带范围与运算符重载。
- `scripts/Models/RogueliteDefinitions.cs`
- 肉鸽继承定义(校友录/传承/职称保留)。
- `scripts/Models/StaffModel.cs`
- 雇员/合作者模型(博后/小老师)与动机组件。
- `scripts/Models/StatusValue.cs`
- 带阈值的状态值(压力/理智/忠诚等)。
- `scripts/Models/StudentModel.cs`
- 学生模型(类型、进度、贡献记录)。
- `scripts/Models/SynergyDefinitions.cs`
- 羁绊/职业/特质定义与叠层结构 `SynergyTier`
- `scripts/Models/Task.cs`
- 运行时任务模型 `TaskModel` 与进度数据结构。
- `scripts/Models/TaskDefinitions.cs`
- 任务定义与枚举(类型/难度/奖励/需求)。
- `scripts/Models/UnitComponents.cs`
- Unit 组件结构(身份/属性/状态/标签/分配/位置/装备)。
- `scripts/Models/UnitModel.cs`
- Unit 组合容器,聚合上述组件。
## 3. 配置文件说明(`resources/definitions`
### 3.1 `resources/definitions/campus_behavior.json`
行为系统核心数值配置,动作数值按秒生效。
全局阈值与衰减:
- `CriticalSanityThreshold`:理智过低触发人工湖。
- `CriticalStaminaThreshold`:体力过低触发强制休息。
- `CriticalStressThreshold`:压力过高触发人工湖。
- `HungerThreshold`:饥饿过低触发食堂。
- `EnergyThreshold`:精力过低触发睡觉/咖啡。
- `SocialThreshold`:社交过低触发社交行为。
- `LowMoodThreshold`:心情过低触发宿舍休息。
- `HungerDecayPerSecond`:基础饥饿衰减。
- `EnergyDecayPerSecond`:基础精力衰减。
- `StaminaDecayPerSecond`:基础体力衰减。
- `StressGrowthPerSecond`:基础压力增长。
- `SocialDecayPerSecond`:基础社交衰减。
- `DecisionIntervalSeconds`:打断/重评估间隔。
ActionConfigs
- `ActionId`:动作枚举名。
- `LocationId`:地点枚举名。
- `DurationSeconds`:动作持续时间。
- `HungerDelta`/`EnergyDelta`/`StaminaDelta`。
- `StressDelta`/`MoodDelta`/`SocialDelta`。
- `SanityDelta`/`HealthDelta`。
### 3.2 `resources/definitions/archetypes.json`
- 数组形式的 `ArchetypeDefinition`
- `Header``Id`、`Name`、`Description`、`Tags`。
- `Tiers`:叠层配置列表。
- `RequiredCount`:触发层数。
- `Modifiers`:数值修饰与 `RuleIds`
### 3.3 `resources/definitions/roles.json`
- 数组形式的 `RoleDefinition`
- `Header`、`Tiers` 与 archetypes 一致。
- `AllowedDisciplineIds`:学科限制列表。
### 3.4 `resources/definitions/traits.json`
- 数组形式的 `TraitDefinition`
- `Header`Id 与名称说明。
- `Modifiers`:数值修饰与 `RuleIds`
### 3.5 `resources/definitions/disciplines.json`
- 数组形式的 `DisciplineDefinition`
- `Header`:学科 Id、名称、描述、Tags。
- `Buff`学科被动Name/Description/Modifiers
- `RolePoolIds`:角色池 Id 列表。
- `ItemPoolIds`:装备池 Id 列表。
- `TaskKeywordIds`:任务关键词 Id 列表。
### 3.6 `resources/definitions/discipline_biology.tres`
- Godot 资源版学科定义(`DisciplineDefinitionResource`)。
- 主要字段:
- `Id`/`NameKey`/`NameFallback`/`DescriptionKey`/`DescriptionFallback`。
- `IconPath`/`Tags`。
- `BuffNameKey`/`BuffNameFallback`。
- `BuffDescriptionKey`/`BuffDescriptionFallback`/`BuffRuleIds`。
- `RolePoolIds`/`ItemPoolIds`/`TaskKeywordIds`。
## 4. 规则扩展建议
- 新增地点:扩展 `CampusLocationId`,并添加 `Locations` 标记。
- 新增动作:扩展 `CampusActionId`,并添加 ActionConfig。
- 新增 AI 规则:新增 Provider 并加入 `BuildProviders()`

View File

@ -0,0 +1,132 @@
# 校园角色行为规则文档
**版本:** 1.0
**依据:** 基于 `design.md`, `任务与经济系统.md`, `学科与流派系统.md`, `装备与设施系统.md`, `角色与羁绊系统.md` 整合生成。
---
## 1. 行为逻辑总述 (General Behavior Logic)
在游戏的“自走阶段”30秒自由行动角色学生/导师/职工)并非仅仅是执行任务的机器,而是拥有**生理需求**、**心理状态**和**个人性格**的自主智能体。
角色的行为决策遵循以下**优先级队列**
1. **生存危机 (Critical State):** 压力爆表San值过低或 体力耗尽 $\rightarrow$ 强制中断当前行为,前往特定场所恢复。
2. **导师指令 (Assigned Task):** 玩家手动拖拽或分配的任务 $\rightarrow$ 前往对应的工作场所(实验室/图书馆/机房)。
3. **生理/心理需求 (Needs):** 饥饿、轻度疲劳、需要社交 $\rightarrow$ 前往食堂、寝室或咖啡店。
4. **特质驱动 (Trait-Driven):** 基于“卷王”、“摸鱼党”等标签的自发行为 $\rightarrow$ 加班或闲逛。
5. **闲置/游荡 (Idle):** 无事可做时在校园内随机移动或触发彩蛋交互。
---
## 2. 场所行为详解 (Location Behaviors)
校园分为8个核心区域每个区域对应特定的行为模式和属性变化。
### 2.1 实验室 (Laboratory)
* **功能定义:** 科研生产的核心区域,进行“学术探索”任务。
* **进入条件:**
* 被分配了【实验类】任务(如“产出论文草稿”)。
* 具有【卷王】特质的角色在闲置时会自动进入。
* 具有【炼金术士】、【实验狗】标签的角色偏好此地。
* **行为状态:**
* **做实验 (Experimenting):** 消耗体力,增加压力,产出任务进度。
* **仪器操作 (Operating):** 只有装备了特定道具(如移液枪)或特定职业角色才会触发的高效动作。
* **特殊事件:**
* *爆炸:* 化学/生化类角色有概率触发,导致周围角色停止动作并扣血。
* *甚至不是人AI成精* 不进入实验室,而是通过网络远程占用设备。
### 2.2 图书馆 (Library)
* **功能定义:** 知识获取与文本生产区域。
* **进入条件:**
* 被分配了【写作类】任务(如“论文润色”、“文献综述”)。
* 具有【笔杆子】、【思想者】、【写手】标签的角色偏好此地。
* 角色处于“查阅资料”状态(任务进度卡住时触发)。
* **行为状态:**
* **静默写作 (Silent Writing):** 持续产出,低噪音。
* **寻找灵感 (Seeking Inspiration):** 在书架间移动,不产出进度,但增加下一次产出的暴击率。
### 2.3 食堂 (Canteen)
* **功能定义:** 生理补给站。
* **进入条件:**
* 角色【饥饿值】低于阈值。
* 【大胃王】特质的角色会频繁进入。
* 午餐/晚餐时间(游戏内特定时间段)引发群体移动。
* **行为状态:**
* **进食 (Eating):** 消耗金钱(玩家资金),快速恢复体力。
* **吐槽 (Gossiping):** 进食时与邻座角色交互随机恢复心情或传播“摸鱼”Buff。
### 2.4 寝室 (Dormitory)
* **功能定义:** 休息与娱乐San值避风港。
* **进入条件:**
* 角色【体力】极低或【心情】较差。
* 具有【摸鱼党】特质的角色在工作间隙溜入。
* 【极客】职业的角色可能在寝室进行“远程办公”(如果有相关羁绊)。
* **行为状态:**
* **睡眠 (Sleeping):** 大幅恢复体力,时间较长。
* **打游戏/刷剧 (Gaming/Chilling):** 恢复心情,恢复少量体力。
* **甚至不回寝室:** 【卷王】(6层羁绊) 会无视寝室需求,直接在工位打地铺。
### 2.5 人工湖 (Artificial Lake)
* **功能定义:** 心理急救中心。
* **进入条件:**
* 角色处于**【崩溃 (Mental Breakdown)】**状态(压力满)。
* 角色处于**【顿悟 (Epiphany)】**前摇状态(常见于【思想者】职业)。
* **行为状态:**
* **发呆/看鸭子 (Staring):** 角色停止一切响应,缓慢降低压力值。若被打断,压力值回弹。
* **跳河 (Suicide):** 极低概率触发,因为长期处于崩溃状态导致角色退出。
### 2.6 咖啡店 (Coffee Shop)
* **功能定义:** 兴奋剂补给站Buff获取点。
* **进入条件:**
* 角色【精力】下降但仍有任务在身。
* 具有【咖啡因依赖】特质的角色必须定期进入否则通过Debuff降低效率。
* 【富家子弟】偏好的休息场所。
* **行为状态:**
* **购买饮料 (Buying):** 消耗金钱获得短时“攻速提升”Buff。
* **商务洽谈 (Business Talk):** 【大忽悠】或【经济学】角色在此可能触发“获得额外经费”的小事件。
### 2.7 行政楼 (Administration Building)
* **功能定义:** 财务报销与行政手续处理。
* **进入条件:**
* 任务阶段涉及到“报销”、“审批”环节。
* 触发【事务型任务】(如“年度报表”、“安全检查”)。
* 【管家】、【调研员】常驻区域。
* **行为状态:**
* **排队 (Queuing):** 极度消耗心情,无产出。
* **扯皮 (Arguing):** 【刺头】或【法学】角色可能触发,减少排队时间或免除惩罚。
* **盖章 (Stamping):** 任务完成的必要步骤。
### 2.8 足球场 (Football Field)
* **功能定义:** 锻炼身体,发泄精力。
* **进入条件:**
* 角色【健康值】较低,需要锻炼。
* 【环境科学】、【调研员】等高体力需求职业的训练场。
* 没有任务且不想回寝室时。
* **行为状态:**
* **跑步 (Running):** 恢复健康值,微量降低压力。
* **甚至在踢球:** 如果凑齐足够多的【摸鱼党】,可能会组织一场球赛,吸引周围人围观(降低周围人工作效率)。
---
## 3. 角色特质对行为的修正 (Trait Modifiers)
角色的特质Traits和羁绊Synergies会覆盖或修正上述默认行为规则。
| 特质/羁绊 | 修正规则 |
| :--- | :--- |
| **【卷王】 (The Grinder)** | **拒绝休息:** 当体力/心情低于阈值时,仍有 50% 概率拒绝前往寝室/湖边,而是去咖啡店买咖啡强撑。 |
| **【摸鱼党】 (The Slacker)** | **路径偏移:** 在前往实验室/图书馆的路上,有 30% 概率“迷路”进食堂或寝室逗留 5 秒。 |
| **【社恐】 (Social Phobia)** | **避开人群:** 动态检测人数。如果目标区域(如食堂)人数 > 3会转身去买自动贩卖机或饿着或者去无人的角落如人工湖死角。 |
| **【社牛/大忽悠】** | **聚集效应:** 倾向于前往人数最多的区域,且进入后会引发周围角色短暂停顿(听他说话)。 |
| **【洁癖/强迫症】** | **环境敏感:** 如果实验室发生过“爆炸”或有人呕吐,会拒绝进入该区域直到被清理。 |
| **【甚至不是人】** | **幽灵行为:** 不需要进食(不去食堂),不需要睡觉(不去寝室),只消耗电费(常驻机房/实验室)。 |
---
## 4. 寻路与交互逻辑 (Pathfinding & Interaction)
* **碰撞体积:** 角色之间存在碰撞体积,狭窄通道(如行政楼走廊)容易发生拥堵,增加焦躁感(压力上升)。
* **交互气泡:**
* 角色头顶会显示当前意图的气泡图标(如:🍔 = 去食堂,🧪 = 去实验室,💤 = 去寝室)。
* 当两个角色相遇时若羁绊相关如两个【二次元】会弹出“握手”或“爱心”表情并短暂交换Buff。

21
package-lock.json generated Normal file
View File

@ -0,0 +1,21 @@
{
"name": "super-mentor",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"yargs-parser": "^22.0.0"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"yargs-parser": "^22.0.0"
}
}

View File

@ -8,7 +8,7 @@
},
"Description": {
"Key": "archetype.grinder.desc",
"Fallback": "They live in the lab and push everything faster."
"Fallback": "Obsessed with work; always pushing the lab faster."
},
"Tags": [ "archetype" ]
},
@ -17,18 +17,27 @@
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 5, "Multiplier": 1.1 }
{ "Type": "Activation", "Add": 0, "Multiplier": 1.15 }
],
"RuleIds": [ "rule:grinder_stress_up" ]
"RuleIds": [ "rule:grinder_stress_growth_20" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 10, "Multiplier": 1.2 }
{ "Type": "Activation", "Add": 0, "Multiplier": 1.35 }
],
"RuleIds": [ "rule:grinder_overwork" ]
"RuleIds": [ "rule:grinder_overwork_rage" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 0, "Multiplier": 1.60 }
],
"RuleIds": [ "rule:grinder_no_sleep_life_cost" ]
}
}
]
@ -42,7 +51,7 @@
},
"Description": {
"Key": "archetype.slacker.desc",
"Fallback": "They recover morale but slow down the team."
"Fallback": "Finds the best corners to rest and spread chill vibes."
},
"Tags": [ "archetype" ]
},
@ -51,18 +60,129 @@
"RequiredCount": 2,
"Modifiers": {
"StatusModifiers": [
{ "Type": "Mood", "Add": 5, "Multiplier": 1.05 }
{ "Type": "Mood", "Add": 0, "Multiplier": 1.50 }
],
"RuleIds": [ "rule:slacker_relax" ]
"RuleIds": [ "rule:slacker_rest_recovery_50" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"StatusModifiers": [
{ "Type": "Stress", "Add": -5, "Multiplier": 0.9 }
{ "Type": "Stress", "Add": 0, "Multiplier": 1.30 }
],
"RuleIds": [ "rule:slacker_spread" ]
"RuleIds": [ "rule:slacker_spread_breaks" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:slacker_no_reputation_loss_on_fail" ]
}
}
]
},
{
"Header": {
"Id": "core:archetype_elite",
"Name": {
"Key": "archetype.elite.name",
"Fallback": "Elite"
},
"Description": {
"Key": "archetype.elite.desc",
"Fallback": "Well-funded and well-connected; money solves problems."
},
"Tags": [ "archetype" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"ResourceModifiers": [
{ "Type": "Money", "Add": 0, "Multiplier": 1.10 }
],
"RuleIds": [ "rule:elite_interest_10" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:elite_shop_tier_up", "rule:elite_refund_50" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:elite_buy_progress" ]
}
}
]
},
{
"Header": {
"Id": "core:archetype_prodigy",
"Name": {
"Key": "archetype.prodigy.name",
"Fallback": "Prodigy"
},
"Description": {
"Key": "archetype.prodigy.desc",
"Fallback": "Brilliant but eccentric; thrives in extremes."
},
"Tags": [ "archetype" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Academic", "Add": 0, "Multiplier": 1.10 }
],
"RuleIds": [ "rule:prodigy_crit_15" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:prodigy_crit_50_on_s_tasks" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:prodigy_solitary_scaling" ]
}
}
]
},
{
"Header": {
"Id": "core:archetype_mascot",
"Name": {
"Key": "archetype.mascot.name",
"Fallback": "Mascot"
},
"Description": {
"Key": "archetype.mascot.desc",
"Fallback": "Low output, high morale and administrative favors."
},
"Tags": [ "archetype" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"StatusModifiers": [
{ "Type": "Mood", "Add": 2, "Multiplier": 1.0 }
],
"RuleIds": [ "rule:mascot_mood_regen" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:mascot_admin_fast", "rule:mascot_admin_immunity" ]
}
}
]

View File

@ -0,0 +1,140 @@
{
"CriticalSanityThreshold": 15,
"CriticalStaminaThreshold": 12,
"CriticalStressThreshold": 90,
"HungerThreshold": 30,
"EnergyThreshold": 25,
"SocialThreshold": 35,
"LowMoodThreshold": 25,
"HungerDecayPerSecond": 0.60,
"EnergyDecayPerSecond": 0.50,
"StaminaDecayPerSecond": 0.40,
"StressGrowthPerSecond": 0.45,
"SocialDecayPerSecond": 0.35,
"DecisionIntervalSeconds": 0.50,
"ActionConfigs": [
{
"ActionId": "Experimenting",
"LocationId": "Laboratory",
"DurationSeconds": 6.0,
"HungerDelta": -0.60,
"EnergyDelta": -0.80,
"StaminaDelta": -1.00,
"StressDelta": 0.90,
"MoodDelta": -0.20,
"SocialDelta": -0.10
},
{
"ActionId": "Writing",
"LocationId": "Library",
"DurationSeconds": 6.0,
"HungerDelta": -0.40,
"EnergyDelta": -0.60,
"StaminaDelta": -0.60,
"StressDelta": 0.60,
"MoodDelta": -0.10,
"SocialDelta": -0.20
},
{
"ActionId": "Eating",
"LocationId": "Canteen",
"DurationSeconds": 4.5,
"HungerDelta": 3.00,
"EnergyDelta": 0.40,
"StaminaDelta": 0.80,
"StressDelta": -0.50,
"MoodDelta": 0.30,
"SocialDelta": 0.40
},
{
"ActionId": "Sleeping",
"LocationId": "Dormitory",
"DurationSeconds": 7.5,
"HungerDelta": -0.20,
"EnergyDelta": 2.40,
"StaminaDelta": 2.20,
"StressDelta": -0.90,
"MoodDelta": 0.50,
"SocialDelta": -0.20
},
{
"ActionId": "Chilling",
"LocationId": "Dormitory",
"DurationSeconds": 5.0,
"HungerDelta": -0.10,
"EnergyDelta": 0.80,
"StaminaDelta": 0.60,
"StressDelta": -0.50,
"MoodDelta": 0.80,
"SocialDelta": 0.10
},
{
"ActionId": "Staring",
"LocationId": "ArtificialLake",
"DurationSeconds": 5.5,
"HungerDelta": -0.20,
"EnergyDelta": 0.20,
"StaminaDelta": 0.20,
"StressDelta": -1.20,
"MoodDelta": 0.30,
"SocialDelta": -0.30,
"SanityDelta": 0.80
},
{
"ActionId": "CoffeeBreak",
"LocationId": "CoffeeShop",
"DurationSeconds": 4.0,
"HungerDelta": -0.10,
"EnergyDelta": 1.50,
"StaminaDelta": 0.30,
"StressDelta": -0.20,
"MoodDelta": 0.40,
"SocialDelta": 0.30
},
{
"ActionId": "Administration",
"LocationId": "AdministrationBuilding",
"DurationSeconds": 5.0,
"HungerDelta": -0.20,
"EnergyDelta": -0.40,
"StaminaDelta": -0.30,
"StressDelta": 0.80,
"MoodDelta": -0.60,
"SocialDelta": -0.10
},
{
"ActionId": "Running",
"LocationId": "FootballField",
"DurationSeconds": 4.5,
"HungerDelta": -0.30,
"EnergyDelta": -0.20,
"StaminaDelta": -0.20,
"StressDelta": -0.40,
"MoodDelta": 0.40,
"SocialDelta": 0.20,
"HealthDelta": 0.80
},
{
"ActionId": "Socializing",
"LocationId": "CoffeeShop",
"DurationSeconds": 4.5,
"HungerDelta": -0.10,
"EnergyDelta": 0.30,
"StaminaDelta": 0.10,
"StressDelta": -0.30,
"MoodDelta": 0.70,
"SocialDelta": 1.20
},
{
"ActionId": "Wandering",
"LocationId": "RandomWander",
"DurationSeconds": 4.0,
"HungerDelta": -0.10,
"EnergyDelta": -0.10,
"StaminaDelta": -0.10,
"StressDelta": -0.10,
"MoodDelta": 0.10,
"SocialDelta": -0.10
}
]
}

View File

@ -0,0 +1,438 @@
[
{
"Header": {
"Id": "core:role_coder",
"Name": {
"Key": "role.coder.name",
"Fallback": "Coder"
},
"Description": {
"Key": "role.coder.desc",
"Fallback": "Specializes in engineering tasks and computer rooms."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Engineering", "Add": 0, "Multiplier": 1.20 }
]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:coder_copy_paste" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:coder_remote_work" ]
}
}
]
},
{
"Header": {
"Id": "core:role_writer",
"Name": {
"Key": "role.writer.name",
"Fallback": "Writer"
},
"Description": {
"Key": "role.writer.desc",
"Fallback": "Turns ideas into papers with steady output."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Writing", "Add": 0, "Multiplier": 1.20 }
]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:writer_citation_bonus" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:writer_auto_generation" ]
}
}
]
},
{
"Header": {
"Id": "core:role_lab_rat",
"Name": {
"Key": "role.lab_rat.name",
"Fallback": "Lab Rat"
},
"Description": {
"Key": "role.lab_rat.desc",
"Fallback": "Lives in the lab and keeps experiments running."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 0, "Multiplier": 1.10 }
],
"RuleIds": [ "rule:lab_rat_move_speed_30" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"ResourceModifiers": [
{ "Type": "Money", "Add": 0, "Multiplier": 1.40 }
],
"RuleIds": [ "rule:lab_rat_extra_reagents" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:lab_rat_dual_equipment" ]
}
}
]
},
{
"Header": {
"Id": "core:role_presenter",
"Name": {
"Key": "role.presenter.name",
"Fallback": "Presenter"
},
"Description": {
"Key": "role.presenter.desc",
"Fallback": "Handles defenses, meetings, and funding pitches."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Social", "Add": 0, "Multiplier": 1.20 }
],
"RuleIds": [ "rule:presenter_pitch_success_20" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:presenter_double_funding" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:presenter_hype_aura" ]
}
}
]
},
{
"Header": {
"Id": "core:role_scribe",
"Name": {
"Key": "role.scribe.name",
"Fallback": "Scribe"
},
"Description": {
"Key": "role.scribe.desc",
"Fallback": "A reliable writing role used by all disciplines."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Writing", "Add": 0, "Multiplier": 1.15 }
]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:scribe_reputation_bonus" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:scribe_split_publication" ]
}
}
]
},
{
"Header": {
"Id": "core:role_orator",
"Name": {
"Key": "role.orator.name",
"Fallback": "Orator"
},
"Description": {
"Key": "role.orator.desc",
"Fallback": "Brings in resources and keeps morale afloat."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Social", "Add": 0, "Multiplier": 1.15 }
]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"ResourceModifiers": [
{ "Type": "Money", "Add": 0, "Multiplier": 1.30 }
],
"RuleIds": [ "rule:orator_extra_funding" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:orator_morale_shield" ]
}
}
]
},
{
"Header": {
"Id": "core:role_steward",
"Name": {
"Key": "role.steward.name",
"Fallback": "Steward"
},
"Description": {
"Key": "role.steward.desc",
"Fallback": "Keeps admin tasks and logistics under control."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 0, "Multiplier": 1.10 }
],
"RuleIds": [ "rule:steward_admin_speed_50" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"ResourceModifiers": [
{ "Type": "Money", "Add": 0, "Multiplier": 0.80 }
],
"RuleIds": [ "rule:steward_cost_reduction" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:steward_auto_supply" ]
}
}
]
},
{
"Header": {
"Id": "core:role_alchemist",
"Name": {
"Key": "role.alchemist.name",
"Fallback": "Alchemist"
},
"Description": {
"Key": "role.alchemist.desc",
"Fallback": "Consumes reagents for high-risk experiments."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"ResourceModifiers": [
{ "Type": "Money", "Add": 0, "Multiplier": 0.80 }
],
"RuleIds": [ "rule:alchemist_reagent_discount" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:alchemist_explosion_5", "rule:alchemist_success_boost" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:alchemist_fail_progress_persist" ]
}
}
],
"AllowedDisciplineIds": [
"core:discipline_biology",
"core:discipline_chemistry",
"core:discipline_environment",
"core:discipline_materials",
"core:discipline_medicine",
"core:discipline_agriculture"
]
},
{
"Header": {
"Id": "core:role_geek",
"Name": {
"Key": "role.geek.name",
"Fallback": "Geek"
},
"Description": {
"Key": "role.geek.desc",
"Fallback": "Consumes compute power for engineering output."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Engineering", "Add": 0, "Multiplier": 1.15 }
],
"RuleIds": [ "rule:geek_cooling_boost" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:geek_progress_inherit_30" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:geek_remote_work", "rule:geek_network_immunity" ]
}
}
],
"AllowedDisciplineIds": [
"core:discipline_computer",
"core:discipline_physics",
"core:discipline_math",
"core:discipline_mechanical"
]
},
{
"Header": {
"Id": "core:role_surveyor",
"Name": {
"Key": "role.surveyor.name",
"Fallback": "Surveyor"
},
"Description": {
"Key": "role.surveyor.desc",
"Fallback": "Collects field data and brings it back for analysis."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 0, "Multiplier": 1.10 }
],
"RuleIds": [ "rule:surveyor_move_speed_30" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:surveyor_extra_leads" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:surveyor_reviewer_insight" ]
}
}
],
"AllowedDisciplineIds": [
"core:discipline_economics",
"core:discipline_management",
"core:discipline_law"
]
},
{
"Header": {
"Id": "core:role_thinker",
"Name": {
"Key": "role.thinker.name",
"Fallback": "Thinker"
},
"Description": {
"Key": "role.thinker.desc",
"Fallback": "Slow but capable of sudden leaps of insight."
},
"Tags": [ "role" ]
},
"Tiers": [
{
"RequiredCount": 2,
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Academic", "Add": 0, "Multiplier": 0.90 }
],
"RuleIds": [ "rule:thinker_epiphany_10" ]
}
},
{
"RequiredCount": 4,
"Modifiers": {
"RuleIds": [ "rule:thinker_low_mood_boost" ]
}
},
{
"RequiredCount": 6,
"Modifiers": {
"RuleIds": [ "rule:thinker_masterwork" ]
}
}
],
"AllowedDisciplineIds": [
"core:discipline_philosophy",
"core:discipline_literature",
"core:discipline_art"
]
}
]

View File

@ -0,0 +1,283 @@
[
{
"Header": {
"Id": "core:trait_caffeine_dependence",
"Name": {
"Key": "trait.caffeine.name",
"Fallback": "Caffeine Dependence"
},
"Description": {
"Key": "trait.caffeine.desc",
"Fallback": "Needs coffee to work at full speed."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_requires_coffee" ]
}
},
{
"Header": {
"Id": "core:trait_night_owl",
"Name": {
"Key": "trait.night_owl.name",
"Fallback": "Night Owl"
},
"Description": {
"Key": "trait.night_owl.desc",
"Fallback": "Shines after 18:00 and drifts in the daytime."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_night_owl" ]
}
},
{
"Header": {
"Id": "core:trait_ocd",
"Name": {
"Key": "trait.ocd.name",
"Fallback": "OCD"
},
"Description": {
"Key": "trait.ocd.desc",
"Fallback": "Must push tasks to 100% before stopping."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_ocd_full_completion" ]
}
},
{
"Header": {
"Id": "core:trait_otaku",
"Name": {
"Key": "trait.otaku.name",
"Fallback": "Otaku"
},
"Description": {
"Key": "trait.otaku.desc",
"Fallback": "Mood never drops near figurine decorations."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_otaku_figurine" ]
}
},
{
"Header": {
"Id": "core:trait_glass_heart",
"Name": {
"Key": "trait.glass_heart.name",
"Fallback": "Glass Heart"
},
"Description": {
"Key": "trait.glass_heart.desc",
"Fallback": "Faints when scolded or after paper rejection."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_glass_heart" ]
}
},
{
"Header": {
"Id": "core:trait_big_eater",
"Name": {
"Key": "trait.big_eater.name",
"Fallback": "Big Eater"
},
"Description": {
"Key": "trait.big_eater.desc",
"Fallback": "Consumes double salary but has double stamina cap."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"StatusModifiers": [
{ "Type": "Stamina", "Add": 0, "Multiplier": 2.0 }
],
"RuleIds": [ "rule:trait_big_eater_salary" ]
}
},
{
"Header": {
"Id": "core:trait_social_phobia",
"Name": {
"Key": "trait.social_phobia.name",
"Fallback": "Social Phobia"
},
"Description": {
"Key": "trait.social_phobia.desc",
"Fallback": "Works better alone and avoids crowds."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_social_phobia" ]
}
},
{
"Header": {
"Id": "core:trait_social_butterfly",
"Name": {
"Key": "trait.social_butterfly.name",
"Fallback": "Social Butterfly"
},
"Description": {
"Key": "trait.social_butterfly.desc",
"Fallback": "Boosts nearby teammates when chatting."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Social", "Add": 5, "Multiplier": 1.05 }
],
"RuleIds": [ "rule:trait_social_butterfly" ]
}
},
{
"Header": {
"Id": "core:trait_lucky",
"Name": {
"Key": "trait.lucky.name",
"Fallback": "Lucky"
},
"Description": {
"Key": "trait.lucky.desc",
"Fallback": "Bad events never trigger when participating."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_lucky" ]
}
},
{
"Header": {
"Id": "core:trait_unlucky",
"Name": {
"Key": "trait.unlucky.name",
"Fallback": "Unlucky"
},
"Description": {
"Key": "trait.unlucky.desc",
"Fallback": "Bad events are more frequent but growth is faster."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Activation", "Add": 5, "Multiplier": 1.05 }
],
"RuleIds": [ "rule:trait_unlucky" ]
}
},
{
"Header": {
"Id": "core:trait_keyboard_warrior",
"Name": {
"Key": "trait.keyboard_warrior.name",
"Fallback": "Keyboard Warrior"
},
"Description": {
"Key": "trait.keyboard_warrior.desc",
"Fallback": "Excels in online PR or debate tasks."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Writing", "Add": 5, "Multiplier": 1.05 }
],
"RuleIds": [ "rule:trait_keyboard_warrior" ]
}
},
{
"Header": {
"Id": "core:trait_looks_matter",
"Name": {
"Key": "trait.looks_matter.name",
"Fallback": "Looks Matter"
},
"Description": {
"Key": "trait.looks_matter.desc",
"Fallback": "Loyalty barely drops with attractive teammates."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"StatusModifiers": [
{ "Type": "Loyalty", "Add": 5, "Multiplier": 1.0 }
],
"RuleIds": [ "rule:trait_looks_matter" ]
}
},
{
"Header": {
"Id": "core:trait_nice_guy",
"Name": {
"Key": "trait.nice_guy.name",
"Fallback": "Nice Guy"
},
"Description": {
"Key": "trait.nice_guy.desc",
"Fallback": "Auto-helps others but overworks easily."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"StatusModifiers": [
{ "Type": "Mood", "Add": 5, "Multiplier": 1.0 }
],
"RuleIds": [ "rule:trait_nice_guy" ]
}
},
{
"Header": {
"Id": "core:trait_rebel",
"Name": {
"Key": "trait.rebel.name",
"Fallback": "Rebel"
},
"Description": {
"Key": "trait.rebel.desc",
"Fallback": "Cannot be commanded directly but has high stats."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"AttributeModifiers": [
{ "Type": "Academic", "Add": 10, "Multiplier": 1.0 },
{ "Type": "Engineering", "Add": 10, "Multiplier": 1.0 },
{ "Type": "Writing", "Add": 10, "Multiplier": 1.0 },
{ "Type": "Financial", "Add": 10, "Multiplier": 1.0 },
{ "Type": "Social", "Add": 10, "Multiplier": 1.0 },
{ "Type": "Activation", "Add": 10, "Multiplier": 1.0 }
],
"RuleIds": [ "rule:trait_rebel" ]
}
},
{
"Header": {
"Id": "core:trait_not_human",
"Name": {
"Key": "trait.not_human.name",
"Fallback": "Not Human"
},
"Description": {
"Key": "trait.not_human.desc",
"Fallback": "Needs no food or sleep but burns compute costs."
},
"Tags": [ "trait" ]
},
"Modifiers": {
"RuleIds": [ "rule:trait_not_human" ]
}
}
]

View File

@ -1,6 +1,8 @@
using Godot;
using System;
using System.Collections.Generic;
using Core;
using Models;
public partial class CampusController : Node2D
{
@ -10,13 +12,26 @@ public partial class CampusController : Node2D
private Button _logToggle;
[Export] public PackedScene StudentScene { get; set; }
[Export] public int StudentCount { get; set; } = 6;
[Export] public int StudentCount { get; set; } = 5;
[Export] public float CoverageStep { get; set; } = 48.0f;
[Export] public int MaxCoveragePoints { get; set; } = 200;
[Export] public string BehaviorConfigPath { get; set; } = "res://resources/definitions/campus_behavior.json";
[Export] public int RandomSeed { get; set; } = 0;
[Export] public int AssignedTaskChancePercent { get; set; } = 60;
private NavigationRegion2D _navigationRegion;
private Node2D _studentsRoot;
private readonly List<Vector2> _coveragePoints = new();
private readonly List<CampusBehaviorAgent> _behaviorAgents = new();
private readonly CampusBehaviorWorld _behaviorWorld = new();
private readonly CampusLocationRegistry _locationRegistry = new();
private CampusBehaviorConfig _behaviorConfig;
private GameContentDatabase _contentDatabase;
private List<string> _archetypeIds = new();
private List<string> _roleIds = new();
private List<string> _traitIds = new();
private List<string> _disciplineIds = new();
private Random _random;
private bool _spawnPending = true;
private bool _navBakePending = false;
private bool _navBakeReady = false;
@ -40,6 +55,8 @@ public partial class CampusController : Node2D
_taskToggle.Toggled += OnTaskToggled;
_logToggle.Toggled += OnLogToggled;
InitializeBehaviorAssets();
// 导航区域与学生容器初始化
_navigationRegion = GetNodeOrNull<NavigationRegion2D>("Sprite2D/NavigationRegion2D");
_studentsRoot = GetNodeOrNull<Node2D>("Students");
@ -60,6 +77,84 @@ public partial class CampusController : Node2D
public override void _Process(double delta)
{
TrySpawnStudents();
UpdateBehaviorAgents((float)delta);
}
private void InitializeBehaviorAssets()
{
_behaviorConfig = CampusBehaviorConfig.Load(BehaviorConfigPath);
_random = RandomSeed == 0 ? new Random() : new Random(RandomSeed);
LoadContentDatabase();
CacheLocations();
}
private void LoadContentDatabase()
{
var registry = new ContentRegistry();
var jsonSource = new JsonContentSource(0);
jsonSource.DataPaths.Add("res://resources/definitions/disciplines.json");
jsonSource.DataPaths.Add("res://resources/definitions/archetypes.json");
jsonSource.DataPaths.Add("res://resources/definitions/roles.json");
jsonSource.DataPaths.Add("res://resources/definitions/traits.json");
registry.RegisterSource(jsonSource);
_contentDatabase = registry.BuildDatabase();
_archetypeIds = new List<string>(_contentDatabase.Archetypes.Keys);
_roleIds = new List<string>(_contentDatabase.Roles.Keys);
_traitIds = new List<string>(_contentDatabase.Traits.Keys);
_disciplineIds = new List<string>(_contentDatabase.Disciplines.Keys);
if (_archetypeIds.Count == 0 || _roleIds.Count == 0 || _traitIds.Count == 0)
{
GD.PushWarning("Behavior content definitions are missing; random tags will be limited.");
}
}
private void CacheLocations()
{
var locationsRoot = GetNodeOrNull<Node2D>("Locations");
if (locationsRoot == null)
{
GD.PushWarning("Campus scene is missing Locations root; agents will wander only.");
return;
}
RegisterLocation(locationsRoot, "Location_Lab", CampusLocationId.Laboratory);
RegisterLocation(locationsRoot, "Location_Library", CampusLocationId.Library);
RegisterLocation(locationsRoot, "Location_Canteen", CampusLocationId.Canteen);
RegisterLocation(locationsRoot, "Location_Dorm", CampusLocationId.Dormitory);
RegisterLocation(locationsRoot, "Location_Lake", CampusLocationId.ArtificialLake);
RegisterLocation(locationsRoot, "Location_Coffee", CampusLocationId.CoffeeShop);
RegisterLocation(locationsRoot, "Location_Admin", CampusLocationId.AdministrationBuilding);
RegisterLocation(locationsRoot, "Location_Field", CampusLocationId.FootballField);
}
private void RegisterLocation(Node2D root, string nodeName, CampusLocationId id)
{
var node = root.GetNodeOrNull<Node2D>(nodeName);
if (node == null)
{
GD.PushWarning($"Campus location marker not found: {nodeName}");
return;
}
_locationRegistry.Register(id, node.GlobalPosition);
}
private void UpdateBehaviorAgents(float delta)
{
if (_behaviorAgents.Count == 0) return;
_behaviorWorld.Clear();
foreach (var agent in _behaviorAgents)
{
_behaviorWorld.AddOccupant(agent.Runtime.CurrentLocationId);
}
foreach (var agent in _behaviorAgents)
{
agent.Tick(delta);
}
}
private void OnTaskToggled(bool pressed)
@ -176,16 +271,138 @@ public partial class CampusController : Node2D
}
_studentsRoot.AddChild(student);
student.Name = $"CampusStudent_{i + 1}";
student.SetNavigationMap(map);
// 随机放置在可行走区域,并设置不同的巡游起点
var randomIndex = GD.RandRange(0, _coveragePoints.Count - 1);
// 随机放置在可行走区域,并交给行为系统控制
var randomIndex = _random != null
? _random.Next(0, _coveragePoints.Count)
: (int)GD.RandRange(0, _coveragePoints.Count - 1);
student.GlobalPosition = _coveragePoints[randomIndex];
student.ConfigurePatrol(_coveragePoints, i * 7);
student.ApplyRandomTheme();
var runtime = BuildRandomAgentRuntime(i);
student.Name = runtime.Name;
var agent = new CampusBehaviorAgent(
student,
runtime,
_behaviorConfig,
_locationRegistry,
_behaviorWorld,
_random,
_coveragePoints);
_behaviorAgents.Add(agent);
LogSpawn(runtime);
}
}
private static readonly CampusTaskType[] TaskTypePool =
{
CampusTaskType.Experiment,
CampusTaskType.Writing,
CampusTaskType.Administration,
CampusTaskType.Exercise,
CampusTaskType.Coding,
CampusTaskType.Social
};
private CampusAgentRuntime BuildRandomAgentRuntime(int index)
{
var rng = _random ?? Random.Shared;
var name = $"Agent_{index + 1}";
var student = new StudentModel(name, rng);
AssignRandomTags(student.Core, rng);
ApplyRandomStatus(student, rng);
var needs = new CampusAgentNeeds(
rng.Next(20, 100),
rng.Next(20, 100),
rng.Next(20, 100),
rng.Next(60, 100));
var runtime = new CampusAgentRuntime(student, needs);
if (rng.Next(0, 100) < AssignedTaskChancePercent && TaskTypePool.Length > 0)
{
var taskType = TaskTypePool[rng.Next(0, TaskTypePool.Length)];
var duration = rng.Next(8, 16);
runtime.AssignedTask = new CampusTask(taskType, duration);
}
return runtime;
}
private void AssignRandomTags(UnitModel unit, Random rng)
{
unit.Tags.ArchetypeIds.Clear();
unit.Tags.RoleIds.Clear();
unit.Tags.TraitIds.Clear();
var archetypeId = PickRandomId(_archetypeIds, rng);
if (!string.IsNullOrWhiteSpace(archetypeId))
{
unit.Tags.ArchetypeIds.Add(archetypeId);
}
var roleId = PickRandomId(_roleIds, rng);
if (!string.IsNullOrWhiteSpace(roleId))
{
unit.Tags.RoleIds.Add(roleId);
}
var traitCount = _traitIds.Count == 0 ? 0 : rng.Next(1, Math.Min(3, _traitIds.Count + 1));
for (var i = 0; i < traitCount; i++)
{
var traitId = PickRandomId(_traitIds, rng);
if (!string.IsNullOrWhiteSpace(traitId) && !unit.Tags.TraitIds.Contains(traitId))
{
unit.Tags.TraitIds.Add(traitId);
}
}
var disciplineId = PickRandomId(_disciplineIds, rng);
if (!string.IsNullOrWhiteSpace(disciplineId))
{
unit.Tags.DisciplineId = disciplineId;
}
}
private void ApplyRandomStatus(StudentModel student, Random rng)
{
var unit = student.Core;
unit.Statuses.Stress.Current.Value = rng.Next(0, 60);
unit.Statuses.Sanity.Current.Value = rng.Next(50, 100);
unit.Statuses.Mood.Value = rng.Next(30, 90);
student.Progress.Stamina.Current.Value = rng.Next(25, 100);
}
private static string PickRandomId(List<string> ids, Random rng)
{
if (ids == null || ids.Count == 0) return null;
return ids[rng.Next(0, ids.Count)];
}
private void LogSpawn(CampusAgentRuntime runtime)
{
var unit = runtime.Unit;
var archetypes = unit.Tags.ArchetypeIds.Count == 0 ? "none" : string.Join(",", unit.Tags.ArchetypeIds);
var roles = unit.Tags.RoleIds.Count == 0 ? "none" : string.Join(",", unit.Tags.RoleIds);
var traits = unit.Tags.TraitIds.Count == 0 ? "none" : string.Join(",", unit.Tags.TraitIds);
var taskInfo = runtime.AssignedTask != null
? $"{runtime.AssignedTask.Type} ({runtime.AssignedTask.RemainingSeconds:0}s)"
: "none";
var attributes =
$"A:{unit.Attributes.Academic.DisplayInt()} " +
$"E:{unit.Attributes.Engineering.DisplayInt()} " +
$"W:{unit.Attributes.Writing.DisplayInt()} " +
$"F:{unit.Attributes.Financial.DisplayInt()} " +
$"S:{unit.Attributes.Social.DisplayInt()} " +
$"Act:{unit.Attributes.Activation.DisplayInt()}";
GD.Print($"[CampusAI] Spawned {runtime.Name} archetype={archetypes} role={roles} traits={traits} task={taskInfo} attrs={attributes}");
}
private List<Vector2> BuildCoveragePoints()
{
var points = new List<Vector2>();

View File

@ -2019,7 +2019,6 @@ offset_right = 955.0
offset_bottom = 535.0
[node name="TopBar" parent="." instance=ExtResource("2_p4tmp")]
visible = false
offset_bottom = 55.0
[node name="Task" parent="." instance=ExtResource("3_4gjr3")]
@ -2032,3 +2031,29 @@ offset_bottom = 455.0
[node name="TileMapLayer" type="TileMapLayer" parent="."]
visible = false
tile_set = SubResource("TileSet_74kl0")
[node name="Locations" type="Node2D" parent="."]
[node name="Location_Lab" type="Node2D" parent="Locations"]
position = Vector2(150, 196)
[node name="Location_Library" type="Node2D" parent="Locations"]
position = Vector2(440, 196)
[node name="Location_Canteen" type="Node2D" parent="Locations"]
position = Vector2(745, 166)
[node name="Location_Dorm" type="Node2D" parent="Locations"]
position = Vector2(848, 192)
[node name="Location_Lake" type="Node2D" parent="Locations"]
position = Vector2(943, 179)
[node name="Location_Coffee" type="Node2D" parent="Locations"]
position = Vector2(160, 395)
[node name="Location_Admin" type="Node2D" parent="Locations"]
position = Vector2(296, 452)
[node name="Location_Field" type="Node2D" parent="Locations"]
position = Vector2(560, 300)

View File

@ -0,0 +1,778 @@
using System;
using System.Collections.Generic;
using Godot;
using Models;
/// <summary>
/// Runtime data for a campus agent. This keeps Godot nodes and pure data separate
/// so the behavior system can be tested without scene dependencies.
/// </summary>
public sealed class CampusAgentRuntime
{
public StudentModel Student { get; }
public CampusAgentNeeds Needs { get; }
public CampusTask AssignedTask { get; set; }
public CampusLocationId CurrentLocationId { get; set; } = CampusLocationId.None;
public UnitModel Unit => Student.Core;
public string Name
{
get => Student.Name;
set => Student.Name = value;
}
public CampusAgentRuntime(StudentModel student, CampusAgentNeeds needs)
{
Student = student ?? throw new ArgumentNullException(nameof(student));
Needs = needs ?? throw new ArgumentNullException(nameof(needs));
}
public bool HasTrait(string traitId) => Unit.Tags.TraitIds.Contains(traitId);
public bool HasRole(string roleId) => Unit.Tags.RoleIds.Contains(roleId);
public bool HasArchetype(string archetypeId) => Unit.Tags.ArchetypeIds.Contains(archetypeId);
}
/// <summary>
/// Intent produced by the planner. It captures both the action and the destination
/// so the state machine can move and execute without re-running the decision logic.
/// </summary>
public sealed class CampusBehaviorIntent
{
public CampusBehaviorPriority Priority { get; }
public CampusActionId ActionId { get; }
public CampusLocationId LocationId { get; }
public string Reason { get; }
public CampusBehaviorIntent(CampusBehaviorPriority priority, CampusActionId actionId, CampusLocationId locationId, string reason)
{
Priority = priority;
ActionId = actionId;
LocationId = locationId;
Reason = reason ?? string.Empty;
}
public bool Matches(CampusBehaviorIntent other)
{
if (other == null) return false;
return Priority == other.Priority && ActionId == other.ActionId && LocationId == other.LocationId;
}
}
/// <summary>
/// Shared context passed into providers/states so they can evaluate the same data
/// without hard-coding dependencies.
/// </summary>
public sealed class CampusBehaviorContext
{
public CampusAgentRuntime Agent { get; }
public CampusBehaviorConfig Config { get; }
public CampusLocationRegistry Locations { get; }
public CampusBehaviorWorld World { get; }
public Random Rng { get; }
public List<Vector2> WanderPoints { get; }
public CampusBehaviorContext(
CampusAgentRuntime agent,
CampusBehaviorConfig config,
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List<Vector2> wanderPoints)
{
Agent = agent;
Config = config;
Locations = locations;
World = world;
Rng = rng;
WanderPoints = wanderPoints;
}
}
/// <summary>
/// Providers represent a single rule in the priority queue. Each provider returns
/// a behavior intent or null if it cannot apply to the current context.
/// </summary>
public interface ICampusBehaviorProvider
{
CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context);
}
/// <summary>
/// Critical state provider: handles sanity collapse, extreme stress, or exhaustion.
/// This is the highest priority in the decision queue.
/// </summary>
public sealed class CriticalBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _grinderArchetypeId;
public CriticalBehaviorProvider(string grinderArchetypeId)
{
_grinderArchetypeId = grinderArchetypeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
var config = context.Config;
var stamina = agent.Student.Progress.Stamina.Current.Value;
var sanity = agent.Unit.Statuses.Sanity.Current.Value;
var stress = agent.Unit.Statuses.Stress.Current.Value;
if (sanity <= config.CriticalSanityThreshold || stress >= config.CriticalStressThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.Staring,
CampusLocationId.ArtificialLake,
"critical_sanity_or_stress");
}
if (stamina <= config.CriticalStaminaThreshold)
{
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"grinder_forced_coffee");
}
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.Sleeping,
CampusLocationId.Dormitory,
"critical_stamina");
}
return null;
}
}
/// <summary>
/// Assigned task provider: if the agent has a task, it is executed before needs.
/// </summary>
public sealed class AssignedTaskBehaviorProvider : ICampusBehaviorProvider
{
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var task = context.Agent.AssignedTask;
if (task == null || task.IsComplete) return null;
var action = MapTaskToAction(task.Type);
var location = context.Config.GetActionConfig(action)?.LocationId ?? CampusLocationId.Laboratory;
return new CampusBehaviorIntent(
CampusBehaviorPriority.AssignedTask,
action,
location,
$"assigned_task_{task.Type}");
}
public static CampusActionId MapTaskToAction(CampusTaskType taskType)
{
return taskType switch
{
CampusTaskType.Experiment => CampusActionId.Experimenting,
CampusTaskType.Writing => CampusActionId.Writing,
CampusTaskType.Administration => CampusActionId.Administration,
CampusTaskType.Exercise => CampusActionId.Running,
CampusTaskType.Coding => CampusActionId.Experimenting,
CampusTaskType.Social => CampusActionId.Socializing,
_ => CampusActionId.Wandering
};
}
}
/// <summary>
/// Needs provider: hunger, fatigue, mood, and social needs are handled here.
/// It sits below assigned tasks but above trait-driven idle behavior.
/// </summary>
public sealed class NeedsBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _traitNotHumanId;
private readonly string _traitCaffeineId;
private readonly string _grinderArchetypeId;
public NeedsBehaviorProvider(string traitNotHumanId, string traitCaffeineId, string grinderArchetypeId)
{
_traitNotHumanId = traitNotHumanId;
_traitCaffeineId = traitCaffeineId;
_grinderArchetypeId = grinderArchetypeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
var config = context.Config;
var needs = agent.Needs;
var isNotHuman = agent.HasTrait(_traitNotHumanId);
if (!isNotHuman && needs.Hunger.Value <= config.HungerThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Eating,
CampusLocationId.Canteen,
"need_hunger");
}
var stamina = agent.Student.Progress.Stamina.Current.Value;
if (!isNotHuman && (needs.Energy.Value <= config.EnergyThreshold || stamina <= config.CriticalStaminaThreshold))
{
if (agent.HasTrait(_traitCaffeineId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"need_caffeine");
}
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"grinder_refuse_sleep");
}
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Sleeping,
CampusLocationId.Dormitory,
"need_sleep");
}
var mood = agent.Unit.Statuses.Mood.Value;
if (mood <= config.LowMoodThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Chilling,
CampusLocationId.Dormitory,
"need_mood");
}
if (needs.Social.Value <= config.SocialThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Socializing,
CampusLocationId.CoffeeShop,
"need_social");
}
return null;
}
}
/// <summary>
/// Trait-driven provider: applies long-term personality or tag tendencies when
/// there is no urgent need.
/// </summary>
public sealed class TraitBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _archetypeGrinderId;
private readonly string _archetypeSlackerId;
private readonly string _traitSocialPhobiaId;
private readonly string _traitSocialButterflyId;
private readonly string _roleLabRatId;
private readonly string _roleAlchemistId;
private readonly string _roleWriterId;
private readonly string _roleScribeId;
public TraitBehaviorProvider(
string archetypeGrinderId,
string archetypeSlackerId,
string traitSocialPhobiaId,
string traitSocialButterflyId,
string roleLabRatId,
string roleAlchemistId,
string roleWriterId,
string roleScribeId)
{
_archetypeGrinderId = archetypeGrinderId;
_archetypeSlackerId = archetypeSlackerId;
_traitSocialPhobiaId = traitSocialPhobiaId;
_traitSocialButterflyId = traitSocialButterflyId;
_roleLabRatId = roleLabRatId;
_roleAlchemistId = roleAlchemistId;
_roleWriterId = roleWriterId;
_roleScribeId = roleScribeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
if (agent.HasTrait(_traitSocialPhobiaId))
{
var quietSpot = PickLeastCrowded(context, CampusLocationId.ArtificialLake, CampusLocationId.Dormitory, CampusLocationId.Library);
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Staring,
quietSpot,
"trait_social_phobia");
}
if (agent.HasTrait(_traitSocialButterflyId))
{
var socialSpot = PickMostCrowded(context, CampusLocationId.Canteen, CampusLocationId.CoffeeShop);
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Socializing,
socialSpot,
"trait_social_butterfly");
}
if (agent.HasArchetype(_archetypeGrinderId) || agent.HasRole(_roleLabRatId) || agent.HasRole(_roleAlchemistId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Experimenting,
CampusLocationId.Laboratory,
"trait_prefers_lab");
}
if (agent.HasRole(_roleWriterId) || agent.HasRole(_roleScribeId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Writing,
CampusLocationId.Library,
"trait_prefers_library");
}
if (agent.HasArchetype(_archetypeSlackerId))
{
var action = context.Rng.NextDouble() < 0.3
? CampusActionId.Eating
: CampusActionId.Chilling;
var location = action == CampusActionId.Eating ? CampusLocationId.Canteen : CampusLocationId.Dormitory;
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
action,
location,
"trait_slacker");
}
return null;
}
private static CampusLocationId PickLeastCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
{
if (candidates == null || candidates.Length == 0) return CampusLocationId.ArtificialLake;
var best = candidates[0];
var bestCount = context.World.GetOccupancy(best);
for (var i = 1; i < candidates.Length; i++)
{
var count = context.World.GetOccupancy(candidates[i]);
if (count < bestCount)
{
best = candidates[i];
bestCount = count;
}
}
return best;
}
private static CampusLocationId PickMostCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
{
if (candidates == null || candidates.Length == 0) return CampusLocationId.Canteen;
var best = candidates[0];
var bestCount = context.World.GetOccupancy(best);
for (var i = 1; i < candidates.Length; i++)
{
var count = context.World.GetOccupancy(candidates[i]);
if (count > bestCount)
{
best = candidates[i];
bestCount = count;
}
}
return best;
}
}
/// <summary>
/// Idle provider: default fallback when nothing else applies.
/// </summary>
public sealed class IdleBehaviorProvider : ICampusBehaviorProvider
{
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Idle,
CampusActionId.Wandering,
CampusLocationId.RandomWander,
"idle_wander");
}
}
/// <summary>
/// Planner executes providers in priority order. This lets us add or remove
/// providers without editing the state machine.
/// </summary>
public sealed class CampusBehaviorPlanner
{
private readonly List<ICampusBehaviorProvider> _providers;
public CampusBehaviorPlanner(IEnumerable<ICampusBehaviorProvider> providers)
{
_providers = new List<ICampusBehaviorProvider>(providers ?? Array.Empty<ICampusBehaviorProvider>());
}
public CampusBehaviorIntent PickIntent(CampusBehaviorContext context)
{
foreach (var provider in _providers)
{
var intent = provider.TryCreateIntent(context);
if (intent != null)
{
return intent;
}
}
return new CampusBehaviorIntent(CampusBehaviorPriority.Idle, CampusActionId.Wandering, CampusLocationId.RandomWander, "fallback_idle");
}
}
/// <summary>
/// State interface for the AI FSM. Each state can transition by requesting
/// a change via the owning behavior agent.
/// </summary>
public interface ICampusBehaviorState
{
void Enter(CampusBehaviorAgent agent);
void Tick(CampusBehaviorAgent agent, float delta);
void Exit(CampusBehaviorAgent agent);
}
/// <summary>
/// State machine wrapper to enforce enter/exit semantics.
/// </summary>
public sealed class CampusBehaviorStateMachine
{
private ICampusBehaviorState _current;
public void ChangeState(CampusBehaviorAgent agent, ICampusBehaviorState next)
{
_current?.Exit(agent);
_current = next;
_current?.Enter(agent);
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
_current?.Tick(agent, delta);
}
}
/// <summary>
/// Decision state: pick a new intent and immediately transition to movement.
/// This keeps the intent selection isolated and easy to extend.
/// </summary>
public sealed class CampusDecisionState : ICampusBehaviorState
{
public void Enter(CampusBehaviorAgent agent)
{
var intent = agent.PlanNextIntent();
agent.StartIntent(intent);
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
}
public void Exit(CampusBehaviorAgent agent)
{
}
}
/// <summary>
/// Movement state: navigate to the intent's target location.
/// Once the agent arrives, it transitions into the action state.
/// </summary>
public sealed class CampusMoveState : ICampusBehaviorState
{
private CampusBehaviorIntent _intent;
public CampusMoveState(CampusBehaviorIntent intent)
{
_intent = intent;
}
public void Enter(CampusBehaviorAgent agent)
{
var target = agent.ResolveTargetPosition(_intent);
agent.Student.SetBehaviorTarget(target);
agent.Log($"moving_to {target} action={_intent.ActionId} reason={_intent.Reason}");
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
if (agent.Student.HasReachedBehaviorTarget())
{
agent.Student.ClearBehaviorTarget();
agent.StateMachine.ChangeState(agent, new CampusActionState(_intent));
}
}
public void Exit(CampusBehaviorAgent agent)
{
}
}
/// <summary>
/// Action state: apply per-second deltas and update task progress.
/// When the action duration expires, transition back to decision.
/// </summary>
public sealed class CampusActionState : ICampusBehaviorState
{
private readonly CampusBehaviorIntent _intent;
private float _remaining;
private CampusActionConfig _actionConfig;
private float _logTimer;
public CampusActionState(CampusBehaviorIntent intent)
{
_intent = intent;
}
public void Enter(CampusBehaviorAgent agent)
{
_actionConfig = agent.Config.GetActionConfig(_intent.ActionId);
_remaining = _actionConfig?.DurationSeconds ?? 4.0f;
_logTimer = 0.0f;
agent.Runtime.CurrentLocationId = _intent.LocationId;
agent.Log($"action_start {_intent.ActionId} duration={_remaining:0.0}s reason={_intent.Reason}");
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
if (_remaining <= 0.0f)
{
agent.StateMachine.ChangeState(agent, new CampusDecisionState());
return;
}
if (_actionConfig != null)
{
ApplyActionDelta(agent, delta, _actionConfig);
}
AdvanceTask(agent, delta);
_remaining -= delta;
_logTimer += delta;
if (_logTimer >= 1.5f)
{
_logTimer = 0.0f;
agent.Log($"action_tick {_intent.ActionId} remaining={Mathf.Max(0.0f, _remaining):0.0}s");
}
}
public void Exit(CampusBehaviorAgent agent)
{
agent.Runtime.CurrentLocationId = CampusLocationId.None;
agent.Log($"action_end {_intent.ActionId}");
}
private static void ApplyActionDelta(CampusBehaviorAgent agent, float delta, CampusActionConfig action)
{
var needs = agent.Runtime.Needs;
needs.Hunger.Add(action.HungerDelta * delta);
needs.Energy.Add(action.EnergyDelta * delta);
needs.Social.Add(action.SocialDelta * delta);
needs.Health.Add(action.HealthDelta * delta);
agent.Runtime.Student.Progress.Stamina.Add(action.StaminaDelta * delta);
agent.Runtime.Unit.Statuses.Stress.Add(action.StressDelta * delta);
agent.Runtime.Unit.Statuses.Sanity.Add(action.SanityDelta * delta);
agent.Runtime.Unit.Statuses.Mood.Add(action.MoodDelta * delta);
}
private void AdvanceTask(CampusBehaviorAgent agent, float delta)
{
var task = agent.Runtime.AssignedTask;
if (task == null) return;
var actionForTask = AssignedTaskBehaviorProvider.MapTaskToAction(task.Type);
if (actionForTask != _intent.ActionId) return;
task.Advance(delta);
if (task.IsComplete)
{
agent.Log($"task_complete {task.Type}");
agent.Runtime.AssignedTask = null;
}
}
}
/// <summary>
/// Main behavior agent that drives one campus character. It owns the planner,
/// state machine, and applies baseline stat decay on every tick.
/// </summary>
public sealed class CampusBehaviorAgent
{
public CampusStudent Student { get; }
public CampusAgentRuntime Runtime { get; }
public CampusBehaviorConfig Config { get; }
public CampusBehaviorStateMachine StateMachine { get; } = new();
private readonly CampusBehaviorPlanner _planner;
private readonly CampusBehaviorContext _context;
private CampusBehaviorIntent _currentIntent;
private float _decisionTimer;
public CampusBehaviorAgent(
CampusStudent student,
CampusAgentRuntime runtime,
CampusBehaviorConfig config,
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List<Vector2> wanderPoints)
{
Student = student ?? throw new ArgumentNullException(nameof(student));
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Config = config ?? new CampusBehaviorConfig();
_context = new CampusBehaviorContext(runtime, Config, locations, world, rng ?? new Random(), wanderPoints ?? new List<Vector2>());
_planner = new CampusBehaviorPlanner(BuildProviders());
Student.EnableBehaviorControl();
StateMachine.ChangeState(this, new CampusDecisionState());
}
public void Tick(float delta)
{
ApplyBaselineDecay(delta);
TryInterrupt(delta);
StateMachine.Tick(this, delta);
}
public CampusBehaviorIntent PlanNextIntent()
{
var intent = _planner.PickIntent(_context);
Log($"intent {intent.Priority} {intent.ActionId} -> {intent.LocationId} reason={intent.Reason}");
return intent;
}
public void StartIntent(CampusBehaviorIntent intent)
{
_currentIntent = intent;
StateMachine.ChangeState(this, new CampusMoveState(intent));
}
public Vector2 ResolveTargetPosition(CampusBehaviorIntent intent)
{
if (intent.LocationId == CampusLocationId.RandomWander)
{
return PickRandomWanderPoint();
}
if (_context.Locations != null && _context.Locations.TryGetPosition(intent.LocationId, out var position))
{
return position;
}
return PickRandomWanderPoint();
}
public void Log(string message)
{
GD.Print($"[CampusAI:{Runtime.Name}] {message}");
}
private void ApplyBaselineDecay(float delta)
{
var needs = Runtime.Needs;
var isNotHuman = Runtime.HasTrait(CampusTraitIds.NotHuman);
var hungerDecay = Config.HungerDecayPerSecond;
if (Runtime.HasTrait(CampusTraitIds.BigEater))
{
hungerDecay *= 1.5f;
}
if (!isNotHuman)
{
needs.Hunger.Add(-hungerDecay * delta);
needs.Energy.Add(-Config.EnergyDecayPerSecond * delta);
Runtime.Student.Progress.Stamina.Add(-Config.StaminaDecayPerSecond * delta);
}
needs.Social.Add(-Config.SocialDecayPerSecond * delta);
Runtime.Unit.Statuses.Stress.Add(Config.StressGrowthPerSecond * delta);
}
private void TryInterrupt(float delta)
{
_decisionTimer += delta;
if (_decisionTimer < Config.DecisionIntervalSeconds)
{
return;
}
_decisionTimer = 0.0f;
var candidate = _planner.PickIntent(_context);
if (candidate == null) return;
if (_currentIntent == null || candidate.Priority < _currentIntent.Priority)
{
Log($"interrupt {candidate.Priority} {candidate.ActionId} reason={candidate.Reason}");
StartIntent(candidate);
}
}
private Vector2 PickRandomWanderPoint()
{
if (_context.WanderPoints == null || _context.WanderPoints.Count == 0)
{
return Student.GlobalPosition;
}
var index = _context.Rng.Next(0, _context.WanderPoints.Count);
return _context.WanderPoints[index];
}
private IEnumerable<ICampusBehaviorProvider> BuildProviders()
{
yield return new CriticalBehaviorProvider(CoreIds.ArchetypeGrinder);
yield return new AssignedTaskBehaviorProvider();
yield return new NeedsBehaviorProvider(CampusTraitIds.NotHuman, CampusTraitIds.CaffeineDependence, CoreIds.ArchetypeGrinder);
yield return new TraitBehaviorProvider(
CoreIds.ArchetypeGrinder,
CoreIds.ArchetypeSlacker,
CampusTraitIds.SocialPhobia,
CampusTraitIds.SocialButterfly,
CoreIds.RoleLabRat,
CoreIds.RoleAlchemist,
CoreIds.RoleWriter,
CoreIds.RoleScribe);
yield return new IdleBehaviorProvider();
}
}
/// <summary>
/// Centralized IDs for traits referenced by the behavior system.
/// Keeping them here avoids scattering magic strings.
/// </summary>
public static class CampusTraitIds
{
public const string CaffeineDependence = "core:trait_caffeine_dependence";
public const string SocialPhobia = "core:trait_social_phobia";
public const string SocialButterfly = "core:trait_social_butterfly";
public const string NotHuman = "core:trait_not_human";
public const string BigEater = "core:trait_big_eater";
}

View File

@ -0,0 +1 @@
uid://bd1bjq6vrs4hh

View File

@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using Godot;
using Models;
/// <summary>
/// Location identifiers used by the campus behavior system.
/// These map to Node2D markers in campus.tscn so the AI can pick targets by name.
/// </summary>
public enum CampusLocationId
{
None,
Laboratory,
Library,
Canteen,
Dormitory,
ArtificialLake,
CoffeeShop,
AdministrationBuilding,
FootballField,
RandomWander
}
/// <summary>
/// Action identifiers used by the behavior planner and state machine.
/// Each action is configured via campus_behavior.json for duration and stat deltas.
/// </summary>
public enum CampusActionId
{
None,
Experimenting,
Writing,
Eating,
Sleeping,
Chilling,
Staring,
CoffeeBreak,
Administration,
Running,
Socializing,
Wandering
}
/// <summary>
/// Priority levels match the design doc ordering: lower value = higher priority.
/// </summary>
public enum CampusBehaviorPriority
{
Critical = 0,
AssignedTask = 1,
Needs = 2,
Trait = 3,
Idle = 4
}
/// <summary>
/// Minimal task types for the campus demo. These are not full gameplay tasks,
/// just drivers for the assigned-task priority in the AI.
/// </summary>
public enum CampusTaskType
{
Experiment,
Writing,
Administration,
Exercise,
Coding,
Social
}
/// <summary>
/// Action configuration loaded from JSON. Deltas are applied per second while
/// the action is running, so longer actions accumulate more effect.
/// </summary>
public sealed class CampusActionConfig
{
public CampusActionId ActionId { get; set; }
public CampusLocationId LocationId { get; set; }
public float DurationSeconds { get; set; }
public float HungerDelta { get; set; }
public float EnergyDelta { get; set; }
public float StaminaDelta { get; set; }
public float StressDelta { get; set; }
public float MoodDelta { get; set; }
public float SocialDelta { get; set; }
public float SanityDelta { get; set; }
public float HealthDelta { get; set; }
}
/// <summary>
/// Global behavior configuration for campus AI. This is intentionally data-driven
/// so balancing can happen in JSON without touching code.
/// </summary>
public sealed class CampusBehaviorConfig
{
public float CriticalSanityThreshold { get; set; } = 15f;
public float CriticalStaminaThreshold { get; set; } = 12f;
public float CriticalStressThreshold { get; set; } = 90f;
public float HungerThreshold { get; set; } = 30f;
public float EnergyThreshold { get; set; } = 25f;
public float SocialThreshold { get; set; } = 35f;
public float LowMoodThreshold { get; set; } = 25f;
public float HungerDecayPerSecond { get; set; } = 0.6f;
public float EnergyDecayPerSecond { get; set; } = 0.5f;
public float StaminaDecayPerSecond { get; set; } = 0.4f;
public float StressGrowthPerSecond { get; set; } = 0.45f;
public float SocialDecayPerSecond { get; set; } = 0.35f;
public float DecisionIntervalSeconds { get; set; } = 0.5f;
public List<CampusActionConfig> ActionConfigs { get; set; } = new();
private readonly Dictionary<CampusActionId, CampusActionConfig> _actionLookup = new();
public CampusActionConfig GetActionConfig(CampusActionId id)
{
if (_actionLookup.Count == 0)
{
BuildLookup();
}
return _actionLookup.TryGetValue(id, out var config) ? config : null;
}
private void BuildLookup()
{
_actionLookup.Clear();
if (ActionConfigs == null) return;
foreach (var config in ActionConfigs)
{
_actionLookup[config.ActionId] = config;
}
}
public static CampusBehaviorConfig Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
GD.PushWarning("Campus behavior config path is empty; using defaults.");
return new CampusBehaviorConfig();
}
var resolvedPath = path.StartsWith("res://") || path.StartsWith("user://")
? ProjectSettings.GlobalizePath(path)
: path;
if (!File.Exists(resolvedPath))
{
GD.PushWarning($"Campus behavior config not found at {resolvedPath}; using defaults.");
return new CampusBehaviorConfig();
}
var json = File.ReadAllText(resolvedPath);
if (string.IsNullOrWhiteSpace(json))
{
GD.PushWarning($"Campus behavior config is empty at {resolvedPath}; using defaults.");
return new CampusBehaviorConfig();
}
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new JsonStringEnumConverter());
try
{
var config = JsonSerializer.Deserialize<CampusBehaviorConfig>(json, options);
return config ?? new CampusBehaviorConfig();
}
catch (Exception ex)
{
GD.PushWarning($"Failed to parse campus behavior config: {ex.Message}");
return new CampusBehaviorConfig();
}
}
}
/// <summary>
/// Simple location registry that maps logical location ids to scene positions.
/// This keeps the behavior system independent from scene tree details.
/// </summary>
public sealed class CampusLocationRegistry
{
private readonly Dictionary<CampusLocationId, Vector2> _locations = new();
public void Register(CampusLocationId id, Vector2 position)
{
if (id == CampusLocationId.None) return;
_locations[id] = position;
}
public bool TryGetPosition(CampusLocationId id, out Vector2 position)
{
return _locations.TryGetValue(id, out position);
}
}
/// <summary>
/// Tracks current occupancy per location so traits like social phobia can react
/// to crowd size without hard-coding scene knowledge.
/// </summary>
public sealed class CampusBehaviorWorld
{
private readonly Dictionary<CampusLocationId, int> _occupancy = new();
public void Clear()
{
_occupancy.Clear();
}
public void AddOccupant(CampusLocationId id)
{
if (id == CampusLocationId.None || id == CampusLocationId.RandomWander) return;
if (!_occupancy.ContainsKey(id))
{
_occupancy[id] = 0;
}
_occupancy[id] += 1;
}
public int GetOccupancy(CampusLocationId id)
{
return _occupancy.TryGetValue(id, out var count) ? count : 0;
}
}
/// <summary>
/// Lightweight task container for the campus demo; it just tracks remaining work.
/// </summary>
public sealed class CampusTask
{
public CampusTaskType Type { get; }
public float RemainingSeconds { get; private set; }
public CampusTask(CampusTaskType type, float remainingSeconds)
{
Type = type;
RemainingSeconds = Mathf.Max(0f, remainingSeconds);
}
public void Advance(float delta)
{
RemainingSeconds = Mathf.Max(0f, RemainingSeconds - delta);
}
public bool IsComplete => RemainingSeconds <= 0f;
}
/// <summary>
/// Custom needs that are not yet part of the core UnitModel (hunger/social/energy).
/// Uses PropertyValue so it plugs into the existing numeric system.
/// </summary>
public sealed class CampusAgentNeeds
{
public PropertyValue Hunger { get; }
public PropertyValue Energy { get; }
public PropertyValue Social { get; }
public PropertyValue Health { get; }
public CampusAgentNeeds(float hunger, float energy, float social, float health)
{
Hunger = new PropertyValue(hunger);
Energy = new PropertyValue(energy);
Social = new PropertyValue(social);
Health = new PropertyValue(health);
}
}

View File

@ -0,0 +1 @@
uid://dbp5g1aqtlqhi

View File

@ -24,6 +24,9 @@ public partial class CampusStudent : CharacterBody2D
private Rid _navigationMap;
private Vector2 _currentTarget = Vector2.Zero;
private bool _hasTarget;
private bool _behaviorControlEnabled;
private Vector2 _behaviorTarget = Vector2.Zero;
private bool _behaviorHasTarget;
private bool _usePhysicsMovement = true;
[Export] public float MoveSpeed { get; set; } = 60.0f;
[Export] public float TargetReachDistance { get; set; } = 6.0f;
@ -66,7 +69,7 @@ public partial class CampusStudent : CharacterBody2D
public override void _PhysicsProcess(double delta)
{
_lastDelta = delta;
if (_navigationAgent == null || _patrolPoints.Count == 0)
if (_navigationAgent == null || (!_behaviorControlEnabled && _patrolPoints.Count == 0) || (_behaviorControlEnabled && !_behaviorHasTarget))
{
PlayIdleAnimation();
return;
@ -74,7 +77,22 @@ public partial class CampusStudent : CharacterBody2D
// 到达目标点或无路可走时,切换到下一个巡游点
if (_navigationAgent.IsNavigationFinished())
{
if (_behaviorControlEnabled)
{
Velocity = Vector2.Zero;
if (!EnableAvoidance && _usePhysicsMovement)
{
MoveAndSlide();
}
PlayIdleAnimation();
UpdateStuckTimer(delta);
return;
}
AdvanceTarget();
}
var nextPosition = _navigationAgent.GetNextPathPosition();
var toNext = nextPosition - GlobalPosition;
@ -121,6 +139,43 @@ public partial class CampusStudent : CharacterBody2D
if (_navigationAgent != null) AdvanceTarget();
}
public void EnableBehaviorControl()
{
_behaviorControlEnabled = true;
_behaviorHasTarget = false;
_patrolConfigured = false;
_patrolPoints.Clear();
}
public void SetBehaviorTarget(Vector2 target)
{
_behaviorControlEnabled = true;
_behaviorTarget = target;
_behaviorHasTarget = true;
_currentTarget = target;
_hasTarget = true;
_stuckTimer = 0.0f;
if (_navigationAgent != null)
{
_navigationAgent.TargetPosition = target;
}
}
public void ClearBehaviorTarget()
{
_behaviorHasTarget = false;
_hasTarget = false;
_currentTarget = Vector2.Zero;
_stuckTimer = 0.0f;
}
public bool HasReachedBehaviorTarget()
{
if (!_behaviorHasTarget || _navigationAgent == null) return true;
return _navigationAgent.IsNavigationFinished();
}
public void SetNavigationMap(Rid map)
{
// 由校园控制器传入导航地图,供本地边界夹紧使用
@ -163,6 +218,7 @@ public partial class CampusStudent : CharacterBody2D
private void AdvanceTarget()
{
if (_patrolPoints.Count == 0 || _navigationAgent == null) return;
if (_behaviorControlEnabled) return;
// 避免当前点过近导致原地抖动,最多尝试一轮
for (var i = 0; i < _patrolPoints.Count; i++)

View File

@ -0,0 +1 @@
uid://ctbmcynvl8ffm

View File

@ -0,0 +1 @@
uid://fpwckd80v5nv

View File

@ -0,0 +1 @@
uid://bkgwb8oeer5ri

View File

@ -0,0 +1 @@
uid://bfehg7ijcybie

View File

@ -0,0 +1 @@
uid://drbjfxehidfrn

View File

@ -0,0 +1 @@
uid://dmhvn0hu7qlne

View File

@ -0,0 +1 @@
uid://cqw1q6qv873he

View File

@ -40,6 +40,8 @@ public sealed class GameSession
var jsonSource = new JsonContentSource(10);
jsonSource.DataPaths.Add("res://resources/definitions/disciplines.json");
jsonSource.DataPaths.Add("res://resources/definitions/archetypes.json");
jsonSource.DataPaths.Add("res://resources/definitions/roles.json");
jsonSource.DataPaths.Add("res://resources/definitions/traits.json");
registry.RegisterSource(jsonSource);
var content = registry.BuildDatabase();

View File

@ -0,0 +1 @@
uid://cltmymdid63wi

View File

@ -0,0 +1 @@
uid://dg4tya2t3wgel

View File

@ -0,0 +1 @@
uid://bi61o586eqdyu

View File

@ -0,0 +1 @@
uid://c7ng0xpd2iul1

1
scripts/Core/Mvc.cs.uid Normal file
View File

@ -0,0 +1 @@
uid://bqconwrqysw5b

View File

@ -0,0 +1 @@
uid://l1exgbqpbayh

View File

@ -0,0 +1 @@
uid://b8dobhu11y8kt

View File

@ -0,0 +1 @@
uid://bpvgbmxh1gq6u

View File

@ -0,0 +1 @@
uid://cdec5vh0h2md

View File

@ -0,0 +1 @@
uid://dco1nttd0pgqp

View File

@ -0,0 +1 @@
uid://i6eakpcujm0q

View File

@ -0,0 +1 @@
uid://bm4fbpk2hmxkj

View File

@ -0,0 +1 @@
uid://buicivgjyfews

View File

@ -0,0 +1 @@
uid://bpjfyyhudrq8b

View File

@ -0,0 +1 @@
uid://mtm1ypht0ynd

View File

@ -0,0 +1 @@
uid://cjcfah7uglbad

View File

@ -0,0 +1 @@
uid://cx1jd2ydj5nof

View File

@ -0,0 +1 @@
uid://vqos5kin02p1

View File

@ -0,0 +1 @@
uid://bth3oambl7pnk

View File

@ -0,0 +1 @@
uid://huerc661rxe7

View File

@ -6,14 +6,19 @@
</PropertyGroup>
<ItemGroup>
<Content Include="docs\design.md" />
<Content Include="docs\校园行为系统文档.md" />
<Content Include="docs\装备与设施系统.md" />
<Content Include="docs\学科与流派系统.md" />
<Content Include="docs\任务与经济系统.md" />
<Content Include="docs\角色与羁绊系统.md" />
<Content Include="docs\角色与行为规则.md" />
<Content Include="README.md" />
<Content Include="resources\definitions\archetypes.json" />
<Content Include="resources\definitions\campus_behavior.json" />
<Content Include="resources\definitions\disciplines.json" />
<Content Include="resources\definitions\discipline_biology.tres" />
<Content Include="resources\definitions\roles.json" />
<Content Include="resources\definitions\traits.json" />
</ItemGroup>
<ItemGroup>
<Folder Include="addons\" />