구현 코드

타일 영역 메시, 비동기 대화와 설정 구조의 구현 코드를 정리했습니다. 사례별 설명을 먼저 읽고 필요한 코드를 펼쳐 볼 수 있습니다. 프로젝트 링크에는 해당 기능의 개발 배경과 담당 업무를 적었습니다.

타일맵 기반 3D게임(개발중)

타일 영역 경계 메시 생성

프로젝트 설명 →

점령한 육각형 타일들을 하나의 영역으로 묶고, 바깥 테두리를 띠 형태로 표시하는 기능입니다. 타일마다 높이가 달라 LineRenderer로 그린 선이 비틀리는 문제가 있었고, 외곽 경로를 따라 메시를 직접 생성하도록 변경했습니다.

구현은 영역 바깥에 있는 변을 찾는 단계, 변을 순서대로 연결하는 단계, 연결된 경로를 따라 폭이 있는 메시를 만드는 단계로 나눴습니다. 아래 코드는 각 단계의 주요 로직입니다.

Unity 에디터 — 점령 타일 목록(Occupied Tiles)에서 자동 생성된 경계 메시(Border Objects)
Unity 에디터 — 점령 타일 목록(Occupied Tiles)에서 자동 생성된 경계 메시(Border Objects)

타일맵 기반 3D게임 · 설명용 발췌

타일맵 기반 3D게임(개발중)

1. 외곽 엣지 추출

프로젝트 설명 →

각 타일의 변을 집계해 두 타일이 공유하는 내부 변을 제외했습니다. 한 번만 나타나는 변을 영역의 외곽으로 사용합니다.

코드 보기 · 51
private struct UndirectedEdgeKey : System.IEquatable<UndirectedEdgeKey>
{
    private readonly Vector2Int _min;
    private readonly Vector2Int _max;
    public UndirectedEdgeKey(Vector2Int first, Vector2Int second)
    {
        var isOrdered = first.x < second.x || (first.x == second.x && first.y < second.y);
        _min = isOrdered ? first : second;
        _max = isOrdered ? second : first;
    }
    public bool Equals(UndirectedEdgeKey other) => _min == other._min && _max == other._max;
    public override int GetHashCode() => HashCode.Combine(_min, _max);
}
 
// 좌표를 SnapScale 배 정수로 반올림해 부동소수 오차를 제거
private static Vector2Int SnapToGrid(Vector3 position) => new Vector2Int(
    Mathf.RoundToInt(position.x * SnapScale), Mathf.RoundToInt(position.z * SnapScale));
 
public static List<Edge> GetBoundaryEdges(
    IReadOnlyList<Vector3> tileCenters, float radius, float yOffset)
{
    var edgeCounts = new Dictionary<UndirectedEdgeKey, EdgeInfo>();
    foreach (var center in tileCenters)
    {
        for (var side = 0; side < HexSides; side++)
        {
            var corner = GetHexCornerWorldPosition(center, side, radius, yOffset);
            var nextCorner = GetHexCornerWorldPosition(center, (side + 1) % HexSides, radius, yOffset);
            RegisterEdge(edgeCounts, corner, nextCorner);
        }
    }
 
    var boundary = new List<Edge>();
    foreach (var entry in edgeCounts)
        if (entry.Value.Count == 1)   // 두 타일이 공유한 변은 2, 외곽 변만 1
            boundary.Add(entry.Value.Edge);
    return boundary;
}
 
private static void RegisterEdge(
    Dictionary<UndirectedEdgeKey, EdgeInfo> edgeCounts, Vector3 corner, Vector3 nextCorner)
{
    var key = new UndirectedEdgeKey(SnapToGrid(corner), SnapToGrid(nextCorner));
    if (edgeCounts.TryGetValue(key, out var info))
    {
        info.Count++;
        edgeCounts[key] = info;
        return;
    }
    edgeCounts[key] = new EdgeInfo { Count = 1, Edge = new Edge { start = corner, end = nextCorner } };
}

타일맵 기반 3D게임 · 설명용 발췌

타일맵 기반 3D게임(개발중)

2. 폐곡선 복원

프로젝트 설명 →

외곽 변의 인접 관계를 그래프로 구성하고, 연결된 점을 따라가며 폐곡선을 만듭니다. 메시 생성에 필요한 정점 순서를 구하는 단계입니다.

