ThreeKingdomsSlingshot/Assets/Scripts/Runtime/SlingshotInput.cs

459 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 슬링샷 조작: 아군 유닛을 잡고(누름) 뒤로 당긴 뒤(드래그) 놓으면(드롭) 반대 방향으로 이동·공격.
/// 유닛 탭(아군·적군) = 그 유닛의 사거리 표시(RangeIndicator) · 빈 곳 드래그 = 전장 스크롤 · 휠 = 줌 · 미니맵 탭 = 그 지점으로 이동.
/// 버튼 없음. 결과 화면에서는 탭으로 계속. 추가 이동만 남았을 때 빈 곳 탭 = 건너뛰기.
/// 궁병도 다른 병종과 같이 놓으면 바로 행동한다(조준 방향에 적이 없으면 목표점까지 이동 · PD 2026-11-04 2차 지시).
/// </summary>
public class SlingshotInput : MonoBehaviour
{
static readonly Color TargetFreeColor = Color.white;
static readonly Color TargetHitColor = new Color(1f, 0.6f, 0.1f);
static readonly Color ShotLineColor = new Color(1f, 0.92f, 0.6f, 0.95f);
static readonly Color ShotLineFriendlyColor = new Color(1f, 0.2f, 0.2f, 0.95f);
static readonly Color SplashAreaColor = new Color(0.7f, 0.45f, 1f, 0.22f);
static readonly Color SplashAreaFriendlyColor = new Color(1f, 0.25f, 0.25f, 0.26f);
BattleManager battle;
Camera cam;
BattleHUD hud;
CameraController camCtrl;
RangeIndicator range;
/// <summary>결과 화면 탭(= 계속) 을 건너뛸 자리인가(화면 좌표) — 결과 패널 「다시」 버튼 위(#1104).</summary>
public System.Func<Vector2, bool> HoldResultTap;
/// <summary>이 자리(화면 좌표) 누름은 전장 조작으로 보지 않는다(스킬 테스트 씬 패널 위 · 2026-11-09). 뗄 때까지 무시.</summary>
public System.Func<Vector2, bool> BlockPress;
bool pressBlocked;
Unit selected;
bool dragging;
bool panning;
bool emptyPress;
bool previewValid;
Vector2 lastScreen;
float panPixels;
Vector2 currentTarget;
LineRenderer aimLine;
LineRenderer pullLine;
LineRenderer shotLine; // 궁병·책사 투사체 경로(쏘는 위치 → 첫 피격 지점 · PD 2026-11-05 #1087 ⑥)
SpriteRenderer targetRing;
SpriteRenderer splashArea; // 책사 광역 원(중심 = 첫 피격 유닛 · #1087 ⑤)
SpriteRenderer teleportArea; // 변진(장합) 목표 지점 반경(PD #1093 B)
readonly List<Unit> highlighted = new List<Unit>();
/// <summary>방향 결정 UI = IB GameTouchArrow 이식(고리 + 화살표 · PD 2026-11-10 「IB 와 같이 방향 결정 UI 등을 그대로 이식」) — 전에 쓰던 조준선 · 당김선 대신.</summary>
IbAimArrow ibArrow;
/// <summary>슬링샷 진행 중 캐릭터 연출(IB 이식): 행동할 유닛 발밑 원 · 몸 둘레 빛 + 조준 중 모든 유닛 속성 방울.</summary>
IbUnitMarks marks;
public IbUnitMarks Marks
{
get { return marks; }
}
public bool AimArrowVisible
{
get { return ibArrow != null && ibArrow.ArrowVisible; }
}
public Unit Selected
{
get { return selected; }
}
public bool ShotLineVisible
{
get { return shotLine != null && shotLine.enabled; }
}
public bool SplashVisible
{
get { return splashArea != null && splashArea.gameObject.activeSelf; }
}
/// <summary>책사 광역 원의 지름(월드) = strategistSplashRadius × 2 · 중심 = SplashCenter.</summary>
public float SplashDiameter
{
get { return splashArea != null ? splashArea.transform.localScale.x : 0f; }
}
public Vector2 SplashCenter
{
get { return splashArea != null ? (Vector2)splashArea.transform.position : Vector2.zero; }
}
public RangeIndicator Range
{
get { return range; }
}
float TapPixels
{
get { return Mathf.Max(8f, Screen.height * 0.012f); }
}
public void Init(BattleManager b, Camera c, BattleHUD h, CameraController cc)
{
battle = b;
cam = c;
hud = h;
camCtrl = cc;
aimLine = MakeLine("AimLine", 0.08f, new Color(1f, 1f, 1f, 0.9f), 10);
pullLine = MakeLine("PullLine", 0.035f, new Color(1f, 1f, 1f, 0.35f), 9);
shotLine = MakeLine("ShotLine", 0.05f, ShotLineColor, 10);
targetRing = ProceduralSprites.NewSprite(transform, "TargetRing", ProceduralSprites.Ring, Color.white, 0.7f, 11);
targetRing.enabled = false;
splashArea = ProceduralSprites.NewSprite(transform, "SplashArea", ProceduralSprites.Circle, SplashAreaColor, 1f, -8);
splashArea.gameObject.SetActive(false);
teleportArea = ProceduralSprites.NewSprite(transform, "TeleportArea", ProceduralSprites.Circle, new Color(1f, 0.85f, 0.3f, 0.18f), 1f, -8);
teleportArea.gameObject.SetActive(false);
var rgo = new GameObject("RangeIndicator");
rgo.transform.SetParent(transform, false);
range = rgo.AddComponent<RangeIndicator>();
range.Init(b.Config.archerRangeRatio);
var ago = new GameObject("IbAimArrow");
ago.transform.SetParent(transform, false);
ibArrow = ago.AddComponent<IbAimArrow>();
ibArrow.Init(b.Board);
marks = ago.AddComponent<IbUnitMarks>();
}
LineRenderer MakeLine(string name, float width, Color color, int order)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
var lr = go.AddComponent<LineRenderer>();
lr.useWorldSpace = true;
lr.positionCount = 2;
lr.startWidth = width;
lr.endWidth = width;
lr.material = new Material(Shader.Find("Sprites/Default"));
lr.startColor = color;
lr.endColor = color;
lr.sortingOrder = order;
lr.numCapVertices = 4;
lr.enabled = false;
return lr;
}
Vector2 ScreenToWorld(Vector2 screen)
{
Vector3 p = screen;
p.z = -cam.transform.position.z;
return cam.ScreenToWorldPoint(p);
}
void Update()
{
if (battle == null || battle.Config == null) return;
var phase = battle.Phase;
if (phase == BattlePhase.StageClear || phase == BattlePhase.Defeat)
{
if (Input.GetMouseButtonDown(0) && (HoldResultTap == null || !HoldResultTap(Input.mousePosition))) battle.ContinueAfterResult();
return;
}
Vector2 screen = Input.mousePosition;
if (Input.GetMouseButtonDown(0)) pressBlocked = BlockPress != null && BlockPress(screen);
if (pressBlocked)
{
if (Input.GetMouseButtonUp(0) || !Input.GetMouseButton(0)) pressBlocked = false;
return;
}
if (Input.GetMouseButtonDown(0)) OnPress(screen);
else if (Input.GetMouseButton(0))
{
if (dragging) OnDrag(ScreenToWorld(screen));
else if (panning) OnPan(screen);
}
if (Input.GetMouseButtonUp(0)) OnRelease(screen);
// 조준 중(첫 손가락으로 당기는 중) 다른 손가락으로 스킬 버튼 탭 = 예약/취소(PD #1093 B)
if (dragging && hud != null && Input.touchCount > 1)
{
for (int i = 1; i < Input.touchCount; i++)
{
var t = Input.GetTouch(i);
if (t.phase == TouchPhase.Began && hud.TrySkillTap(t.position) && selected != null && previewValid) ShowPreview(selected, currentTarget, null);
}
}
RefreshTeleportArea();
float wheel = Input.mouseScrollDelta.y;
if (Mathf.Abs(wheel) > 0.01f && camCtrl != null) camCtrl.Zoom(1f - wheel * 0.1f);
}
/// <summary>변진 목표 지점 대기 중이면 반경 원 표시.</summary>
void RefreshTeleportArea()
{
var sys = battle.Skills;
var u = sys != null ? sys.PendingUnit : null;
bool on = u != null && u.IsAlive && sys.PendingRadius > 0f; // 변진 목표 · 맹장 복귀 지점(조조 방향 탭 = 반경 없음)
if (on)
{
float d = sys.PendingRadius * 2f;
Vector2 c = sys.PendingKind == SkillSystem.TapKind.Teleport ? u.Position : sys.PendingCenter;
teleportArea.transform.position = new Vector3(c.x, c.y, 0f);
teleportArea.transform.localScale = new Vector3(d, d, 1f);
}
if (teleportArea.gameObject.activeSelf != on) teleportArea.gameObject.SetActive(on);
}
public bool TeleportAreaVisible
{
get { return teleportArea != null && teleportArea.gameObject.activeSelf; }
}
void OnPress(Vector2 screen)
{
lastScreen = screen;
panPixels = 0f;
dragging = false;
panning = false;
emptyPress = false;
previewValid = false;
// 스킬 버튼(HUD) 탭 = 예약/취소/즉시 발동 · 변진 대기 중 = 목표 지점 탭(PD #1093 B)
if (hud != null && hud.TrySkillTap(screen)) return;
string tpMsg;
if (battle.Skills != null && battle.Skills.TryTeleportTap(ScreenToWorld(screen), out tpMsg))
{
if (!string.IsNullOrEmpty(tpMsg)) battle.LastMessage = tpMsg;
return;
}
Vector2 mapWorld;
if (hud != null && camCtrl != null && hud.TryMinimapTap(screen, out mapWorld))
{
camCtrl.FocusOn(mapWorld);
return;
}
Vector2 w = ScreenToWorld(screen);
var u = battle.UnitNear(w, Faction.Player);
if (u == null) u = battle.UnitNear(w, null); // 적 유닛 터치 = 적의 사거리 표시
Select(u);
if (u != null && battle.CanAct(u))
{
dragging = true;
if (u.View != null) u.View.SetAiming(true);
if (ibArrow != null) ibArrow.Begin(u); // IB Ready: 누른 순간 고리
if (marks != null) marks.ShowBubbles(battle.Units); // IB: 조준 중 모든 유닛 속성 방울
}
else if (u == null)
{
panning = true;
emptyPress = true;
}
}
void OnPan(Vector2 screen)
{
Vector2 d = screen - lastScreen;
lastScreen = screen;
panPixels += d.magnitude;
if (panPixels > TapPixels && camCtrl != null)
{
camCtrl.PanBy(-d * camCtrl.WorldUnitsPerPixel);
}
}
void OnDrag(Vector2 w)
{
if (selected == null) return;
Vector2 pull = w - selected.Position;
Vector2 launch = -pull;
float mag = launch.magnitude;
if (mag < battle.Config.minDragDistance)
{
HidePreview();
if (ibArrow != null) ibArrow.Aim(selected, Vector2.zero); // 최소 당김 안 = 고리만 · 화살표 숨김
previewValid = false;
return;
}
float sling = SkillMods.SlingMax(selected); // 동남풍·적토마(PD #1093 B)
if (mag > sling) launch = launch / mag * sling;
currentTarget = battle.Board.ClampInside(selected.Position + launch, selected.Radius);
previewValid = true;
ShowPreview(selected, currentTarget, w);
}
void OnRelease(Vector2 screen)
{
if (dragging && selected != null && previewValid && battle.CanAct(selected))
{
var t = currentTarget;
var u = selected;
HidePreview();
if (ibArrow != null) ibArrow.End();
if (marks != null) marks.HideBubbles();
if (u.View != null) u.View.SetAiming(false);
Select(null);
battle.RequestPlayerAction(u, t);
}
else if (emptyPress && panPixels <= TapPixels)
{
battle.OnEmptyTap();
}
else
{
HidePreview();
if (ibArrow != null) ibArrow.End();
if (marks != null) marks.HideBubbles();
if (dragging && selected != null && selected.View != null) selected.View.SetAiming(false);
}
dragging = false;
panning = false;
emptyPress = false;
previewValid = false;
}
/// <summary>유닛 선택(아군·적군 · null = 해제): 선택 링 + HUD 정보 + 사거리 표시(궁병은 도넛 · 범위 안 적 유무로 색).</summary>
public void Select(Unit u)
{
if (selected != null && selected.View != null)
{
selected.View.SetSelected(false);
selected.View.SetAiming(false);
}
selected = u;
if (u != null && u.View != null) u.View.SetSelected(true);
if (hud != null) hud.SetSelected(u);
if (range != null)
{
range.Show(u);
if (u != null && UnitTypeInfo.IsRanged(u.Type)) range.SetArcherReachable(battle.AnyEnemyWithinArcherReach(u));
}
if (marks != null) marks.ShowTurn(u != null && u.Faction == Faction.Player && battle.CanAct(u) ? u : null); // IB ShowUserTurnMark
}
/// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary>
public int ShowPreview(Unit u, Vector2 target, Vector2? pointer)
{
Vector2 aim = target;
target = battle.Resolver.ReachPoint(u, target); // 늪·고지 = 실제 정지점(PD #1097)
if (ibArrow != null)
{
// IB 방향 결정 UI: 화살표 = 쏠 방향 · 길이 = IB 공식(당김 × 0.6 · 유닛 반지름 기준) — 조준선 · 당김선 대신(PD 2026-11-10)
ibArrow.Aim(u, aim - u.Position);
aimLine.enabled = false;
pullLine.enabled = false;
}
else
{
aimLine.enabled = true;
aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f));
aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f));
pullLine.enabled = pointer.HasValue;
if (pointer.HasValue)
{
pullLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f));
pullLine.SetPosition(1, new Vector3(pointer.Value.x, pointer.Value.y, 0f));
}
}
targetRing.enabled = true;
targetRing.transform.position = new Vector3(target.x, target.y, 0f);
bool moveOnly = u.BonusMoveAvailable;
var finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, battle.Config.finalHitTolerance);
targetRing.color = finalHit != null ? TargetHitColor : TargetFreeColor;
targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f);
ClearHighlights();
var shot = battle.Resolver.PreviewShot(u, aim, moveOnly);
var gl = battle.Gimmicks;
if (gl != null) gl.Preview.Show(u, aim, moveOnly, shot); // 예상 궤적(튕김·밀림·연쇄·반작용) · 기믹 반응 · 지대 띠(PD #1097 · #1098)
if (shot != null)
{
return ShowShot(u, shot);
}
HideShot();
foreach (var e in battle.Resolver.PredictTargets(u, aim, moveOnly)) Highlight(e, false);
return highlighted.Count;
}
/// <summary>
/// 궁병·책사 조준(PD 2026-11-05 #1087 ⑤⑥): 화살 경로선(쏘는 위치 → 첫 피격 지점) + 첫 피격 유닛 강조(같은 편이면 붉게)
/// · 책사 = 맞을 자리 광역 원 + 휩쓸릴 유닛 강조(같은 편 붉게). 반환 = 맞을 상대 유닛 수.
/// </summary>
int ShowShot(Unit u, ActionResolver.ShotPlan shot)
{
if (!shot.Fires)
{
HideShot();
return 0;
}
bool friendlyCenter = shot.Hit != null && shot.Hit.Faction == u.Faction;
shotLine.enabled = true;
shotLine.startColor = shotLine.endColor = friendlyCenter ? ShotLineFriendlyColor : ShotLineColor;
shotLine.SetPosition(0, new Vector3(shot.From.x, shot.From.y, 0f));
shotLine.SetPosition(1, new Vector3(shot.To.x, shot.To.y, 0f));
int foes = 0;
if (shot.Hit != null)
{
Highlight(shot.Hit, friendlyCenter);
if (!friendlyCenter) foes++;
}
bool splash = u.Type == UnitType.Strategist && shot.HasSplash; // 덤불·수레에 막혀도 그 자리 광역(PD #1097)
if (splash)
{
bool anyFriendly = friendlyCenter;
foreach (var s in shot.Splash)
{
bool friendly = s.Faction == u.Faction;
Highlight(s, friendly);
if (friendly) anyFriendly = true;
else foes++;
}
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) · 안개 중심 = ×p1
splashArea.transform.position = new Vector3(shot.SplashCenter.x, shot.SplashCenter.y, 0f);
splashArea.transform.localScale = new Vector3(d, d, 1f);
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;
}
if (splashArea.gameObject.activeSelf != splash) splashArea.gameObject.SetActive(splash);
return foes;
}
void HideShot()
{
shotLine.enabled = false;
splashArea.gameObject.SetActive(false);
}
void Highlight(Unit e, bool friendly)
{
if (e == null || highlighted.Contains(e)) return;
if (e.View != null) e.View.SetHighlight(true, friendly);
highlighted.Add(e);
}
public void HidePreview()
{
aimLine.enabled = false;
pullLine.enabled = false;
targetRing.enabled = false;
HideShot();
ClearHighlights();
if (battle != null && battle.Gimmicks != null) battle.Gimmicks.Preview.Hide();
}
void ClearHighlights()
{
foreach (var h in highlighted)
{
if (h != null && h.View != null) h.View.SetHighlight(false);
}
highlighted.Clear();
}
}
}