diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index bceccfac..3d976196 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -48,6 +48,13 @@ var charge_layers: int: var _deep_paid_count := 0 var deep_paid_count: int: get: return _deep_paid_count +# 效果引擎(内容数据驱动扩展批 T1——dig_paid/charge_full 发射面;round_end 转存决策面) +var _fx: EffectEngine = null +var _last_stop_ap := 0 # 停手侧剩余 AP 记账(转存决策移至 round_end 发射点——grant_ap(transfer) 订阅定夺) + + +func set_fx(fx: EffectEngine) -> void: + _fx = fx func _init(db_ref: ConfigDb) -> void: @@ -98,6 +105,10 @@ func request_dig(chain_size: int, clears_map: bool) -> bool: if feat_dig_extra > 0 and _ap >= cost + feat_dig_extra: cost += feat_dig_extra _deep_paid_count += 1 + # dig_paid 发射点(内容数据驱动扩展批 T1——付费开掘发起、动作经济闸收对价前; + # 深挖钻头 counter_spend 对价订阅面。双轨期对价仍走 feat_dig_extra 旧面) + if _fx != null: + _fx.dispatch("dig_paid", {"ap_cost": cost, "ap_surplus": _ap - cost}) if _ap < cost: _reject("resource_face") return false @@ -137,21 +148,35 @@ func _spend_and_arbitrate(action_kind: String, clears_map: bool) -> bool: # 停手:剩余 AP 1:1 转存 RAP;行动经济 直发 stopped 路径 round_end(双发射源)。 # 整备军备改写(迭代 12 T5——feat_ap_carry=true 时剩余 AP 转蓄力层〔上限 3〕替代 RAP 转存: -# 「不动也是行动」——cleared 作废口径改写的停手侧承载;cleared 分支 AP 恒清零维持既有作废语义) +# 「不动也是行动」——cleared 作废口径改写的停手侧承载;cleared 分支 AP 恒清零维持既有作废语义)。 +# 内容数据驱动扩展批 T1:本方法改记账制——剩余 AP 存 _last_stop_ap 后清零,转存决策移至 +# round_end 发射点 finish_round_stop(grant_ap(mode:transfer) 订阅定夺;双轨期旧旗 feat_ap_carry +# 并行生效,行为与迁移前逐位一致)。 func request_stop() -> bool: if _phase != Phase.ACTIVE: _reject("phase") return false - if feat_ap_carry: - _charge_layers = mini(3, _charge_layers + _ap) - else: - _rap += _ap + _last_stop_ap = _ap _ap = 0 _finish_round("stopped", "same_board") round_end_stopped.emit({"path_tag": "stopped", "continuation_hint": "same_board"}) return true +# 轮末停手转存决策(round_end 发射点调用——编排器授予段尾):transfer=true=剩余 AP 转蓄力层 +# (上限 3,整备军备面);false=1:1 转存 RAP(缺省)。非停手轮(cleared/耗尽)_last_stop_ap=0 +# 两分支均零操作。转蓄后达上限的沿发 charge_full(R6 框架先行——现无订阅者)。 +func finish_round_stop(transfer: bool) -> void: + if transfer: + var prev_layers := _charge_layers + _charge_layers = mini(3, _charge_layers + _last_stop_ap) + if _fx != null and _charge_layers >= 3 and prev_layers < 3: + _fx.dispatch("charge_full", {"layers": _charge_layers}) + else: + _rap += _last_stop_ap + _last_stop_ap = 0 + + # 轮内刷新(2026-09-12 方案 A 用户拍板;等待期通道 2026-09-15 用户拍板整体移除):唯一入口 = # Active 相位——仅扣 RAP,相位与 AP 均不动。刷新不打断轮次、不触发结算;换图编排归 战斗结算 # (retire + install 立即执行,玩家同轮继续挖新图)。决策主体 = 玩家(经 HUD框架发起)纪律不变。 diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 740830d4..935b8217 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -84,6 +84,38 @@ var _feat_combo := 0 # 矿脉直觉连击计数(battle 域——safe +1 var _feat_deep_given := 0 # 深挖钻头本轮已追加份数(battle 域——v2 本格双份制:safe 事件按付费数上限) var _feat_paid_at_round_start := 0 # 轮初付费计数快照(v2——本轮付费次数=当前值−快照) var _feat_magma_layers := 0 # 熔火之躯积层(battle 域——受地形伤 +1,轮末反打清零) +# 效果引擎(内容数据驱动扩展批 T1——发布订阅解释器;begin_battle 从持有集/装备集重建订阅。 +# 双轨过渡期:发射点 dispatch 仅观测命中(施加面仍走 feat_flags 旧消费点),T3 起逐点切换) +var _fx: EffectEngine + + +# 事件发射薄面(16 发射点统一入口——订阅空=零循环零扰动;确定性同步分发零 RNG) +func _fx_emit(event: String, payload: Dictionary) -> Array: + if _fx == null: + return [] + return _fx.dispatch(event, payload, Callable(self, "_fx_fact")) + + +# 补充事实面(fact 取值第二优先源——payload 未携带的运行态读数) +func _fx_fact(fact: String): + match fact: + "charge_layers": + return action_economy.charge_layers + "nest_count": + var n := 0 + for eff in _status_effects.query_active("enemy"): + if String(eff.effect_id) == "weaken": + n += 1 + return n + "round_index": + return round_index + "flags_count": + var hits_n := 0 + for fevt in _facts_flags: + if bool(fevt.get("hit", false)): + hits_n += 1 + return hits_n + return null func _attune_has(item_id: String) -> bool: @@ -191,6 +223,7 @@ var _hero_atk := 0 # 主角攻击基值快照(encounte func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: db = db_ref rng = rng_ref + _fx = EffectEngine.new(db_ref) _board_generator = BoardGenerator.new() action_economy = ActionEconomy.new(db) minesweeper = MinesweeperRules.new(db, action_economy) @@ -199,6 +232,9 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: _reward_economy = RewardEconomy.new(db, rng) _damage = DamageController.new(db, rng, _enemy_system, _status_effects) _damage.emit_row_cb = _emit_row # 行协议归属本类(9 类行协议 + 来源断言不外泄) + _damage.set_fx(_fx) # 效果引擎注入(attack_commit/clamp_check/armor_roll/kill/damage_taken 发射面) + action_economy.set_fx(_fx) # dig_paid/charge_full 发射面 + _reward_economy.set_fx(_fx) # reward_roll 发射面 minesweeper.cell_revealed.connect(_on_cell_revealed) minesweeper.flag_judged.connect(_on_flag_judged) minesweeper.round_end_board.connect(_on_round_end) @@ -267,6 +303,9 @@ func begin_battle(enc: Dictionary) -> bool: if tree_rules.has("posture_first_strike"): action_economy.queue_ap_delta(1) _damage.feat_flags["bulwark"] = 1 if tree_rules.has("posture_bulwark") else 0 + # 效果引擎订阅重建(内容数据驱动扩展批 T1——每战从持有集/装备集重建,中途获得下一战生效; + # 树规则合成订阅同面装载。装载序=feats 表序→treasure 表序→树规则,写死保种子复放) + _fx.rebuild((enc.get("feats", []) as Array), attunement, tree_rules) # 迭代 10 奖励重构批——跨冒险行动点加成轴(统一冒险加算区第四轴):经排队修正通道兑现 # 「每场战斗开始首轮 AP +加成值」(queue_ap_delta = 下轮开轮基线加算、notify_round_start 兑现后 # 清零——首轮即消费,后续轮次不重复兑现;语义锚 = 行动经济 GDD「跨冒险行动点加成轴」节) @@ -281,6 +320,7 @@ func begin_battle(enc: Dictionary) -> bool: return false round_index = 1 _roll_round_actions() # 开轮摇号(敌人技能池批——首轮同样摇;先于 round_started,挖掘期动画位可读) + _fx_emit("round_start", {"round": round_index}) round_started.emit(round_index) return true @@ -471,6 +511,10 @@ func _settle() -> void: # 作废(归因行在案);标空判定已随旗擦除自然缺席(被挖的旗不在轮末在场基线)。 _emit_row("reward_ineffective", "safe_cell", evt.cell, ri, "奖励作废(插旗格直接挖开——零惩罚零奖励)", 0, {}, "") continue + # dig_reveal 发射点(内容数据驱动扩展批 T1——安全开格结算完成;载荷含首格事实与变异类型) + _fx_emit("dig_reveal", {"cell": evt.cell, "round": ri, + "is_first_dig_of_battle": not _feat_first_dig_used, + "terrain_kind": (evt.get("variant_ids", []) as Array)}) _grant_routed(_feat_yield_adjust(snap, true), "positive", "safe_cell", evt.cell, ri, "") # 开格流连击/深层产出(迭代 12 T5):矿脉直觉连击 = safe +1(mine 清零在负向分支); # 深挖钻头深层产出 = 每 cap 格额外 grant 一份(收益面补偿额外 AP 成本——R4 甲非信息税对价) @@ -505,6 +549,9 @@ func _settle() -> void: elif evt.kind == "mine": # 插旗不可逆四情况②:插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常兑付——惊动巢穴), # 标中不发(旗已随挖掘擦除、不入轮末判定基线)。 + # mine_hit 发射点(内容数据驱动扩展批 T1——踩雷时;减半/反打/连击清零订阅面) + _fx_emit("mine_hit", {"cell": evt.cell, "round": ri, + "damage": int((snap as Dictionary).get("power", 0))}) _grant_routed(_feat_yield_adjust(snap, false), "negative", "mine_hit", evt.cell, ri, "") _feat_combo = 0 # 矿脉直觉连击清零(踩雷断连——「推理密度直接奖励」的对侧) # 地形变异 兑付段(授予段尾——触发枚举 on_open:挖开即伤、chain 通道同口径;伤害单据案C 暂存到 @@ -521,6 +568,9 @@ func _settle() -> void: var vt := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), "board", \ int(vrow.get("effect_value", 0)), DamageController.TARGET_HERO, ri, evt.cell, "", 0, "", {"variant_id": String(vid)}) _submit(vt, false) + # damage_taken 发射点(内容数据驱动扩展批 T1——kind=terrain;荆棘/地脉/熔火订阅面) + _fx_emit("damage_taken", {"kind": "terrain", "amount": int(vrow.get("effect_value", 0)), + "source": String(vid), "cell": evt.cell, "round": ri}) # 吞噬流四面(迭代 12 T6——地形从纯税到第三资源带;伤害仍发生=乙稿判据 3 承载): # ①荆棘编织者=挖开伤害型地形格额外掉落一次格面资源(主动吃地形换资源) if int(_damage.feat_flags.get("terrain_dig_double", 0)) > 0: @@ -541,6 +591,16 @@ func _settle() -> void: # 结算 board_typed 必中伤害〔复用零新配方;「旗所在巢穴敌」简化为 default_target 最前存活敌—— # 台账披露待追认〕;完美布防=本轮旗批全中(hit>0 且 miss==0)时旗伤 ×mult——全对推理奖励)。 # 扫雷结算触发点=flag_strike/flag_all_hit/nest_aura 挂点承载红线①;伤害单据案C 暂存战斗段末兑付。 + # flag_settle 发射点(内容数据驱动扩展批 T1——轮末旗伤结算;战旗开单/完美布防订阅面) + var fs_hits := 0 + var fs_misses := 0 + for fevt in _facts_flags: + if bool(fevt.get("hit", false)): + fs_hits += 1 + else: + fs_misses += 1 + _fx_emit("flag_settle", {"flags_hit_all": fs_hits > 0 and fs_misses == 0, + "flags_count": fs_hits, "round": ri}) if int(_damage.feat_flags.get("flag_strike_value", 0)) > 0: var hits := 0 var misses := 0 @@ -570,6 +630,13 @@ func _settle() -> void: DamageController.TARGET_ALL_ALIVE, ri, minesweeper.hero_pos(), "", 0, "", {}) _submit(mg_t, false) _feat_magma_layers = 0 + # round_end 发射点(内容数据驱动扩展批 T1——每轮结算收口;熔火反打/整备转蓄力订阅面。 + # 转蓄力等价说明:停手侧 AP 已在 request_stop 记账(last_stop_ap),转存决策在本点由 + # grant_ap(mode:transfer) 订阅定夺——双轨期旧旗 feat_ap_carry 并行生效,无订阅走 RAP + # 转存缺省,行为与迁移前逐位一致) + _fx_emit("round_end", {"round": ri}) + action_economy.finish_round_stop(action_economy.feat_ap_carry + or (_fx != null and _fx.has_effect_on("round_end", "grant_ap", "transfer"))) if _facts_path == "cleared": _grant_clear_reward(ri) _damage.flush() # 授予段界结账(案C 后本段伤害单据全数暂存,此处对空队 no-op——保留作防御性段界) @@ -646,6 +713,8 @@ func use_treasure(handle: int, target: Vector2i) -> Dictionary: func(c: Vector2i): return not board.is_revealed(c)) if not bool(v.ok): return v # ② 拒绝零消耗 + # treasure_used 发射点(内容数据驱动扩展批 T1——使用主动秘宝受理面;掘进蓄能 counter_spend 订阅面) + _fx_emit("treasure_used", {"item_id": String(v.item_id), "target": target}) # 掘进蓄能受理闸(迭代 11 同调批 T6——bank>0 才可发起,一行一点;积点不足拒绝零消耗) if String(v.item_id) == "dig_charge" and _attune_has("dig_charge"): if _attune_bank <= 0: @@ -840,6 +909,9 @@ func _judge_flag(evt: Dictionary, ri: int) -> void: return # 去重跳过(资格以雷格实例为键——排雷玩法 诚实重复上报的对侧);零明细行 _flag_consumed[cell] = true _perfect_hit_true += 1 + # flag_hit 发射点(内容数据驱动扩展批 T1——标中雷兑付时;殉爆/中和/图谱订阅面) + _fx_emit("flag_hit", {"cell": cell, "round": ri, + "adjacent_terrain": (minesweeper.current_board().variant_ids(cell) if minesweeper.current_board() != null else [])}) # 标中殉爆(迭代 11 同调批 T5——底稿 §3:每标中一面旗,全体存活敌吃 N 群伤;无类型走 blast 通道) if _attune_has("flag_burst"): var fbt := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "attune_flag_burst", "标中殉爆", "board", @@ -941,6 +1013,15 @@ func _roll_round_actions() -> void: _emit_row("highlight", "enemy_action", Vector2i(-1, -1), round_index, "%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))], 0, {"skill_id": sid}, "skill_unlock") + # enemy_hp_threshold 发射点(内容数据驱动扩展批 T1——血量阈值沿;对所有带该字段敌人逐个发射, + # 非 Boss 专属——Boss 语义留给订阅者 when 判定。pct=本次解锁条目的 unlock_hp_pct 表值) + var pct_v := 0 + for pe in (u.get("pool", []) as Array): + if String((pe as Dictionary).get("skill_id", "")) == sid and (pe as Dictionary).has("unlock_hp_pct"): + pct_v = int((pe as Dictionary).unlock_hp_pct) + break + _fx_emit("enemy_hp_threshold", {"unit": String(u.enemy_id), "pct": pct_v, + "is_boss": String(encounter.get("node_type", "")) == "boss"}) var entries: Array = _enemy_system.rollable_skills(u) if _enemy_system.basic_streak(u) >= pity_m: var specials: Array = _enemy_system.special_skills(u) @@ -1132,6 +1213,7 @@ func _exit_distribution() -> void: _refresh_used_this_round = 0 round_index += 1 _roll_round_actions() # 开轮摇号——每轮恰摇一次(轮内刷新换图不重摇:refresh 不走本通道) + _fx_emit("round_start", {"round": round_index}) round_started.emit(round_index) diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 93fc496b..5989e9bd 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -60,6 +60,9 @@ var _outcomes := {} # ticket_id → "redeemed"/"zero"/"voided"( var _seq := 0 # 流水号 var _halted := false # 主角倒下终止旗(死亡事件置位——flush 余单作废) var _hero_ac_override := -1 # 主角护甲等级合流入参(begin_battle 显式设定——-1 = 表值兜底,口径同战斗编排器) +# 效果引擎(内容数据驱动扩展批 T1——attack_commit/clamp_check/armor_roll/kill/damage_taken 发射面; +# 双轨过渡期仅观测命中,施加面仍走 feat_flags 旧消费点) +var _fx: EffectEngine = null # 特性求值旗(迭代 12 T2——begin_battle 编排器经 FeatsEngine 求值后注入;空字典=零扰动。 # rider_flat=英雄侧必中附加值〔奥术余烬族〕/rider_crit=档二 rider 可暴击/first_dig_spell=每战首次 # 开安全格追加魔法矢〔消费在编排器授予段〕/clamp_magic_light·clamp_magic_heavy=克制点直调) @@ -79,6 +82,26 @@ func set_hero_ac_override(v: int) -> void: _hero_ac_override = v +# 效果引擎注入(战斗编排器 _init——事件发射面接线) +func set_fx(fx: EffectEngine) -> void: + _fx = fx + + +# 事件发射薄面(发射点统一入口——订阅空=零循环零扰动) +func _fx_dispatch(event: String, payload: Dictionary) -> void: + if _fx != null: + _fx.dispatch(event, payload) + + +# 巢穴控制数(fact nest_count 取数面——敌方 weaken 实例数,猎巢人订阅面) +func _nest_count() -> int: + var n := 0 + for eff in effects.query_active("enemy"): + if String(eff.effect_id) == "weaken": + n += 1 + return n + + # ---- 开单面(流程调用——编排器六调用点的唯一形态)---- # 单据构造(缺省填齐——调用点只写差异栏;context = kind 专用:enemy_skill 带 skill_id/baseline, @@ -196,8 +219,15 @@ func _resolve_hero_attack(t: Dictionary) -> void: return var armor: String = String(target.get("armor_type", "light")) var atk_type: String = String(t.attack_type) + var matchup := (atk_type + "_vs_" + armor) if atk_type != "" else "" + # attack_commit 发射点(内容数据驱动扩展批 T1——我方伤害单据开立、管线算术前;载荷 atk/target/ + # matchup + 挖掘 streak 事实〔连珠矢/执拗订阅面〕+巢穴控制数〔猎巢人〕) + _fx_dispatch("attack_commit", {"atk": int(t.base_value), "target": String(target.display_name), + "matchup": matchup, "dig_streak": int(feat_flags.get("dig_streak", 0)), "nest_count": _nest_count()}) var roll_read: Dictionary = _armor_roll() var threshold: int = defender_ac(target) + int(db.settlement.attack_bonus) + # armor_roll 发射点(内容数据驱动扩展批 T1——敌护甲掷骰判定;免掷恒折扣订阅面) + _fx_dispatch("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)}) # 矿工重锤·命中交换(迭代 12 T4 普攻流——正负双修原生形态:命中判定减成 trade_hit 点 # 〔更易命中〕换攻击增伤 trade_atk%——高 AC 负期望/低 AC 正期望) var trade_hit := int(feat_flags.get("hit_trade_hit", 0)) @@ -219,6 +249,9 @@ func _resolve_hero_attack(t: Dictionary) -> void: var shift := int(feat_flags.get("discount_shift", 0)) if shift > 0 and armor == "light" and discount < 100: discount = _discount_shift_up(discount, shift) + # clamp_check 发射点(内容数据驱动扩展批 T1——克制关系判定修正点;魔网/湛蓝/猎手订阅面) + if atk_type != "": + _fx_dispatch("clamp_check", {"matchup": matchup}) var cdelta: int = _counter_delta(atk_type, armor) + _feat_clamp_bonus(atk_type, armor) # 淬毒刃纹(迭代 11 同调批 T5——底稿 §3:对被克制敌人的穿刺/魔法直伤 +N;条件 = 克制成立 × # 攻击类型限定,主角攻击面专属——棋盘克制直伤走 _resolve_board_typed 不吃本加成) @@ -271,6 +304,9 @@ func _resolve_hero_attack(t: Dictionary) -> void: payload["hero_rider"] = hero_rider units.apply_damage(target, landed) payload["killed"] = not target.alive # 击杀标记在扣血后取(原语义不变) + # kill 发射点(内容数据驱动扩展批 T1——敌人死亡确认;贯穿射击 overflow 订阅面) + if bool(payload.get("killed", false)): + _fx_dispatch("kill", {"target": String(target.display_name), "overflow": landed}) # 碎甲震击(迭代 12 T4 普攻流——命中重甲附加「护甲裂解」):复用 off_guard(enemy percent # +25 受伤加深——零新状态行);apply_effect 域闭/重复授予由状态容器自行守卫。 var crack := int(feat_flags.get("crack_value", 0)) @@ -308,6 +344,9 @@ func _resolve_enemy_skill(t: Dictionary) -> void: var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.crit_multiplier), 0) var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output")) var total: int = maxi(int(db.settlement.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区 + # damage_taken 发射点(内容数据驱动扩展批 T1——kind=enemy_skill;铁壁减伤订阅面) + _fx_dispatch("damage_taken", {"kind": "enemy_skill", "amount": total, + "source": String(t.context.get("skill_id", "")), "round": int(t.round_index)}) # 铁壁整备(迭代 12 T5 蓄力流——每层下轮开场 S 点减伤;减后过下限保底) var shield := int(feat_flags.get("shield_per_layer", 0)) * int(feat_flags.get("charge_layers", 0)) # 稳健拓荒(迭代 12 T10——Fork posture B 侧 rule 键:受击减 1;两侧对称弱值=「不同强非强弱」) @@ -337,7 +376,12 @@ func _resolve_board_typed(t: Dictionary) -> void: _ineffective_row(t, "无存活敌人") _outcomes[int(t.ticket_id)] = "zero" return - var delta: int = _counter_delta(String(t.attack_type), String(target.get("armor_type", "light"))) + _feat_clamp_bonus(String(t.attack_type), String(target.get("armor_type", "light"))) + var armor_b: String = String(target.get("armor_type", "light")) + var atk_b: String = String(t.attack_type) + # clamp_check 发射点(内容数据驱动扩展批 T1——克制判定修正点棋盘面;与 hero_attack 同表同点) + if atk_b != "": + _fx_dispatch("clamp_check", {"matchup": atk_b + "_vs_" + armor_b}) + var delta: int = _counter_delta(atk_b, armor_b) + _feat_clamp_bonus(atk_b, armor_b) var dealt: int = DamageMath.counter_component(int(t.base_value), delta, int(db.settlement.damage_floor)) units.apply_damage(target, dealt) emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt, diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd new file mode 100644 index 00000000..77405cab --- /dev/null +++ b/game/core/effect_engine.gd @@ -0,0 +1,274 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-10-06(迭代 17 卡⓪ 内容数据驱动扩展批 T1——效果引擎框架) +# 内容数据驱动扩展(任务书 design/research/任务书-内容数据驱动扩展批-2026-10-06.md;设计权威 +# design/research/研究-内容数据驱动扩展架构-2026-10-06.md):把「内容的效果行为」从散布在结算 +# 代码里的硬编码分支搬进内容数据行三个字段——订阅哪个事件(on)/ 什么条件(when)/ 什么效果(do) +# ——由本解释器统一执行。完成后加一件「已有效果类型」的新专长/新秘宝=只改 .tres 数据行,零代码。 +# 架构模型(R1 裁决):发布订阅——结算管线同步 emit 事件(无队列无异步,确定性优先)→ 本引擎遍历 +# 持有订阅集匹配 → 返回按声明序排列的效果命中(do 列表),由发射点按组合规则施加。 +# 多订阅与冲突(R3 裁决):一事件多订阅=常态;冲突在效果类型层用组合规则封死(§3.4 六族);遍历序 +# =数据表声明序(feats 表序→treasure 表序→树规则合成行,装载时定死),写死保种子复放。 +# 重建时机(v1.1 补):每战 begin_battle 从持有集/装备集重建 trigger 列表——冒险中途获得/换取的 +# 专长下一战生效,同战内不热载,确定性优先。计数器账本随重建清零(battle 域)。 +# 确定性契约:全同步调用、零 RNG、零 signal 时序依赖;同输入同命中序。 +# 护栏保留:事件/效果/事实/算子四词表封闭(本文件常量),行内出域=加载期校验致命(schema 侧), +# 运行期 dispatch 对未知事件/效果类型 fail-fast(push_error+断言)。 +class_name EffectEngine +extends RefCounted + +## 事件词汇(16 个发射点——任务书 §3.1 定稿;4 个登记暂不做事件不入本表) +const EVENTS := ["dig_reveal", "dig_paid", "flag_hit", "mine_hit", "attack_commit", "clamp_check", + "armor_roll", "kill", "damage_taken", "enemy_hp_threshold", "round_start", "flag_settle", + "round_end", "reward_roll", "treasure_used", "charge_full"] + +## 扫雷侧事件集(🔴 流派扫雷触发铁律新形态:每流派 ≥1 行订阅本集事件——schema 加载校验。 +## 原 MINE_HOOKS 的同义迁移:挖掘/标旗/轮末结算面事件) +const MINE_EVENTS := ["dig_reveal", "dig_paid", "flag_hit", "mine_hit", "flag_settle", "round_end", "reward_roll"] + +## 效果类型词汇(解释器 switch 闭集——任务书 §3.3 定稿 19 行;类型实数 24,含共享行拆型: +## grant_gp/bias_wallet、counter_add/counter_spend、reveal_tiles/remove_mines/refresh_pick、 +## bias_rewards/preview。任务书标题计 23 系笔误,落码按行内容全收——commit 留痕) +const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open_ticket", + "attack_bonus", "attack_segments", "yield_mult", "spawn_spell", "overflow", "grant_material", + "grant_ap", "grant_gp", "bias_wallet", "shield", "spike", "apply_status", "counter_add", + "counter_spend", "rewrite_board", "reveal_tiles", "remove_mines", "refresh_pick", + "bias_rewards", "preview"] + +## fact 条件词汇(封闭枚举 9 项——任务书 §四定稿) +const FACTS := ["matchup", "is_first_dig_of_battle", "terrain_kind", "target_armor_class", + "flags_count", "charge_layers", "nest_count", "round_index", "counter"] + +## when 算子词汇(封闭——fact 查询面落法随批登记:mod_zero=计数器取模判满〔掘进蓄能〕、 +## advantageous=克制矩阵正面判定〔淬毒刃纹〕、gte_ref/lt_ref=与另一计数器比较〔深挖钻头配额〕、 +## ends_with=后缀匹配〔对某甲族的泛条件〕。均系映射表现状条件装进 9 项 fact 的查询面落法, +## 代拍板白名单覆盖,commit 留痕) +const OPS := ["eq", "neq", "gt", "gte", "lt", "lte", "ends_with", "has", "mod_zero", + "advantageous", "gte_ref", "lt_ref"] + +## when 条件键白名单(trigger 行 when 数组元素键) +const WHEN_KEYS := ["fact", "op", "value", "id", "ref", "value_from"] + +## do 效果键白名单(trigger 行 do 字典键——零裸 flat 同族护栏:kind 外键全部参数引用面) +const DO_KEYS := ["kind", "value", "value_from", "delta", "delta_from", "mult", "mult_from", + "cap", "cap_from", "per", "divisor", "dtype", "mode", "channel", "counter_id", "reset_at", + "set", "status_id", "label", "fallback", "target_kinds"] + +## 主动件分发通道(不经事件订阅——刷新/显影/移除是玩家主动动作;use_treasure 按 kind 分发) +const ACTIVE_KINDS := ["reveal_tiles", "preview", "grant_ap", "remove_mines", "refresh_pick"] + +# ---- 订阅集与计数器账本 ---- +var _triggers: Array = [] # [{source, id, on, when, do}](装载序=声明序,重建时定死) +var _counters: Dictionary = {} # counter_id → int(battle 域账本;重建清零;重置点由订阅行声明) +var _reset_on_round_start: Array[String] = [] # round_start 重置点计数器名单(装载时收集) +var db: ConfigDb # 矩阵/表读取(advantageous 算子消费)——可空(纯计数器用法零依赖) + + +func _init(db_ref: ConfigDb = null) -> void: + db = db_ref + + +## 订阅装载(每战 begin_battle 调用——重建时机 v1.1):feats 表序→treasure 表序(各按持有/装备 +## 集过滤)→树规则合成行。同一数据行的多个 trigger 按行内数组序。计数器账本同步清零并按订阅行 +## 声明收集重置点(round_start)。 +func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Array = []) -> void: + _triggers.clear() + _counters.clear() + _reset_on_round_start.clear() + if db != null and db.feats != null: + for row in db.feats.rows: + var fid := String(row.get("feat_id", "")) + if not feat_ids.has(fid): + continue + for tr in (row.get("triggers", []) as Array): + _push_trigger("feat", fid, tr) + if db != null: + for row in db.treasure_attunement_rows(): + var iid := String(row.get("item_id", "")) + if not item_ids.has(iid): + continue + for tr in (row.get("triggers", []) as Array): + _push_trigger("attune", iid, tr) + # 树规则合成订阅(T5——规则键效果走同面:非数据行,词表同闭集) + for key_v in tree_rules: + var rk := String(key_v) + match rk: + "posture_first_strike": + _push_trigger("tree", rk, {"on": "round_start", + "when": [{"fact": "round_index", "op": "eq", "value": 1}], + "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}) + "posture_bulwark": + _push_trigger("tree", rk, {"on": "damage_taken", + "do": {"kind": "shield", "value": 1}}) + + +func _push_trigger(source: String, id: String, tr: Dictionary) -> void: + var on := String(tr.get("on", "")) + assert(EVENTS.has(on), "[效果引擎] 订阅事件出域: %s(%s %s)" % [on, source, id]) + var t := {"source": source, "id": id, "on": on, + "when": (tr.get("when", []) as Array).duplicate(true), + "do": (tr.get("do", {}) as Dictionary).duplicate(true)} + _triggers.append(t) + # 计数器重置点收集(counter_add/counter_spend 行声明 reset_at) + var do_d: Dictionary = t.do + if do_d.has("counter_id") and String(do_d.get("reset_at", "")) == "round_start": + var cid := String(do_d.counter_id) + if not _reset_on_round_start.has(cid): + _reset_on_round_start.append(cid) + + +## 订阅查询面(发射点缺省行为仲裁用——如 round_end 无 transfer 订阅时走 RAP 转存) +func has_effect_on(event: String, kind: String, mode: String = "") -> bool: + for tr in _triggers: + if String(tr.on) != event: + continue + var d: Dictionary = tr.do + if String(d.get("kind", "")) == kind and (mode == "" or String(d.get("mode", "")) == mode): + # 有 when 时不能预判命中(发射点缺省行为保守按「可能命中」计——命中由 dispatch 定夺) + return true + return false + + +## 事件分发(发射点唯一入口):返回命中效果列表(装载序=声明序;零命中=空数组零扰动)。 +## payload = 事件现场(fact 取值第一优先源);facts = 补充事实面(charge_layers/nest_count 等 +## 运行态读数 Callable——payload 未带时回落)。 +func dispatch(event: String, payload: Dictionary, facts: Callable = Callable()) -> Array: + assert(EVENTS.has(event), "[效果引擎] 未知事件: %s(发射点出域)" % event) + if event == "round_start": + for cid in _reset_on_round_start: + _counters[cid] = 0 + var hits: Array = [] + for tr in _triggers: + if String(tr.on) != event: + continue + if not _when_ok(tr.when, payload, facts): + continue + hits.append(tr.do) + return hits + + +## 计数器读数(fact counter 与发射点配额面共用) +func counter(counter_id: String) -> int: + return int(_counters.get(counter_id, 0)) + + +func counter_add(counter_id: String, value: int, set_mode: bool = false) -> void: + if set_mode: + _counters[counter_id] = value + else: + _counters[counter_id] = int(_counters.get(counter_id, 0)) + value + + +## 计数器消耗(counter_spend 组合规则——按订阅序消耗共享资源,耗尽即止): +## 返回 true=消耗成功;false=资源不足(调用方按 reason 通道拒绝)。 +func counter_spend(counter_id: String, value: int) -> bool: + var cur := int(_counters.get(counter_id, 0)) + if cur < value: + return false + _counters[counter_id] = cur - value + return true + + +# ---- when 判定(条件谓词——零随机全确定) ---- + +func _when_ok(when: Array, payload: Dictionary, facts: Callable) -> bool: + for c_v in when: + if not _cond_ok(c_v as Dictionary, payload, facts): + return false + return true + + +func _cond_ok(c: Dictionary, payload: Dictionary, facts: Callable) -> bool: + var fact := String(c.get("fact", "")) + var op := String(c.get("op", "eq")) + var val = c.get("value", null) + var actual = _fact_value(fact, c, payload, facts) + match op: + "eq": + return str(actual) == str(val) + "neq": + return str(actual) != str(val) + "gt": + return int(actual) > int(val) + "gte": + return int(actual) >= int(val) + "lt": + return int(actual) < int(val) + "lte": + return int(actual) <= int(val) + "ends_with": + if val is Array: + for v in (val as Array): + if String(actual).ends_with(String(v)): + return true + return false + return String(actual).ends_with(String(val)) + "has": + if actual is Array: + return (actual as Array).has(val) + return false + "mod_zero": + var m := int(val) + return m > 0 and int(actual) % m == 0 + "advantageous": + # 克制矩阵正面判定(matchup = "{attack_type}_vs_{armor}"——矩阵值 > 0 = 克制成立) + return _counter_delta_of(String(actual)) > 0 + "gte_ref": + return int(actual) >= counter(String(c.get("ref", ""))) + "lt_ref": + return int(actual) < counter(String(c.get("ref", ""))) + assert(false, "[效果引擎] when 算子出域: %s" % op) + return false + + +func _fact_value(fact: String, c: Dictionary, payload: Dictionary, facts: Callable): + match fact: + "counter": + return counter(String(c.get("id", ""))) + _: + # fact 取值序:payload 同名字段优先 → 补充事实面(发射点注入的运行态读数) + if payload.has(fact): + return payload[fact] + if facts.is_valid(): + return facts.call(fact) + return null + + +## 克制矩阵读值(advantageous 算子消费——与伤害控制器 _counter_delta 同源同表) +func _counter_delta_of(matchup: String) -> int: + if db == null or matchup == "" or not matchup.contains("_vs_"): + return 0 + var parts := matchup.split("_vs_") + if parts.size() != 2: + return 0 + var tbl: Dictionary = db.settlement.armor_effectiveness + var row: Dictionary = tbl.get(String(parts[0]), {}) + return int(row.get(String(parts[1]), 0)) + + +# ---- 组合规则(任务书 §3.4 六族——效果类型的必填属性,发射点施加面引用) ---- + +## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap(非 transfer)、 +## counter_add、shield——发射点逐条累加收集器。 +## 乘法族(收集后一次施加,正负分桶):yield_mult、damage_mult。 +## 任意真族(任一命中即生效):spawn_spell、overflow、spike。 +## 按序改写族(收集→单点按声明序施加):modify_ticket 及一切改写窗口效果。 +## 按序消耗族(按订阅序消耗共享资源,耗尽即止;可选 fallback):counter_spend。 +## 即时执行族(每条命中各自执行一次,执行序=声明序):open_ticket、grant_ap(mode:transfer)、 +## rewrite_board、apply_status——发射点逐条执行。 +static func combo_of(kind: String) -> String: + match kind: + "add_ticket_rider", "attack_bonus", "grant_material", "counter_add", "shield": + return "additive" + "grant_ap": + return "additive" # mode:transfer 由发射点即时执行族承载(转移非加法) + "yield_mult", "damage_mult": + return "multiplicative" + "spawn_spell", "overflow", "spike": + return "any_true" + "modify_ticket": + return "ordered_write" + "counter_spend": + return "ordered_spend" + "open_ticket", "rewrite_board", "apply_status": + return "immediate" + return "immediate" # 默认即时执行(每条命中各自执行一次) diff --git a/game/core/effect_engine.gd.uid b/game/core/effect_engine.gd.uid new file mode 100644 index 00000000..d63bf9a6 --- /dev/null +++ b/game/core/effect_engine.gd.uid @@ -0,0 +1 @@ +uid://dgo4b5frip3e5 diff --git a/game/core/reward_economy.gd b/game/core/reward_economy.gd index 57943374..96704b68 100644 --- a/game/core/reward_economy.gd +++ b/game/core/reward_economy.gd @@ -23,6 +23,12 @@ var _granted := {} # Vector2i → 快照(重复受理幂等返 var _clear_identity := "" var _clear_granted := false var _clear_snapshot := {} +# 效果引擎(内容数据驱动扩展批 T1——reward_roll 发射面;偏运硬币 bias_rewards 订阅面) +var _fx: EffectEngine = null + + +func set_fx(fx: EffectEngine) -> void: + _fx = fx func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: @@ -59,6 +65,10 @@ func roll_board(eligible: Array) -> bool: _clear_granted = false _clear_snapshot = {} var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(格批 + 清图批尾 + 区间行追加取号) + # reward_roll 发射点(内容数据驱动扩展批 T1——格面资源掷定、摇号前权重合成层; + # 偏运硬币 bias_rewards 订阅面。双轨期权重投影仍走 luck_bias 旧面,发射仅观测) + if _fx != null: + _fx.dispatch("reward_roll", {"polarity": "positive", "cell_count": list.size()}) var cell_rows: Array = _biased_cell_rows() # cell 池正向行 weight>0(偏运硬币装配时稀有正向 kinds 权重 +N——池结构零触碰) var clear_rows: Array = _pool_rows("clear_default") # ① 身份批:每非封锁格恰一类型身份(雷安同池——同一行集同一权重分布)