코드 보기 · 55
private const int MinLoopPoints = 3;   // 점이 셋 미만이면 면이 되지 않는다
private static readonly Vector2Int NoPrevious = new Vector2Int(int.MinValue, int.MinValue);
 
public static List<List<Vector3>> BuildOrderedLoops(List<Edge> edges)
{
    var (adjacency, worldPositions) = BuildAdjacency(edges);
    var visited = new HashSet<Vector2Int>();
    var loops = new List<List<Vector3>>();
    foreach (var start in adjacency.Keys)
    {
        if (visited.Contains(start))
            continue;
        var loop = TraceLoop(start, adjacency, worldPositions, visited);
        if (loop.Count >= MinLoopPoints)
            loops.Add(loop);
    }
    return loops;
}
 
private static (Dictionary<Vector2Int, List<Vector2Int>>, Dictionary<Vector2Int, Vector3>)
    BuildAdjacency(List<Edge> edges)
{
    var adjacency = new Dictionary<Vector2Int, List<Vector2Int>>();
    var worldPositions = new Dictionary<Vector2Int, Vector3>();
    foreach (var edge in edges)
    {
        var startKey = SnapToGrid(edge.start);
        var endKey = SnapToGrid(edge.end);
        AddUndirectedEdge(adjacency, startKey, endKey);
        worldPositions.TryAdd(startKey, edge.start);
        worldPositions.TryAdd(endKey, edge.end);
    }
    return (adjacency, worldPositions);
}
 
private static List<Vector3> TraceLoop(Vector2Int start,
    Dictionary<Vector2Int, List<Vector2Int>> adjacency,
    Dictionary<Vector2Int, Vector3> worldPositions, HashSet<Vector2Int> visited)
{
    var loop = new List<Vector3>();
    var (current, previous) = (start, NoPrevious);
    while (true)
    {
        visited.Add(current);
        loop.Add(worldPositions[current]);
 
        var next = FindNextNeighbor(adjacency[current], previous);
        var isLoopClosed = next == null || next.Value == start || visited.Contains(next.Value);
        if (isLoopClosed)
            break;
        previous = current;
        current = next.Value;
    }
    return loop;
}

타일맵 기반 3D게임 · 설명용 발췌

타일맵 기반 3D게임(개발중)

3. 테두리 폭과 모서리 접합

프로젝트 설명 →

외곽 경로만으로는 두께가 없는 선이므로, 각 지점의 양쪽에 정점을 만들어 띠 형태의 메시로 구성했습니다. 폭을 계산하는 방향은 XZ 평면에서 구해 타일의 높이 변화와 분리했습니다.

모서리는 앞뒤 구간의 가장자리를 이어 주는 마이터 조인(miter join)으로 처리했습니다. 좁은 각도에서 접합부가 길게 돌출되지 않도록 보정값의 범위를 제한했습니다.

코드 보기 · 60
// pointCount: 점 개수 · baseIndex: 시작 정점 · Flat(v): y=0 투영 · Wrap(k, n): 순환 인덱스 · EpsSqr: 미소값²
const float MinMiterDot = 0.5f;   // 예각에서 폭이 두 배를 넘지 않게 하는 하한
 
for (var i = 0; i < pointCount; i++)
{
    var point = loop[i];
    var incoming = FindIncomingDirection(loop, i);
    var outgoing = FindOutgoingDirection(loop, i);
 
    // 폭 방향을 월드 업 기준으로 고정 (카메라 위치와 무관)
    var incomingNormal = Vector3.Cross(Vector3.up, incoming);
    var outgoingNormal = Vector3.Cross(Vector3.up, outgoing);
    var (miter, miterScale) = ComputeMiter(incomingNormal, outgoingNormal);
    AddBandVertexPair(point, miter * halfWidth * miterScale);
}
 
// 루프의 감김 방향을 확인해 뒤집힌 면을 보정
var cross = Vector3.Cross(verts[baseIndex + 1] - verts[baseIndex],
    verts[baseIndex + 2] - verts[baseIndex]);
if (Vector3.Dot(cross, transform.InverseTransformDirection(Vector3.up)) < 0f)
    SwapInnerOuter(verts, uvs);
 
for (var i = 0; i < pointCount; i++)   // 인접 정점 쌍을 이어 쿼드 스트립 구성
    AddQuad(tris, baseIndex + i * 2, baseIndex + i * 2 + 1,
        baseIndex + ((i + 1) % pointCount) * 2, baseIndex + ((i + 1) % pointCount) * 2 + 1);
 
