Passion & UI

This commit is contained in:
SoulliesOfficial
2026-06-12 17:11:39 -04:00
parent 7bc1e1722c
commit 6d7ebc5825
3444 changed files with 865284 additions and 463132 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame;
using SickscoreGames.HUDNavigationSystem;
using Sirenix.OdinInspector;
using UnityEngine;
@@ -49,6 +50,21 @@ namespace Cielonos.Core.Interaction
{
interactionTrigger.GetComponent<Collider>().enabled = enabled;
navigation.showIndicator = enabled && navigation.showIndicator;
if (!enabled)
{
var player = MainGameManager.Player;
if (player != null && player.interactionSc != null && player.interactionSc.currentInteractable == this)
{
// 从玩家的选择列表中移除此物体的所有选项,防止残留选项造成后续按 R 键误触发
foreach (var choice in choices)
{
player.interactionSc.currentChoices.Remove(choice);
}
// 清除控制器引用的当前交互对象,并隐藏 UI Area
player.interactionSc.RemoveCurrentInteractable(this);
}
}
}
}
@@ -81,11 +97,16 @@ namespace Cielonos.Core.Interaction
{
public string choiceName;
public Action action;
public InteractionChoice(string name, Action action)
/// <summary>
/// 此选项是否可执行。false 时 UI 灰显,按下 R 键也不会触发 action
/// </summary>
public bool isInteractable;
public InteractionChoice(string name, Action action, bool isInteractable = true)
{
this.choiceName = name;
this.action = action;
this.isInteractable = isInteractable;
}
}
}

View File

@@ -98,5 +98,13 @@ namespace Cielonos.MainGame
public const string Luck = "Luck";
/// <summary> 诅咒值 </summary>
public const string Curse = "Curse";
/// <summary> 获得激情值加成 </summary>
public const string PassionIncreaseAmplifier = "PassionIncreaseAmplifier";
/// <summary> 随时间流逝失去激情值的加成 </summary>
public const string PassionTimeDecreaseAmplifier = "PassionTimeDecreaseAmplifier";
/// <summary> 因为反应导致失去激情值的加成 </summary>
public const string PassionReactiveDecreaseAmplifier = "PassionReactiveDecreaseAmplifier";
/// <summary> 激情值保持时长 </summary>
public const string PassionKeepDuration = "PassionKeepDuration";
}
}

View File

@@ -24,6 +24,8 @@ namespace AK
public static uint FUTUREWAND_ULTIMATEBEAMSHOOT = 72993210U;
public static uint FUTUREWAND_WEAKATTACKHIT = 1899836792U;
public static uint ICECONE_SHOOT = 825310803U;
public static uint MB_PLAYSONG = 483420602U;
public static uint MB_STOPSONG = 488144416U;
public static uint NEXUS_CORE_FINISH = 1395302990U;
public static uint NEXUSCRAB_BUMPMOVE = 1132556820U;
public static uint NEXUSCRAB_CLAWSTAB = 1464824934U;
@@ -52,6 +54,8 @@ namespace AK
public static uint RAINING_STOP = 452863620U;
public static uint SCANNER_SCAN = 1011332595U;
public static uint STOPBGM = 3203837125U;
public static uint UI_CLICK = 2249769530U;
public static uint UI_HOVER = 2118900976U;
} // public class EVENTS
public class STATES
@@ -71,6 +75,20 @@ namespace AK
} // public class STATES
public class SWITCHES
{
public class MB_SONGS
{
public static uint GROUP = 257450755U;
public class SWITCH
{
public static uint YVAINE = 1368372185U;
} // public class SWITCH
} // public class MB_SONGS
} // public class SWITCHES
public class BANKS
{
public static uint INIT = 1355168291U;

View File

@@ -36,6 +36,16 @@ namespace Cielonos.MainGame
public bool canTriggerHitEvent = true;
public List<string> tags = new List<string>();
public Action updateAction;
/// <summary>
/// 当前是否处于反应窗口(含 grace before/after。无 TimeSubmodule 时回退到 isEnabling。
/// </summary>
public bool isReactionActive => timeSm?.IsReactionActive() ?? isEnabling;
/// <summary>
/// 在 grace window 期间已成功反应(格挡/闪避)的目标,不再对其造成伤害。
/// </summary>
[HideInInspector] public HashSet<GameObject> reactedTargets = new HashSet<GameObject>();
[Title("Submodules")]
[HideInEditorMode] public TransformSubmodule transformSm;
@@ -60,6 +70,7 @@ namespace Cielonos.MainGame
this.targetFractions = targetFractions.ToList();
this.canTriggerHitEvent = true;
this.tags = new List<string>();
this.reactedTargets.Clear();
attackSm = null;
timeSm = null;
@@ -159,6 +170,16 @@ namespace Cielonos.MainGame
timeSm = new TimeSubmodule(this, lifeTime, delayTime, enableTime, enableAction, timeOutAction);
return this as T;
}
/// <summary>
/// 设置带反应 grace window 的时间子模块。graceBefore/graceAfter 为 0 时行为与无 grace window 一致。
/// </summary>
public T SetTimeSubmodule<T>(float lifeTime, float delayTime, float enableTime,
Action enableAction, Action timeOutAction, float graceBefore) where T : AttackAreaBase
{
timeSm = new TimeSubmodule(this, lifeTime, delayTime, enableTime, enableAction, timeOutAction, graceBefore);
return this as T;
}
#endregion
#region HitSubmodule
@@ -295,7 +316,8 @@ namespace Cielonos.MainGame
}
protected virtual void HitOnTarget(Collider hitCollider, Vector3 hitPosition, out Attack.Result attackResult)
protected virtual void HitOnTarget(Collider hitCollider, Vector3 hitPosition,
out Attack.Result attackResult, bool onlyCheckReaction = false)
{
CharacterBase target = hitCollider.GetComponentInParent<CharacterBase>();
@@ -316,6 +338,15 @@ namespace Cielonos.MainGame
return;
}
// Grace window 期间仅执行反应检测,不造成伤害和其他效果
if (onlyCheckReaction)
{
attackResult.isBlocked = reactionSm?.CheckBlock(target, creator, hitPosition) ?? false;
attackResult.isDodged = reactionSm?.CheckDodge(target) ?? false;
attackResult.isReflected = reactionSm?.CheckReflection(target) ?? false;
return;
}
if (moveSm is { stopWhenHit: true })
{
moveSm.canMove = false;

View File

@@ -21,7 +21,8 @@ namespace Cielonos.MainGame
public override void HitCharacter(Collider characterCollider, Vector3 hitPosition)
{
if (!isEnabling)
// 既不在伤害阶段也不在反应窗口,直接跳过
if (!isEnabling && !isReactionActive)
{
return;
}
@@ -30,13 +31,33 @@ namespace Cielonos.MainGame
if (!IsValidTarget(targetCharacter)) return;
// 已在 grace window 期间成功反应的目标不再处理
if (reactedTargets.Contains(targetCharacter.gameObject))
{
return;
}
if (hitSm.checkedObjects.Contains(targetCharacter.gameObject))
{
return;
}
hitSm.AddCheckedObject(targetCharacter.gameObject);
// 仅在反应窗口内enable 阶段之前或之后),只做反应检测
if (!isEnabling && isReactionActive)
{
HitOnTarget(characterCollider, areaCollider.ClosestPoint(targetCharacter.CenterPoint.position),
out Attack.Result graceResult, onlyCheckReaction: true);
if (graceResult.isBlocked || graceResult.isDodged)
{
reactedTargets.Add(targetCharacter.gameObject);
hitSm.AddCheckedObject(targetCharacter.gameObject);
Debug.Log($"[NormalArea] Target {targetCharacter.name} successfully reacted during grace window. Blocked: {graceResult.isBlocked}, Dodged: {graceResult.isDodged}");
}
return;
}
// 正常 enable 阶段:造成伤害
hitSm.AddCheckedObject(targetCharacter.gameObject);
HitOnTarget(characterCollider, areaCollider.ClosestPoint(targetCharacter.CenterPoint.position), out _);
}
}

View File

