Переглянути джерело

Add configurable boss action workflow

bang 2 тижнів тому
батько
коміт
697347d2fd
5 змінених файлів з 110 додано та 13 видалено
  1. 15 1
      server.py
  2. 8 1
      slot_workflow.py
  3. 30 0
      spine_builder.py
  4. 43 11
      templates/SlotGame.ts
  5. 14 0
      web/index.html

+ 15 - 1
server.py

@@ -141,6 +141,8 @@ def _creative_fallback(data):
         "characterCount": data.get("characterCount", 10),
         "uiCompleteness": data.get("uiCompleteness", "full"),
         "feedbackIntensity": data.get("feedbackIntensity", "standard"),
+        "enableBoss": bool(data.get("enableBoss", True)),
+        "bossPresence": data.get("bossPresence", "full"),
         "enableMathModel": bool(data.get("enableMathModel", True)),
         "features": features,
     }
@@ -181,6 +183,13 @@ def build_game_plan(slot_request, creative_payload, source):
             "reelExperience": slot_request.get("reelMode"),
             "volatility": slot_request.get("volatility"),
             "differentiators": hook_parts,
+            "bossDesign": {
+                "enabled": bool(slot_request.get("enableBoss", True)),
+                "presence": slot_request.get("bossPresence", "full"),
+                "idle": "静静等待时会呼吸、环顾、蓄力",
+                "playerWin": "玩家赢钱时撒币,然后受击裂开爆炸",
+                "playerLose": "玩家输钱时举剑嘲讽、踩踏并攻击",
+            },
             "assetStrategy": {
                 "symbolCount": slot_request.get("characterCount"),
                 "uiCompleteness": slot_request.get("uiCompleteness"),
@@ -213,6 +222,8 @@ def creative_to_slot_request(data):
         "characterCount": data.get("characterCount", 10),
         "uiCompleteness": data.get("uiCompleteness", "full"),
         "feedbackIntensity": data.get("feedbackIntensity", "standard"),
+        "enableBoss": bool(data.get("enableBoss", True)),
+        "bossPresence": data.get("bossPresence", "full"),
         "features": data.get("features") or [],
         "enableMathModel": bool(data.get("enableMathModel", True)),
     }
@@ -270,6 +281,8 @@ def creative_to_slot_request(data):
                     "characterCount": "6|8|10|12",
                     "uiCompleteness": "basic|full",
                     "feedbackIntensity": "quiet|standard|loud",
+                    "enableBoss": "boolean",
+                    "bossPresence": "light|standard|full",
                     "enableMathModel": "boolean",
                     "features": "array of cascades, free_spins, wilds, hold_win, multipliers"
                 },
@@ -291,7 +304,8 @@ def creative_to_slot_request(data):
     fallback = _creative_fallback(payload)
     out = dict(fallback)
     for key in ("gameId", "title", "theme", "style", "reelMode", "volatility", "targetRtp",
-                "characterCount", "uiCompleteness", "feedbackIntensity", "enableMathModel"):
+                "characterCount", "uiCompleteness", "feedbackIntensity", "enableBoss",
+                "bossPresence", "enableMathModel"):
         if key in obj and obj[key] not in (None, ""):
             out[key] = obj[key]
     if isinstance(obj.get("features"), list) and obj["features"]:

+ 8 - 1
slot_workflow.py

@@ -306,8 +306,12 @@ def build_slot_config(data):
             "enabled": bool(data.get("enableBoss", True)),
             "id": data.get("bossId") or "boss_demon_lord",
             "title": data.get("bossTitle") or "大魔王关主",
+            "presence": data.get("bossPresence") or "full",
+            "idleLoop": ["idle", "watch", "charge"],
             "winReaction": "boss_cracks_open_and_explodes_when_player_wins",
             "loseReaction": "boss_raises_greatsword_and_taunts_after_player_loses",