private static (Vector3 direction, float scale) ComputeMiter(
    Vector3 incomingNormal, Vector3 outgoingNormal)
{
    var miter = incomingNormal + outgoingNormal;
    if (miter.sqrMagnitude < EpsSqr)   // 되돌아 꺾이는 경우
        return (incomingNormal.normalized, 1f);
    miter.Normalize();
    var scale = 1f / Mathf.Max(Vector3.Dot(miter, incomingNormal.normalized), MinMiterDot);
    return (miter, scale);
}
private void AddBandVertexPair(Vector3 point, Vector3 offset)
{
    verts.Add(transform.InverseTransformPoint(point - offset));   // 안쪽
    verts.Add(transform.InverseTransformPoint(point + offset));   // 바깥쪽
    uvs.Add(new Vector2(0.5f, 0f));
    uvs.Add(new Vector2(0.5f, 1f));
}
private static Vector3 FindIncomingDirection(List<Vector3> loop, int i)
    => FindSegmentDirection(loop, i - 1, step: -1);
private static Vector3 FindOutgoingDirection(List<Vector3> loop, int i)
    => FindSegmentDirection(loop, i, step: +1);
// startIndex 부터 step 방향으로 훑어, 길이 0 세그먼트를 건너뛴 첫 유효 세그먼트의 단위 방향
private static Vector3 FindSegmentDirection(List<Vector3> loop, int startIndex, int step)
{
    var pointCount = loop.Count;
    for (var offset = 0; offset < pointCount - 1; offset++)
    {
        var from = Wrap(startIndex + offset * step, pointCount);
        var direction = Flat(loop[Wrap(from + 1, pointCount)] - loop[from]);
        if (direction.sqrMagnitude >= EpsSqr)
            return direction.normalized;
    }
    return Vector3.zero;
}

타일맵 기반 3D게임 · 설명용 발췌

FAKEBOOK

분기 대화의 비동기 취소 설계

프로젝트 설명 →

메시지 큐를 처리하는 작업과 분기 조건을 확인하는 작업을 병행했습니다. 조건에 따라 전환할 때 취소 토큰으로 이전 출력과 대기를 중단하도록 구현했습니다.

코드 보기 · 48
// _cts: 이 대화 객체(DMInfo)가 보유한 CancellationTokenSource 필드
private async UniTask ProcessForkedMessageAsync(IDMMessageWrapData currentMessage)
{
    if (currentMessage.Conditions.IsNullOrZeroCount())
    {
        _forkedDMQueue.Enqueue(currentMessage);   // 합류 조건이 없는 메시지는 큐에 쌓기만 한다
        return;
    }
 
    var consumeQueueTask = ConsumeForkedQueueAsync(_cts.Token);
    await WaitDefaultIntervalAsync(currentMessage, _cts.Token);
    var joinTask = ShowJoinMessageAsync(currentMessage, _cts.Token)
        .ContinueWith(() =>
        {
            var hasRemainingForkedMessages = _isForkedStateProperty.Value
                && _forkedDMQueue.Count > 0;
            if (hasRemainingForkedMessages)
                _cts.Cancel();
        });
 
    await UniTask.WhenAll(consumeQueueTask, joinTask);
    await VerifyShownMessagesAsync(currentMessage, _cts.Token);
}
private async UniTask ShowJoinMessageAsync(
    IDMMessageWrapData currentMessage, CancellationToken cancellationToken)
{
    await WaitRequirementsAsync(currentMessage, cancellationToken);
    await ShowMessageWithIntervalAsync(currentMessage);   // SendMessage 처리가 이 호출 안에서 함께 일어난다
}
private async UniTask ConsumeForkedQueueAsync(CancellationToken cancellationToken)
{
    while (_forkedDMQueue.Count > 0)
    {
        var message = _forkedDMQueue.Dequeue();
        try
        {
            await ShowMessageAsync(message, cancellationToken);
            if (IsLastMessage(message))
                NotifyLastMessage();
        }
        catch (OperationCanceledException)
        {
            ResetForkedQueue();
            break;
        }
    }
    _isForkedStateProperty.Value = false;
}

FAKEBOOK · 설명용 발췌

FAKEBOOK