@@ -37,7 +37,8 @@ namespace Cielonos.MainGame
public override void HitCharacter(Collider characterCollider, Vector3 hitPosition)
{
if (!isEnabling)
// 既不在伤害阶段也不在反应窗口,直接跳过
if (!isEnabling && !isReactionActive)
{
return;
}
@@ -46,13 +47,31 @@ namespace Cielonos.MainGame
if (!IsValidTarget(targetCharacter)) return;
// 已在 grace window 期间成功反应的目标不再处理
if (reactedTargets.Contains(targetCharacter.gameObject))
{
return;
}
if (hitSm.checkedObjects.Contains(targetCharacter.gameObject))
{
return;
}
// 仅在反应窗口内enable 阶段之前或之后),只做反应检测
if (!isEnabling && isReactionActive)
{
HitOnTarget(characterCollider, hitPosition, out Attack.Result graceResult, onlyCheckReaction: true);
if (graceResult.isBlocked || graceResult.isDodged)
{
reactedTargets.Add(targetCharacter.gameObject);
hitSm.AddCheckedObject(targetCharacter.gameObject);
}
return;
}
// 正常 enable 阶段:造成伤害
hitSm.AddCheckedObject(targetCharacter.gameObject);
HitOnTarget(characterCollider, hitPosition, out _);
}
@@ -81,9 +100,12 @@ namespace Cielonos.MainGame
public partial class Projectile
{
protected override void HitOnTarget(Collider hitCollider, Vector3 hitPosition, out Attack.Result result)
protected override void HitOnTarget(Collider hitCollider, Vector3 hitPosition, out Attack.Result result,
bool onlyCheckReaction = false)
{
base.HitOnTarget(hitCollider, hitPosition, out result);
base.HitOnTarget(hitCollider, hitPosition, out result, onlyCheckReaction);
if (onlyCheckReaction) return;
if (!result.isReflected && ++currentPenetrateCount >= maximumPenetrateCount)
{

View File

@@ -59,15 +59,8 @@ namespace Cielonos.MainGame
if (characterBlockSm.isBlocking)
{
/*if (canBeBlocked)
{
}*/
BlockSource firstBlockSource;
if (hasPerfectBlock && (!hasPerfectWindowTime || owner.timeSm.enablingTimer <= 0.2f)
&& characterBlockSm.isPerfectBlocking)
if (hasPerfectBlock && (!hasPerfectWindowTime || owner.timeSm.enablingTimer <= 0.2f) && characterBlockSm.isPerfectBlocking)
{
firstBlockSource = characterBlockSm.blockSources.Find(source =>
source.perfectBlockType >= attackArea.attackSm.attackValue.breakthroughType && source.isDuringPerfectBlock);
@@ -79,12 +72,13 @@ namespace Cielonos.MainGame
perfectBlockAction?.Invoke(blocker);
blocker.eventSm.onBlockSuccess.Invoke(attackArea, firstBlockSource);
blocker.eventSm.onPerfectBlockSuccess.Invoke(attackArea, firstBlockSource);
Debug.Log($"[ReactionSubmodule] Perfect block successful! Blocker: {blocker.name}, Attack: {attackArea.name}");
return true;
}
}
else
{
firstBlockSource = characterBlockSm.blockSources.Find(source =>
firstBlockSource = characterBlockSm.blockSources.Find(source =>
source.normalBlockType >= attackArea.attackSm.attackValue.breakthroughType);
if (firstBlockSource != null)
{
@@ -93,6 +87,7 @@ namespace Cielonos.MainGame
normalBlockAction?.Invoke(blocker);
blocker.eventSm.onBlockSuccess.Invoke(attackArea, firstBlockSource);
blocker.eventSm.onNormalBlockSuccess.Invoke(attackArea, firstBlockSource);
Debug.Log($"[ReactionSubmodule] Normal block successful! Blocker: {blocker.name}, Attack: {attackArea.name}");
return true;
}
}
@@ -143,7 +138,7 @@ namespace Cielonos.MainGame
{
DodgeSource firstDodgeSource;
if (hasPerfectDodge && owner.timeSm.enablingTimer <= 0.2f && characterDodgeSm.isPerfectDodging)
if (hasPerfectDodge && owner.timeSm.enablingTimer <= 0.15f && characterDodgeSm.isPerfectDodging)
{
firstDodgeSource = characterDodgeSm.dodgeSources.Find(source => source.isDuringPerfectDodge);
firstDodgeSource.PerfectDodge(owner);

View File

@@ -17,6 +17,11 @@ namespace Cielonos.MainGame
public List<ScheduledAction> scheduledActions;
private List<ScheduledAction> toBeExecutedScheduledActions = new List<ScheduledAction>();
/// <summary>
/// enable 阶段开始前允许反应的提前量(秒),默认 0 表示无提前 grace window。
/// </summary>
public float reactionGraceBefore;
public TimeSubmodule(AttackAreaBase attackArea, float lifeTime, Action timeOutAction = null) : base(attackArea)
{
this.isEnabling = true;
@@ -51,7 +56,14 @@ namespace Cielonos.MainGame
});
}
public TimeSubmodule(AttackAreaBase attackArea, float lifeTime, float delayTime, float enableTime, Action enableAction, Action timeOutAction) : base(attackArea)
public TimeSubmodule(AttackAreaBase attackArea, float lifeTime, float delayTime, float enableTime,
Action enableAction, Action timeOutAction)
: this(attackArea, lifeTime, delayTime, enableTime, enableAction, timeOutAction, 0f)
{
}
public TimeSubmodule(AttackAreaBase attackArea, float lifeTime, float delayTime, float enableTime,
Action enableAction, Action timeOutAction, float graceBefore) : base(attackArea)
{
this.isEnabling = true;
this.lifeTime = lifeTime;
@@ -78,6 +90,8 @@ namespace Cielonos.MainGame
this.enablingTimer = 0;
this.remainingEnableTime = enableTime;
this.enableAction = enableAction;
this.reactionGraceBefore = graceBefore;
}
public TimeSubmodule AddScheduleAction(Action action, float delay)
@@ -85,6 +99,28 @@ namespace Cielonos.MainGame
scheduledActions.Add(new ScheduledAction(action, delay));
return this;
}
/// <summary>
/// 判断当前时刻是否处于反应窗口内(包含 grace 区间和 enable 阶段本身)。
/// before grace: delay 阶段末尾的 reactionGraceBefore 秒内。
/// after grace: enable 结束后的 reactionGraceAfter 秒内。
/// </summary>
public bool IsReactionActive()
{
// 在 enable 阶段本身,反应始终可用
if (attackArea.isEnabling)
{
return true;
}
// before grace: delay 尚未结束,但已进入 grace 窗口
if (delayTime > 0f && reactionGraceBefore > 0f && delayTime <= reactionGraceBefore)
{
return true;
}
return false;
}
}
public partial class TimeSubmodule

View File

@@ -31,16 +31,16 @@ namespace Cielonos.MainGame.Buffs
{
string displayNameKey = buff.GetType().Name + "_DisplayName";
string originalFunctionTextKey = buff.GetType().Name + "_FunctionText";
this.displayName = displayNameKey.Localize();
this.originalFunctionText = originalFunctionTextKey.Localize();
this.displayName = displayNameKey.Localize("Buffs");
this.originalFunctionText = originalFunctionTextKey.Localize("Buffs");
this.interpretedFunctionText = this.originalFunctionText;
parameterGetters = new Dictionary<string, Func<string>>();
}
public ContentSubmodule(BuffBase<T> buff, string displayNameKey, string originalFunctionTextKey) : base(buff)
{
this.displayName = displayNameKey.Localize();
this.originalFunctionText = originalFunctionTextKey.Localize();
this.displayName = displayNameKey.Localize("Buffs");
this.originalFunctionText = originalFunctionTextKey.Localize("Buffs");
this.interpretedFunctionText = this.originalFunctionText;
parameterGetters = new Dictionary<string, Func<string>>();
}

View File

@@ -30,13 +30,25 @@ namespace Cielonos.MainGame.Characters
public partial class L1_Assault_Pawn
{
/// <summary>
/// 近战攻击命中区域的反应 grace window 提前量(秒)。
/// 允许玩家在伤害判定开始前提前格挡/闪避。
/// </summary>
private const float PunchReactionGraceBefore = 0.15f;
/// <summary>
/// 近战攻击命中区域的反应 grace window 延后量(秒)。
/// 允许玩家在伤害判定结束后仍可格挡/闪避。
/// </summary>
private const float PunchReactionGraceAfter = 0.1f;
private NormalArea GeneratePunch(string vfxName, AttackUnit attackUnit)
{
NormalArea punch = vfxData.SpawnVFX(vfxName, this, transform).GetComponentInChildren<NormalArea>();
punch.Initialize<NormalArea>(this, Fraction.Player)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(1f)
.SetTimeSubmodule<NormalArea>(1f, 0.1f, 0.04f, null, null, 0.1f)
.SetHitSubmodule<NormalArea>();
punch.SetImpulseSubmodule().WithCustomForce(transform.forward);
punch.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_LIGHTATTACKHIT);

View File

@@ -44,6 +44,11 @@ namespace Cielonos.MainGame.Characters
PlayerCanvas.BossInfoUIArea.RemoveInfoUnit(this);
//MainGameManager.Player.feedbackSc["NexusFinish"].Play();
}
if (MainGameManager.Player.viewSc.lockTargetModule.lockTarget == this)
{
MainGameManager.Player.viewSc.lockTargetModule.UnlockTarget();
}
base.Die();
CombatManager.EnemySm.RemoveEnemy(this);

View File

@@ -6,13 +6,6 @@ namespace Cielonos.MainGame.Characters
{
public class AudioSubcontroller : SubcontrollerBase<CharacterBase>
{
//public AudioContainer audioContainer;
public override void Initialize()
{
base.Initialize();
}
public void PlayFootStepSound(AnimationEvent animationEvent)
{
if (animationEvent.animatorClipInfo.weight > 0.5f)

View File

@@ -168,18 +168,18 @@ namespace Cielonos.MainGame.Characters
this.normalBlockEffect = data.normalBlockEffect;
this.normalBlockSound = data.normalBlockSound;
this.triggerTime = 0.06f;
this.triggerTime = 0f;
}
public void PerfectBlock(AttackAreaBase attackArea, Vector3 blockEffectPosition)
{
if(triggerTime > 0f) return;
triggerTime = 0.04f;
triggerTime = 0.02f;
if (sourceItem != null)
{
AudioManager.Post(perfectBlockSound, sourceCharacter.gameObject);
sourceItem.feedbackSc.PlayFeedback("PerfectBlock");
sourceItem.feedbackSc?.PlayFeedback("PerfectBlock");
}
VFXObject.Spawn(perfectBlockEffect, sourceCharacter, blockEffectPosition, Quaternion.identity);
@@ -188,17 +188,18 @@ namespace Cielonos.MainGame.Characters
BlockSubmodule blockSm = sourceCharacter.reactionSc.blockSm;
blockSm.perfectBlockedTarget = attackArea.creator;
blockSm.afterPerfectBlockTimer.Reset(blockBufferTime + 0.25f);
Debug.Log($"[BlockSource] Perfect block triggered. Perfect block type: {perfectBlockType}, Block buffer time: {blockBufferTime}");
}
public void NormalBlock(AttackAreaBase attackArea, Vector3 blockEffectPosition)
{
if (triggerTime > 0f) return;
triggerTime = 0.04f;
triggerTime = 0.02f;
if (sourceItem != null)
{
AudioManager.Post(normalBlockSound, sourceCharacter.gameObject);
sourceItem.feedbackSc.PlayFeedback("NormalBlock");
sourceItem.feedbackSc?.PlayFeedback("NormalBlock");
}
VFXObject.Spawn(normalBlockEffect, sourceCharacter, blockEffectPosition, Quaternion.identity);
@@ -207,6 +208,7 @@ namespace Cielonos.MainGame.Characters
BlockSubmodule blockSm = sourceCharacter.reactionSc.blockSm;
blockSm.normalBlockedTarget = attackArea.creator;
blockSm.afterPerfectBlockTimer.Reset(blockBufferTime + 0.25f);
Debug.Log($"[BlockSource] Normal block triggered. Normal block type: {normalBlockType}, Block buffer time: {blockBufferTime}");
}
}
}

View File

@@ -161,7 +161,7 @@ namespace Cielonos.MainGame.Characters
public static DodgeSource Default(CharacterBase sourceCharacter, float duration = Mathf.Infinity)
{
DodgeSource defaultDodge = new DodgeSource(sourceCharacter, null,
"DefaultDodge", 0, "NormalDodge", "PerfectDodge", duration, 0.2f);
"DefaultDodge", 0, "NormalDodge", "PerfectDodge", duration, 0.15f);
if (sourceCharacter is Player player)
{
defaultDodge.onPerfectDodge = () =>

View File

@@ -122,7 +122,7 @@ namespace Cielonos.MainGame
/// 覆写播放核心:融入战斗专属的可播放性检测、前置打断清理及事件控制。
/// </summary>
public override bool Play(FuncAnimData funcAnimData, float animationSpeedMultiplier = 1f, float transitionDuration = 0.1f,
bool isNormalizedTransition = false, List<FuncAnimPayloadBase> runtimeStartEvents = null)
bool isNormalizedTransition = false, float normalizedStartTime = -1f, List<FuncAnimPayloadBase> runtimeStartEvents = null)
{
var newRtFuncAnim = new RuntimeFuncAnim(funcAnimData, Executor);
@@ -145,7 +145,7 @@ namespace Cielonos.MainGame
float finalSpeedMultiplier = funcAnimData.animInfo.isAffectedBySpeedMultiplier ? animationSpeedMultiplier : 1f;
// 4. 委派底层物理播放器执行 CrossFade 过渡与启动
bool playSuccess = base.Play(funcAnimData, finalSpeedMultiplier, transitionDuration, isNormalizedTransition, runtimeStartEvents);
bool playSuccess = base.Play(funcAnimData, finalSpeedMultiplier, transitionDuration, isNormalizedTransition, normalizedStartTime, runtimeStartEvents);
if (playSuccess)
{

View File

@@ -36,7 +36,7 @@ namespace Cielonos.MainGame.Characters
string collectionPath = AssetDatabase.GUIDToAssetPath(collectionID[0]);
AttributeCollection collection = AssetDatabase.LoadAssetAtPath<AttributeCollection>(collectionPath);
foreach (KeyValuePair<string, float> pair in collection.originalAttributes)
{
if (!originalAttributes.ContainsKey(pair.Key))

View File

@@ -19,12 +19,15 @@ namespace Cielonos.MainGame.Characters
private void Update()
{
if (player.selfTimeSm.TimeScale == 0)
// selfTimeSm.TimeScale 不包含 Unity 的 Time.timeScale
// 因此暂停时 TimeScale 可能非零但 DeltaTime 为零,需检查 DeltaTime 避免除零。
float deltaTime = player.selfTimeSm.DeltaTime;
if (deltaTime < Mathf.Epsilon)
{
return;
}
Vector3 moveDirection = player.landMovementSc.horizontalMovement / player.selfTimeSm.DeltaTime;
Vector3 moveDirection = player.landMovementSc.horizontalMovement / deltaTime;
moveDirection *= 0.1f;
Transform playerTransform = player.transform;

View File

@@ -2,6 +2,7 @@ using System;
using Cielonos.MainGame.Inventory;
using Cielonos.MainGame.UI;
using Sirenix.OdinInspector;
using SLSUtilities.UI;
using UniRx;
using Unity.Cinemachine;
using UnityEngine;
@@ -61,8 +62,11 @@ namespace Cielonos.MainGame.Characters
var cinemachineInput = player.viewSc.freeLookCamera.GetComponent<CinemachineInputAxisController>();
cinemachineInput.enabled = isLocked;
});
player.inputSc.isCursorLocked.Value = false;
preinputSubmodule = new PlayerPreinputSubmodule(this);
InputBindingResolver.Initialize(inputActions.asset, "KeyboardMouse");
}
private void Start()
@@ -136,7 +140,7 @@ namespace Cielonos.MainGame.Characters
{
if (ctx.performed && isCursorLocked.Value)
{
Debug.Log("Value: " + ctx.ReadValue<float>());
//Debug.Log("Value: " + ctx.ReadValue<float>());
operation.SelectLockonTarget(ctx.ReadValue<float>());
}
};
@@ -158,6 +162,19 @@ namespace Cielonos.MainGame.Characters
operation.WalkRelease();
}
};
// 交互选项导航:滚轮向上 / ↑ = -1向上滚轮向下 / ↓ = +1向下
// PassThrough + Axis 类型在松手/滚轮归零时也会触发 performedraw = 0需过滤
inputActions.Player.NavigateInteractionChoice.performed += ctx =>
{
if (isCursorLocked.Value)
{
float raw = ctx.ReadValue<float>();
if (Mathf.Approximately(raw, 0f)) return;
int delta = raw > 0f ? -1 : 1;
operation.NavigateInteractionChoice(delta);
}
};
}
private MainWeaponBase currentMainWeapon => player.inventorySc.equipmentSm.currentMainWeapon;
@@ -309,7 +326,7 @@ namespace Cielonos.MainGame.Characters
private void RegisterSupportEquipmentInputs()
{
inputActions.Player.UseSupportEquipment0.performed += ctx =>
inputActions.Player.SupportEquipment0.performed += ctx =>
{
if (ctx.performed && isCursorLocked.Value)
{
@@ -317,7 +334,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment0.canceled += ctx =>
inputActions.Player.SupportEquipment0.canceled += ctx =>
{
if (ctx.canceled && isCursorLocked.Value)
{
@@ -325,7 +342,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment1.performed += ctx =>
inputActions.Player.SupportEquipment1.performed += ctx =>
{
if (ctx.performed && isCursorLocked.Value)
{
@@ -333,7 +350,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment1.canceled += ctx =>
inputActions.Player.SupportEquipment1.canceled += ctx =>
{
if (ctx.canceled && isCursorLocked.Value)
{
@@ -341,7 +358,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment2.performed += ctx =>
inputActions.Player.SupportEquipment2.performed += ctx =>
{
if (ctx.performed && isCursorLocked.Value)
{
@@ -349,7 +366,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment2.canceled += ctx =>
inputActions.Player.SupportEquipment2.canceled += ctx =>
{
if (ctx.canceled && isCursorLocked.Value)
{
@@ -357,7 +374,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment3.performed += ctx =>
inputActions.Player.SupportEquipment3.performed += ctx =>
{
if (ctx.performed && isCursorLocked.Value)
{
@@ -365,7 +382,7 @@ namespace Cielonos.MainGame.Characters
}
};
inputActions.Player.UseSupportEquipment3.canceled += ctx =>
inputActions.Player.SupportEquipment3.canceled += ctx =>
{
if (ctx.canceled && isCursorLocked.Value)
{
@@ -376,12 +393,38 @@ namespace Cielonos.MainGame.Characters
private void RegisterFunctionInputs()
{
inputActions.Player.Inventory.performed += ctx =>
{
var inventoryPage = PlayerCanvas.MainGamePages.inventoryPage;
if(inventoryPage.IsOpen)
{
inventoryPage.Close();
}
else
{
inventoryPage.Open();
}
};
inputActions.Player.Map.performed += ctx =>
{
var mapPage = PlayerCanvas.MainGamePages.mapPage;
if(mapPage.IsOpen) mapPage.Close();
else mapPage.Open();
};
inputActions.Player.Escape.performed += ctx =>
{
// 优先关闭栈顶页面(包括 Settings 覆盖在 Pause 之上的情况)
if (UIPageManager.Instance != null && UIPageManager.Instance.HasOpenPages)
{
UIPageManager.Instance.CloseTopPage();
}
else
{
PlayerCanvas.PauseUIPage.Open();
}
};
}
}

View File

@@ -40,6 +40,28 @@ namespace Cielonos.MainGame.Characters
public partial class BackpackSubmodule
{
public bool HaveItem(string itemClass)
{
Type type = Type.GetType($"Cielonos.MainGame.Inventory.Collections.{itemClass}");
if (type == null)
{
Debug.LogError($"[Backpack] 无法找到道具类类型:'{itemClass}'。确保它在命名空间 Cielonos.MainGame.Inventory.Collections 中。");
return false;
}
if (typeof(MainWeaponBase).IsAssignableFrom(type))
return mainWeapons.Exists(item => item.GetType() == type);
if (typeof(SupportEquipmentBase).IsAssignableFrom(type))
return supportEquipments.Exists(item => item.GetType() == type);
if (typeof(PassiveEquipmentBase).IsAssignableFrom(type))
return passiveEquipments.Exists(item => item.GetType() == type);
if (typeof(ConsumableBase).IsAssignableFrom(type))
return consumables.Exists(item => item.GetType() == type);
Debug.LogError($"[Backpack] 类型 '{itemClass}' 不属于任何已知的道具基类。");
return false;
}
/// <summary>
/// 通过 Resources 加载 Prefab实例化到对应的 Container 下,然后加入背包。
/// 若为消耗品且背包中已存在同类型实例,则直接堆叠,不再重复生成。

View File

@@ -27,11 +27,17 @@ namespace Cielonos.MainGame.Characters
RegisterOperations();
backpackSm ??= new BackpackSubmodule(this);
equipmentSm ??= new EquipmentSubmodule(this);
//backpackSm.ObtainItem<DualHarmony>();
/*backpackSm.ObtainItem<Polychrome>();
backpackSm.ObtainItem<Polychrome>();
backpackSm.ObtainItem<FutureWand>();
backpackSm.ObtainItem<Ascension>();
backpackSm.ObtainItem<Passion>();
backpackSm.ObtainItem<ThermalDetonator>();
//backpackSm.ObtainItem<PerceptiveMetalPlating>();
//backpackSm.ObtainItem<DualHarmony>();
/*backpackSm.ObtainItem<Polychrome>();
backpackSm.ObtainItem<BlackHoleDisplacer>();
backpackSm.ObtainItem<DecayPropagator>();
backpackSm.ObtainItem<DecayAccelerationCoil>();
@@ -40,10 +46,10 @@ namespace Cielonos.MainGame.Characters
backpackSm.ObtainItem<PhotonPolarizer>();
backpackSm.ObtainItem<PhotonDissociator>();*/
/*foreach (MainWeaponBase mainWeapon in backpackSm.mainWeapons)
foreach (MainWeaponBase mainWeapon in backpackSm.mainWeapons)
{
equipmentSm.PrepareMainWeapon(mainWeapon);
}*/
}
//backpackSm.ObtainItem<BlackHoleDisplacer>();
}
@@ -177,10 +183,13 @@ namespace Cielonos.MainGame.Characters
private void SwitchMainWeapon(int direction)
{
int currentIndex = equipmentSm.preparedMainWeapons.IndexOf(currentMainWeapon);
currentMainWeapon?.OnSwitchOut();
equipmentSm.RemoveMainWeapon();
int newIndex = (currentIndex + direction + equipmentSm.preparedMainWeapons.Count) % equipmentSm.preparedMainWeapons.Count;
Debug.Log($"Switching main weapon from index {currentIndex} to {newIndex}");
equipmentSm.EquipMainWeapon(equipmentSm.preparedMainWeapons[newIndex]);
MainWeaponBase newWeapon = equipmentSm.preparedMainWeapons[newIndex];
equipmentSm.EquipMainWeapon(newWeapon);
newWeapon.OnSwitchIn();
}
private void MainWeaponPrimaryPress()

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Cielonos.Core.Interaction;
using Cielonos.MainGame.UI;
using SLSUtilities.UI;
using UnityEngine;
namespace Cielonos.MainGame.Characters
@@ -8,42 +9,166 @@ namespace Cielonos.MainGame.Characters
public class PlayerInteractionSubcontroller : SubcontrollerBase<Player>
{
public Player player => owner;
/// <summary>当前进入触发区的所有交互选项(由 InteractionTrigger 维护)。</summary>
public List<InteractionChoice> currentChoices = new List<InteractionChoice>();
/// <summary>
/// 当前激活的可交互对象(最近一次进入触发区的对象)。
/// MechanicalTableUI、LogisticsCenterUI 等通过此字段获取驱动它们的场景对象。
/// </summary>
/// <summary>当前高亮(选中)的选项索引。</summary>
public int currentChoiceIndex { get; private set; }
/// <summary>当前激活的可交互对象(最近一次进入触发区的对象)。</summary>
public InteractableObjectBase currentInteractable { get; private set; }
/// <summary>
/// 当有 UIPage 打开时为 true此时交互 UI 被挂起、交互操作被屏蔽。
/// </summary>
private bool _isSuspended;
public override void Initialize()
{
base.Initialize();
player.operationSc.OnInteract += () => ExecuteChoice(0);
player.operationSc.OnInteract += ExecuteCurrentChoice;
player.operationSc.OnNavigateInteractionChoice += NavigateChoice;
// 订阅 UIPageManager 的输入阻塞事件,联动 InteractionUIArea 的显隐
if (UIPageManager.Instance != null)
{
UIPageManager.Instance.OnInputBlockChanged += OnUIInputBlockChanged;
}
}
/// <summary>由 InteractionTrigger 在进入触发区时调用,记录当前激活对象。</summary>
private void OnDestroy()
{
if (UIPageManager.Instance != null)
{
UIPageManager.Instance.OnInputBlockChanged -= OnUIInputBlockChanged;
}
}
// ====================================================================
// UIPage 联动
// ====================================================================
/// <summary>
/// 当任何 UIPage 打开/关闭时由 UIPageManager 触发。
/// blocked = true有 UIPage 打开 → 挂起 InteractionUIArea隐藏但不清除状态
/// blocked = false所有 UIPage 关闭 → 若仍在交互范围内则恢复显示。
/// </summary>
private void OnUIInputBlockChanged(bool blocked)
{
_isSuspended = blocked;
if (blocked)
{
// 仅隐藏 UI不清除 currentInteractable / currentChoices
PlayerCanvas.InteractionUIArea?.Hide();
}
else
{
// 所有页面关闭后,若仍在交互范围内,恢复 InteractionUIArea
if (currentInteractable != null && currentChoices.Count > 0)
{
PlayerCanvas.InteractionUIArea?.Show(currentChoices, currentChoiceIndex);
}
}
}
// ====================================================================
// 进入 / 离开触发区
// ====================================================================
/// <summary>由 InteractionTrigger 在进入触发区时调用,记录当前激活对象并显示选项 UI。</summary>
public void SetCurrentInteractable(InteractableObjectBase interactable)
{
currentInteractable = interactable;
currentChoiceIndex = 0;
// 自动跳到第一个可用的选项
SnapToFirstInteractable();
// 若当前没有 UIPage 打开,才显示交互选项
if (!_isSuspended)
{
PlayerCanvas.InteractionUIArea?.Show(currentChoices, currentChoiceIndex);
}
}
/// <summary>由 InteractionTrigger 在退出触发区时调用,清除当前激活对象。</summary>
/// <summary>由 InteractionTrigger 在退出触发区时调用,清除当前激活对象并隐藏选项 UI。</summary>
public void RemoveCurrentInteractable(InteractableObjectBase interactable)
{
if (currentInteractable == interactable) currentInteractable = null;
if (currentInteractable != interactable) return;
currentInteractable = null;
PlayerCanvas.InteractionUIArea?.Hide();
}
public void ExecuteChoice(int index = 0)
// ====================================================================
// 导航与执行
// ====================================================================
/// <summary>
/// 导航选中索引。delta = 1向下/滚轮向下delta = -1向上/滚轮向上)。
/// 会自动跳过 isInteractable = false 的选项。
/// 若处于挂起状态(有 UIPage 打开),忽略导航。
/// </summary>
public void NavigateChoice(int delta)
{
if (index < 0 || index >= currentChoices.Count)
if (_isSuspended) return;
if (currentChoices.Count == 0) return;
int startIndex = currentChoiceIndex;
int next = currentChoiceIndex;
do
{
Debug.LogWarning("Invalid interaction choice index.");
next = (next + delta + currentChoices.Count) % currentChoices.Count;
if (next == startIndex) break; // 绕了一圈,无可用选项,保持原位
}
while (!currentChoices[next].isInteractable);
currentChoiceIndex = next;
PlayerCanvas.InteractionUIArea?.UpdateHighlight(currentChoiceIndex);
}
/// <summary>
/// 执行当前高亮的选项。若该选项 isInteractable = false 或处于挂起状态,则什么都不做。
/// </summary>
public void ExecuteCurrentChoice()
{
if (_isSuspended) return;
if (currentChoices.Count == 0) return;
if (currentChoiceIndex < 0 || currentChoiceIndex >= currentChoices.Count) return;
InteractionChoice choice = currentChoices[currentChoiceIndex];
if (!choice.isInteractable)
{
Debug.Log($"[InteractionSc] 选项 '{choice.choiceName}' 当前不可用。");
return;
}
var choice = currentChoices[index];
choice.action?.Invoke();
}
// ====================================================================
// 内部工具
// ====================================================================
/// <summary>将 currentChoiceIndex 移到第一个 isInteractable = true 的选项。</summary>
private void SnapToFirstInteractable()
{
if (currentChoices.Count == 0) return;
for (int i = 0; i < currentChoices.Count; i++)
{
int index = (currentChoiceIndex + i) % currentChoices.Count;
if (currentChoices[index].isInteractable)
{
currentChoiceIndex = index;
return;
}
}
// 所有选项均不可用时,停在 0
currentChoiceIndex = 0;
}
}
}

View File

@@ -19,6 +19,7 @@ namespace Cielonos.MainGame.Characters
public partial class PlayerOperationSubcontroller
{
public event Action OnInteract;
public event Action<int> OnNavigateInteractionChoice;
public event Action<Vector3, float> OnDash;
public event Action<float> OnDodge;
@@ -67,6 +68,8 @@ namespace Cielonos.MainGame.Characters
{
public void Interact() => OnInteract?.Invoke();
public void NavigateInteractionChoice(int delta) => OnNavigateInteractionChoice?.Invoke(delta);
public void Dash(Vector3 direction, float length = -1) => OnDash?.Invoke(direction, length);
public void Dodge(float length = -1) => OnDodge?.Invoke(length);

View File

@@ -7,13 +7,12 @@ namespace Cielonos.MainGame.Environments
{
public class EnvironmentManager : Singleton<EnvironmentManager>
{
[Required]
public RainingSubmodule rainingSm;
public RainingSubcontroller rainingSc;
private void Start()
{
rainingSm.rainingState.SetValue();
rainingSm.rainingStart.Post(gameObject);
rainingSc.Initialize();
//rainingSm.StopRain();
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using AK.Wwise;
using Sirenix.OdinInspector;
using UnityEngine;
using Event = AK.Wwise.Event;
namespace Cielonos.MainGame.Environments
{
public class RainingSubcontroller : SubcontrollerBase<EnvironmentManager>
{
public State rainingState;
public Event rainingStart;
public Event rainingStop;
public ParticleSystem rainingEnvironmentVFX;
public ParticleSystem rainingScreenVFX;
public override void Initialize()
{
base.Initialize();
StopRain();
}
[Button]
public void StartRain()
{
rainingState.SetValue();
rainingStart.Post(owner.gameObject);
rainingScreenVFX.Play();
rainingEnvironmentVFX.Play();
}
[Button]
public void StopRain()
{
rainingStop.Post(owner.gameObject);
rainingScreenVFX.Stop();
rainingEnvironmentVFX.Stop();
}
}
}

View File

@@ -1,19 +0,0 @@
using System;
using AK.Wwise;
using UnityEngine;
using Event = AK.Wwise.Event;
namespace Cielonos.MainGame.Environments
{
public class RainingSubmodule : SubmoduleBase<EnvironmentManager>
{
public State rainingState;
public Event rainingStart;
public Event rainingStop;
public RainingSubmodule(EnvironmentManager owner) : base(owner)
{
}
}
}

View File

@@ -1,6 +1,8 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using UnityEngine;
using Yarn.Unity;
namespace Cielonos.MainGame.Map
{
@@ -46,6 +48,9 @@ namespace Cielonos.MainGame.Map
[TitleGroup("Zone 池")]
public List<ZoneData> logisticsCenterZones = new List<ZoneData>();
[TitleGroup("Zone 池")]
public SerializedDictionary<MapNodeType, List<ZoneData>> zonePools = new SerializedDictionary<MapNodeType, List<ZoneData>>();
// ----------------------------------------------------------------
// 特殊节点数量配额
// ----------------------------------------------------------------

View File

@@ -465,8 +465,7 @@ namespace Cielonos.MainGame
}
else
{
// 没有 ZoneData 的节点(如 MedicalStation直接切换阶段
TransitionToPhase(targetPhase);
throw new Exception($"[RunManager] 节点 {currentRun.currentPosition} 没有配置 ZoneData无法进入对应阶段。");
}
}

View File

@@ -21,6 +21,14 @@ namespace Cielonos.MainGame.Interactions
{
private const int OFFER_COUNT = 3;
[TitleGroup("刷新配置")]
[SerializeField]
[Tooltip("玩家可刷新机械台的次数上限。")]
private int maxRefreshCount = 1;
[ShowInInspector, ReadOnly]
private int _refreshesRemaining;
[TitleGroup("稀有度配置")]
[Tooltip("每种稀有度对应的 table 模型。生成时,根据稀有度生成对应模型。")]
[SerializeField]
@@ -47,9 +55,12 @@ namespace Cielonos.MainGame.Interactions
protected override void InitializeChoices()
{
choices.Add(new InteractionChoice("Open Mechanical Table", OpenTable));
_refreshesRemaining = maxRefreshCount;
choices.Add(new InteractionChoice("查看", OpenTable));
choices.Add(new InteractionChoice("刷新", RefreshTable, isInteractable: _refreshesRemaining > 0));
}
/// <summary>
/// 由 RunManager 或场景加载逻辑调用,预先配置本机械台的稀有度和物品列表。
/// 使用节点坐标派生的 RNG 确保同 seed 可复现。
@@ -76,15 +87,12 @@ namespace Cielonos.MainGame.Interactions
private void OpenTable()
{
// 如果未通过 Setup() 预配置自动配置fallback
if (!_isAvailable && (_currentOffers == null || _currentOffers.Count == 0))
if (!_isAvailable)
{
System.Random fallbackRng = RunManager.Instance?.currentRun?.randomizer?.Next() ?? new System.Random();
Setup(fallbackRng);
Debug.Log("[MechanicalTable] 机械台已用完。");
return;
}
if (!_isAvailable) return;
if (_currentOffers == null || _currentOffers.Count == 0)
{
Debug.LogWarning("[MechanicalTable] 候选池为空,没有符合条件的装备。");
@@ -96,6 +104,30 @@ namespace Cielonos.MainGame.Interactions
uiPage.Open();
}
private void RefreshTable()
{
if (_refreshesRemaining <= 0) return;
_refreshesRemaining--;
// 重新随机生成稀有度与物品池
System.Random rng = RunManager.Instance?.currentRun?.randomizer?.Next() ?? new System.Random();
Setup(rng);
// 更新"刷新"选项的可用状态
choices[1].isInteractable = _refreshesRemaining > 0;
// 通知 InteractionUIPage 刷新显示
var interactionSc = MainGameManager.Player?.interactionSc;
if (interactionSc != null)
{
PlayerCanvas.InteractionUIArea?.Show(interactionSc.currentChoices, interactionSc.currentChoiceIndex);
}
Debug.Log($"[MechanicalTable] 已刷新,剩余刷新次数:{_refreshesRemaining}");
}
private void HandleOfferSelected(int index)
{
if (index < 0 || index >= _currentOffers.Count) return;

View File

@@ -44,7 +44,7 @@ namespace Cielonos.MainGame.Interactions
/// </summary>
[Button("播放动作")]
public override bool Play(string animationName, float speedMultiplier = 1f, float transitionDuration = 0.1f,
bool isNormalizedTransition = false, List<FuncAnimPayloadBase> runtimeStartEvents = null)
bool isNormalizedTransition = false, float normalizedStartTime = -1f, List<FuncAnimPayloadBase> runtimeStartEvents = null)
{
if (!Collection.TryGetValue(animationName, out FuncAnimData funcAnimData))
{
@@ -53,7 +53,7 @@ namespace Cielonos.MainGame.Interactions
}
// NPC 播放动作采取无条件强制覆盖机制,直接委派给基类完成底层播放
return base.Play(funcAnimData, speedMultiplier, transitionDuration, isNormalizedTransition, runtimeStartEvents);
return base.Play(funcAnimData, speedMultiplier, transitionDuration, isNormalizedTransition, normalizedStartTime, runtimeStartEvents);
}
}
}

View File

@@ -62,7 +62,14 @@ namespace Cielonos.MainGame.Inventory
public partial class ItemBase
{
public bool IsUpgradable => upgradeData != null;
/// <summary>
/// 返回描述文本中 {key} 占位符对应的动态值字典。
/// 子类覆写此方法以提供运行时的展示数值(如伤害、消耗、暴击率等)。
/// 由 <see cref="UI.DisplayTextResolver"/> 在渲染描述条目时调用。
/// </summary>
public virtual Dictionary<string, string> GetDescriptionArgs() => null;
public virtual void Initialize()
{
feedbackSc?.Initialize();
@@ -145,7 +152,9 @@ namespace Cielonos.MainGame.Inventory
protected bool PlayTargetedAnimation(string animationName, CharacterBase target = null,
float adsorptionMinDistance = 1f, bool keepAdsorption = false, bool autoRotate = true,
FuncAnimSubmodule funcAnimSm = null, float animationSpeedMultiplier = 1f,
float transitionDuration = 0.1f, bool isNormalizedTransition = false, string comboTreeName = "Main")
float transitionDuration = 0.1f, bool isNormalizedTransition = false,
float normalizedStartTime = -1f,
string comboTreeName = "Main")
{
funcAnimSm ??= fullBodyFuncAnimSm;
@@ -155,7 +164,7 @@ namespace Cielonos.MainGame.Inventory
animationSpeedMultiplier *= player.attributeSm[CharacterAttribute.AttackSpeed];
}
if (funcAnimSm.Play(data, animationSpeedMultiplier, transitionDuration, isNormalizedTransition))
if (funcAnimSm.Play(data, animationSpeedMultiplier, transitionDuration, isNormalizedTransition, normalizedStartTime))
{
float actionCoolDownTime = funcAnimSm.currentData.Interval(IntervalType.ActionDisruption).StartTime /
funcAnimSm.currentPlaySpeedMultiplier;

View File

@@ -58,6 +58,24 @@ namespace Cielonos.MainGame.Inventory
activeAttributeSm?.RefreshAllModifiedAttributes();
DespawnViewObjects();
}
/// <summary>
/// 当该武器作为主武器切入(切换为当前手持)时调用。
/// 用于执行切入瞬间的特有动作(如格挡、出招等)。
/// </summary>
public virtual void OnSwitchIn()
{
}
/// <summary>
/// 当该武器切出(换成其他主武器)时调用。
/// 可用于在此处清理仅在切入时存在的临时状态(如临时格挡源)。
/// </summary>
public virtual void OnSwitchOut()
{
}
}
public partial class MainWeaponBase

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using NUnit.Framework;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Serialization;
namespace Cielonos.MainGame.Inventory
{
@@ -23,6 +24,17 @@ namespace Cielonos.MainGame.Inventory
Epsilon,
Aleph
}
[HideReferenceObjectPicker]
public class ItemDescription
{
public string descriptionKey;
public ItemDescription(string descriptionKey)
{
this.descriptionKey = descriptionKey;
}
}
[CreateAssetMenu(fileName = "ContentData", menuName = "Cielonos/Items/ContentData")]
public partial class ContentData : SerializedScriptableObject
@@ -39,13 +51,11 @@ namespace Cielonos.MainGame.Inventory
[ReadOnly]
public string displayNameKey;
[ReadOnly]
public string descriptionKey;
[ShowIf("isMainWeapon")]
public Sprite rectIcon;
[HideIf("isMainWeapon")]
public Sprite squareIcon;
[ListDrawerSettings(CustomAddFunction = "AddDescription", ListElementLabelName = "descriptionKey")]
public List<ItemDescription> descriptions;
[FormerlySerializedAs("squareIcon")]
public Sprite itemIcon;
[TitleGroup("Drop Settings")]
[Tooltip("掉落权重,越大越容易被随机选中。设为 0 则不会出现在随机池中。")]
@@ -57,7 +67,7 @@ namespace Cielonos.MainGame.Inventory
public partial class ContentData
{
private bool isMainWeapon => itemType == ItemType.MainWeapon;
private bool IsMainWeapon => itemType == ItemType.MainWeapon;
private void CreateID()
{
@@ -67,17 +77,15 @@ namespace Cielonos.MainGame.Inventory
return;
}
string itemType = this.itemType.ToString();
string className = this.itemClass.Name;
displayNameKey = $"{itemType}_{className}_Name";
descriptionKey = $"{itemType}_{className}_Desc";
displayNameKey = $"{className}_Name";
descriptions ??= new List<ItemDescription>();
}
}
#if UNITY_EDITOR
public partial class ContentData
{
#if UNITY_EDITOR
private List<string> GetAllTags()
{
return new List<string>
@@ -89,9 +97,8 @@ namespace Cielonos.MainGame.Inventory
}
/// <summary>
/// 获取项目中所有继承自ItemBase的类的名称列表用于编辑器下拉选择。可以根据需要添加命名空间过滤等逻辑。
/// 获取项目中所有继承自ItemBase的类的名称列表用于编辑器下拉选择。
/// </summary>
/// <returns></returns>
private List<Type> GetAllItemTypes()
{
List<Type> itemTypes = new List<Type>();
@@ -121,10 +128,15 @@ namespace Cielonos.MainGame.Inventory
"VitalisBiomass", //维塔利斯 - 生物
"Area6", //大陆技术领 第六区 - 尖端科技
"AcademyOfMagicalSciences", //魔法科学院 - 魔法
};
}
#endif
private void AddDescription()
{
int index = descriptions.Count;
string descriptionKey = $"{itemClass.Name}_Desc{index}";
descriptions.Add(new ItemDescription(descriptionKey));
}
}
}
#endif
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame.Inventory;
using SLSUtilities.General;
namespace Cielonos.MainGame.Items
{
/// <summary>
/// 物品排序模式。
/// </summary>
public enum ItemSortMode
{
/// <summary>
/// 默认:按类型(主武器→支援→被动→消耗品)→ 稀有度(高→低)→ 名称。
/// Extender 插队到其宿主物品之后。
/// </summary>
Default,
/// <summary>按稀有度(高→低)→ 名称。</summary>
ByRarity,
/// <summary>完全按显示名称排序。</summary>
ByName
}
/// <summary>
/// 物品排序工具类,与 <see cref="ItemFilter"/> 配合使用。
/// <para>
/// 通过 <see cref="NameResolver"/> 可替换名称解析策略以适配不同的本地化方案。
/// 默认使用 <c>ContentData.displayNameKey.Localize()</c>。
/// </para>
/// </summary>
public static class ItemSorter
{
/// <summary>
/// 名称解析委托。排序时通过此方法获取物品的显示名称。
/// 替换此委托即可切换本地化或自定义命名策略。
/// </summary>
public static Func<ItemBase, string> NameResolver { get; set; } = DefaultNameResolver;
private static string DefaultNameResolver(ItemBase item)
{
if (item?.contentData == null) return string.Empty;
return item.contentData.displayNameKey.Localize("Items");
}
/// <summary>
/// 对物品列表就地排序。
/// </summary>
public static void Sort(List<ItemBase> items, ItemSortMode mode)
{
if (items == null || items.Count <= 1) return;
switch (mode)
{
case ItemSortMode.Default:
SortDefault(items);
break;
case ItemSortMode.ByRarity:
SortByRarity(items);
break;
case ItemSortMode.ByName:
SortByName(items);
break;
}
}
// ─────────────────── Default ───────────────────
private static void SortDefault(List<ItemBase> items)
{
// 1. 分离已绑定宿主的 Extender
List<ExtenderBase> attachedExtenders = new List<ExtenderBase>();
List<ItemBase> regularItems = new List<ItemBase>();
foreach (ItemBase item in items)
{
if (item is ExtenderBase ext && ext.IsAttached)
{
attachedExtenders.Add(ext);
}
else
{
regularItems.Add(item);
}
}
// 2. 对常规物品排序:类型 → 稀有度(降序)→ 名称
regularItems.Sort(CompareByTypeRarityName);
// 3. Extender 之间按稀有度(降序)→ 名称排序,保证同宿主下多个 Extender 顺序稳定
attachedExtenders.Sort((a, b) =>
{
int cmp = CompareRarityDescending(a, b);
return cmp != 0 ? cmp : CompareName(a, b);
});
// 4. 将 Extender 插入到宿主之后
items.Clear();
items.AddRange(regularItems);
foreach (ExtenderBase ext in attachedExtenders)
{
int hostIndex = items.IndexOf(ext.Host);
if (hostIndex >= 0)
{
int insertPos = hostIndex + 1;
while (insertPos < items.Count
&& items[insertPos] is ExtenderBase sibling
&& sibling.IsAttached
&& sibling.Host == ext.Host)
{
insertPos++;
}
items.Insert(insertPos, ext);
}
else
{
// 宿主不在列表中(被筛选等情况),追加到末尾
items.Add(ext);
}
}
}
// ─────────────────── ByRarity ───────────────────
private static void SortByRarity(List<ItemBase> items)
{
items.Sort((a, b) =>
{
int cmp = CompareRarityDescending(a, b);
return cmp != 0 ? cmp : CompareName(a, b);
});
}
// ─────────────────── ByName ───────────────────
private static void SortByName(List<ItemBase> items)
{
items.Sort(CompareName);
}
// ─────────────────── Comparison Helpers ───────────────────
private static int CompareByTypeRarityName(ItemBase a, ItemBase b)
{
int typeA = GetTypePriority(a);
int typeB = GetTypePriority(b);
if (typeA != typeB) return typeA.CompareTo(typeB);
int cmp = CompareRarityDescending(a, b);
return cmp != 0 ? cmp : CompareName(a, b);
}
/// <summary>稀有度降序比较(高稀有度在前)。</summary>
private static int CompareRarityDescending(ItemBase a, ItemBase b)
{
int ra = a?.contentData != null ? (int)a.contentData.itemRarity : -1;
int rb = b?.contentData != null ? (int)b.contentData.itemRarity : -1;
return rb.CompareTo(ra);
}
/// <summary>按本地化显示名称比较,使用当前文化区域排序规则。</summary>
private static int CompareName(ItemBase a, ItemBase b)
{
return string.Compare(NameResolver(a), NameResolver(b), StringComparison.CurrentCulture);
}
private static int GetTypePriority(ItemBase item)
{
if (item?.contentData == null) return int.MaxValue;
return item.contentData.itemType switch
{
ItemType.MainWeapon => 0,
ItemType.Support => 1,
ItemType.Passive => 2,
ItemType.Consumable => 3,
_ => int.MaxValue
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2ff012bda76daf45bedac90a22477a9

View File

@@ -18,11 +18,15 @@ namespace Cielonos.MainGame.Inventory.Collections
{
base.OnEquipped();
RegisterFunctionsToAnimSc();
if(!player.inputSc.IsMoving) PlayTargetedAnimation("Equip");
viewObjects["Wand"].SetFadeAnim(0.5f);
//PlayerCanvas.MainWeaponUIArea.displayer.SetFrameOutline(1);
}
public override void OnSwitchIn()
{
if(!player.inputSc.IsMoving) PlayTargetedAnimation("Equip");
}
public override void OnPrimaryPress()
{
if (!_isHoldingAttack && player.inputSc.IsHoldingSpecialA)

View File

@@ -1,664 +0,0 @@
using System.Collections.Generic;
using ChocDino.UIFX;
using Cielonos.MainGame.Buffs.Character;
using Cielonos.MainGame.Characters;
using Cielonos.MainGame.Effects.Feedback;
using Cielonos.MainGame.UI;
using SLSUtilities.Feedback;
using SLSUtilities.General;
using SLSUtilities.FunctionalAnimation;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class Polychrome : MainWeaponBase
{
public float techniqueScore;
private PolychromeExtraUIContainer ExtraUIContainer => extraUIContainer as PolychromeExtraUIContainer;
private bool _canAirLightAttack;
private bool _canAirHeavyAttack;
protected override void Update()
{
if (player.inventorySc.equipmentSm.currentMainWeapon == this)
{
functionSm?.Update(player.selfTimeSm.DeltaTime);
}
}
public override void OnEquipped()
{
base.OnEquipped();
extraUIContainer = Instantiate(extraUIContainerPrefab, PlayerCanvas.MainWeaponUIArea.transform).GetComponent<MainWeaponExtraUIContainer>();
extraUIContainer.mainWeapon = this;
_currentKatanaParticle = string.Empty;
player.eventSm.onFirstJump.TryAdd(nameof(Polychrome), new PrioritizedAction(() =>
{
_canAirLightAttack = true;
_canAirHeavyAttack = true;
comboSm["AirLight"].Reset();
}));
player.eventSm.onAfterGetAttacked.TryAdd(nameof(Polychrome), new PrioritizedAction<AttackAreaBase, Attack.Result>((_, _) =>
{
ModifyTechniqueScore(-0.2f);
}));
RegisterFunctionsToAnimSc(StayBlocking);
viewObjects["Katana"].SetFadeAnim(0.2f);
viewObjects["Saya"].SetFadeAnim(0.2f);
PlayTargetedAnimation("EquipBlock");
SetBlock(equipBlockData);
player.selfTimeSm.AddLocalTimer(0.4f, () =>
{
RemoveBlock(equipBlockData);
fullBodyFuncAnimSm.Stop("EquipBlock");
});
}
public override void OnUnequipped()
{
base.OnUnequipped();
Destroy(extraUIContainer.gameObject);
ClearTechniqueScore();
player.eventSm.onFirstJump.Remove(nameof(Polychrome));
player.eventSm.onAfterGetAttacked.Remove(nameof(Polychrome));
}
public override void OnPrimaryPress()
{
if (player.statusSm.HasStatus(StatusType.Stun))
{
return;
}
if (player.reactionSc.blockSm.HaveBlockSource(blockData.blockName))
{
return;
}
if (player.landMovementSc.isJumping)
{
if (!_canAirLightAttack || !functionSm["LightAttack"].IsAvailable())
{
return;
}
if (PlayTargetedAnimation("AirAttack" + comboSm["AirLight"].GetNextNodeName("L")))
{
comboSm["AirLight"].NextCombo("L");
functionSm["LightAttack"].Execute();
if (comboSm["AirLight"].GetCurrentNodeName() == "L1")
{
_canAirLightAttack = false;
}
}
return;
}
/*if (player.inputSc.IsHoldingSpecialA && functionSm["LightAttack"].IsAvailable())
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(5);
if (PlayTargetedAnimation("AttackRC", target, 1f, true))
{
comboSm.main.NextCombo("L");
functionSm["LightAttack"].Execute();
}
return;
}*/
if (player.landMovementSc.isSprinting && functionSm["LightAttack"].IsAvailable() && fullBodyFuncAnimSm.CheckPlayability())
{
comboSm.main.Reset();
functionSm["LightAttack"].Execute();
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(8);
PlayTargetedAnimation("RunAttack", target);
comboSm.main.NextCombo("L");
return;
}
if (functionSm["LightAttack"].IsAvailable())
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(5);
string nextNodeName = comboSm.main.GetNextNodeName("L");
bool keepAdsorption = nextNodeName is "L4" or "L5";
if (PlayTargetedAnimation("Attack" + comboSm.main.GetNextNodeName("L"), target, 1f, keepAdsorption))
{
float totalTime = 0f;
CombatManager.EnemySm.activeEnemiesList.ForEach(enemy =>
{
(enemy as Enemy).behaviorSc.DispatchContextEvent("PlayerLightAttack", totalTime);
});
comboSm.main.NextCombo("L");
functionSm["LightAttack"].Execute();
}
}
}
public override void OnSecondaryPress()
{
if (player.statusSm.HasStatus(StatusType.Stun))
{
return;
}
List<Enemy> availableEnemies = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 4);
BlockSubmodule blockSm = player.reactionSc.blockSm;
if (!blockSm.afterPerfectBlockTimer.isCompleted)
{
Enemy blockedTarget = blockSm.perfectBlockedTarget as Enemy;
bool successPlayed;
if (blockedTarget != null && !availableEnemies.Contains(blockedTarget))
{
Debug.Log(blockedTarget.name);
float distance = Vector3.Distance(player.transform.position.Flatten(), blockedTarget.transform.position.Flatten());
successPlayed = PlayTargetedAnimation(distance > 2f ? "DodgeParryAttack" : "BlockParryAttack", blockedTarget);
}
else
{
successPlayed = PlayTargetedAnimation("BlockParryAttack");
}
if(successPlayed)
{
blockSm.afterPerfectBlockTimer.Complete();
RemoveBlock();
}
}
DodgeSubmodule dodgeSm = player.reactionSc.dodgeSm;
if (!dodgeSm.afterPerfectDodgeTimer.isCompleted)
{
Enemy dodgedTarget = dodgeSm.perfectDodgedTarget as Enemy;
bool successPlayed;
if (dodgedTarget != null && !availableEnemies.Contains(dodgedTarget))
{
float distance = Vector3.Distance(player.transform.position.Flatten(), dodgedTarget.transform.position.Flatten());
successPlayed = PlayTargetedAnimation(distance > 2f ? "DodgeParryAttack" : "BlockParryAttack", dodgedTarget);
}
else
{
successPlayed = PlayTargetedAnimation("BlockParryAttack");
}
if(successPlayed)
{
dodgeSm.afterPerfectDodgeTimer.Complete();
}
}
if (player.reactionSc.blockSm.HaveBlockSource(blockData.blockName))
{
return;
}
if (player.landMovementSc.isJumping)
{
if (!_canAirHeavyAttack || !functionSm["HeavyAttack"].IsAvailable())
{
return;
}
if (PlayTargetedAnimation("AirAttackRStart"))
{
player.landMovementSc.ExtraJump(10f);
comboSm.main.Reset();
functionSm["HeavyAttack"].Execute();
_canAirLightAttack = false;
_canAirHeavyAttack = false;
}
return;
}
if (player.inputSc.IsHoldingSpecialA)
{
if (functionSm["DisruptionAttack"].IsAvailable())
{
string suffix = techniqueScore switch
{
< 1f => "A",
< 3f => "B",
>= 3f => "C",
_ => "A"
};
List<Enemy> disruptable = CombatManager.EnemySm.GetDisruptableEnemies(availableEnemies);
Enemy target = CombatManager.EnemySm.GetScoredEnemies(availableEnemies)
.ApplyScoreModifier(disruptable, 0f, 1f).BestEnemy();
if (PlayTargetedAnimation("DisruptionAttack" + suffix, target))
{
if (disruptable.Count > 0)
{
FeedbackClip timeScaleModifierClip = player.feedbackSc.GetFeedbackData("DisruptionStartup")
.Clip<TimeScaleModifierAction>("Time");
float duration = fullBodyFuncAnimSm.collection["DisruptionAttack" + suffix]
.Interval(IntervalType.Startup).Duration * 2;
timeScaleModifierClip.duration = duration;
player.feedbackSc.PlayFeedback("DisruptionStartup");
}
if (suffix == "B")
{
ModifyTechniqueScore(-1, false);
}
else if (suffix == "C")
{
ModifyTechniqueScore(-3, false);
}
comboSm.main.Reset();
functionSm["DisruptionAttack"].Execute();
}
}
return;
}
if (functionSm["HeavyAttack"].IsAvailable())
{
Enemy target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
string nextNodeName = comboSm.main.GetNextNodeName("R");
bool keepAdsorption = nextNodeName is "RC";
if (PlayTargetedAnimation("Attack" + nextNodeName, target, 1f, keepAdsorption))
{
float totalTime = fullBodyFuncAnimSm.GetIntervalScaledDuration(IntervalType.Startup) - 0.2f;
CombatManager.EnemySm.activeEnemiesList.ForEach(enemy =>
{
(enemy as Enemy)!.behaviorSc.DispatchContextEvent("PlayerHeavyAttack", totalTime);
});
comboSm.main.NextCombo("R");
functionSm["HeavyAttack"].Execute();
}
}
}
public override void OnSpecialCPress()
{
comboSm.main.Reset();
SetBlock();
if (!player.statusSm.HasStatus(StatusType.Stun))
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(5);
if (fullBodyFuncAnimSm.Stop(DisruptionType.ForcedAction))
{
PlayTargetedAnimation("Block", target, 2f, false, true, upperBodyFuncAnimSm, 1f, 0.1f, true);
}
}
}
public override void OnSpecialCRelease()
{
if (upperBodyFuncAnimSm.currentRuntimeFuncAnim is { animationName: "Block" })
{
upperBodyFuncAnimSm.Stop(DisruptionType.ForcedAction);
}
player.selfTimeSm.AddLocalTimer(0.04f, () => RemoveBlock());
}
public override void OnSpecialBPress()
{
//测试完美格挡和完美闪避
/*SetBlock();
player.reactionSc.blockSm.GetCurrentBlockSource().PerfectBlock(null, player.centerPosition);
RemoveBlock();*/
player.operationSc.Dodge();
DodgeSource defaultDodge = DodgeSource.Default(player);
player.reactionSc.dodgeSm.ApplyDodge(defaultDodge);
player.reactionSc.dodgeSm.GetCurrentDodgeSource().PerfectDodge(null);
player.reactionSc.dodgeSm.RemoveDodge("DefaultDodge");
}
}
public partial class Polychrome
{
private void FAPF_GenerateNormalSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
string hitFeedback = p.str1 switch
{
"ProbingAttack" => "SingleNormalHit",
_ => "MultiNormalHit"
};
GenerateNormalSlash(p.str0, attackData[p.str1], hitFeedback);
}
private void FAPF_GenerateAirNormalSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateNormalSlash(p.str0, attackData[p.str1], "SingleNormalHit");
}
private void FAPF_GenerateHeavySlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateHeavySlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateUltimateSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateUltimateSlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateDisruptionSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateDisruptionSlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateMovingSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateDisruptionSlash(p.str0, attackData[p.str1]);
}
}
public partial class Polychrome
{
private NormalArea GenerateNormalSlash(string vfxName, AttackUnit attackUnit, string hitFeedback)
{
NormalArea slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.02f)
.SetHitSubmodule<NormalArea>();
slash.SetImpulseSubmodule().WithRepulsion(2f);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_LIGHTATTACKHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData(hitFeedback).Action<CameraPositionShakeAction>("Camera");
float magnitude = hitFeedback == "SingleNormalHit" ? 0.12f : 0.06f;
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * magnitude;
feedbackSc.PlayFeedback(hitFeedback);
ModifyTechniqueScore(0.02f);
/*if (attackUnit.unitName == "InstantAttack")
{
if (enemy.buffSm.HasBuff<ElectronicParalysis>())
{
slash.attackSm.attackValue.damage *= 2f;
}
}*/
});
return slash;
}
private NormalArea GenerateHeavySlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy);
if (!HasExtender<PhotonDissociator>())
{
slash.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.04f)
.SetHitSubmodule<NormalArea>();
}
else // 如果有Photon Dissociator重攻击拆分为五段每段伤害为原来的30%,时间和判定也相应调整
{
AttackUnit modifiedUnit = attackUnit.Clone();
modifiedUnit.startDamage *= 0.5f;
slash.SetAttackSubmodule<NormalArea>(modifiedUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.04f, 0.4f)
.SetHitSubmodule<NormalArea>(0.1f, 3);
}
slash.SetImpulseSubmodule(1f).WithRepulsion(5f);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
ModifyTechniqueScore(0.2f);
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
ModifyTechniqueScore(0.05f);
});
return slash;
}
private NormalArea GenerateDisruptionSlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.04f)
.SetHitSubmodule<NormalArea>();
slash.SetImpulseSubmodule().WithRepulsion(5f);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
ModifyTechniqueScore(0.2f);
feedbackSc.PlayFeedback("Breakthrough");
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm
.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
});
return slash;
}
private NormalArea GenerateUltimateSlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.04f)
.SetHitSubmodule<NormalArea>();
slash.SetImpulseSubmodule(1f).WithRepulsion(5f);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
feedbackSc.PlayFeedback("Breakthrough");
});
return slash;
}
}
public partial class Polychrome
{
public BlockData equipBlockData;
private string _blockAnimName = "BlockL";
private void SetBlock(BlockData blockData = null)
{
blockData ??= this.blockData;
BlockSource blockSource = blockData.CreateBlockSource(player, this);
player.landMovementSc.canDash = false;
player.landMovementSc.canDodge = false;
blockSource.onNormalBlock = (attackArea) =>
{
_blockAnimName = _blockAnimName == "BlockL" ? "BlockR" : "BlockL";
animationSc.fullBodyFuncAnimSm.Play(_blockAnimName, 1, 0);
var rotationShakeAction = feedbackSc.GetFeedbackData("NormalBlock").Action<CameraRotationShakeAction>("Camera");
rotationShakeAction.amplitude = _blockAnimName == "BlockL" ?
new Vector3(-0f, -2f, 1f) :
new Vector3(-0f, 2f, -1f);
feedbackSc.PlayFeedback("NormalBlock");
if (attackArea is NormalArea)
{
ModifyTechniqueScore(-0.1f);
}
};
blockSource.onPerfectBlock = (attackArea) =>
{
_blockAnimName = _blockAnimName == "BlockL" ? "BlockR" : "BlockL";
animationSc.fullBodyFuncAnimSm.Play(_blockAnimName, 1, 0);
var rotationShakeAction = feedbackSc.GetFeedbackData("PerfectBlock").Action<CameraRotationShakeAction>("Camera");
rotationShakeAction.amplitude = _blockAnimName == "BlockL" ?
new Vector3(0f, -4f, 2f) :
new Vector3(0f, 4f, -2f);
feedbackSc.PlayFeedback("PerfectBlock");
if (attackArea is NormalArea)
{
ModifyTechniqueScore(0.2f);
if (HasExtender<PhotonPolarizer>())
{
attackArea.creator.GetHit(Breakthrough.Type.Forced, out _);
attackArea.creator.movementSc.impulseSm.ApplyKnockback(player.transform.forward, 6f);
}
}
};
player.reactionSc.blockSm.ApplyBlock(blockSource);
}
private void StayBlocking()
{
if (player.inputSc.IsHoldingSpecialB)
{
player.movementSc.canMove.Modify(true);
player.movementSc.canRotate.Modify(true);
OnSpecialBPress();
}
}
private void RemoveBlock(BlockData blockData = null)
{
blockData ??= this.blockData;
player.landMovementSc.canDash = true;
player.landMovementSc.canDodge = true;
player.reactionSc.blockSm.RemoveBlock(blockData.blockName);
}
}
public partial class Polychrome
{
private string _currentKatanaParticle;
/// <summary>
/// 清空技巧分数
/// </summary>
private void ClearTechniqueScore()
{
techniqueScore = 0;
UpdateTechniqueVisuals();
}
/// <summary>
/// 增减技巧分数0~3当减少时不会扣除整数部分
/// </summary>
private void ModifyTechniqueScore(float amount, bool protectInteger = true)
{
if (amount > 0)
{
techniqueScore = Mathf.Clamp(techniqueScore + amount, 0, 5);
}
else
{
float floor = Mathf.Floor(techniqueScore);
if (protectInteger)
{
techniqueScore = Mathf.Max(techniqueScore + amount, floor);
}
else
{
techniqueScore = Mathf.Max(techniqueScore + amount, 0);
}
}
UpdateTechniqueVisuals();
}
private void UpdateTechniqueVisuals()
{
//开关粒子特效
if (techniqueScore < 1)
{
if (_currentKatanaParticle != string.Empty)
{
viewObjects["Katana"].functionalParts[_currentKatanaParticle].GetComponent<ParticleSystem>().Stop();
_currentKatanaParticle = string.Empty;
//PlayerCanvas.MainWeaponUIArea.displayer.frame.GetComponent<GlowFilter>().Strength = 0f;
}
}
else if (techniqueScore >= 1 && techniqueScore < 3)
{
if (_currentKatanaParticle != "ParticleStage0")
{
if (_currentKatanaParticle != string.Empty)
{
viewObjects["Katana"].functionalParts[_currentKatanaParticle].GetComponent<ParticleSystem>().Stop();
}
_currentKatanaParticle = "ParticleStage0";
viewObjects["Katana"].functionalParts[_currentKatanaParticle].GetComponent<ParticleSystem>().Play();
//PlayerCanvas.MainWeaponUIArea.displayer.frame.GetComponent<GlowFilter>().Strength = 0.3f;
}
}
else if (techniqueScore >= 3)
{
if (_currentKatanaParticle != "ParticleStage1")
{
if (_currentKatanaParticle != string.Empty)
{
viewObjects["Katana"].functionalParts[_currentKatanaParticle].GetComponent<ParticleSystem>().Stop();
}
_currentKatanaParticle = "ParticleStage1";
viewObjects["Katana"].functionalParts[_currentKatanaParticle].GetComponent<ParticleSystem>().Play();
//PlayerCanvas.MainWeaponUIArea.displayer.frame.GetComponent<GlowFilter>().Strength = 0.5f;
}
}
//设置UI星星数量
ExtraUIContainer.SetStars(Mathf.FloorToInt(techniqueScore));
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cf9abeac90b4c334b91136fbc464e06d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,418 @@
using System.Collections.Generic;
using ChocDino.UIFX;
using Cielonos.MainGame.Buffs.Character;
using Cielonos.MainGame.Characters;
using Cielonos.MainGame.Effects.Feedback;
using Cielonos.MainGame.UI;
using SLSUtilities.Feedback;
using SLSUtilities.General;
using SLSUtilities.FunctionalAnimation;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class Polychrome : MainWeaponBase
{
private PassionSystem _passionSystem;
private bool _canAirLightAttack;
private bool _canAirHeavyAttack;
protected override void Update()
{
if (player.inventorySc.equipmentSm.currentMainWeapon == this)
{
functionSm?.Update(player.selfTimeSm.DeltaTime);
}
}
public override void OnEquipped()
{
base.OnEquipped();
_currentKatanaParticle = string.Empty;
player.eventSm.onFirstJump.TryAdd(nameof(Polychrome), new PrioritizedAction(() =>
{
_canAirLightAttack = true;
_canAirHeavyAttack = true;
comboSm["AirLight"].Reset();
}));
_passionSystem = CombatManager.GetCombatSystem<PassionSystem>();
_passionSystem.OnLevelChanged += OnPassionLevelChanged;
RegisterFunctionsToAnimSc(StayBlocking);
viewObjects["Katana"].SetFadeAnim(0.2f);
viewObjects["Saya"].SetFadeAnim(0.2f);
UpdateViewObjectVisuals();
}
public override void OnUnequipped()
{
base.OnUnequipped();
_passionSystem.OnLevelChanged -= OnPassionLevelChanged;
player.eventSm.onFirstJump.Remove(nameof(Polychrome));
}
private void OnPassionLevelChanged(int oldLevel, int newLevel)
{
UpdateViewObjectVisuals();
}
public override void OnSwitchIn()
{
if (_passionSystem.LevelIndex >= 3)
{
List<Enemy> availableEnemies = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 4);
List<Enemy> disruptable = CombatManager.EnemySm.GetDisruptableEnemies(availableEnemies);
Enemy target = CombatManager.EnemySm.GetScoredEnemies(availableEnemies)
.ApplyScoreModifier(disruptable, 0f, 1f).BestEnemy();
if (PlayTargetedAnimation("DisruptionAttackB", target))
{
FeedbackClip timeScaleModifierClip = player.feedbackSc.GetFeedbackData("DisruptionStartup")
.Clip<TimeScaleModifierAction>("Time");
float duration = fullBodyFuncAnimSm.collection["DisruptionAttackB"]
.Interval(IntervalType.Startup).Duration * 2;
timeScaleModifierClip.duration = duration;
player.feedbackSc.PlayFeedback("DisruptionStartup");
comboSm.main.Reset();
_passionSystem.DecreasePassion(100, false);
}
}
else
{
PlayTargetedAnimation("EquipBlock");
SetBlock(equipBlockData);
player.selfTimeSm.AddLocalTimer(0.4f, () =>
{
RemoveBlock(equipBlockData);
fullBodyFuncAnimSm.Stop("EquipBlock");
});
}
}
public override void OnPrimaryPress()
{
if (player.statusSm.HasStatus(StatusType.Stun))
{
return;
}
if (player.reactionSc.blockSm.HaveBlockSource(blockData.blockName))
{
return;
}
if (player.landMovementSc.isJumping)
{
if (!_canAirLightAttack || !functionSm["LightAttack"].IsAvailable())
{
return;
}
if (PlayTargetedAnimation("AirAttack" + comboSm["AirLight"].GetNextNodeName("L")))
{
comboSm["AirLight"].NextCombo("L");
functionSm["LightAttack"].Execute();
if (comboSm["AirLight"].GetCurrentNodeName() == "L1")
{
_canAirLightAttack = false;
}
}
return;
}
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(5);
if (player.landMovementSc.isSprinting && functionSm["LightAttack"].IsAvailable())
{
comboSm.main.Reset();
if (PlayTargetedAnimation("RunAttack", target))
{
comboSm.main.NextCombo("L");
}
functionSm["LightAttack"].Execute();
return;
}
if (functionSm["LightAttack"].IsAvailable())
{
string nextNodeName = comboSm.main.GetNextNodeName("L");
bool keepAdsorption = nextNodeName is "L4" or "L5";
if (PlayTargetedAnimation("Attack" + comboSm.main.GetNextNodeName("L"), target, 1f, keepAdsorption))
{
float totalTime = 0f;
CombatManager.EnemySm.activeEnemiesList.ForEach(enemy =>
{
(enemy as Enemy).behaviorSc.DispatchContextEvent("PlayerLightAttack", totalTime);
});
comboSm.main.NextCombo("L");
functionSm["LightAttack"].Execute();
}
}
}
private bool TryPlayParryAttack(Enemy parryTarget, List<Enemy> availableEnemies)
{
if (parryTarget != null && !availableEnemies.Contains(parryTarget))
{
float distance = Vector3.Distance(player.transform.position.Flatten(), parryTarget.transform.position.Flatten());
return PlayTargetedAnimation(distance > 2f ? "DodgeParryAttack" : "BlockParryAttack", parryTarget);
}
return PlayTargetedAnimation("BlockParryAttack");
}
public override void OnSecondaryPress()
{
if (player.statusSm.HasStatus(StatusType.Stun))
{
return;
}
List<Enemy> availableEnemies = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 4);
BlockSubmodule blockSm = player.reactionSc.blockSm;
if (!blockSm.afterPerfectBlockTimer.isCompleted)
{
Enemy blockedTarget = blockSm.perfectBlockedTarget as Enemy;
if (TryPlayParryAttack(blockedTarget, availableEnemies))
{
blockSm.afterPerfectBlockTimer.Complete();
RemoveBlock();
}
}
DodgeSubmodule dodgeSm = player.reactionSc.dodgeSm;
if (!dodgeSm.afterPerfectDodgeTimer.isCompleted)
{
Enemy dodgedTarget = dodgeSm.perfectDodgedTarget as Enemy;
if (TryPlayParryAttack(dodgedTarget, availableEnemies))
{
dodgeSm.afterPerfectDodgeTimer.Complete();
}
}
if (player.reactionSc.blockSm.HaveBlockSource(blockData.blockName))
{
return;
}
if (player.landMovementSc.isJumping)
{
if (!_canAirHeavyAttack || !functionSm["HeavyAttack"].IsAvailable())
{
return;
}
if (PlayTargetedAnimation("AirAttackRStart"))
{
player.landMovementSc.ExtraJump(10f);
comboSm.main.Reset();
functionSm["HeavyAttack"].Execute();
_canAirLightAttack = false;
_canAirHeavyAttack = false;
}
return;
}
if (player.inputSc.IsHoldingSpecialA)
{
if (functionSm["DisruptionAttack"].IsAvailable())
{
bool isEnhanced = _passionSystem.LevelIndex >= 3;
string suffix = isEnhanced ? "B" : "A";
List<Enemy> disruptable = CombatManager.EnemySm.GetDisruptableEnemies(availableEnemies);
Enemy target = CombatManager.EnemySm.GetScoredEnemies(availableEnemies)
.ApplyScoreModifier(disruptable, 0f, 1f).BestEnemy();
if (PlayTargetedAnimation("DisruptionAttack" + suffix, target))
{
if (disruptable.Count > 0)
{
FeedbackClip timeScaleModifierClip = player.feedbackSc.GetFeedbackData("DisruptionStartup")
.Clip<TimeScaleModifierAction>("Time");
float duration = fullBodyFuncAnimSm.collection["DisruptionAttack" + suffix]
.Interval(IntervalType.Startup).Duration * 2;
timeScaleModifierClip.duration = duration;
player.feedbackSc.PlayFeedback("DisruptionStartup");
}
if (isEnhanced)
{
_passionSystem.DecreasePassion(100f, false);
}
comboSm.main.Reset();
functionSm["DisruptionAttack"].Execute();
}
}
return;
}
if (functionSm["HeavyAttack"].IsAvailable())
{
Enemy target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
string nextNodeName = comboSm.main.GetNextNodeName("R");
bool keepAdsorption = nextNodeName is "RC";
if (PlayTargetedAnimation("Attack" + nextNodeName, target, 1f, keepAdsorption))
{
float totalTime = fullBodyFuncAnimSm.GetIntervalScaledDuration(IntervalType.Startup) - 0.2f;
CombatManager.EnemySm.activeEnemiesList.ForEach(enemy =>
{
(enemy as Enemy)!.behaviorSc.DispatchContextEvent("PlayerHeavyAttack", totalTime);
});
comboSm.main.NextCombo("R");
functionSm["HeavyAttack"].Execute();
}
}
}
public override void OnSpecialCPress()
{
comboSm.main.Reset();
SetBlock();
if (!player.statusSm.HasStatus(StatusType.Stun))
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(5);
if (fullBodyFuncAnimSm.Stop(DisruptionType.ForcedAction))
{
PlayTargetedAnimation("Block", target, 2f, false, true, upperBodyFuncAnimSm, 1f, 0.1f, true);
}
}
}
public override void OnSpecialCRelease()
{
if (upperBodyFuncAnimSm.currentRuntimeFuncAnim is { animationName: "Block" })
{
upperBodyFuncAnimSm.Stop(DisruptionType.ForcedAction);
}
player.selfTimeSm.AddLocalTimer(0.04f, () => RemoveBlock());
}
public override void OnSpecialBPress()
{
player.operationSc.Dodge();
DodgeSource defaultDodge = DodgeSource.Default(player);
player.reactionSc.dodgeSm.ApplyDodge(defaultDodge);
player.reactionSc.dodgeSm.GetCurrentDodgeSource().PerfectDodge(null);
player.reactionSc.dodgeSm.RemoveDodge("DefaultDodge");
}
}
public partial class Polychrome
{
private string _currentKatanaParticle;
private void UpdateViewObjectVisuals()
{
if (_passionSystem == null) return;
int level = _passionSystem.LevelIndex;
// 开关粒子特效
if (level < 3) // C, BA级
{
if (_currentKatanaParticle != string.Empty)
{
GetParticle().Stop();
_currentKatanaParticle = string.Empty;
}
}
else if (level <= 4) // SSS级 (2)
{
if (_currentKatanaParticle != "ParticleStage0")
{
if (_currentKatanaParticle != string.Empty)
{
GetParticle().Stop();
}
_currentKatanaParticle = "ParticleStage0";
GetParticle().Play();
}
}
else // SSS级及以上
{
if (_currentKatanaParticle != "ParticleStage1")
{
if (_currentKatanaParticle != string.Empty)
{
GetParticle().Stop();
}
_currentKatanaParticle = "ParticleStage1";
GetParticle().Play();
}
}
return;
ParticleSystem GetParticle()
{
return viewObjects["Katana"].functionalParts.TryGetValue(_currentKatanaParticle, out GameObject particleObj) ?
particleObj.GetComponent<ParticleSystem>() : null;
}
}
}
public partial class Polychrome
{
public override Dictionary<string, string> GetDescriptionArgs()
{
var args = new Dictionary<string, string>();
if (attackData == null) return args;
// TODO: 将下列 AttackUnit key 替换为 AttackData ScriptableObject 中的实际 key 名
TryPopulateAttack(args, "light", "ProbingAttack");
// TryPopulateAttack(args, "heavy", "HeavySlash");
// TryPopulateAttack(args, "disruption_a", "DisruptionA");
// TryPopulateAttack(args, "disruption_b", "DisruptionB");
// TryPopulateAttack(args, "disruption_c", "DisruptionC");
return args;
}
private void TryPopulateAttack(Dictionary<string, string> args, string prefix, string unitKey)
{
if (attackData.attackUnits.TryGetValue(unitKey, out AttackUnit unit))
{
DisplayTextResolver.PopulateAttackArgs(args, prefix, unit, player);
}
}
}
// =================================================================
// 以下为已废弃的 ExtraUIContainer 相关代码 / Deprecated ExtraUIContainer Codes
// =================================================================
/*
public partial class Polychrome
{
private PolychromeExtraUIContainer ExtraUIContainer => extraUIContainer as PolychromeExtraUIContainer;
// OnEquipped:
// extraUIContainer = Instantiate(extraUIContainerPrefab, PlayerCanvas.MainWeaponUIArea.transform).GetComponent<MainWeaponExtraUIContainer>();
// extraUIContainer.mainWeapon = this;
// OnUnequipped:
// Destroy(extraUIContainer.gameObject);
// UpdateVisuals:
// if (ExtraUIContainer != null)
// {
// ExtraUIContainer.SetStars(level);
// }
}
*/
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using ChocDino.UIFX;
using Cielonos.MainGame.Buffs.Character;
using Cielonos.MainGame.Characters;
using Cielonos.MainGame.Effects.Feedback;
using Cielonos.MainGame.UI;
using SLSUtilities.Feedback;
using SLSUtilities.General;
using SLSUtilities.FunctionalAnimation;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class Polychrome
{
private void FAPF_GenerateNormalSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
string hitFeedback = p.str1 switch
{
"ProbingAttack" => "SingleNormalHit",
_ => "MultiNormalHit"
};
GenerateNormalSlash(p.str0, attackData[p.str1], hitFeedback);
}
private void FAPF_GenerateAirNormalSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateNormalSlash(p.str0, attackData[p.str1], "SingleNormalHit");
}
private void FAPF_GenerateHeavySlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateHeavySlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateUltimateSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateUltimateSlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateDisruptionSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateDisruptionSlash(p.str0, attackData[p.str1]);
}
private void FAPF_GenerateMovingSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateDisruptionSlash(p.str0, attackData[p.str1]);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 203220f978ce416996bd6fcafbebae8c

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using ChocDino.UIFX;
using Cielonos.MainGame.Buffs.Character;
using Cielonos.MainGame.Characters;
using Cielonos.MainGame.Effects.Feedback;
using Cielonos.MainGame.UI;
using SLSUtilities.Feedback;
using SLSUtilities.General;
using SLSUtilities.FunctionalAnimation;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class Polychrome
{
private NormalArea CreateBaseSlash(string vfxName, AttackUnit attackUnit, float timeDuration = 1f, float hitActiveDuration = 0.04f)
{
NormalArea slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(timeDuration, hitActiveDuration)
.SetHitSubmodule<NormalArea>();
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
return slash;
}
private NormalArea GenerateNormalSlash(string vfxName, AttackUnit attackUnit, string hitFeedback)
{
NormalArea slash = CreateBaseSlash(vfxName, attackUnit, 1f, 0.02f);
slash.SetImpulseSubmodule().WithRepulsion(2f);
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_LIGHTATTACKHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData(hitFeedback).Action<CameraPositionShakeAction>("Camera");
float magnitude = hitFeedback == "SingleNormalHit" ? 0.12f : 0.06f;
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * magnitude;
feedbackSc.PlayFeedback(hitFeedback);
});
return slash;
}
private NormalArea GenerateHeavySlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash;
if (!HasExtender<PhotonDissociator>())
{
slash = CreateBaseSlash(vfxName, attackUnit, 1f, 0.04f);
}
else // 如果有Photon Dissociator重攻击拆分为五段每段伤害为原来的30%,时间和判定也相应调整
{
AttackUnit modifiedUnit = attackUnit.Clone();
modifiedUnit.startDamage *= 0.5f;
slash = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
slash.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(modifiedUnit)
.SetTimeSubmodule<NormalArea>(1f, 0.04f, 0.4f)
.SetHitSubmodule<NormalArea>(0.1f, 3);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
}
slash.SetImpulseSubmodule(1f).WithRepulsion(5f);
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
});
return slash;
}
private NormalArea GenerateDisruptionSlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash = CreateBaseSlash(vfxName, attackUnit, 1f, 0.04f);
slash.SetImpulseSubmodule().WithRepulsion(5f);
slash.attackSm.breakthroughAction = (enemy, hitPosition) =>
{
feedbackSc.PlayFeedback("Breakthrough");
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
});
return slash;
}
private NormalArea GenerateUltimateSlash(string vfxName, AttackUnit attackUnit)
{
NormalArea slash = CreateBaseSlash(vfxName, attackUnit, 1f, 0.04f);
slash.SetImpulseSubmodule(1f).WithRepulsion(5f);
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");
positionShakeAction.amplitude = vfxData.Get(vfxName).slashScreenPosition.normalized * 0.18f;
feedbackSc.PlayFeedback("HeavyHit");
feedbackSc.PlayFeedback("Breakthrough");
});
return slash;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74fd17b16e9d4f9db2c66c437c071b42