+            "playerWinSequence": ["coin_throw", "hurt", "explode"],
+            "playerLoseSequence": ["taunt", "stomp", "attack"],
         },
         "feedback": FEEDBACK_RULES.get(data.get("feedbackIntensity", "standard"), FEEDBACK_RULES["standard"]),
         "assetGeneration": {
@@ -380,7 +384,7 @@ def build_manifest(slot_config):
             "id": boss_id,
             "type": "spine_parts",
             "role": "boss",
-            "animations": ["idle", "taunt", "attack", "hurt", "explode"],
+            "animations": ["idle", "watch", "charge", "coin_throw", "taunt", "stomp", "attack", "hurt", "explode"],
             "prompt": (
                 "a cute stylized mobile game demon lord boss character, full body, oversized dark armor, "
                 "horned crown, glowing eyes, holding a huge greatsword, triumphant villain pose, one boot "
@@ -399,6 +403,9 @@ def build_manifest(slot_config):
                 {"id": "horn_left", "parent": "head", "x": -70, "y": 70, "prompt": "left curved horn of demon lord boss"},
                 {"id": "horn_right", "parent": "head", "x": 70, "y": 70, "prompt": "right curved horn of demon lord boss"},
                 {"id": "crack_core", "parent": "torso", "x": 0, "y": 160, "prompt": "glowing purple gold magical crack core explosion light for boss armor"},
+                {"id": "coin_bag", "parent": "left_arm", "x": -75, "y": -45, "prompt": "small purple villain coin bag overflowing with shiny gold coins, mobile game boss prop"},
+                {"id": "coin_splash", "parent": "torso", "x": 0, "y": 220, "prompt": "arc of shiny gold coins flying outward, transparent background, clean mobile game VFX prop"},
+                {"id": "defeated_hero_shadow", "parent": "root", "x": 95, "y": -185, "prompt": "tiny simple defeated hero silhouette shadow under a demon boss boot, comedic mobile game style, no gore, no blood"},
             ],
         })
 

+ 30 - 0
spine_builder.py

@@ -327,6 +327,29 @@ def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
             "cape": {"rotate": _rotate_keys([(0, -2), (0.6, 3), (1.2, -2)])},
             "head": {"rotate": _rotate_keys([(0, 0), (0.6, -2), (1.2, 0)])},
         }}
+    if "watch" in animations:
+        anims["watch"] = {"bones": {
+            "torso": {"rotate": _rotate_keys([(0, 0), (0.25, -3), (0.55, 3), (0.9, 0)])},
+            "head": {"rotate": _rotate_keys([(0, 0), (0.25, -12), (0.55, 12), (0.9, 0)])},
+            "horn_left": {"rotate": _rotate_keys([(0, 0), (0.55, -6), (0.9, 0)])},
+            "horn_right": {"rotate": _rotate_keys([(0, 0), (0.55, 6), (0.9, 0)])},
+        }}
+    if "charge" in animations:
+        anims["charge"] = {"bones": {
+            "torso": {"scale": _scale_keys([(0, 1, 1), (0.35, 1.08, 0.92), (0.7, 1, 1)])},
+            "crack_core": {"scale": _scale_keys([(0, 0.75, 0.75), (0.35, 1.6, 1.6), (0.7, 0.9, 0.9)])},
+            "greatsword": {"rotate": _rotate_keys([(0, 0), (0.35, -20), (0.7, 0)])},
+        }}
+    if "coin_throw" in animations:
+        anims["coin_throw"] = {"bones": {
+            "left_arm": {"rotate": _rotate_keys([(0, 0), (0.2, 42), (0.45, -18), (0.75, 0)])},
+            "coin_bag": {"rotate": _rotate_keys([(0, 0), (0.2, 28), (0.45, -22), (0.75, 0)])},
+            "coin_splash": {
+                "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": -170, "y": 120}, {"time": 0.75, "x": -40, "y": 20}],
+                "scale": _scale_keys([(0, 0.35, 0.35), (0.25, 1.35, 1.35), (0.75, 0.75, 0.75)]),
+            },
+            "head": {"rotate": _rotate_keys([(0, 0), (0.35, -7), (0.75, 0)])},
+        }}
     if "taunt" in animations or "attack" in animations:
         anims["taunt"] = {"bones": {
             "torso": {"rotate": _rotate_keys([(0, 0), (0.18, -7), (0.45, 4), (0.7, 0)])},
@@ -339,6 +362,13 @@ def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
             "right_arm": {"rotate": _rotate_keys([(0, -18), (0.16, 38), (0.34, -12), (0.55, 0)])},
             "greatsword": {"rotate": _rotate_keys([(0, -28), (0.16, 60), (0.34, -20), (0.55, 0)])},
         }}