대화 취소 시 진행 상태 복원

프로젝트 설명 →

출력 간격을 기다리는 동안에는 진행 위치가 이미 갱신되어 있습니다. 취소 시 진행 위치를 복원하고, finally에서 대기 상태를 정리하도록 구현했습니다.

코드 보기 · 52
private async UniTask ShowMessageWithIntervalAsync(IDMMessageWrapData currentMessage)
{
    EnterIntervalState(currentMessage);
    var isForkedJoinPoint = _isForkedStateProperty.Value
        && currentMessage.Conditions.IsNullOrZeroCount() == false;
    if (isForkedJoinPoint)
        SkipQueuedMessageIndices();
 
    ShowMessageAndAdvance(currentMessage);
 
    try
    {
        var shouldWaitInterval = currentMessage.SendMessage == false && currentMessage.Interval > 0;
        if (shouldWaitInterval)
            await WaitIntervalAsync(currentMessage);
    }
    catch (OperationCanceledException)
    {
        RollbackIntervalProgress(currentMessage);   // 취소는 합류 메시지에서만 일어난다
        throw;
    }
    finally
    {
        ExitIntervalState();
    }
}
private int QueuedMessageSpan => _forkedDMQueue.Count + 1;   // 큐에 남은 메시지 수 + 합류 메시지
private void SkipQueuedMessageIndices() => _messageIndex += QueuedMessageSpan;
private void EnterIntervalState(IDMMessageWrapData currentMessage)
{
    _isIntervalInProgressProperty.Value = true;
    _isSkipIntervalProperty.Value = false;
    currentMessage.IsIntervalProperty.Value = true;
}
private void ShowMessageAndAdvance(IDMMessageWrapData currentMessage)
{
    LastMessage = _dmWrapDataList[_messageIndex];
    currentMessage.IsShownProperty.Value = true;
    _messageIndex++;
    _intervalSubject.OnNext(true);
}
private void ExitIntervalState()
{
    _intervalSubject.OnNext(false);
    _isIntervalInProgressProperty.Value = false;
}
private void RollbackIntervalProgress(IDMMessageWrapData currentMessage)
{
    _messageIndex -= QueuedMessageSpan;
    LastMessage = _dmWrapDataList[_messageIndex];
    currentMessage.IsIntervalProperty.Value = false;
}

FAKEBOOK · 설명용 발췌

ELEMENTAIL

설정 데이터·뷰·컨트롤러 구성

프로젝트 설명 →

설정 값을 저장하는 데이터, 입력을 받는 뷰, 둘을 연결하는 컨트롤러로 나눴습니다. 언어·비디오·사운드 설정이 공통 저장·갱신 코드를 사용하도록 했습니다.

코드 보기 · 46
public interface ISettingsData
{
    void Load();
    void Save();
}
public interface ISettingsView
{
    event Action<string, object> ValueChanged;   // 사용자가 옵션을 바꾸면 (key, value)
    void Initialize(ISettingsData settings);
    void Commit();                               // 화면의 값을 데이터에 반영
    void Refresh(string key);                    // 데이터의 값을 화면에 반영
}
 
public abstract class SettingsController : MonoBehaviour
{
    private ISettingsData _data;
    private ISettingsView _view;
    public event Action<string, object> ValueChanged;
 
    public void Initialize(ISettingsData data, ISettingsView view)
    {
        _data = data ?? throw new ArgumentNullException(nameof(data));
        _view = view ?? throw new ArgumentNullException(nameof(view));
        _data.Load();
        _view.Initialize(_data);
        _view.ValueChanged += OnViewValueChanged;
    }
    public void Apply()
    {
        _view.Commit();
        _data.Save();
    }
    private void OnViewValueChanged(string key, object value)
    {
        ApplyToEngine(key, value);
        ValueChanged?.Invoke(key, value);
        _view.Refresh(key);
    }
    // 오디오 믹서, 해상도처럼 엔진에 반영하는 방법은 카테고리마다 다르다
    protected abstract void ApplyToEngine(string key, object value);
    private void OnDestroy()
    {
        if (_view != null)
            _view.ValueChanged -= OnViewValueChanged;
    }
}

ELEMENTAIL · 설명용 발췌

ELEMENTAIL

설정 카테고리의 공통 저장·갱신 처리

프로젝트 설명 →