View File

@@ -0,0 +1,71 @@
using Cielonos.MainGame.Characters;
using Cielonos.MainGame.Effects.Feedback;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class Polychrome
{
public BlockData equipBlockData;
private string _blockAnimName = "BlockL";
private void PlayBlockReaction(string feedbackName, float amplitudeScale)
{
_blockAnimName = _blockAnimName == "BlockL" ? "BlockR" : "BlockL";
animationSc.fullBodyFuncAnimSm.Play(_blockAnimName, 1, 0);
var rotationShakeAction = feedbackSc.GetFeedbackData(feedbackName).Action<CameraRotationShakeAction>("Camera");
Vector3 baseAmplitude = _blockAnimName == "BlockL" ? new Vector3(0f, -2f, 1f) : new Vector3(0f, 2f, -1f);
rotationShakeAction.amplitude = baseAmplitude * amplitudeScale;
feedbackSc.PlayFeedback(feedbackName);
}
private void SetBlock(BlockData blockData = null)
{
blockData ??= this.blockData;
BlockSource blockSource = blockData.CreateBlockSource(player, this);
player.landMovementSc.canDash = false;
player.landMovementSc.canDodge = false;
blockSource.onNormalBlock = (attackArea) =>
{
PlayBlockReaction("NormalBlock", 1f);
};
blockSource.onPerfectBlock = (attackArea) =>
{
PlayBlockReaction("PerfectBlock", 2f);
if (attackArea is NormalArea)
{
if (HasExtender<PhotonPolarizer>()) // 如果有Photon Polarizer完美格挡会弹开并打断敌人
{
attackArea.creator.GetHit(Breakthrough.Type.Forced, out _);
attackArea.creator.movementSc.impulseSm.ApplyKnockback(player.transform.forward, 6f);
}
}
};
player.reactionSc.blockSm.ApplyBlock(blockSource);
}
private void StayBlocking()
{
if (player.inputSc.IsHoldingSpecialB)
{
player.movementSc.canMove.Modify(true);
player.movementSc.canRotate.Modify(true);
OnSpecialBPress();
}
}
private void RemoveBlock(BlockData blockData = null)
{
blockData ??= this.blockData;
player.landMovementSc.canDash = true;
player.landMovementSc.canDodge = true;
player.reactionSc.blockSm.RemoveBlock(blockData.blockName);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c0162c88523a431b94eef1774904165c

View File

@@ -0,0 +1,100 @@
using Cielonos.MainGame.Characters;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 激情系统配套被动装备
/// - 提供与激情系统等级相关的属性加成。
/// - 加成效果根据激情等级逐级递增,最高可达 SSS 级别。
/// 具体效果如下:
/// - C级+8 能量回复,+0 攻击获得能量0% 攻击速度0% 暴击率0% 受到的最终伤害倍率
/// - B级+4 能量回复,+0 攻击获得能量1% 攻击速度2% 暴击率0% 受到的最终伤害倍率
/// - A级+2 能量回复,+0 攻击获得能量2% 攻击速度4% 暴击率0% 受到的最终伤害倍率
/// - S级+1 能量回复,+1 攻击获得能量3% 攻击速度6% 暴击率5% 受到的最终伤害倍率
/// - SS级+1 能量回复,+1 攻击获得能量4% 攻击速度8% 暴击率10% 受到的最终伤害倍率
/// - SSS级+1 能量回复,+1 攻击获得能量5% 攻击速度10% 暴击率20% 受到的最终伤害倍率
/// </summary>
public partial class Passion : PassiveEquipmentBase
{
private PassionSystem _passionSystem;
private int PassionLevel => _passionSystem.LevelIndex;
public override void Initialize()
{
base.Initialize();
_passionSystem = CombatManager.GetCombatSystem<PassionSystem>();
passiveAttributeSm = new AttributeSubmodule(this, passiveAttributeData);
}
public override void OnObtained()
{
base.OnObtained();
_passionSystem.OnLevelChanged += Refresh;
UpdateAttributes();
passiveAttributeSm.RefreshAllModifiedAttributes();
}
public override void OnDiscarded()
{
base.OnDiscarded();
_passionSystem.OnLevelChanged -= Refresh;
}
private void Refresh(int oldLevel, int newLevel)
{
UpdateAttributes();
passiveAttributeSm.RefreshAllModifiedAttributes();
}
private void UpdateAttributes()
{
passiveAttributeSm.charAttrNumericChange[CharacterAttribute.EnergyRegeneration] = GetEnergyRegen();
passiveAttributeSm.chaAttrPercentageChangeOfAccumulation[CharacterAttribute.AttackSpeed] = GetAttackSpeed();
passiveAttributeSm.charAttrNumericChange[CharacterAttribute.CriticalAttackProbability] = GetCriticalAttackProbability();
passiveAttributeSm.chaAttrPercentageChangeOfMultiplication[CharacterAttribute.FinalDamageReceivedMultiplier] = GetFinalDamageReceivedMultiplier();
}
}
public partial class Passion
{
private float GetEnergyRegen()
{
return PassionLevel switch
{
0 => 8.0f, // C-Rank
1 => 4.0f, // B-Rank
2 => 2.0f, // A-Rank
_ => 1.0f // S, SS, SSS (Ranks 3, 4, 5)
};
}
private float GetEnergyGainByAttack()
{
if (PassionLevel < 3) return 0f; // C, B, A ranks have no extra energy gain
return 1f; // S, SS, SSS ranks provide +1 energy gain on attack
}
private float GetAttackSpeed()
{
return PassionLevel * 0.01f;
}
private float GetCriticalAttackProbability()
{
return PassionLevel * 0.02f;
}
private float GetFinalDamageReceivedMultiplier()
{
return PassionLevel switch
{
3 => 1.05f, // S-Rank (LevelIndex 3)
4 => 1.10f, // SS-Rank (LevelIndex 4)
5 => 1.20f, // SSS-Rank (LevelIndex 5)
_ => 1.00f // C, B, A-Ranks (0, 1, 2)
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e775fb6352f80c45b99fc0a2000d573

View File

@@ -2,8 +2,12 @@ using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 空间扭曲卡尺 / Spatial Warp Caliper
/// 增加 (10% + 等级 * 1%) 的攻击射程/范围
/// </summary>
public class SpatialWarpCaliper : PassiveEquipmentBase
{
//增加 (10% + 等级 * 1%) 的攻击射程/范围
}
}

View File

@@ -5,7 +5,7 @@ namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 共生回路 / Symbiotic Loop
/// 玩家完成一个战斗房间的清理后,恢复(5 + 等级 * 1)的生命值。
/// 玩家完成一个战斗房间的清理后,恢复(2 + 等级 * 0.4)的生命值。
/// </summary>
public class SymbioticLoop : PassiveEquipmentBase
{

View File

@@ -5,10 +5,12 @@ using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 蝮蛇之牙 / Viper's Fang
/// 每次攻击根据敌人当前已经损失的生命百分比,增加额外伤害(最大增加 20 伤害)
/// </summary>
public partial class VipersFang : PassiveEquipmentBase
{
//每次攻击根据敌人当前已经损失的生命百分比,增加额外伤害(最大增加 20 伤害)
public override void OnObtained()
{
base.OnObtained();

View File

@@ -17,6 +17,7 @@ namespace Cielonos.MainGame.Inventory
public bool Has(string attributeName) => itemAttributeGroup.current.ContainsKey(attributeName);
public float Get(string attributeName, float defaultValue) => itemAttributeGroup.current.GetValueOrDefault(attributeName, defaultValue);
public AttributeSubmodule(ItemBase owner, AttributeData data, UpgradeData upgradeData = null) : base(owner)
{

View File

@@ -0,0 +1,24 @@
using Cielonos.MainGame.Characters;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 知觉金属装甲 / Perceptive Metal Plating
/// 具有感知能力的金属装甲,有概率对敌人的攻击自动进行格挡。
/// </summary>
public class PerceptiveMetalPlating : SupportEquipmentBase
{
public override void OnEquipped()
{
base.OnEquipped();
BlockSource blockSource = blockData.CreateBlockSource(player, this);
player.reactionSc.blockSm.ApplyBlock(blockSource);
}
public override void OnUnequipped()
{
base.OnUnequipped();
player.reactionSc.blockSm.RemoveBlock(blockData.blockName);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d21a64bb5f0e6843a5b7475705d64cf

View File

@@ -8,7 +8,7 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.MainGame
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 屏幕下方的节拍滚动时间轴 UI 控制器。

View File

@@ -0,0 +1,378 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame.Characters;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using UnityEngine;
using UnityEngine.Serialization;
namespace Cielonos.MainGame
{
/// <summary>
/// 激情系统(战斗评分/战斗引擎)
/// 管理玩家的定性与定量战斗状态。
/// 采用逐级递进机制,每个等级具有独立的 0-100% 进度。
/// </summary>
public partial class PassionSystem : CombatSystemBase
{
// ==========================================
// 配置与设置 / Settings & Configurations
// ==========================================
[TitleGroup("衰减设置")]
[Tooltip("每秒基础衰减率(占当前等级总进度的百分比)。")]
[SerializeField] private float baseDecayRate = 5f;
// ==========================================
// 运行时状态 / Runtime State
// ==========================================
[TitleGroup("运行时状态")]
[ReadOnly] [ShowInInspector] public int levelIndex;
[ReadOnly] [ShowInInspector] public float percent;
[ReadOnly] [ShowInInspector] private float _timeSinceLastAction;
[TitleGroup("等级配置")]
public List<PassionLevel> passionLevels;
// ==========================================
// 事件 / Events
// ==========================================
/// <summary>
/// 当激情等级段位发生改变时触发。参数1旧等级索引参数2新等级索引。
/// </summary>
public event Action<int, int> OnLevelChanged;
/// <summary>
/// 当当前等级的百分比进度发生改变时触发。
/// </summary>
public event Action<float> OnPercentChanged;
/// <summary>
/// 当激情值增加时触发。参数1实际增加值参数2是否应用了乘数通常代表主动或技术行为加成
/// </summary>
public event Action<float, bool> OnPassionIncreased;
/// <summary>
/// 当激情值减少时触发。参数1实际减少值参数2是否应用了受击乘数通常代表受击等负面反应降低
/// </summary>
public event Action<float, bool> OnPassionDecreased;
// ==========================================
// 属性 / Properties
// ==========================================
public int LevelIndex => levelIndex;
public float Percent => percent;
public float TimeSinceLastAction => _timeSinceLastAction;
public PassionLevel.Type CurrentLevelType => passionLevels[levelIndex].levelType;
// ==========================================
// 生命周期 / Lifecycle
// ==========================================
private void Awake()
{
InitializeRankInfos();
}
private void Start()
{
PlayerEventSubmodule eventSm = MainGameManager.Player.eventSm;
eventSm.onFinishAttack.TryAdd("PassionSystem",
new PrioritizedAction<AttackAreaBase, CharacterBase, Attack.Result>(
(attacker, target, result) => OnAttackHit(result.value.breakthroughType)
)
);
eventSm.onPerfectBlockSuccess.TryAdd("PassionSystem",
new PrioritizedAction<AttackAreaBase, BlockSource>(
(attacker, source) => OnPerfectBlock()
)
);
eventSm.onPerfectDodgeSuccess.TryAdd("PassionSystem",
new PrioritizedAction<AttackAreaBase, DodgeSource>(
(attacker, source) => OnPerfectDodge()
)
);
eventSm.onAfterGetAttacked.TryAdd("PassionSystem",
new PrioritizedAction<AttackAreaBase, Attack.Result>(
(attacker, result) => OnGetHit(result.value.breakthroughType)
)
);
}
private void Update()
{
var player = MainGameManager.Player;
if (player == null || player.selfTimeSm == null) return;
float playerLocalDt = player.selfTimeSm.DeltaTime;
_timeSinceLastAction += playerLocalDt;
// 如果无动作时间超过延迟设定,则触发自然衰减
float keepDuration = player.attributeSm[CharacterAttribute.PassionKeepDuration];
if (_timeSinceLastAction >= keepDuration)
{
float amplifier = player.attributeSm[CharacterAttribute.PassionTimeDecreaseAmplifier];
float decayRate = baseDecayRate * passionLevels[levelIndex].timeDecreaseMultiplier * (1 + amplifier);
// 时间引起的自然衰减:直接传入计算时间乘数后的值,并忽略受击等其他乘数,标记 isTimeDecay 为 true
DecreasePassion(decayRate * playerLocalDt, false, isTimeDecay: true);
}
}
}
public partial class PassionSystem
{
[Serializable]
public struct PassionLevel
{
public enum Type
{
C = 0,
B = 1,
A = 2,
S = 3,
SS = 4,
SSS = 5
}
public Type levelType;
public Color levelColor; // 该等级的 UI 显示颜色
public float increaseMultiplier; // 激情值获取乘数
[FormerlySerializedAs("decayMultiplier")]
public float timeDecreaseMultiplier; // 随时间自然衰减的乘数
public float reactiveDecreaseMultiplier; // 受击时瞬间衰减的乘数
public PassionLevel(Type levelType, Color levelColor, float increaseMultiplier, float timeDecreaseMultiplier, float reactiveDecreaseMultiplier)
{
this.levelType = levelType;
this.levelColor = levelColor;
this.increaseMultiplier = increaseMultiplier;
this.timeDecreaseMultiplier = timeDecreaseMultiplier;
this.reactiveDecreaseMultiplier = reactiveDecreaseMultiplier;
}
}
[Button("初始化段位配置")]
private void InitializeRankInfos()
{
if (passionLevels == null || passionLevels.Count == 0)
{
passionLevels = new List<PassionLevel>
{
new PassionLevel(PassionLevel.Type.C, new Color(0f, 0.8f, 1f, 1f), 1f, 1f, 1f),
new PassionLevel(PassionLevel.Type.B, new Color(0.2f, 0.6f, 1f, 1f), 0.95f, 1f, 1.25f),
new PassionLevel(PassionLevel.Type.A, new Color(0.4f, 0.4f, 1f, 1f), 0.9f, 1f, 1.5f),
new PassionLevel(PassionLevel.Type.S, new Color(0.8f, 0.2f, 0.8f, 1f), 0.85f, 1.5f, 2f),
new PassionLevel(PassionLevel.Type.SS, new Color(0.9f, 0.2f, 0.4f, 1f), 0.8f, 1.5f, 3f),
new PassionLevel(PassionLevel.Type.SSS, new Color(1f, 0f, 0f, 1f), 0.8f, 1.5f, 4f),
};
}
}
}
public partial class PassionSystem
{
// ==========================================
// 公共 API / Public API
// ==========================================
/// <summary>
/// 重置衰减计时器,延迟自然衰减的触发。
/// </summary>
public void ResetDecayTimer()
{
_timeSinceLastAction = 0f;
}
/// <summary>
/// 当攻击命中敌人时触发根据打断等级Breakthrough.Type增加激情值。
/// </summary>
/// <param name="breakthroughType">攻击的打断级别</param>
public void OnAttackHit(Breakthrough.Type breakthroughType)
{
float baseAmount = breakthroughType switch
{
Breakthrough.Type.None => 0f,
Breakthrough.Type.Weak => 1f,
Breakthrough.Type.Medium => 2f,
Breakthrough.Type.Heavy => 4f,
Breakthrough.Type.Disruption => 4f,
Breakthrough.Type.Forced => 4f,
_ => 0f
};
Debug.Log($"[PassionSystem] OnAttackHit 触发,打断类型: {breakthroughType},激情值基础增加量: {baseAmount}%");
IncreasePassion(baseAmount);
}
/// <summary>
/// 触发完美格挡时增加激情值。
/// </summary>
public void OnPerfectBlock()
{
IncreasePassion(20f);
}
/// <summary>
/// 触发完美闪避时增加激情值。
/// </summary>
public void OnPerfectDodge()
{
IncreasePassion(20f);
}
/// <summary>
/// 当主角受到伤害时触发根据敌人的攻击打断等级Breakthrough.Type降低激情值。
/// </summary>
/// <param name="breakthroughType">敌人攻击的打断级别</param>
public void OnGetHit(Breakthrough.Type breakthroughType)
{
float baseAmount = breakthroughType switch
{
Breakthrough.Type.None => 0f,
Breakthrough.Type.Weak => 1f,
Breakthrough.Type.Medium => 5f,
Breakthrough.Type.Heavy => 10f,
Breakthrough.Type.Disruption => 10f,
Breakthrough.Type.Forced => 10f,
_ => 1f
};
DecreasePassion(baseAmount);
}
/// <summary>
/// 增加激情值。自动处理等级提升与溢出(当百分比超过 100% 时自动进入下一等级)。
/// </summary>
/// <param name="amount">基础增加百分比量0-100 范围)。</param>
/// <param name="applyMultipliers">是否应用当前等级的获取乘数。</param>
public void IncreasePassion(float amount, bool applyMultipliers = true)
{
if (amount <= 0f) return;
// 重置衰减计时器以推迟自然衰减
ResetDecayTimer();
float finalAmount = amount;
if (applyMultipliers)
{
var player = MainGameManager.Player;
float amplifier = player.attributeSm[CharacterAttribute.PassionIncreaseAmplifier];
finalAmount *= passionLevels[levelIndex].increaseMultiplier * (1 + amplifier);
}
percent += finalAmount;
OnPassionIncreased?.Invoke(finalAmount, applyMultipliers);
int oldLevel = levelIndex;
while (percent >= 100f)
{
if (levelIndex >= passionLevels.Count - 1)
{
percent = 100f; // 钳制在最大等级SSS
break;
}
else
{
percent -= 100f;
levelIndex++;
}
}
if (oldLevel != levelIndex)
{
OnLevelChanged?.Invoke(oldLevel, levelIndex);
}
OnPercentChanged?.Invoke(percent);
}
/// <summary>
/// 减少激情值。自动处理等级降低与退级(当百分比低于 0% 时自动退回上一等级)。
/// </summary>
/// <param name="amount">基础减少百分比量0-100 范围)。</param>
/// <param name="applyMultipliers">是否应用当前等级的受击衰减乘数。</param>
/// <param name="isTimeDecay">是否为时间引起的自然衰减(如果为 true当跌至 0% 时会短暂保留等级而非直接退级)</param>
public void DecreasePassion(float amount, bool applyMultipliers = true, bool isTimeDecay = false)
{
if (amount <= 0f) return;
float finalAmount = amount;
if (applyMultipliers)
{
var player = MainGameManager.Player;
float amplifier = player.attributeSm[CharacterAttribute.PassionReactiveDecreaseAmplifier];
finalAmount *= passionLevels[levelIndex].reactiveDecreaseMultiplier * (1 + amplifier);
}
// 如果是时间自然衰减,且当前等级进度会从大于 0% 跌至 0% 以下:
// 我们将其钳制在 0% 并重置衰减计时器(暂停衰减一段时间),而不是立即降低等级。
// 注意当percent正好等于0时不会进入这个条件分支于是PassionLevel会正常退级。
// 这样设计是为了让玩家在自然衰减时有一个短暂的缓冲区,而不是直接退级,增加游戏的容错性和玩家体验。
if (isTimeDecay)
{
if (percent > 0f && percent - finalAmount <= 0f)
{
percent = 0f;
ResetDecayTimer();
OnPassionDecreased?.Invoke(finalAmount, applyMultipliers);
OnPercentChanged?.Invoke(percent);
return;
}
}
percent -= finalAmount;
OnPassionDecreased?.Invoke(finalAmount, applyMultipliers);
int oldLevel = levelIndex;
while (percent < 0f)
{
if (levelIndex <= 0)
{
percent = 0f; // 钳制在最低等级C
break;
}
else
{
percent += 100f;
levelIndex--;
}
}
if (oldLevel != levelIndex)
{
OnLevelChanged?.Invoke(oldLevel, levelIndex);
}
OnPercentChanged?.Invoke(percent);
}
}
#if UNITY_EDITOR
public partial class PassionSystem
{
// ==========================================
// 调试工具 / Debug Tools
// ==========================================
[Button("Debug: 增加激情值 100%")]
public void IncreasePassion()
{
IncreasePassion(100f, false);
}
[Button("Debug: 减少激情值 100%")]
public void DecreasePassion()
{
DecreasePassion(100f, false);
}
}
#endif
}

View File

@@ -0,0 +1,143 @@
using System;
using Cielonos.MainGame;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using DG.Tweening;
namespace Cielonos.MainGame.UI
{
public class PassionSystemUIArea : UIElementBase
{
[Header("UI References")]
public TMP_Text levelText;
public TMP_Text percentageText;
private PassionSystem _passionSystem;
// 缓存原始位置以解决高频动画重合重叠时的坐标偏移累积问题
private Vector3 _levelTextOrigPos;
private Vector3 _percentTextOrigPos;
private void Start()
{
// 获取单例系统,确保如果未找到直接抛出异常
_passionSystem = CombatManager.GetCombatSystem<PassionSystem>();
_passionSystem.OnLevelChanged += OnLevelChanged;
_passionSystem.OnPercentChanged += OnPercentChanged;
_passionSystem.OnPassionIncreased += OnPassionIncreased;
_passionSystem.OnPassionDecreased += OnPassionDecreased;
// 缓存初始相对坐标
_levelTextOrigPos = levelText.transform.localPosition;
_percentTextOrigPos = percentageText.transform.localPosition;
// 设定初始显示
UpdateDisplay(_passionSystem.levelIndex, _passionSystem.percent);
}
private void OnDestroy()
{
if (_passionSystem != null)
{
_passionSystem.OnLevelChanged -= OnLevelChanged;
_passionSystem.OnPercentChanged -= OnPercentChanged;
_passionSystem.OnPassionIncreased -= OnPassionIncreased;
_passionSystem.OnPassionDecreased -= OnPassionDecreased;
}
}
private void OnLevelChanged(int oldLevel, int newLevel)
{
UpdateDisplay(newLevel, _passionSystem.percent);
if (newLevel > oldLevel)
{
PlayLevelUpAnimation();
}
else if (newLevel < oldLevel)
{
PlayLevelDownAnimation();
}
}
private void OnPercentChanged(float percent)
{
UpdateDisplay(_passionSystem.levelIndex, percent);
}
private void OnPassionIncreased(float amount, bool applyMultipliers)
{
PlayPercentIncreaseAnimation();
}
private void OnPassionDecreased(float amount, bool applyMultipliers)
{
// 只有当受击等 reactive 负面反应触发减少时才播放受击抖动
if (applyMultipliers)
{
PlayPercentDecreaseAnimation();
}
}
private void UpdateDisplay(int currentLevel, float currentPercent)
{
var levelInfo = _passionSystem.passionLevels[currentLevel];
// 更新文本与颜色,无防御性 Null 校验以保证能抛出显式异常
levelText.text = levelInfo.levelType.ToString();
levelText.color = levelInfo.levelColor;
percentageText.text = $"{Mathf.FloorToInt(currentPercent)}%";
percentageText.color = levelInfo.levelColor;
}
// ==========================================
// DOTween 动画及重叠冲突重置逻辑
// ==========================================
private void PlayLevelUpAnimation()
{
// 杀死该 Transform 上的全部活动动画,并立即恢复初值状态,彻底解决高频触发下的形变累积问题
levelText.transform.DOKill();
levelText.transform.localScale = Vector3.one;
levelText.transform.localPosition = _levelTextOrigPos;
// 升级:弹性膨胀反馈,显式调用 .Play() 以确保播放
levelText.transform.DOPunchScale(new Vector3(0.5f, 0.5f, 0f), 0.45f, 10, 1f).Play();
}
private void PlayLevelDownAnimation()
{
levelText.transform.DOKill();
levelText.transform.localScale = Vector3.one;
levelText.transform.localPosition = _levelTextOrigPos;
// 降级:左右剧烈晃动,以及缩水回弹,显式调用 .Play()
levelText.transform.DOShakePosition(0.4f, new Vector3(10f, 0f, 0f), 20, 90f).Play();
levelText.transform.DOPunchScale(new Vector3(-0.25f, -0.25f, 0f), 0.35f, 5, 0.5f).Play();
}
private void PlayPercentIncreaseAnimation()
{
percentageText.transform.DOKill();
percentageText.transform.localScale = Vector3.one;
percentageText.transform.localPosition = _percentTextOrigPos;
// 增加百分比:轻微向上跳动和快速缩放弹跳,显式调用 .Play()
percentageText.transform.DOPunchScale(new Vector3(0.18f, 0.18f, 0f), 0.15f, 6, 0.5f).Play();
}
private void PlayPercentDecreaseAnimation()
{
percentageText.transform.DOKill();
percentageText.transform.localScale = Vector3.one;
percentageText.transform.localPosition = _percentTextOrigPos;
// 受击损失百分比:上下震颤摇晃,以及明显的凹陷缩水,显式调用 .Play()
percentageText.transform.DOShakePosition(0.35f, new Vector3(6f, 4f, 0f), 22).Play();
percentageText.transform.DOPunchScale(new Vector3(-0.2f, -0.2f, 0f), 0.25f, 5, 0.5f).Play();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b466a93d19466da4698bcafb1b8a0fc3

View File

@@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Cielonos.MainGame
{
public partial class RatingSystem : CombatSystemBase
{
public int rank;
public float percent;
public List<RankInfo> rankInfos;
private void Awake()
{
InitializeRankInfos();
}
}
public partial class RatingSystem
{
public struct RankInfo
{
public string rankName;
public float increaseMultiplier;
public float decreaseMultiplier;
public RankInfo(string rankName, float increaseMultiplier, float decreaseMultiplier)
{
this.rankName = rankName;
this.increaseMultiplier = increaseMultiplier;
this.decreaseMultiplier = decreaseMultiplier;
}
}
private void InitializeRankInfos()
{
rankInfos = new List<RankInfo>
{
new RankInfo("D", 1f, 1f),
new RankInfo("C", 0.95f, 1.25f),
new RankInfo("B", 0.9f, 1.5f),
new RankInfo("A", 0.85f, 1.75f),
new RankInfo("S", 0.8f, 2f),
new RankInfo("SS", 0.7f, 3f),
new RankInfo("SSS", 0.6f, 5f)
};
}
}
}

View File

@@ -5,7 +5,7 @@ using Sirenix.OdinInspector;
using SLSUtilities.General;
using SLSUtilities.UI;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Localization.Settings;
using UnityEngine.SceneManagement;
namespace Cielonos.MainGame
@@ -25,6 +25,7 @@ namespace Cielonos.MainGame
{
base.Awake();
sceneSm = new SceneSubmodule(this);
LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.GetLocale("zh-CN");
}
private void Start()
@@ -55,32 +56,8 @@ namespace Cielonos.MainGame
}
}
private void Update()
{
if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame)
{
// 优先:如果有 UI 页面打开ESC 关闭栈顶页面
if (UIPageManager.Instance != null && UIPageManager.Instance.HasOpenPages)
{
UIPageManager.Instance.CloseTopPage();
return;
}
#if !UNITY_EDITOR
// 无页面打开时的 fallback切换鼠标锁定状态仅 Build
if (Cursor.lockState == CursorLockMode.Locked)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
else
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
#endif
}
}
// ESC 统一由 PlayerInputSubcontroller.RegisterFunctionInputs() 处理,
// 不再在此处重复监听,避免双重触发。
private void OnDestroy()
{

View File

@@ -53,5 +53,11 @@ namespace Cielonos.Narrative
startInventory.consumables.Add(itemSaveData);
}
}
[YarnFunction("check_have_item")]
public static bool CheckHaveItem(string itemClass)
{
return MainGameManager.Player.inventorySc.backpackSm.HaveItem(itemClass);
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8721503fdcd8f3b49aa18debeee1787a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -17,7 +17,7 @@ namespace Cielonos.MainGame.Narrative
private void OnTriggerEnter(Collider other)
{
// 如果是一次性触发器且已经激活过,直接忽略
if (oneShot && hasFired)
if (once && hasTriggered)
{
return;
}
@@ -26,7 +26,7 @@ namespace Cielonos.MainGame.Narrative
if (other.CompareTag(playerTag))
{
Debug.Log($"[AreaNarrativeTrigger] 玩家进入区域 '{gameObject.name}',激活剧情故事 ID: '{storyId}'");
Fire();
Trigger();
}
}
}

View File

@@ -4,10 +4,10 @@ using UnityEngine;
namespace Cielonos.MainGame.Narrative
{
/// <summary>
/// 自动启动剧情触发器。
/// 自动计时器剧情触发器。
/// 当场景加载完成并且该脚本执行 Start 生命周期时,自动启动指定剧情(常用于关卡旁白、开场白或开局引导)。
/// </summary>
public class StartNarrativeTrigger : NarrativeTrigger
public class TimerNarrativeTrigger : NarrativeTrigger
{
[Header("Start Trigger Settings")]
[SerializeField]
@@ -29,13 +29,13 @@ namespace Cielonos.MainGame.Narrative
private void ExecuteStartTrigger()
{
// 一次性触发器安全保护
if (oneShot && hasFired)
if (once && hasTriggered)
{
return;
}
Debug.Log($"[StartNarrativeTrigger] 场景启动自动触发剧情故事 ID: '{storyId}',延迟: {delaySeconds} 秒");
Fire();
Trigger();
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 05cbb92ceab464d40add2eb0539ccd57
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,26 @@
using SLSUtilities.UI;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Cielonos.UI
{
public class CieButton : UIElementBase
{
public Button button;
private void Start()
{
button ??= GetComponent<Button>();
var trigger = button.gameObject.AddComponent<EventTrigger>();
//悬停和按下时播放声音
var pointerEnter = new EventTrigger.Entry { eventID = EventTriggerType.PointerEnter };
pointerEnter.callback.AddListener(_ => AudioManager.Post(AK.EVENTS.UI_HOVER, button.gameObject));
trigger.triggers.Add(pointerEnter);
var pointerDown = new EventTrigger.Entry { eventID = EventTriggerType.PointerDown };
pointerDown.callback.AddListener(_ => AudioManager.Post(AK.EVENTS.UI_CLICK, button.gameObject));
trigger.triggers.Add(pointerDown);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7979f5a4eaa18c24997479b9744fe8c7

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a03a9ea48aa335d4296ffed48fec6213
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 确认页面的单个按钮配置。
/// </summary>
public class ConfirmButtonConfig
{
/// <summary>按钮显示文本(传入时应已完成本地化)。</summary>
public string Label { get; }
/// <summary>
/// 点击回调。为 null 表示仅关闭确认页面,不执行额外操作。
/// 回调在页面关闭动画结束后触发。
/// </summary>
public Action OnClick { get; }
public ConfirmButtonConfig(string label, Action onClick = null)
{
Label = label;
OnClick = onClick;
}
}
/// <summary>
/// 确认页面的完整配置数据,用于动态创建 <see cref="ConfirmUIPage"/>。
/// </summary>
public class ConfirmPageConfig
{
/// <summary>标题文本。</summary>
public string Title { get; set; } = string.Empty;
/// <summary>描述文本。</summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 按钮列表,按从左到右的顺序排列。
/// 至少需要一个按钮。
/// </summary>
public List<ConfirmButtonConfig> Buttons { get; set; } = new();
/// <summary>是否允许按 ESC 关闭(等同于取消),默认 true。</summary>
public bool AllowEscClose { get; set; } = true;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d24d0a11550bba44b9c9dbff81a0d9eb

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using DG.Tweening;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 通用确认 / 信息提示页面。
/// <para>
/// 作为动态 Prefab 实例化,使用完毕后自动销毁。
/// 通过 <see cref="Show"/> 静态方法创建并显示。
/// </para>
/// <para>
/// 支持任意数量的按钮:
/// - 两个按钮(确认 / 取消)用于需要用户确认的场景。
/// - 单个按钮(确定)用于信息提示场景。
/// </para>
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class ConfirmUIPage : UIPageBase
{
// ──────────────────── 常量 ────────────────────
private const float FadeInDuration = 0.2f;
private const float FadeOutDuration = 0.15f;
// ──────────────────── 序列化引用 ────────────────────
[Header("Content")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text descriptionText;
[Header("Buttons")]
[SerializeField] private Transform buttonContainer;
[SerializeField] private Button buttonPrefab;
// ──────────────────── 运行时状态 ────────────────────
private readonly List<Button> spawnedButtons = new();
private Action pendingCallback;
private bool allowEscClose = true;
/// <inheritdoc/>
public override bool CloseOnEsc => allowEscClose;
protected override void Start()
{
}
// ──────────────────── 静态入口 ────────────────────
/// <summary>
/// 创建并显示一个确认页面实例。
/// </summary>
/// <param name="config">页面配置,包含标题、描述和按钮。</param>
/// <returns>已创建的页面实例。</returns>
public static ConfirmUIPage Show(ConfirmPageConfig config)
{
if (config == null)
throw new ArgumentNullException(nameof(config));
var manager = UIPageManager.Instance;
var go = manager.InstantiateDynamicPage(manager.ConfirmPagePrefab);
var page = go.GetComponent<ConfirmUIPage>();
if (page == null)
{
Debug.LogError("[ConfirmUIPage] Prefab is missing ConfirmUIPage component.");
Destroy(go);
return null;
}
page.Initialize(config);
page.Open();
return page;
}
// ──────────────────── 初始化 ────────────────────
/// <summary>
/// 根据配置初始化页面内容和按钮。
/// </summary>
private void Initialize(ConfirmPageConfig config)
{
allowEscClose = config.AllowEscClose;
if (titleText != null)
titleText.text = config.Title;
if (descriptionText != null)
descriptionText.text = config.Description;
CreateButtons(config.Buttons);
}
private void CreateButtons(List<ConfirmButtonConfig> buttonConfigs)
{
if (buttonPrefab == null)
{
Debug.LogError("[ConfirmUIPage] buttonPrefab is not assigned.");
return;
}
// 隐藏模板按钮
buttonPrefab.gameObject.SetActive(false);
foreach (var btnConfig in buttonConfigs)
{
var button = Instantiate(buttonPrefab, buttonContainer);
button.gameObject.SetActive(true);
var label = button.GetComponentInChildren<TMP_Text>();
if (label != null)
label.text = btnConfig.Label;
var callback = btnConfig.OnClick;
button.onClick.AddListener(() => OnButtonClicked(callback));
spawnedButtons.Add(button);
}
}
// ──────────────────── 生命周期 ────────────────────
public override void Open()
{
if (IsOpen) return;
IsOpen = true;
gameObject.SetActive(true);
canvasGroup.alpha = 0f;
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
UIPageManager.Instance.RegisterPage(this);
canvasGroup.DOFade(1f, FadeInDuration)
.SetUpdate(true)
.OnComplete(() =>
{
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
OnPageOpened();
}).Play();
}
public override void Close()
{
if (!IsOpen) return;
IsOpen = false;
UIPageManager.Instance.UnregisterPage(this);
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
canvasGroup.DOFade(0f, FadeOutDuration)
.SetUpdate(true)
.OnComplete(() =>
{
OnPageClosed();
Destroy(gameObject);
}).Play();
}
protected override void OnPageClosed()
{
// 先触发基类事件
base.OnPageClosed();
// 执行挂起的按钮回调
var callback = pendingCallback;
pendingCallback = null;
callback?.Invoke();
// 清理按钮监听
foreach (var btn in spawnedButtons)
{
if (btn != null)
btn.onClick.RemoveAllListeners();
}
spawnedButtons.Clear();
}
// ──────────────────── 按钮处理 ────────────────────
private void OnButtonClicked(Action callback)
{
if (!IsOpen) return;
pendingCallback = callback;
Close();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 56cd460c1559ee647aebac48a61fbc28

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f006c806bdd62b04a965e96c18c13637
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,81 @@
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.UI
{
/// <summary>
/// 交互选项列表中单个条目的 UI 组件。
/// 由 InteractionUIPage 生成并驱动。
/// 支持三种视觉状态:普通、高亮(选中)、不可用(灰色)。
/// </summary>
public class InteractionChoiceItem : UIElementBase
{
[SerializeField] private TMP_Text labelText;
[Tooltip("普通状态下的背景(可选)。")]
[SerializeField] private Image backgroundImage;
[Tooltip("高亮指示器,例如左侧箭头图标(当选中时显示)。")]
[SerializeField] private GameObject highlightIndicator;
[Header("颜色配置")]
[SerializeField] private Color normalColor = Color.white;
[SerializeField] private Color highlightedColor = new Color(0.4f, 1f, 1f); // 青色
[SerializeField] private Color disabledColor = new Color(0.45f, 0.45f, 0.45f); // 灰色
private bool _isInteractable = true;
// ====================================================================
// 对外接口
// ====================================================================
/// <summary>
/// 初始化条目内容。由 InteractionUIPage 在 Show() 时调用。
/// </summary>
/// <param name="choiceName">选项名称。</param>
/// <param name="isInteractable">false 时以灰色显示且不可高亮。</param>
public void Setup(string choiceName, bool isInteractable)
{
_isInteractable = isInteractable;
if (labelText != null)
{
labelText.text = choiceName;
labelText.color = isInteractable ? normalColor : disabledColor;
}
if (highlightIndicator != null)
{
highlightIndicator.SetActive(false);
}
}
/// <summary>
/// 设置高亮状态。由 InteractionUIPage.UpdateHighlight() 调用。
/// 不可交互的选项不会显示高亮。
/// </summary>
public void SetHighlighted(bool highlighted)
{
bool showHighlight = highlighted && _isInteractable;
if (highlightIndicator != null)
{
highlightIndicator.SetActive(showHighlight);
}
if (labelText != null)
{
if (!_isInteractable)
{
labelText.color = disabledColor;
}
else
{
labelText.color = showHighlight ? highlightedColor : normalColor;
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 664206634872acf4ea30f2cc8be87424

View File

@@ -0,0 +1,130 @@
using System.Collections.Generic;
using Cielonos.Core.Interaction;
using DG.Tweening;
using Sirenix.OdinInspector;
using SLSUtilities.UI;
using UnityEngine;
namespace Cielonos.UI
{
/// <summary>
/// 交互选项列表 UI 页面。
/// 玩家进入可交互物体范围时由 PlayerInteractionSubcontroller 调用 Show(),离开时调用 Hide()。
/// 当选项数量为 1 时,直接显示单个选项而不需要导航;
/// 当选项数量大于 1 时,玩家可用鼠标滚轮或上/下键切换高亮选项R 键执行。
/// </summary>
public class InteractionUIArea : UIElementBase
{
[Title("容器")]
[Tooltip("单个选项条目的预制体,需包含 InteractionChoiceItem 组件。")]
[SerializeField] private GameObject choiceItemPrefab;
[Tooltip("选项条目的父节点Layout Group。")]
[SerializeField] private RectTransform choiceContainer;
private readonly List<InteractionChoiceItem> _items = new List<InteractionChoiceItem>();
private void Start()
{
Hide();
}
// ====================================================================
// 对外接口
// ====================================================================
/// <summary>
/// 显示选项列表并高亮指定索引的选项。
/// 由 PlayerInteractionSubcontroller.SetCurrentInteractable() 调用。
/// </summary>
public void Show(List<InteractionChoice> choices, int selectedIndex)
{
gameObject.SetActive(true);
canvasGroup.DOFade(1f, 0.25f).From(0).OnComplete(() =>
{
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
}).Play();
rectTransform.DOLocalMoveY(-50f, 0.25f).From(-250f).Play();
ClearItems();
if (choices == null || choices.Count == 0)
{
gameObject.SetActive(false);
return;
}
foreach (InteractionChoice choice in choices)
{
InteractionChoiceItem item = CreateItem();
if (item != null)
{
item.Setup(choice.choiceName, choice.isInteractable);
}
}
UpdateHighlight(selectedIndex);
}
/// <summary>
/// 更新高亮选项。由 PlayerInteractionSubcontroller.NavigateChoice() 调用。
/// </summary>
public void UpdateHighlight(int selectedIndex)
{
for (int i = 0; i < _items.Count; i++)
{
_items[i].SetHighlighted(i == selectedIndex);
}
}
/// <summary>
/// 隐藏选项列表。由 PlayerInteractionSubcontroller.RemoveCurrentInteractable() 调用。
/// </summary>
public override void Hide()
{
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
canvasGroup.DOFade(0f, 0.25f).OnComplete(() =>
{
ClearItems();
gameObject.SetActive(false);
}).Play();
rectTransform.DOLocalMoveY(-250f, 0.25f).Play();
}
// ====================================================================
// 内部工具
// ====================================================================
private InteractionChoiceItem CreateItem()
{
if (choiceItemPrefab == null || choiceContainer == null)
{
Debug.LogError("[InteractionUIPage] choiceItemPrefab 或 choiceContainer 未在 Inspector 中配置。");
return null;
}
GameObject go = Instantiate(choiceItemPrefab, choiceContainer);
InteractionChoiceItem item = go.GetComponent<InteractionChoiceItem>();
if (item == null)
{
Debug.LogError("[InteractionUIPage] choiceItemPrefab 缺少 InteractionChoiceItem 组件。");
Destroy(go);
return null;
}
_items.Add(item);
return item;
}
private void ClearItems()
{
foreach (InteractionChoiceItem item in _items)
{
if (item != null) Destroy(item.gameObject);
}
_items.Clear();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c8943c86810b074d955a3585bc5e71e

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3b5632578b757434d86d7140c9f6a154
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,462 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Cielonos.MainGame;
using Cielonos.Settings;
using Cielonos.Settings.UI;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Cielonos.UI
{
/// <summary>
/// 设置界面页面(单页滚动式 + 右侧面板)。
/// <para>
/// <b>左侧:</b>所有设置分类在同一页面中按顺序展示,每个分类前插入标题。
/// 通过反射读取各设置类的公共字段,按类型自动选择 Entry Prefab 实例化。
/// 同时支持手动添加的按钮条目(如"Key Bindings"按钮)。
/// </para>
/// <para>
/// <b>右侧:</b>包含两种面板状态:
/// <list type="number">
/// <item>说明面板(<see cref="descriptionPanel"/>):鼠标悬停条目时显示说明文本,离开后隐藏。</item>
/// <item>详情页面(如 <see cref="keyBindingPage"/>):点击按钮后打开的二级 UIPage
/// 打开期间悬停说明被抑制,需手动关闭。</item>
/// </list>
/// </para>
/// </summary>
public class SettingsUIPage : UIPageBase
{
// ──────────────────── Entry Prefabs ──────────────────
[Header("Entry Prefabs")]
[Tooltip("bool 字段使用的 Toggle 条目预制体,需挂载 SettingsEntryToggle。")]
[SerializeField] private GameObject toggleEntryPrefab;
[Tooltip("带 [Range] int 使 Slider SettingsEntrySlider")]
[SerializeField] private GameObject sliderEntryPrefab;
[Tooltip("enum 字段使用的 Dropdown 条目预制体,需挂载 SettingsEntryDropdown。")]
[SerializeField] private GameObject dropdownEntryPrefab;
[Tooltip("按钮条目预制体,需挂载 SettingsEntryButton。")]
[SerializeField] private GameObject buttonEntryPrefab;
// ──────────────────── Section Header ──────────────────
[Header("Section Header")]
[Tooltip("分类标题预制体,需包含 TMP_Text 组件。")]
[SerializeField] private GameObject sectionHeaderPrefab;
// ──────────────────── Left Content ────────────────────
[Header("Left Content")]
[Tooltip("条目的父容器,应挂载 VerticalLayoutGroup 和 ContentSizeFitter。")]
[SerializeField] private RectTransform contentContainer;
// ──────────────────── Right Panel ─────────────────────
[Header("Right Panel - Description")]
[Tooltip("右侧悬停说明面板,显示条目的标题和说明文本。")]
[SerializeField] private SettingsDescriptionPanel descriptionPanel;
[Header("Right Panel - Detail Pages")]
[Tooltip("键位绑定二级页面。")]
[SerializeField] private KeyBindingPage keyBindingPage;
// ──────────────────── Input ───────────────────────────
[Header("Input")]
[Tooltip("用于键位绑定页面的 InputActionAsset。为空时从 Player 获取。")]
[SerializeField] private InputActionAsset inputActionAsset;
// ──────────────────── 底部按钮 ────────────────────
[Header("Bottom Buttons")]
[SerializeField] private Button resetButton;
[SerializeField] private Button backButton;
// ──────────────────── 运行时状态 ──────────────────
private readonly List<SettingsEntryBase> activeFieldEntries = new();
private readonly List<SettingsEntryButton> activeButtonEntries = new();
private readonly List<GameObject> spawnedObjects = new();
/// <summary>是否有详情页面处于打开状态(抑制悬停说明)。</summary>
private bool isDetailPageOpen;
// ──────────────────── 分类定义 ────────────────────
/// <summary>
/// 分类定义显示名称、设置实例、Apply/Reset 回调、附加按钮列表。
/// </summary>
private struct SectionDefinition
{
public string displayName;
public object settingsInstance;
public Action applyAction;
public Action resetAction;
public List<ButtonDefinition> buttons;
}
/// <summary>
/// 手动添加的按钮条目定义。
/// </summary>
private struct ButtonDefinition
{
public string label;
public string description;
public Action onClick;
}
// ──────────────────── 生命周期 ────────────────────
protected override void Start()
{
base.Start();
resetButton?.onClick.AddListener(OnResetClicked);
backButton?.onClick.AddListener(OnBackClicked);
// 监听详情页面的开关状态
if (keyBindingPage != null)
{
keyBindingPage.PageOpened += OnDetailPageOpened;
keyBindingPage.PageClosed += OnDetailPageClosed;
}
}
protected override void OnPageOpened()
{
BuildAllSections();
}
protected override void OnPageClosed()
{
// 先关闭可能打开的详情页面
if (isDetailPageOpen && keyBindingPage != null && keyBindingPage.IsOpen)
keyBindingPage.Close();
ClearAll();
descriptionPanel?.Hide();
GameSettingsManager.Instance?.Save();
}
// ──────────────────── 详情页面状态 ────────────────
private void OnDetailPageOpened()
{
isDetailPageOpen = true;
descriptionPanel?.Hide();
}
private void OnDetailPageClosed()
{
isDetailPageOpen = false;
}
// ──────────────────── 悬停回调 ────────────────────
private void HandleHoverEnter(string title, string description)
{
if (isDetailPageOpen) return;
descriptionPanel?.Show(title, description);
}
private void HandleHoverExit()
{
if (isDetailPageOpen) return;
descriptionPanel?.Hide();
}
// ──────────────────── 分类定义构建 ────────────────
/// <summary>
/// 构建所有分类定义。扩展时在此处追加。
/// </summary>
private List<SectionDefinition> BuildSectionDefinitions()
{
var manager = GameSettingsManager.Instance;
if (manager == null) return null;
return new List<SectionDefinition>
{
new()
{
displayName = "Gameplay",
settingsInstance = manager.gameplay,
applyAction = manager.ApplyGameplay,
resetAction = manager.ResetGameplayToDefault
},
new()
{
displayName = "Graphics",
settingsInstance = manager.graphics,
applyAction = manager.ApplyGraphics,
resetAction = manager.ResetGraphicsToDefault
},
new()
{
displayName = "Sound",
settingsInstance = manager.sound,
applyAction = manager.ApplySound,
resetAction = manager.ResetSoundToDefault
},
new()
{
displayName = "Controls",
settingsInstance = manager.controls,
applyAction = manager.ApplyControls,
resetAction = manager.ResetControlsToDefault,
buttons = new List<ButtonDefinition>
{
new()
{
label = "Key Bindings",
description = "Customize keyboard and mouse bindings for all game actions.",
onClick = OpenKeyBindingPage
}
}
}
};
}
// ──────────────────── 条目生成 ────────────────────
/// <summary>
/// 构建所有分类的标题、字段条目和按钮条目。
/// </summary>
private void BuildAllSections()
{
ClearAll();
var sections = BuildSectionDefinitions();
if (sections == null)
{
Debug.LogError("[SettingsUIPage] GameSettingsManager not found.");
return;
}
foreach (var section in sections)
{
BuildSection(section);
}
}
/// <summary>
/// 构建单个分类的标题、字段条目和按钮条目。
/// </summary>
private void BuildSection(SectionDefinition section)
{
FieldInfo[] fields = section.settingsInstance.GetType()
.GetFields(BindingFlags.Public | BindingFlags.Instance);
// 预扫描:确认该分类至少有一个可显示的字段或按钮
bool hasVisibleContent = section.buttons is { Count: > 0 };
if (!hasVisibleContent)
{
foreach (FieldInfo field in fields)
{
if (field.GetCustomAttribute<SettingsIgnoreAttribute>() == null
&& ResolvePrefab(field) != null)
{
hasVisibleContent = true;
break;
}
}
}
if (!hasVisibleContent) return;
// 插入分类标题
SpawnSectionHeader(section.displayName);
// 生成字段条目
Action applyAction = section.applyAction;
foreach (FieldInfo field in fields)
{
if (field.GetCustomAttribute<SettingsIgnoreAttribute>() != null)
continue;
GameObject prefab = ResolvePrefab(field);
if (prefab == null)
{
Debug.LogWarning(
$"[SettingsUIPage] No matching prefab for field '{field.Name}' " +
$"(type: {field.FieldType.Name}). Skipping.");
continue;
}
GameObject entryObj = Instantiate(prefab, contentContainer);
var entry = entryObj.GetComponent<SettingsEntryBase>();
if (entry == null)
{
Debug.LogError(
$"[SettingsUIPage] Prefab for field '{field.Name}' " +
"is missing a SettingsEntryBase component.");
Destroy(entryObj);
continue;
}
entry.Initialize(section.settingsInstance, field, () => applyAction?.Invoke());
RegisterFieldEntryHover(entry);
activeFieldEntries.Add(entry);
spawnedObjects.Add(entryObj);
}
// 生成按钮条目
if (section.buttons != null)
{
foreach (var btnDef in section.buttons)
{
SpawnButtonEntry(btnDef);
}
}
}
/// <summary>
/// 为字段条目注册悬停回调。
/// </summary>
private void RegisterFieldEntryHover(SettingsEntryBase entry)
{
entry.OnHoverEnter = HandleHoverEnter;
entry.OnHoverExit = HandleHoverExit;
}
/// <summary>
/// 实例化按钮条目。
/// </summary>
private void SpawnButtonEntry(ButtonDefinition def)
{
if (buttonEntryPrefab == null)
{
Debug.LogWarning("[SettingsUIPage] buttonEntryPrefab is not assigned.");
return;
}
GameObject entryObj = Instantiate(buttonEntryPrefab, contentContainer);
var buttonEntry = entryObj.GetComponent<SettingsEntryButton>();
if (buttonEntry == null)
{
Debug.LogError("[SettingsUIPage] buttonEntryPrefab is missing SettingsEntryButton.");
Destroy(entryObj);
return;
}
buttonEntry.Initialize(def.label, def.description, def.onClick);
buttonEntry.OnHoverEnter = HandleHoverEnter;
buttonEntry.OnHoverExit = HandleHoverExit;
activeButtonEntries.Add(buttonEntry);
spawnedObjects.Add(entryObj);
}
/// <summary>
/// 实例化分类标题预制体。
/// </summary>
private void SpawnSectionHeader(string title)
{
if (sectionHeaderPrefab == null)
{
Debug.LogWarning("[SettingsUIPage] sectionHeaderPrefab is not assigned.");
return;
}
GameObject headerObj = Instantiate(sectionHeaderPrefab, contentContainer);
var headerText = headerObj.GetComponentInChildren<TMP_Text>();
if (headerText != null)
headerText.text = title;
spawnedObjects.Add(headerObj);
}
/// <summary>
/// 根据字段类型选择对应的预制体。
/// </summary>
private GameObject ResolvePrefab(FieldInfo field)
{
Type fieldType = field.FieldType;
if (fieldType == typeof(bool))
return toggleEntryPrefab;
if (fieldType == typeof(int))
return sliderEntryPrefab;
if (fieldType.IsEnum)
return dropdownEntryPrefab;
return null;
}
// ──────────────────── 详情页面入口 ────────────────
private void OpenKeyBindingPage()
{
if (keyBindingPage == null)
{
Debug.LogWarning("[SettingsUIPage] keyBindingPage is not assigned.");
return;
}
// 优先使用配置的 InputActionAsset否则尝试从 Player 获取
InputActionAsset asset = inputActionAsset;
if (asset == null)
{
var player = MainGameManager.Player;
if (player != null)
asset = player.inputSc.inputActions.asset;
}
if (asset != null)
{
keyBindingPage.Open(asset);
}
else
{
Debug.LogError("[SettingsUIPage] No InputActionAsset available for KeyBindingPage.");
}
}
// ──────────────────── 底部按钮回调 ────────────────
private void OnResetClicked()
{
GameSettingsManager.Instance?.ResetAllToDefault();
BuildAllSections();
}
private void OnBackClicked()
{
Close();
}
// ──────────────────── 清理 ────────────────────────
private void ClearAll()
{
foreach (var obj in spawnedObjects)
{
if (obj != null)
Destroy(obj);
}
activeFieldEntries.Clear();
activeButtonEntries.Clear();
spawnedObjects.Clear();
}
private void OnDestroy()
{
if (keyBindingPage != null)
{
keyBindingPage.PageOpened -= OnDetailPageOpened;
keyBindingPage.PageClosed -= OnDetailPageClosed;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 06a515f761ecf184399f3506f8cbf989

View File

@@ -0,0 +1,142 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Cielonos.MainGame.Characters;
using UnityEngine;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 通用展示文本解析器。
/// 负责将文本中的 {key} 命名占位符替换为运行时动态值,
/// 并提供各类游戏数据的展示值计算方法。
/// </summary>
public static class DisplayTextResolver
{
private static readonly Regex PlaceholderPattern = new Regex(@"\{(\w+)\}", RegexOptions.Compiled);
// ================================================================
// 核心:占位符替换
// ================================================================
/// <summary>
/// 将文本中的 {key} 占位符替换为 args 字典中对应的值。
/// 未匹配到的占位符保留原样。
/// </summary>
public static string Resolve(string text, Dictionary<string, string> args)
{
if (string.IsNullOrEmpty(text) || args == null || args.Count == 0)
return text;
return PlaceholderPattern.Replace(text, match =>
{
string key = match.Groups[1].Value;
return args.TryGetValue(key, out string value) ? value : match.Value;
});
}
// ================================================================
// 伤害相关
// ================================================================
/// <summary>
/// 计算 AttackUnit 的面板展示伤害值。
/// 仅包含攻击者侧倍率,不含随机偏差和受击者侧减伤。
/// </summary>
public static float ComputeDisplayDamage(AttackUnit unit, CharacterBase attacker)
{
if (unit == null || unit.isInvalidAttack) return 0f;
float damage = unit.startDamage;
if (attacker == null) return damage;
damage *= attacker.attributeSm[CharacterAttribute.AttackDamageMultiplier];
string typeKey = unit.type.AttackTypeToString() + "DamageDealtMultiplier";
damage *= attacker.attributeSm[typeKey];
damage *= attacker.attributeSm[CharacterAttribute.FinalDamageDealtMultiplier];
return damage;
}
/// <summary>
/// 计算 AttackUnit 的面板展示暴击率(含玩家属性加成),结果范围 [0, 1]。
/// </summary>
public static float ComputeDisplayCriticalChance(AttackUnit unit, CharacterBase attacker)
{
if (unit == null || unit.isInvalidAttack) return 0f;
float chance = unit.criticalChance;
if (attacker != null)
chance += attacker.attributeSm[CharacterAttribute.CriticalAttackProbability];
return Mathf.Clamp01(chance);
}
/// <summary>
/// 计算 AttackUnit 的面板展示暴击伤害倍率(含玩家属性加成)。
/// </summary>
public static float ComputeDisplayCriticalMultiplier(AttackUnit unit, CharacterBase attacker)
{
if (unit == null || unit.isInvalidAttack) return 0f;
float multiplier = unit.criticalMultiplier;
if (attacker != null)
multiplier += attacker.attributeSm[CharacterAttribute.CriticalAttackDamageAmplifier];
return multiplier;
}
/// <summary>
/// 批量填充指定 AttackUnit 的伤害、暴击率、暴击倍率到 args 字典中。
/// 生成的 key 格式:{prefix}_damage, {prefix}_crit_chance, {prefix}_crit_multiplier。
/// </summary>
public static void PopulateAttackArgs(
Dictionary<string, string> args, string prefix,
AttackUnit unit, CharacterBase attacker)
{
if (unit == null || unit.isInvalidAttack) return;
args[$"{prefix}_damage"] = FormatInt(ComputeDisplayDamage(unit, attacker));
args[$"{prefix}_crit_chance"] = FormatPercent(ComputeDisplayCriticalChance(unit, attacker));
args[$"{prefix}_crit_multiplier"] = FormatFloat(ComputeDisplayCriticalMultiplier(unit, attacker));
}
// ================================================================
// 资源花费相关
// ================================================================
/// <summary>
/// 计算经过能量花费减少属性调整后的展示能量花费。
/// </summary>
public static float ComputeDisplayEnergyCost(float baseCost, CharacterBase character)
{
if (character == null) return baseCost;
float reduction = character.attributeSm[CharacterAttribute.EnergyCostReduction];
return Mathf.Max(0f, baseCost * (1f - reduction));
}
// ================================================================
// 格式化工具
// ================================================================
/// <summary>
/// 将浮点数格式化为整数字符串(四舍五入)。
/// </summary>
public static string FormatInt(float value)
{
return Mathf.RoundToInt(value).ToString();
}
/// <summary>
/// 将浮点数格式化为百分比字符串(如 0.15 → "15%")。
/// </summary>
public static string FormatPercent(float value)
{
return $"{Mathf.RoundToInt(value * 100f)}%";
}
/// <summary>
/// 将浮点数格式化为保留指定小数位的字符串。
/// </summary>
public static string FormatFloat(float value, int decimals = 1)
{
return value.ToString($"F{decimals}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02361f67e62f0064cbeffcf2ccc79c23

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b651e1fc3b3065b40ae2c20a253de26b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,58 @@
using System.Collections.Generic;
using Cielonos.MainGame.Inventory;
using SLSUtilities.General;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 单条物品描述条目 UI 组件。
/// 渲染管线Localize → DisplayTextResolver.Resolve → InputGlyphParser.Parse → TMP_Text。
/// </summary>
public class ItemDescriptionEntry : MonoBehaviour
{
private const string LocalizationTable = "Items";
[Tooltip("描述文本,显示 descriptionKey 的本地化内容(含动态数值替换和按键图标解析)。")]
public TMP_Text descriptionText;
/// <summary>
/// 使用 <see cref="ItemDescription"/> 的数据填充描述条目。
/// 完整渲染管线:本地化 → {key} 占位符替换 → [Token] 按键图标解析。
/// </summary>
/// <param name="description">描述数据。</param>
/// <param name="descriptionArgs">可选的动态值字典,用于替换本地化文本中的 {key} 占位符。</param>
public void SetDescription(ItemDescription description, Dictionary<string, string> descriptionArgs = null)
{
if (description == null)
{
Clear();
return;
}
if (descriptionText == null) return;
if (string.IsNullOrEmpty(description.descriptionKey))
{
descriptionText.text = string.Empty;
return;
}
string localizedText = description.descriptionKey.Localize(LocalizationTable);
localizedText = DisplayTextResolver.Resolve(localizedText, descriptionArgs);
descriptionText.text = InputGlyphParser.Parse(localizedText);
}
/// <summary>清空条目内容。</summary>
public void Clear()
{
if (descriptionText != null)
{
descriptionText.text = string.Empty;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4bcc59ecfb9425b4a85a1e3ad51c8739

View File

@@ -0,0 +1,177 @@
using System.Collections.Generic;
using Cielonos.MainGame.Inventory;
using Cielonos.MainGame.UI;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.Localization.Settings;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace SLSUtilities.UI
{
/// <summary>
/// 通用物品详情面板,显示物品图标、名称、类型、描述条目、标签等信息。
/// 可被不同的 UIPage 复用(机械台、商店、背包检视等)。
/// </summary>
public class ItemDetailPanel : UIElementBase
{
[Title("Detail Panel References")]
public TMP_Text selectionHintText;
//public Image itemIcon;
public TMP_Text nameText;
public TMP_Text typeRarityText;
public TMP_Text institutionText;
public RectTransform tagContainer;
public GameObject tagPrefab;
// ─────────────────── 描述条目 ───────────────────
[Title("Description Entries")]
[Tooltip("描述条目的容器,应挂载 VerticalLayoutGroup 以实现垂直排列。")]
public RectTransform descriptionContainer;
[Tooltip("描述条目预制体,需挂载 ItemDescriptionEntry 组件。")]
public GameObject descriptionEntryPrefab;
// ─────────────────── 运行时数据 ───────────────────
private ItemBase currentItem;
private readonly List<ItemDescriptionEntry> activeEntries = new List<ItemDescriptionEntry>();
/// <summary>当前显示的物品。</summary>
public ItemBase CurrentItem => currentItem;
/// <summary>
/// 使用指定物品的数据填充详情面板并显示。
/// </summary>
public void SetItem(ItemBase item)
{
currentItem = item;
if (item == null || item.contentData == null)
{
ClearPanel();
return;
}
ContentData data = item.contentData;
// 图标
/*Sprite icon = data.itemIcon;
if (itemIcon != null)
{
itemIcon.sprite = icon;
itemIcon.enabled = icon != null;
}*/
// 名称(本地化)
nameText.text = data.displayNameKey.Localize("Items");
// 类型
typeRarityText.text = data.itemType.ToString() + " - " + data.itemRarity.ToString();
//机构
institutionText.text = string.Empty;
for (var index = 0; index < data.institutions.Count; index++)
{
var institution = data.institutions[index];
string comma = LocalizationSettings.SelectedLocale.Identifier.Code.StartsWith("zh") ? "、" : ", ";
if(index < data.institutions.Count - 1)
institutionText.text += institution.Localize("Items") + comma;
else
institutionText.text += institution.Localize("Items");
}
// 描述条目
RefreshDescriptions(data);
// 标签
RefreshTags(data);
selectionHintText.gameObject.SetActive(false);
}
/// <summary>清空面板内容并隐藏。</summary>
public void ClearPanel()
{
currentItem = null;
//if (itemIcon != null) itemIcon.enabled = false;
if (nameText != null) nameText.text = string.Empty;
if (typeRarityText != null) typeRarityText.text = string.Empty;
ClearDescriptions();
ClearTags();
selectionHintText.gameObject.SetActive(true);
}
// ================================================================
// 描述条目
// ================================================================
private void RefreshDescriptions(ContentData data)
{
ClearDescriptions();
if (descriptionContainer == null || descriptionEntryPrefab == null) return;
if (data.descriptions == null || data.descriptions.Count == 0) return;
Dictionary<string, string> descriptionArgs = currentItem?.GetDescriptionArgs();
foreach (ItemDescription desc in data.descriptions)
{
ItemDescriptionEntry entry = Instantiate(descriptionEntryPrefab, descriptionContainer).GetComponent<ItemDescriptionEntry>();
entry.SetDescription(desc, descriptionArgs);
activeEntries.Add(entry);
}
LayoutRebuilder.ForceRebuildLayoutImmediate(descriptionContainer);
}
private void ClearDescriptions()
{
foreach (ItemDescriptionEntry entry in activeEntries)
{
if (entry != null)
{
Destroy(entry.gameObject);
}
}
activeEntries.Clear();
}
// ================================================================
// 标签
// ================================================================
private void RefreshTags(ContentData data)
{
ClearTags();
if (tagContainer == null || tagPrefab == null || data.tags == null) return;
foreach (string tag in data.tags)
{
GameObject tagObj = Instantiate(tagPrefab, tagContainer);
TMP_Text tagText = tagObj.GetComponentInChildren<TMP_Text>();
if (tagText != null)
{
tagText.text = tag;
}
}
}
private void ClearTags()
{
if (tagContainer == null) return;
for (int i = tagContainer.childCount - 1; i >= 0; i--)
{
Destroy(tagContainer.GetChild(i).gameObject);
}
}
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame.Inventory;
using Cielonos.MainGame.Items;
using SLSUtilities.General;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 单选类别筛选下拉框,基于 <see cref="TMP_Dropdown"/>。
/// 每个选项对应一组 <see cref="ItemType"/>,选中后构建对应的 <see cref="ItemFilter"/>。
/// 第一个选项通常配置为"全部"types 留空即可)。
/// <para>可复用于背包、商店等任何需要按类型筛选物品的 UI 页面。</para>
/// </summary>
public class ItemCategoryDropdown : UIElementBase
{
[Serializable]
public struct CategoryEntry
{
[Tooltip("显示标签,可填入本地化 Key如 'UI_Category_All'),会自动调用 Localize()。")]
public string label;
[Tooltip("对应的物品类型。为空时表示显示所有类型(不筛选)。")]
public ItemType[] types;
}
[Header("Dropdown")]
public TMP_Dropdown dropdown;
[Header("Categories")]
[Tooltip("类别选项列表。第一个通常为'全部'types 留空)。")]
public List<CategoryEntry> categoryEntries = new List<CategoryEntry>
{
new CategoryEntry { label = "全部", types = new[] { ItemType.MainWeapon, ItemType.Support, ItemType.Passive, ItemType.Consumable } },
new CategoryEntry { label = "主武器", types = new[] { ItemType.MainWeapon } },
new CategoryEntry { label = "支援装备", types = new[] { ItemType.Support } },
new CategoryEntry { label = "被动装备", types = new[] { ItemType.Passive } },
new CategoryEntry { label = "消耗品", types = new[] { ItemType.Consumable } }
};
/// <summary>当筛选条件变化时触发,参数为对应的 <see cref="ItemFilter"/>。</summary>
public event Action<ItemFilter> OnFilterChanged;
/// <summary>当前生效的筛选条件。</summary>
public ItemFilter CurrentFilter { get; private set; } = ItemFilter.None;
// ================================================================
// 生命周期
// ================================================================
private void Start()
{
InitializeOptions();
if (dropdown != null)
{
dropdown.onValueChanged.AddListener(OnValueChanged);
}
}
// ================================================================
// 公开接口
// ================================================================
/// <summary>
/// 使用 <see cref="categoryEntries"/> 重新初始化下拉选项。
/// 语言切换后可手动调用以刷新标签文本。
/// </summary>
public void InitializeOptions()
{
if (dropdown == null) return;
dropdown.ClearOptions();
List<string> labels = new List<string>(categoryEntries.Count);
foreach (CategoryEntry entry in categoryEntries)
{
labels.Add(entry.label);
}
dropdown.AddOptions(labels);
}
/// <summary>
/// 根据当前选中索引构建 <see cref="ItemFilter"/>。
/// types 为空的选项返回 <see cref="ItemFilter.None"/>(即显示所有)。
/// </summary>
public ItemFilter BuildFilter()
{
return CurrentFilter;
}
/// <summary>重置为第一个选项(通常为"全部"),不触发事件。</summary>
public void ResetToAll()
{
CurrentFilter = ItemFilter.None;
if (dropdown != null)
{
dropdown.SetValueWithoutNotify(0);
}
}
// ─────────────────── 内部 ───────────────────
private void OnValueChanged(int index)
{
if (index < 0 || index >= categoryEntries.Count) return;
CategoryEntry entry = categoryEntries[index];
CurrentFilter = (entry.types == null || entry.types.Length == 0)
? ItemFilter.None
: ItemFilter.ByType(entry.types);
OnFilterChanged?.Invoke(CurrentFilter);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4d9f5140e87ab84f9f53b7fd7839ea3

View File

@@ -1,118 +0,0 @@
using Cielonos.MainGame.Inventory;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace SLSUtilities.UI
{
/// <summary>
/// 通用物品详情面板,显示物品图标、名称、稀有度、描述、标签等信息。
/// 可被不同的 UIPage 复用(机械台、商店、背包检视等)。
/// </summary>
public class ItemDetailPanel : UIElementBase
{
[Title("Detail Panel References")]
public Image itemIcon;
public Image rarityFrame;
public TMP_Text itemNameText;
public TMP_Text itemTypeText;
public TMP_Text itemDescriptionText;
public RectTransform tagContainer;
public GameObject tagPrefab;
private ItemBase currentItem;
/// <summary>当前显示的物品。</summary>
public ItemBase CurrentItem => currentItem;
/// <summary>
/// 使用指定物品的数据填充详情面板并显示。
/// </summary>
public void SetItem(ItemBase item)
{
currentItem = item;
if (item == null || item.contentData == null)
{
ClearPanel();
return;
}
ContentData data = item.contentData;
// 图标:主武器使用 rectIcon其他使用 squareIcon
Sprite icon = data.itemType == ItemType.MainWeapon ? data.rectIcon : data.squareIcon;
if (itemIcon != null)
{
itemIcon.sprite = icon;
itemIcon.enabled = icon != null;
}
// 名称(本地化)
if (itemNameText != null)
{
itemNameText.text = data.displayNameKey.Localize();
}
// 类型
if (itemTypeText != null)
{
itemTypeText.text = data.itemType.ToString();
}
// 描述(本地化)
if (itemDescriptionText != null)
{
itemDescriptionText.text = data.descriptionKey.Localize();
}
// 标签
RefreshTags(data);
Show();
}
/// <summary>清空面板内容并隐藏。</summary>
public void ClearPanel()
{
currentItem = null;
if (itemIcon != null) itemIcon.enabled = false;
if (itemNameText != null) itemNameText.text = string.Empty;
if (itemTypeText != null) itemTypeText.text = string.Empty;
if (itemDescriptionText != null) itemDescriptionText.text = string.Empty;
ClearTags();
Hide();
}
private void RefreshTags(ContentData data)
{
ClearTags();
if (tagContainer == null || tagPrefab == null || data.tags == null) return;
foreach (string tag in data.tags)
{
GameObject tagObj = Instantiate(tagPrefab, tagContainer);
TMP_Text tagText = tagObj.GetComponentInChildren<TMP_Text>();
if (tagText != null)
{
tagText.text = tag;
}
}
}
private void ClearTags()
{
if (tagContainer == null) return;
for (int i = tagContainer.childCount - 1; i >= 0; i--)
{
Destroy(tagContainer.GetChild(i).gameObject);
}
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame.Items;
using SLSUtilities.General;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 单选排序模式下拉框,基于 <see cref="TMP_Dropdown"/>。
/// 提供多种 <see cref="ItemSortMode"/>,切换后通知订阅者。
/// <para>可复用于背包、商店等任何需要物品排序的 UI 页面。</para>
/// </summary>
public class ItemSortDropdown : UIElementBase
{
[Header("Dropdown")]
public TMP_Dropdown dropdown;
[Header("Labels")]
[Tooltip("排序模式的显示标签,按顺序对应 Default / ByRarity / ByName。\n" +
"可填入本地化 Key如 'UI_Sort_Default'),会自动调用 Localize()。\n" +
"若无对应翻译则直接显示原文。")]
public List<string> sortModeLabels = new List<string>
{
"默认排序",
"按稀有度",
"按名称"
};
/// <summary>当排序模式变化时触发。</summary>
public event Action<ItemSortMode> OnSortChanged;
/// <summary>当前选中的排序模式。</summary>
public ItemSortMode CurrentMode { get; private set; } = ItemSortMode.Default;
// ================================================================
// 生命周期
// ================================================================
private void Start()
{
InitializeOptions();
if (dropdown != null)
{
dropdown.onValueChanged.AddListener(OnValueChanged);
}
}
// ================================================================
// 公开接口
// ================================================================
/// <summary>
/// 使用 <see cref="sortModeLabels"/> 重新初始化下拉选项。
/// 语言切换后可手动调用以刷新标签文本。
/// </summary>
public void InitializeOptions()
{
if (dropdown == null) return;
dropdown.ClearOptions();
dropdown.AddOptions(sortModeLabels/*.ConvertAll(label => label.Localize("Items"))*/);
}
/// <summary>重置为默认排序模式(不触发事件)。</summary>
public void ResetToDefault()
{
CurrentMode = ItemSortMode.Default;
if (dropdown != null)
{
dropdown.SetValueWithoutNotify(0);
}
}
// ─────────────────── 内部 ───────────────────
private void OnValueChanged(int index)
{
int modeCount = Enum.GetValues(typeof(ItemSortMode)).Length;
if (index >= 0 && index < modeCount)
{
CurrentMode = (ItemSortMode)index;
OnSortChanged?.Invoke(CurrentMode);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9aaaaa3c6c02ad246b7a25a59005f425

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 96289eeb61566e54081a0137954e19a9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,136 @@
using Cielonos.MainGame.Inventory;
using SLSUtilities.General;
using SLSUtilities.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.MainGame.UI
{
/// <summary>
/// 背包网格中的单个物品槽位。
/// 显示物品图标、名称,消耗品额外显示堆叠数量。
/// 点击后通知 InventoryUIPage 选中该物品并更新详情面板。
/// </summary>
public class InventoryItemSelector : UIElementBase
{
private InventoryUIPage Page => PlayerCanvas.MainGamePages.inventoryPage;
// ─────────────────── 数据 ───────────────────
/// <summary>此槽位绑定的物品实例。</summary>
public ItemBase Item { get; private set; }
// ─────────────────── UI 引用 ───────────────────
[Header("UI References")]
public Button button;
public Image background;
public Image itemIcon;
public Image rarityFrame;
//public Image selectorHint;
//public TMP_Text itemName;
[Tooltip("消耗品堆叠数量显示,非消耗品时隐藏。")]
public TMP_Text stackText;
// ─────────────────── 视觉设置 ───────────────────
[Header("Visual Settings")]
public Color normalColor = new Color(0.2f, 0.2f, 0.2f, 0.8f);
public Color selectedColor = new Color(0.4f, 0.6f, 0.9f, 0.9f);
private bool isSelected;
// ================================================================
// 公开接口
// ================================================================
/// <summary>
/// 用给定的物品数据配置此槽位的显示内容。
/// </summary>
public void Setup(ItemBase item)
{
Item = item;
if (item == null || item.contentData == null)
{
Hide();
return;
}
ContentData data = item.contentData;
Sprite icon = data.itemIcon;
if (itemIcon != null)
{
itemIcon.sprite = icon;
itemIcon.enabled = icon != null;
}
// 名称(本地化)
/*if (itemName != null)
{
itemName.text = data.displayNameKey.Localize();
}*/
// 堆叠数量(仅消耗品)
RefreshStackDisplay();
// 默认取消选中
SetSelected(false);
// 绑定按钮
if (button != null)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(OnClicked);
}
Show();
}
/// <summary>
/// 设置选中/取消选中的视觉状态。
/// </summary>
public void SetSelected(bool selected)
{
isSelected = selected;
if (background != null)
{
background.color = isSelected ? selectedColor : normalColor;
}
/*if (selectorHint != null)
{
selectorHint.enabled = isSelected;
}*/
}
/// <summary>
/// 刷新消耗品堆叠数量显示。非消耗品时隐藏堆叠文本。
/// </summary>
public void RefreshStackDisplay()
{
if (stackText == null) return;
if (Item is ConsumableBase consumable)
{
stackText.gameObject.SetActive(true);
stackText.text = consumable.stackAmount.ToString();
}
else
{
stackText.gameObject.SetActive(false);
}
}
// ─────────────────── 内部 ───────────────────
private void OnClicked()
{
Page?.SelectItem(this);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2abecad67abab4e4692a134234d722c8

Some files were not shown because too many files have changed in this diff Show More