+    if "stomp" in animations:
+        anims["stomp"] = {"bones": {
+            "torso": {"translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.16, "x": 0, "y": 34}, {"time": 0.32, "x": 0, "y": -16}, {"time": 0.55, "x": 0, "y": 0}]},
+            "right_leg": {"rotate": _rotate_keys([(0, 0), (0.16, -32), (0.32, 18), (0.55, 0)])},
+            "defeated_hero_shadow": {"scale": _scale_keys([(0, 1, 1), (0.32, 1.35, 0.55), (0.55, 1, 1)])},
+            "greatsword": {"rotate": _rotate_keys([(0, 0), (0.32, -18), (0.55, 0)])},
+        }}
     if "hurt" in animations:
         anims["hurt"] = {"bones": {
             "torso": {"rotate": _rotate_keys([(0, 0), (0.08, 12), (0.22, -8), (0.42, 0)])},

+ 43 - 11
templates/SlotGame.ts

@@ -124,6 +124,7 @@ export class SlotGame extends Component {
   private turboBtn!: Node; private autoBtn!: Node;
   private bossNode: Node | null = null;
   private bossSk: sp.Skeleton | null = null;
+  private bossBusy = false;
 
   onLoad() {
     try { (cc as any).profiler?.hideStats?.(); } catch (e) {}
@@ -221,6 +222,7 @@ export class SlotGame extends Component {
     node.setScale(scale, scale, 1);
     node.setPosition(this.frameW * 0.40, this.frameCY + this.frameH * 0.32, 0);
     this.bossNode = node; this.bossSk = sk;
+    this.schedule(() => this.bossIdleBeat(), 3.2);
   }
 
   // ---------------- HUD:用 hud_pill 美术 ----------------
@@ -364,30 +366,60 @@ export class SlotGame extends Component {
     this.coinShower(Math.max(12, Math.min(40, Math.floor(amount / 15))));
   }
 
-  private bossDefeated() {
+  private playBossAnim(name: string, loop = false) {
     if (!this.bossNode || !this.bossSk) return;
-    this.bossSk.setAnimation(0, 'hurt', false);
-    this.bossSk.addAnimation(0, 'explode', false, 0.08);
-    this.bossSk.addAnimation(0, 'idle', true, 0.3);
+    try { this.bossSk.setAnimation(0, name, loop); }
+    catch (e) { try { this.bossSk.setAnimation(0, 'idle', true); } catch (_e) {} }
+  }
+
+  private addBossAnim(name: string, loop = false, delay = 0) {
+    if (!this.bossSk) return;
+    try { this.bossSk.addAnimation(0, name, loop, delay); } catch (e) {}
+  }
+
+  private bossIdleBeat() {
+    if (!this.bossNode || !this.bossSk || this.spinning || this.bossBusy) return;
+    const anim = Math.random() > 0.5 ? 'watch' : 'charge';
+    this.playBossAnim(anim, false);
+    this.addBossAnim('idle', true, 0);
+    const n = this.bossNode;
+    const base = n.scale.x;
+    tween(n).to(0.25, { scale: new Vec3(base * 1.04, base * 0.98, 1) })
+            .to(0.28, { scale: new Vec3(base, base, 1) }).start();
+  }
+
+  private bossDefeated(amount: number) {
+    if (!this.bossNode || !this.bossSk) return;
+    this.bossBusy = true;
+    this.playBossAnim('coin_throw', false);
+    this.addBossAnim('hurt', false, 0.05);
+    this.addBossAnim('explode', false, 0.08);
+    this.addBossAnim('idle', true, 0.35);
     this.playParticle('boss_explosion');
+    this.coinShower(Math.max(10, Math.min(34, Math.floor(amount / 20))));
     const n = this.bossNode;
     const base = n.scale.x;
     tween(n).to(0.10, { scale: new Vec3(base * 1.18, base * 0.86, 1), angle: -7 })
             .to(0.12, { scale: new Vec3(base * 0.82, base * 1.18, 1), angle: 8 })
-            .to(0.18, { scale: new Vec3(base, base, 1), angle: 0 }, { easing: 'backOut' }).start();
-    this.flashMult('大魔王裂开爆炸!');
+            .to(0.18, { scale: new Vec3(base, base, 1), angle: 0 }, { easing: 'backOut' })
+            .call(() => { this.bossBusy = false; }).start();
+    this.flashMult('大魔王撒币后裂开爆炸!');
   }
 
   private bossTaunt() {
     if (!this.bossNode || !this.bossSk) return;
-    this.bossSk.setAnimation(0, 'taunt', false);
-    this.bossSk.addAnimation(0, 'idle', true, 0);
+    this.bossBusy = true;
+    this.playBossAnim('taunt', false);
+    this.addBossAnim('stomp', false, 0.04);
+    this.addBossAnim('attack', false, 0.02);
+    this.addBossAnim('idle', true, 0);
     const n = this.bossNode;
     const x = n.position.x, y = n.position.y;
     tween(n).to(0.12, { position: new Vec3(x - this.cell * 0.12, y + this.cell * 0.10, 0), angle: -5 })
             .to(0.16, { position: new Vec3(x + this.cell * 0.10, y - this.cell * 0.18, 0), angle: 7 })
-            .to(0.18, { position: new Vec3(x, y, 0), angle: 0 }, { easing: 'backOut' }).start();
-    this.flashMult('大魔王举剑耀武扬威');
+            .to(0.18, { position: new Vec3(x, y, 0), angle: 0 }, { easing: 'backOut' })
+            .call(() => { this.bossBusy = false; }).start();
+    this.flashMult('大魔王举剑踩踏耀武扬威');
   }
   private coinShower(count: number) {
     const sf = this.art['coin']; if (!sf) return;
@@ -680,7 +712,7 @@ export class SlotGame extends Component {
   }
 
   private endRound() {
-    if (this.roundWin > 0) { this.multLabel.string = ''; this.celebrate(this.roundWin); this.bossDefeated(); }
+    if (this.roundWin > 0) { this.multLabel.string = ''; this.celebrate(this.roundWin); this.bossDefeated(this.roundWin); }
     else { this.multLabel.string = ''; this.bossTaunt(); }
     this.spinning = false; this.setSpinEnabled(true);
     if (this.holdActive) return;

+ 14 - 0
web/index.html

@@ -148,6 +148,13 @@
           <option value="quiet">克制</option>
           <option value="loud">夸张</option>
         </select></div>
+      <div class="field"><label><input type="checkbox" id="wfEnableBoss" checked> 生成关主大魔王</label></div>
+      <div class="field"><label>关主表现</label>
+        <select id="wfBossPresence">
+          <option value="full">完整:待机/撒币/裂开/踩踏</option>
+          <option value="standard">标准:输赢反应</option>
+          <option value="light">轻量:只做待机和输赢</option>
+        </select></div>
       <div class="field"><label>初始金币</label>
         <input id="wfStartingBalance" type="number" value="5000" min="100" step="100"></div>
       <div class="field"><label>默认下注</label>
@@ -428,6 +435,7 @@ function renderGamePlan(plan, source){
     `核心钩子:${gd.coreHook||''}`,
     `玩法:${gd.reelExperience||''} / ${gd.volatility||''}`,
     `差异点:${(gd.differentiators||[]).join('、')||'未生成'}`,
+    `关主:${gd.bossDesign?JSON.stringify(gd.bossDesign):'按下方配置生成'}`,
     `美术主题:${art.theme||''}`,
     `美术风格:${art.style||''}`,
     `参考:${(creative.references||[]).join(',')||'无'};上传图 ${creative.uploadedReferenceImages||0} 张`,
@@ -455,6 +463,8 @@ function workflowPayload(){
     characterCount: $('#wfCharacterCount').value,
     uiCompleteness: $('#wfUiCompleteness').value,
     feedbackIntensity: $('#wfFeedbackIntensity').value,
+    enableBoss: $('#wfEnableBoss').checked,
+    bossPresence: $('#wfBossPresence').value,
     startingBalance: $('#wfStartingBalance').value,
     defaultBet: $('#wfDefaultBet').value,
     freeSpinCount: $('#wfFreeSpinCount').value,
@@ -539,6 +549,8 @@ $('#aiWorkflowBtn').onclick=async()=>{
         characterCount:$('#wfCharacterCount').value,
         uiCompleteness:$('#wfUiCompleteness').value,
         feedbackIntensity:$('#wfFeedbackIntensity').value,
+        enableBoss:$('#wfEnableBoss').checked,
+        bossPresence:$('#wfBossPresence').value,
         features:workflowPayload().features,
         targetRtp:$('#wfTargetRtp').value,
         enableMathModel:$('#wfEnableMathModel').checked
@@ -556,6 +568,8 @@ $('#aiWorkflowBtn').onclick=async()=>{
       if(req.characterCount) $('#wfCharacterCount').value=String(req.characterCount);
       if(req.uiCompleteness) $('#wfUiCompleteness').value=req.uiCompleteness;
       if(req.feedbackIntensity) $('#wfFeedbackIntensity').value=req.feedbackIntensity;
+      if(typeof req.enableBoss==='boolean') $('#wfEnableBoss').checked=req.enableBoss;
+      if(req.bossPresence) $('#wfBossPresence').value=req.bossPresence;
       const fs=new Set(req.features||[]);
       $('#wfCascades').checked=fs.has('cascades');
       $('#wfFreeSpins').checked=fs.has('free_spins');