설정 값의 저장 키와 PlayerPrefs 입출력은 기반 클래스에서 관리하고, 하위 클래스는 직렬화 형식을 정의합니다. 뷰에서는 초기값·변경 이벤트·갱신 함수를 바인딩하는 공통 절차를 추출했습니다.

코드 보기 · 62
public abstract class SettingsDataBase : ScriptableObject, ISettingsData
{
    private const string KeyPrefix = "Settings_";
    private string StorageKey => KeyPrefix + GetType().Name;
    protected abstract string Serialize();
    protected abstract void Deserialize(string json);
 
    public void Save()
    {
        PlayerPrefs.SetString(StorageKey, Serialize());
        PlayerPrefs.Save();
    }
    public void Load()
    {
        var json = PlayerPrefs.GetString(StorageKey, string.Empty);
        if (string.IsNullOrEmpty(json))
            return;   // 저장된 값이 없으면 ScriptableObject 의 기본값을 그대로 쓴다
        Deserialize(json);
    }
}
public abstract class SettingsViewBase<TData> : MonoBehaviour, ISettingsView where TData : ISettingsData
{
    private readonly Dictionary<string, Action> _refreshHandlers = new();
    public event Action<string, object> ValueChanged;
 
    public void Initialize(ISettingsData settings) => Initialize((TData)settings);
    protected abstract void Initialize(TData settings);
    public abstract void Commit();
    public void Refresh(string key)
    {
        if (_refreshHandlers.TryGetValue(key, out var refresh))
            refresh();
    }
    protected void RegisterRefreshHandler(string key, Action handler) => _refreshHandlers[key] = handler;
    protected void RaiseValueChanged(string key, object value) => ValueChanged?.Invoke(key, value);
}
 
// 카테고리 하나를 추가할 때 쓰는 뷰. 옵션마다 "읽기"와 "키"만 알려 주면 된다
public class SoundSettingsView : SettingsViewBase<SoundSettingsData>
{
    [SerializeField] private SettingsOptionSlider _masterVolume;
    [SerializeField] private SettingsOptionSlider _bgmVolume;
    [SerializeField] private SettingsOptionSlider _sfxVolume;
 
    protected override void Initialize(SoundSettingsData sound)
    {
        Bind(_masterVolume, SoundSettingsData.MasterVolumeKey, sound.GetMasterVolume);
        Bind(_bgmVolume, SoundSettingsData.BgmVolumeKey, sound.GetBgmVolume);
        Bind(_sfxVolume, SoundSettingsData.SfxVolumeKey, sound.GetSfxVolume);
    }
    private void Bind(SettingsOptionSlider slider, string key, Func<float> read)
    {
        slider.Initialize(read(), value => RaiseValueChanged(key, value));
        RegisterRefreshHandler(key, () => slider.SetValue(read()));
    }
    public override void Commit()
    {
        _masterVolume.Commit();
        _bgmVolume.Commit();
        _sfxVolume.Commit();
    }
}

ELEMENTAIL · 설명용 발췌

FAKEBOOK

업적 표시 요청의 단위 테스트

프로젝트 설명 →

업적 표시 이벤트가 발생하면 Presenter가 표시 함수를 호출하고 해당 데이터를 전달하는지 확인합니다. 호출 여부와 전달값을 기록하는 테스트용 서브클래스를 사용했습니다.

코드 보기 · 45
// NOTE
// AchievementViewPresenter 클래스를 직접 테스트하려 했으나,
// 단순히 Observable의 구독 여부만을 검증하기 위해 Substitute의 Received() 함수를 사용하는 경우,
// AchievementViewPresenter와 IAchievementView, IAchievementDataPresenter를 Mocking하고,
// IAchievementView 내의 IComponentInterfacePool<IAchievementCell>과
// IComponentInterfacePool<IAchievementToastCell> 등도 각각 Mocking해야 하는 복잡성이 있었습니다.
// 특히, [Inject] 속성과 Awake() 함수가 모두 호출되어 Pool이 Init 되었는지를
// 확인하는 부분에서 테스트가 더 복잡해졌습니다.
// 이러한 이유로 AchievementViewPresenterProxy 클래스를 생성하여 테스트를 간소화해보았습니다.
public class AchievementViewPresenterProxy : AchievementViewPresenter
{
    public bool ShowTitleCellCalled { get; private set; } = false;
    public bool ShowToastCellCalled { get; private set; } = false;
    public IReadOnlyList<IAchievementProgress> ReceivedDataList { get; private set; }
    public IAchievementData ReceivedAchievementData { get; private set; }
 
