Git
https://github.com/SeonBab/GameMadang-2025-GameJam
PlayerClimb.cs
public class PlayerClimb : MonoBehaviour
{
[SerializeField] private LayerMask interact;
[SerializeField] private Transform footPoint;
[SerializeField] private float climbJumpForce = 5f;
[SerializeField] private float climbSpeed = 10f;
[SerializeField] private float climbObjectSnapSpeed = 30f;
[SerializeField] private float entryMargin = 0.2f;
[SerializeField] private float endMargin = 0.1f;
[SerializeField] private float exitNudge = 0.05f;
[SerializeField] private float endGraceTime = 0.15f;
[SerializeField] private float edgeSafety = 0.05f;
[SerializeField] private float ropeKickImpulse = 6f;
[SerializeField] private float maxSwingXSpeed = 14f; // 속도 캡
[SerializeField] private float maxSwingYSpeed = 18f;
[SerializeField] private string ladderClimbState = "Climb";
[SerializeField] private AnimationClip ladderClimbClip;
[SerializeField] private string ropeClimbState = "Rope";
[SerializeField] private AnimationClip ropeClimbClip;
[SerializeField] private string ropeSwingState = "Swing";
[SerializeField] private float frameStepInterval = 0.08f;
// 생략
private void FixedUpdate()
{
if (ropeJoint && ropeJoint.connectedBody)
{
RopeTick();
return;
}
if (IsClimbing && currentClimbable is RopeInteractable rope
&& Mathf.Abs(inputHandler.MoveInput.x) > 0.01f)
{
print("Attach to rope");
AttachToRopeSegment(rope);
return;
}
HandleClimbing();
}
private int CheckEntryKey()
{
if (!currentClimbable) return 0;
var feetY = footPoint ? footPoint.position.y : col.bounds.min.y;
var top = currentClimbable.GetTop;
var bottom = currentClimbable.GetBottom;
if (feetY >= top - entryMargin) return -1;
if (feetY <= bottom + entryMargin) return 1;
return 0;
}
private void JumpOnClimb(InputAction.CallbackContext ctx)
{
if (playerLife.IsDead) return;
if (!IsClimbing) return;
EndClimb();
DetachFromRope();
var x = inputHandler.MoveInput.x;
var dir = (Vector2.up + Vector2.right * Mathf.Sign(x)).normalized;
rb.AddForce(dir * climbJumpForce, ForceMode2D.Impulse);
}
private IEnumerator BeginClimbCo()
{
while (currentClimbable &&
Mathf.Abs(currentClimbable.transform.InverseTransformPoint(rb.position).x) >
0.1f)
{
var local = currentClimbable.transform.InverseTransformPoint(rb.position);
local.x = Mathf.Lerp(local.x, 0f, climbObjectSnapSpeed * Time.fixedDeltaTime);
var next = currentClimbable.transform.TransformPoint(local);
rb.MovePosition(next);
yield return new WaitForFixedUpdate();
}
}
private void StartClimb(InputAction.CallbackContext ctx)
{
if (playerLife.IsDead) return;
if (IsClimbing) return;
if (!currentClimbable) return;
var vY = ctx.ReadValue<Vector2>().y;
entryKey = CheckEntryKey();
if (entryKey == -1 && vY >= 0f) return;
if (entryKey == 1 && vY <= 0f) return;
rb.linearVelocity = Vector2.zero;
rb.gravityScale = 0f;
col.isTrigger = true;
IsClimbing = true;
var p = rb.position;
p.y = Mathf.Clamp(
p.y,
currentClimbable.GetBottom + edgeSafety,
currentClimbable.GetTop - edgeSafety
);
rb.position = p;
endBlockUntil = Time.time + endGraceTime;
endArmed = false;
nextStepAt = Time.time;
SelectClimbAnimSet(currentClimbable is RopeInteractable);
StartCoroutine(BeginClimbCo());
}
internal void EndClimb()
{
rb.gravityScale = originalGravity;
col.isTrigger = false;
IsClimbing = false;
transform.rotation = Quaternion.identity;
currentClimbable = null;
animator.speed = 1f;
kickLatch = 0;
}
private void HandleClimbing()
{
if (!IsClimbing) return;
if (!currentClimbable) return;
var v = inputHandler.MoveInput.y;
UpdateAnimation(v);
Vector2 up = !currentClimbable ? transform.up : currentClimbable.transform.up;
var targetDeg = Mathf.Atan2(up.y, up.x) * Mathf.Rad2Deg - 90f;
var next = Mathf.LerpAngle(rb.rotation, targetDeg, Time.fixedDeltaTime * 100f);
rb.MoveRotation(next);
var pos = rb.position + up * (v * climbSpeed * Time.fixedDeltaTime);
var local = currentClimbable.transform.InverseTransformPoint(pos);
local.x = Mathf.Lerp(local.x, 0f, Time.fixedDeltaTime * climbObjectSnapSpeed);
var finalPos = currentClimbable.transform.TransformPoint(local);
rb.MovePosition(finalPos);
var feetY = footPoint ? footPoint.position.y : col.bounds.min.y;
var atTop = Mathf.Abs(feetY - currentClimbable.GetTop) <= endMargin;
var atBottom = Mathf.Abs(feetY - currentClimbable.GetBottom) <= endMargin;
if (Time.time >= endBlockUntil && !atTop && !atBottom)
endArmed = true;
if (!endArmed) return;
if (atTop && v > 0.01f)
{
rb.position += up * exitNudge;
EndClimb();
return;
}
if (atBottom && v < -0.01f)
{
rb.position -= up * exitNudge;
EndClimb();
}
}
private void DetachFromRope()
{
if (!ropeJoint && !ropeJoint.connectedBody) return;
ropeJoint.connectedBody = null;
ropeSeg = null;
IsClimbing = false;
ropeJoint.enabled = false;
}
private void AttachToRopeSegment(RopeInteractable seg)
{
if (!seg) return;
kickLatch = 0;
currentClimbable = null;
transform.rotation = Quaternion.identity;
ropeSeg = seg;
ropeJoint.enabled = true;
ropeJoint.connectedBody = seg.GetComponent<Rigidbody2D>();
ropeJoint.autoConfigureConnectedAnchor = false;
ropeJoint.enableCollision = false;
ropeJoint.useLimits = false;
ropeJoint.anchor = Vector2.zero;
ropeJoint.connectedAnchor = seg.transform.InverseTransformPoint(rb.position);
IsClimbing = true;
rb.gravityScale = originalGravity;
col.isTrigger = false;
if (animator && !string.IsNullOrEmpty(ropeSwingState))
{
animator.speed = 1f; // 루프 재생
animator.Play(Animator.StringToHash(ropeSwingState), 0, 0f);
}
}
private void RopeTick()
{
if (!ropeSeg || !ropeJoint || !ropeJoint.connectedBody) return;
RopeKick();
var v = rb.linearVelocity;
if (Mathf.Abs(v.x) > maxSwingXSpeed) v.x = Mathf.Sign(v.x) * maxSwingXSpeed;
if (Mathf.Abs(v.y) > maxSwingYSpeed) v.y = Mathf.Sign(v.y) * maxSwingYSpeed;
rb.linearVelocity = v;
}
private void RopeKick()
{
var x = inputHandler.MoveInput.x;
var sign = Mathf.Abs(x) > 0.01f ? (int)Mathf.Sign(x) : kickLatch;
if (sign != 0 && sign != kickLatch)
{
var ropeBody = ropeJoint.connectedBody;
Vector2 forceDir = ropeBody.transform.right.normalized * sign;
Vector2 applyAt = rb.worldCenterOfMass;
ropeBody.AddForceAtPosition(forceDir * ropeKickImpulse, applyAt, ForceMode2D.Impulse);
kickLatch = sign;
}
}
}- 기획님이 원하는 조작감으로 수정할 수 있도록 인스펙터에 관련 변수들을 표시 → 하지만 너무 많은 선택권을 줘서 오히려 혼란이 옴
- 림보같은 조작감을 만들기 위해 많은 수정을 거침
- 오르내리기와 스윙을 자유롭게 변환하도록 만들고 싶었지만 시간 부족으로 구현하지 못함
- 로프와 사다리 모두에게 적용될 수 있도록 코드를 통합, Climbable이란 태그가 달려있으면 무엇이든지 오를 수 있도록 구현함
- 사다리를 Ground와 겹쳐서 설치도 되고, 양 끝 부분부터 오르내릴 수 있고 중간에서도 시작할 수 있도록 Collider bound를 기준으로 오를 수 있는 곳인지 체크함 → 더 좋은 아이디어가 있을 것 같지만 시간 부족으로 구현하지 못함
Parkour.cs
public class Parkour : MonoBehaviour
{
public Collider2D IsParkour()
{
var dir = Vector2.up + Vector2.right * (sr.flipX ? 1 : -1);
var hit = Physics2D.Raycast(transform.position, dir.normalized, rayLength,
parkourLayer);
Debug.DrawRay(rb.position, dir.normalized * rayLength, Color.red);
return hit.collider;
}
public void StartParkour(Collider2D wall)
{
if (busy) return;
if (wall) StartCoroutine(ParkourCo(wall));
}
private IEnumerator ParkourCo(Collider2D wall)
{
busy = true;
canJump = false;
col.isTrigger = true;
var savedG = rb.gravityScale;
rb.gravityScale = 0f;
rb.linearVelocity = Vector2.zero;
var targetTopY = wall.bounds.max.y;
animator.SetTrigger(IsParkour1);
var sideDir = Mathf.Sign(wall.bounds.center.x - rb.position.x);
if (sideDir == 0) sideDir = 1f;
var startX = rb.position.x;
var upDistance = Mathf.Max(0f, targetTopY - col.bounds.min.y);
var targetX = startX + sideDir * stepForwardDist;
var xPerY = upDistance > 0f ? stepForwardDist / upDistance : 0f;
while (col.bounds.min.y < targetTopY - epsilon)
{
var needY = targetTopY - col.bounds.min.y;
var stepY = Mathf.Min(needY, parkourSpeed * Time.fixedDeltaTime);
var stepX = sideDir * xPerY * stepY;
rb.MovePosition(new Vector2(rb.position.x + stepX, rb.position.y + stepY));
yield return new WaitForFixedUpdate();
}
var finalCenterY = targetTopY + col.bounds.extents.y;
rb.MovePosition(new Vector2(targetX, finalCenterY));
rb.gravityScale = savedG;
col.isTrigger = false;
yield return new WaitForSeconds(busyTime);
busy = false;
yield return new WaitForSeconds(jumpCooldown);
canJump = true;
}
public bool IsBusy => busy;
public bool CanJump => canJump;
}- 파쿠르 가능한 벽이 있으면 파쿠르를 수행 → 콜라이더를 판정
- 점프 시 raycast를 쏴서 가능한 벽인지 확인 → 기획 상 자동으로 파쿠르가 시작되도록 구현
Elevator.cs
public class Elevator : MonoBehaviour
{
[SerializeField] private Rigidbody2D place;
[SerializeField] private Transform topStop;
[SerializeField] private Transform bottomStop;
[SerializeField] private float speed = 2f;
[SerializeField] private float stopEpsilon = 0.01f;
private Coroutine moveCo;
public void OnSwitch()
{
GoUp();
}
public void OffSwitch()
{
GoDown();
}
public void GoUp()
{
StopNow();
moveCo = StartCoroutine(MoveTo(topStop.position));
}
public void GoDown()
{
StopNow();
moveCo = StartCoroutine(MoveTo(bottomStop.position));
}
public void StopNow()
{
if (moveCo != null) StopCoroutine(moveCo);
moveCo = null;
place.linearVelocity = Vector2.zero;
}
private IEnumerator MoveTo(Vector2 target)
{
yield return new WaitForSeconds(0.5f);
while ((place.position - target).sqrMagnitude > stopEpsilon * stopEpsilon)
{
var next = Vector2.MoveTowards(place.position, target, speed * Time.fixedDeltaTime);
place.MovePosition(next);
yield return new WaitForFixedUpdate();
}
place.MovePosition(target);
moveCo = null;
}
[ContextMenu("Go Up")]
private void CtxGoUp() => GoUp();
[ContextMenu("Go Down")]
private void CtxGoDown() => GoDown();
}- 간단하게 bottomStop 부터 TopStop 까지 place가 이동하도록 구현
- 각 Stop 위치를 조절해서 좌우로 이동하는 것도 가능함
- 단순 엘레베이터가 아닌 퍼즐 요소로도 사용할 수 있었음