Add formal enemies, remaining formal partners and synthesized UI SFX

- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""G3 economy samples from Config/Gameplay/greenfield.json.
Three trajectories from GameDesign 8.2: first day, seven logins, and stuck before a boss.
Ignores natural marble recovery. Pinball candy/coins use an equal chance across the five bins.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / "Config/Gameplay/greenfield.json"
OUT_MD = ROOT / "Doc/Design/G3/EconomySample.md"
OUT_JSON = ROOT / "Doc/Design/G3/economy_sample.json"
# GameDesign 8.2, aligned to the live first-five clear and pinball means.
DESIGN_DAY1_BALLS = 67
DESIGN_TEN_SHOTS = {"candy": 200, "coins": 520, "tickets": 1}
def reward_of(node: dict) -> dict:
src = node.get("reward") or node.get("contents") or {}
return {k: int(src.get(k, 0) or 0) for k in ("coins", "candy", "balls", "tickets")}
def add(total: dict, part: dict, times: int = 1) -> None:
for k in total:
total[k] += part.get(k, 0) * times
def line(name: str, part: dict, times: int = 1) -> str:
shown = {k: part.get(k, 0) * times for k in ("coins", "candy", "balls", "tickets") if part.get(k, 0)}
body = "、".join(f"{label}{shown[key]}" for key, label in (
("balls", "弹珠"), ("candy", "糖果"), ("coins", "金币"), ("tickets", "券")
) if key in shown) or "无资源"
suffix = f" ×{times}" if times > 1 else ""
return f"| {name}{suffix} | {body} |"
def main() -> None:
cfg = json.loads(CONFIG.read_text(encoding="utf-8"))
economy = cfg["economy"]
stages = cfg["stages"]
pinball = cfg["pinball"]
candy_mean = sum(pinball["candy"]) / len(pinball["candy"])
coin_mean = sum(pinball["coins"]) / len(pinball["coins"])
ten = {
"candy": int(round(candy_mean * 10)),
"coins": int(round(coin_mean * 10)),
"tickets": 10 // economy["ticketEvery"],
}
train_to = lambda level: economy["trainCost"] * sum(range(1, level))
zero = {"coins": 0, "candy": 0, "balls": 0, "tickets": 0}
day1 = dict(zero)
rows = []
def take(name: str, part: dict, times: int = 1) -> None:
add(day1, part, times)
rows.append(line(name, part, times))
take("初始弹珠", {"balls": economy["initialBalls"]})
take("欢迎邮件", reward_of(cfg["mail"]["initial"][0]))
take("七日签到第1天", reward_of({"reward": cfg["daily"]["signin"][0]}))
for task in cfg["daily"]["tasks"]:
take(task["title"], reward_of(task))
take(cfg["shop"]["goods"][0]["name"], reward_of(cfg["shop"]["goods"][0]))
take("教学目标(通过1-1)", {"balls": economy["goalBalls"]})
first5 = [s for s in stages if s["id"] <= 5]
for stage in first5:
take(f"首通 {stage['code']}", reward_of(stage))
chest_by_stage = {d["stageId"]: cfg["rewards"]["chests"][d["chest"]] for d in cfg["rewards"]["stageDrops"]}
for stage_id in (1, 3):
chest = chest_by_stage[stage_id]
take(f"打开{chest['name']}({stages[stage_id - 1]['code']}掉落)", reward_of(chest))
design_stage_balls = 17
config_stage_balls = sum(s["reward"]["balls"] for s in first5)
design_matched = (
economy["initialBalls"] + cfg["daily"]["signin"][0]["balls"]
+ sum(t["reward"]["balls"] for t in cfg["daily"]["tasks"])
+ cfg["shop"]["goods"][0]["reward"]["balls"] + design_stage_balls
)
week = dict(zero)
week_rows = []
signin = {"balls": sum(d["balls"] for d in cfg["daily"]["signin"])}
tasks = {"balls": 7 * sum(t["reward"]["balls"] for t in cfg["daily"]["tasks"])}
shop = {"balls": 7 * cfg["shop"]["goods"][0]["reward"]["balls"]}
for name, part in (("七日签到全部领取", signin), ("七日每日任务全部领取", tasks), ("七日免费补给", shop)):
add(week, part)
week_rows.append(line(name, part))
stuck = dict(zero)
stuck_rows = []
def stuck_take(name: str, part: dict, times: int = 1) -> None:
add(stuck, part, times)
stuck_rows.append(line(name, part, times))
stuck_take("初始弹珠", {"balls": economy["initialBalls"]})
stuck_take("欢迎邮件", reward_of(cfg["mail"]["initial"][0]))
stuck_take("签到第1天", reward_of({"reward": cfg["daily"]["signin"][0]}))
for task in cfg["daily"]["tasks"]:
stuck_take(task["title"], reward_of(task))
stuck_take("免费补给", reward_of(cfg["shop"]["goods"][0]))
stuck_take("教学目标", {"balls": economy["goalBalls"]})
for stage in stages:
if stage["id"] >= 5:
break
stuck_take(f"首通 {stage['code']}", reward_of(stage))
stuck_take("打开绿野补给箱", reward_of(chest_by_stage[1]))
need_candy = train_to(5)
need_coins = economy["evolveCoins"]
candy_gap = max(0, need_candy - stuck["candy"])
coin_gap = max(0, need_coins - stuck["coins"])
shots_for_candy = (candy_gap + candy_mean - 1) // candy_mean if candy_mean else 0
shots_for_coins = (coin_gap + coin_mean - 1) // coin_mean if coin_mean else 0
shard_from_chest = 10
shard_gap = economy["evolveShards"] - shard_from_chest
report = {
"config": str(CONFIG.relative_to(ROOT)).replace("\\", "/"),
"ignored": ["natural marble recovery", "pinball path variance beyond bin average"],
"pinballAverage": {"candy": candy_mean, "coins": coin_mean, "perTenShots": ten},
"designDay1Balls": DESIGN_DAY1_BALLS,
"designFormulaWithLiveNonStageSources": design_matched,
"configFirstFiveStageBalls": config_stage_balls,
"day1": day1,
"sevenDaysWithoutClears": week,
"stuckBefore1_5": {
"walletBeforePinball": stuck,
"trainToLevel5Candy": need_candy,
"candyGap": candy_gap,
"pinballShotsToCloseCandy": int(shots_for_candy),
"evolveCoins": need_coins,
"coinGap": coin_gap,
"pinballShotsToCloseCoins": int(shots_for_coins),
"evolveShards": economy["evolveShards"],
"shardsFromChoiceChest": shard_from_chest,
"shardGap": shard_gap,
},
}
OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
def wallet(w: dict) -> str:
return f"弹珠 {w['balls']}、糖果 {w['candy']}、金币 {w['coins']}、捕捉券 {w['tickets']}"
md = f"""# G3 经济样例
由 `Tools/economy_sample.py` 读取 `Config/Gameplay/greenfield.json` 生成。不计弹珠自然恢复。弹珠台按五个落区等概率取均值:糖果 {candy_mean:g}、金币 {coin_mean:g}。实际单次受路径影响。
## 对照设计稿
`GameDesign` 8.2 的首日上限是 **{DESIGN_DAY1_BALLS}** 枚弹珠:初始 20、签到 10、每日任务 15、免费补给 5、前五关首通 {design_stage_balls}。按同一口径合计得到 **{design_matched}**。现行 1-1~1-5 首通弹珠合计是 **{config_stage_balls}**,与稿一致。
稿中「10 发预计糖果 200、金币 520、券 1」。现行均值为糖果 {ten['candy']}、金币 {ten['coins']}、券 {ten['tickets']},与稿一致。
## 首日获取上限
假设当天领完邮件、签到、三个每日任务、免费补给和教学目标,打通 1-1~1-5 并打开已掉落的绿野补给箱与碎片自选箱。自选箱只给碎片,不进下表钱包。溪口大礼箱要先通过 1-5,打开后另有金币 150、糖果 80、弹珠 5、券 1,未计入本表。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(rows)}
合计:**{wallet(day1)}**。这是获取上限,不是要求当天花完。
## 七日回访
不打新关、不发射。每日未领奖励过期,下表按七天都当天领完。签到不循环。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(week_rows)}
合计:**{wallet(week)}**。
## 卡在 1-5 之前
已通过 1-1~1-4,打不过溪口大角王。当天可领来源如下,不含为了拿糖果而发射的弹珠台产出。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(stuck_rows)}
合计:**{wallet(stuck)}**。
- 训练到建议 Lv.5 需要糖果 {need_candy}。账上差 {candy_gap},按均值再发射 {int(shots_for_candy)} 次可补上。主线不另扣体力。
- 进化需要金币 {need_coins}。账上差 {coin_gap},按均值再发射 {int(shots_for_coins)} 次可补上。通过 1-1 后商店每天还能用金币换糖果,那是把金币换成糖果,不增加总资源。
- 进化需要碎片 {economy['evolveShards']}。1-3 自选箱提供 {shard_from_chest},还差 {shard_gap}。重复伙伴每次 {economy['duplicateShards']} 片,不能把碎片换回捕捉券。
训练、进化和再抽一只伙伴都能从免费来源走到,碎片比糖果和金币更慢。
"""
OUT_MD.write_text(md, encoding="utf-8")
print(json.dumps({"day1": day1, "week": week, "stuck": stuck, "tenShots": ten}, ensure_ascii=False))
if __name__ == "__main__":
main()