    // … ShowTitleCell 오버라이드 생략 (동일 패턴) …
 
    public override async UniTask<IDisposable> ShowToastCell(IAchievementData data)
    {
        ShowToastCellCalled = true;
        var disposable = await base.ShowToastCell(data);
        ReceivedAchievementData = data;
        return disposable;
    }
}
 
[UnityTest]
public IEnumerator OnShowToastCellObservable_데이터를_발행하면_해당_데이터를_인수로_ShowToastCell함수_호출()
{
    // Arrange
    SetupMockToastPool();
    var achievementData = CreateMockAchievementData();
    var subject = SetupMockObservable(() => _mockDataPresenter.OnShowToastCellObservable());
 
    var proxyPresenter = Container.InstantiateComponent<AchievementViewPresenterProxy>(_testObj);
 
    // Act
    subject.OnNext(achievementData);
    yield return null;
 
    // Assert
    Assert.IsTrue(proxyPresenter.ShowToastCellCalled);
    Assert.AreEqual(achievementData, proxyPresenter.ReceivedAchievementData);
}

FAKEBOOK · 설명용 발췌

FAKEBOOK

대화 진행에 따른 읽지 않은 메시지 수 검사

프로젝트 설명 →

메시지 5개 중 중간에 답장이 포함된 상황을 만들었습니다. 대화를 진행하다가 확인 위치를 갱신한 뒤, 남은 메시지가 출력됐을 때 레드닷 수가 2인지 검사합니다.

ZIP에 포함된 DMInfoTests.cs와 같은 코드입니다. 테스트 실행에는 원본 프로젝트의 DM 모델과 테스트용 데이터 생성 코드가 필요합니다.

코드 보기 · 63
using Cysharp.Threading.Tasks;
using Fakebook.Model;
using NUnit.Framework;
using System.Collections;
using System.Collections.Generic;
using UniRx;
using UnityEngine.TestTools;
 
[Category("fakebook")]
public class DMInfoTests : Zenject.ZenjectUnitTestFixture
{
    [UnityTest]
    public IEnumerator 메세지데이터가_5개있을때_3번째가_SendMessage직후_5번째까지_진행되면_레드닷은_2개()
    {
        // Arrange
        string actualName = "tester";
 
        List<IDMMessageData> messageList = new()
        {
            DMTestUtil.CreateDMMessage(),
            DMTestUtil.CreateDMMessage(),
            DMTestUtil.CreateDMMessageSendMessageTrue(),
            DMTestUtil.CreateDMMessage(),
            DMTestUtil.CreateDMMessage(),
        };
 
        var dmData = DMTestUtil.CreateDMData(actualName, messageList.ToArray());
        var dmInfo = new DMInfo(dmData);
        dmInfo.OnShowedMessageObservable().Subscribe(_ => { });
        dmInfo.OnCheckShowedMessageObservable().Subscribe(_ => { });
 
        int firstConfirmMessageIndex = 3;
        bool firstWait = false;
        dmInfo.OnCheckConditionObservable().Subscribe(_ => UniTask.FromResult(true));
        dmInfo.OnChatViewOpenObservable().Subscribe(async data =>
        {
            if (firstConfirmMessageIndex-- == 0)
            {
                await UniTask.WaitUntil(() => firstWait);
            }
 
            return true;
        });
 
        _ = dmInfo.StartMessageObservable();
 
        for (int i = 0; i < messageList.Count; i++)
        {
            yield return null;
        }
 
        // Act
        dmInfo.UpdateConfirmIndex();
        firstWait = true;
        for (int i = 0; i < messageList.Count - firstConfirmMessageIndex; i++)
        {
            yield return null;
        }
 
        // Assert
        Assert.AreEqual(2, dmInfo.ReddotProperty.Value);
    }
}

FAKEBOOK · DMInfoTests.cs 원본

ELEMENTAIL

공격 순서에 따른 시간 계산

프로젝트 설명 →

카드 수와 공격 순서에 따라 간격을 줄이고, 전체 연출 시간도 같은 함수로 계산했습니다. 공격 연출과 피해량 계산 연출은 각각 다른 속도 설정을 사용합니다.

