구현 코드
타일 영역 메시, 비동기 대화와 설정 구조의 구현 코드를 정리했습니다. 사례별 설명을 먼저 읽고 필요한 코드를 펼쳐 볼 수 있습니다. 프로젝트 링크에는 해당 기능의 개발 배경과 담당 업무를 적었습니다.
타일맵 기반 3D게임(개발중)
타일 영역 경계 메시 생성
프로젝트 설명 →점령한 육각형 타일들을 하나의 영역으로 묶고, 바깥 테두리를 띠 형태로 표시하는 기능입니다. 타일마다 높이가 달라 LineRenderer로 그린 선이 비틀리는 문제가 있었고, 외곽 경로를 따라 메시를 직접 생성하도록 변경했습니다.
구현은 영역 바깥에 있는 변을 찾는 단계, 변을 순서대로 연결하는 단계, 연결된 경로를 따라 폭이 있는 메시를 만드는 단계로 나눴습니다. 아래 코드는 각 단계의 주요 로직입니다.

타일맵 기반 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 } };
}- 1변의 양 끝점을 같은 순서로 정렬해, 두 타일에서 만든 공유 변이 같은 키를 갖도록 했습니다. (L5–9)
- 2부동소수점 오차로 같은 위치가 다른 키로 분류되지 않도록 좌표를 정수로 반올림해 비교했습니다. (L15–17)
- 3집계 횟수가 1인 변만 외곽 추출 결과에 남깁니다. (L35–36, L40–50)
타일맵 기반 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;
}- 1BuildAdjacency에서 인접 목록을 만들고 TraceLoop에서 경로를 추적합니다. (L6, L21)
- 2출발점으로 돌아오면 현재 경로를 종료합니다. 방문하지 않은 점이 남아 있으면 새 경로의 추적을 시작합니다. (L9–15, L47–50)
타일맵 기반 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;
}- 1삼각형의 법선 방향을 확인하고 필요하면 정점 순서를 변경합니다. (L18–21)
- 2접합부에서는 앞뒤 변의 법선을 합쳐 방향을 구하고 폭을 보정합니다. MinMiterDot으로 보정 범위를 제한했습니다. (L27–35)
- 3앞뒤 변의 방향 탐색은 step을 인자로 받는 FindSegmentDirection으로 공통 처리합니다. (L49–59)
타일맵 기반 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;
}- 1분기 전환 처리 후 남은 메시지가 있으면 큐 처리 작업에 취소를 전달합니다. 나머지 메서드에는 Token을 전달하고, CancellationTokenSource는 취소를 결정하는 메서드에서 관리합니다. (L10–22)
- 2큐 처리 작업은 취소 예외를 받아 남은 큐를 정리합니다. (L41–44)
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;
}- 1메시지를 표시하고 진행 위치를 갱신한 뒤, 출력 간격을 기다립니다. 분기 합류 지점은 별도 조건으로 구분합니다. (L3–9)
- 2취소 예외를 처리할 때 QueuedMessageSpan을 반영해 진행 위치를 복원합니다. 대기 상태 해제는 finally에서 수행합니다. (L17–24, L47–52)
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;
}
}- 1뷰의 값 변경 이벤트를 컨트롤러에서 구독합니다. 설정 키는 카테고리별 데이터 클래스의 문자열 상수로 정의했습니다. 공용 enum에 모든 카테고리의 키를 모으지 않는 구조입니다. (L8, L26)
- 2컨트롤러는 엔진 반영, 변경 알림, 화면 갱신 순서로 처리합니다. 카테고리별 엔진 반영은 ApplyToEngine의 구현으로 분리했습니다. (L33–40)
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();
}
}- 1저장된 값이 없으면 ScriptableObject에 지정한 기본값을 사용합니다. 하위 데이터 클래스는 Serialize와 Deserialize를 구현합니다. (L13–18)
- 2뷰 기반 클래스에 데이터 타입을 제네릭으로 지정했습니다. 사운드 옵션은 공통 Bind 함수로 초기값과 변경·갱신 처리를 연결합니다. (L21–27, L51–54)
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);
}- 1테스트용 서브클래스에서 표시 요청과 전달 데이터를 기록해 검증합니다. 풀 초기화와 UI 구독 과정까지 모의 객체로 구성하지 않아도 표시 요청을 확인할 수 있습니다.
- 2테스트 이름에 확인할 동작을 적고, 표시 함수 호출 여부와 전달된 업적을 각각 검사합니다. (L29–30)
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)