코드 보기 · 116
using UnityEngine;
 
namespace Banjiha.Elementail.Game
{
    internal readonly struct MultiHitTimingConfig
    {
        public readonly float MaxSeconds;
        public readonly float MinSeconds;
        public readonly float MaxSalvoPower;
        public readonly float SalvoIntensityStartHitCount;
        public readonly float SalvoIntensityEndHitCount;
 
        public MultiHitTimingConfig(
            float maxSeconds,
            float minSeconds,
            float maxSalvoPower,
            float salvoIntensityStartHitCount,
            float salvoIntensityEndHitCount)
        {
            MaxSeconds = maxSeconds;
            MinSeconds = minSeconds;
            MaxSalvoPower = maxSalvoPower;
            SalvoIntensityStartHitCount = salvoIntensityStartHitCount;
            SalvoIntensityEndHitCount = salvoIntensityEndHitCount;
        }
    }
 
    internal static class MultiHitTimingCore
    {
        private static float Smoothstep01(float x)
        {
            if (x <= 0f) return 0f;
            if (x >= 1f) return 1f;
            return x * x * (3f - 2f * x);
        }
 
        private static float GetSalvoIntensity(int hitCount, in MultiHitTimingConfig config)
        {
            return Smoothstep01(Mathf.InverseLerp(
                config.SalvoIntensityStartHitCount,
                config.SalvoIntensityEndHitCount,
                hitCount));
        }
 
        public static float GetStepDurationSeconds(int stepIndex, int stepCount, in MultiHitTimingConfig config)
        {
            if (stepCount <= 0)
                return config.MaxSeconds;
            if (stepCount == 1)
                return config.MaxSeconds;
 
            stepIndex = Mathf.Clamp(stepIndex, 0, stepCount - 1);
 
            float u = stepIndex / (float)(stepCount - 1);
            float s = Smoothstep01(u);
 
            float salvoPower = 1f + Mathf.InverseLerp(2f, config.SalvoIntensityEndHitCount, stepCount)
                * (config.MaxSalvoPower - 1f);
            float t = Mathf.Pow(s, salvoPower);
            float effectiveT = Mathf.Clamp01(t * GetSalvoIntensity(stepCount, config));
            return Mathf.Lerp(config.MaxSeconds, config.MinSeconds, effectiveT);
        }
 
        public static float GetTotalStaggerSeconds(int stepCount, in MultiHitTimingConfig config)
        {
            if (stepCount <= 0)
                return 0f;
 
            float sum = 0f;
            for (int i = 0; i < stepCount; i++)
                sum += GetStepDurationSeconds(i, stepCount, config);
            return sum;
        }
    }
 
    public static class MultiHitAttackTiming
    {
        public const float MaxHitSeconds = 0.22f;
 
        public const float MinHitSeconds = 0.052f;
 
        private static readonly MultiHitTimingConfig Config = new(
            MaxHitSeconds,
            MinHitSeconds,
            maxSalvoPower: 1.52f,
            salvoIntensityStartHitCount: 10f,
            salvoIntensityEndHitCount: 30f);
 
        public static float GetHitDurationSeconds(int hitIndex, int hitCount)
            => MultiHitTimingCore.GetStepDurationSeconds(hitIndex, hitCount, Config);
 
        public static float GetTotalStaggerSeconds(int hitCount)
            => MultiHitTimingCore.GetTotalStaggerSeconds(hitCount, Config);
    }
 
    public static class MultiHitCalculateTiming
    {
        public const float MaxStepSeconds = 0.30f;
 
        public const float MinStepSeconds = 0.14f;
 
        private static readonly MultiHitTimingConfig Config = new(
            MaxStepSeconds,
            MinStepSeconds,
            maxSalvoPower: 1.22f,
            salvoIntensityStartHitCount: 20f,
            salvoIntensityEndHitCount: 42f);
 
        public static float GetStepDurationSeconds(int stepIndex, int stepCount)
            => MultiHitTimingCore.GetStepDurationSeconds(stepIndex, stepCount, Config);
 
        public static float GetTotalStaggerSeconds(int stepCount)
            => MultiHitTimingCore.GetTotalStaggerSeconds(stepCount, Config);
    }
}
 

ELEMENTAIL · MultiHitAttackTiming.cs 전체 · cd79b9dc88b9 (2026.05.18)