Import 2D Level

This commit is contained in:
2026-04-30 00:53:30 +02:00
parent c67979c7cf
commit 5797038baf
479 changed files with 430785 additions and 0 deletions
@@ -0,0 +1,76 @@
using System.Collections;
using System.Collections.Generic;
using Platformer.Core;
using Platformer.Model;
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// AnimationController integrates physics and animation. It is generally used for simple enemy animation.
/// </summary>
[RequireComponent(typeof(SpriteRenderer), typeof(Animator))]
public class AnimationController : KinematicObject
{
/// <summary>
/// Max horizontal speed.
/// </summary>
public float maxSpeed = 7;
/// <summary>
/// Max jump velocity
/// </summary>
public float jumpTakeOffSpeed = 7;
/// <summary>
/// Used to indicated desired direction of travel.
/// </summary>
public Vector2 move;
/// <summary>
/// Set to true to initiate a jump.
/// </summary>
public bool jump;
/// <summary>
/// Set to true to set the current jump velocity to zero.
/// </summary>
public bool stopJump;
SpriteRenderer spriteRenderer;
Animator animator;
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
protected virtual void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
animator = GetComponent<Animator>();
}
protected override void ComputeVelocity()
{
if (jump && IsGrounded)
{
velocity.y = jumpTakeOffSpeed * model.jumpModifier;
jump = false;
}
else if (stopJump)
{
stopJump = false;
if (velocity.y > 0)
{
velocity.y = velocity.y * model.jumpDeceleration;
}
}
if (move.x > 0.01f)
spriteRenderer.flipX = false;
else if (move.x < -0.01f)
spriteRenderer.flipX = true;
animator.SetBool("grounded", IsGrounded);
animator.SetFloat("velocityX", Mathf.Abs(velocity.x) / maxSpeed);
targetVelocity = move * maxSpeed;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Collections;
using System.Collections.Generic;
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// DeathZone components mark a collider which will schedule a
/// PlayerEnteredDeathZone event when the player enters the trigger.
/// </summary>
public class DeathZone : MonoBehaviour
{
void OnTriggerEnter2D(Collider2D collider)
{
var p = collider.gameObject.GetComponent<PlayerController>();
if (p != null)
{
var ev = Schedule<PlayerEnteredDeathZone>();
ev.deathzone = this;
}
}
}
}
@@ -0,0 +1,55 @@
using System.Collections;
using System.Collections.Generic;
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// A simple controller for enemies. Provides movement control over a patrol path.
/// </summary>
[RequireComponent(typeof(AnimationController), typeof(Collider2D))]
public class EnemyController : MonoBehaviour
{
public PatrolPath path;
public AudioClip ouch;
internal PatrolPath.Mover mover;
internal AnimationController control;
internal Collider2D _collider;
internal AudioSource _audio;
SpriteRenderer spriteRenderer;
public Bounds Bounds => _collider.bounds;
void Awake()
{
control = GetComponent<AnimationController>();
_collider = GetComponent<Collider2D>();
_audio = GetComponent<AudioSource>();
spriteRenderer = GetComponent<SpriteRenderer>();
}
void OnCollisionEnter2D(Collision2D collision)
{
var player = collision.gameObject.GetComponent<PlayerController>();
if (player != null)
{
var ev = Schedule<PlayerEnemyCollision>();
ev.player = player;
ev.enemy = this;
}
}
void Update()
{
if (path != null)
{
if (mover == null) mover = path.CreateMover(control.maxSpeed * 0.5f);
control.move.x = Mathf.Clamp(mover.Position.x - transform.position.x, -1, 1);
}
}
}
}
@@ -0,0 +1,38 @@
using Platformer.Core;
using Platformer.Model;
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// This class exposes the the game model in the inspector, and ticks the
/// simulation.
/// </summary>
public class GameController : MonoBehaviour
{
public static GameController Instance { get; private set; }
//This model field is public and can be therefore be modified in the
//inspector.
//The reference actually comes from the InstanceRegister, and is shared
//through the simulation and events. Unity will deserialize over this
//shared reference when the scene loads, allowing the model to be
//conveniently configured inside the inspector.
public PlatformerModel model = Simulation.GetModel<PlatformerModel>();
void OnEnable()
{
Instance = this;
}
void OnDisable()
{
if (Instance == this) Instance = null;
}
void Update()
{
if (Instance == this) Simulation.Tick();
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using System;
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// Represebts the current vital statistics of some game entity.
/// </summary>
public class Health : MonoBehaviour
{
/// <summary>
/// The maximum hit points for the entity.
/// </summary>
public int maxHP = 1;
/// <summary>
/// Indicates if the entity should be considered 'alive'.
/// </summary>
public bool IsAlive => currentHP > 0;
int currentHP;
/// <summary>
/// Increment the HP of the entity.
/// </summary>
public void Increment()
{
currentHP = Mathf.Clamp(currentHP + 1, 0, maxHP);
}
/// <summary>
/// Decrement the HP of the entity. Will trigger a HealthIsZero event when
/// current HP reaches 0.
/// </summary>
public void Decrement()
{
currentHP = Mathf.Clamp(currentHP - 1, 0, maxHP);
if (currentHP == 0)
{
var ev = Schedule<HealthIsZero>();
ev.health = this;
}
}
/// <summary>
/// Decrement the HP of the entitiy until HP reaches 0.
/// </summary>
public void Die()
{
while (currentHP > 0) Decrement();
}
void Awake()
{
currentHP = maxHP;
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// Implements game physics for some in game entity.
/// </summary>
public class KinematicObject : MonoBehaviour
{
/// <summary>
/// The minimum normal (dot product) considered suitable for the entity sit on.
/// </summary>
public float minGroundNormalY = .65f;
/// <summary>
/// A custom gravity coefficient applied to this entity.
/// </summary>
public float gravityModifier = 1f;
/// <summary>
/// The current velocity of the entity.
/// </summary>
public Vector2 velocity;
/// <summary>
/// Is the entity currently sitting on a surface?
/// </summary>
/// <value></value>
public bool IsGrounded { get; private set; }
protected Vector2 targetVelocity;
protected Vector2 groundNormal;
protected Rigidbody2D body;
protected ContactFilter2D contactFilter;
protected RaycastHit2D[] hitBuffer = new RaycastHit2D[16];
protected const float minMoveDistance = 0.001f;
protected const float shellRadius = 0.01f;
/// <summary>
/// Bounce the object's vertical velocity.
/// </summary>
/// <param name="value"></param>
public void Bounce(float value)
{
velocity.y = value;
}
/// <summary>
/// Bounce the objects velocity in a direction.
/// </summary>
/// <param name="dir"></param>
public void Bounce(Vector2 dir)
{
velocity.y = dir.y;
velocity.x = dir.x;
}
/// <summary>
/// Teleport to some position.
/// </summary>
/// <param name="position"></param>
public void Teleport(Vector3 position)
{
body.position = position;
velocity *= 0;
body.linearVelocity *= 0;
}
protected virtual void OnEnable()
{
body = GetComponent<Rigidbody2D>();
body.bodyType = RigidbodyType2D.Kinematic;
}
protected virtual void OnDisable()
{
body.bodyType = RigidbodyType2D.Dynamic;
}
protected virtual void Start()
{
contactFilter.useTriggers = false;
contactFilter.SetLayerMask(Physics2D.GetLayerCollisionMask(gameObject.layer));
contactFilter.useLayerMask = true;
}
protected virtual void Update()
{
targetVelocity = Vector2.zero;
ComputeVelocity();
}
protected virtual void ComputeVelocity()
{
}
protected virtual void FixedUpdate()
{
//if already falling, fall faster than the jump speed, otherwise use normal gravity.
if (velocity.y < 0)
velocity += gravityModifier * Physics2D.gravity * Time.deltaTime;
else
velocity += Physics2D.gravity * Time.deltaTime;
velocity.x = targetVelocity.x;
IsGrounded = false;
var deltaPosition = velocity * Time.deltaTime;
var moveAlongGround = new Vector2(groundNormal.y, -groundNormal.x);
var move = moveAlongGround * deltaPosition.x;
PerformMovement(move, false);
move = Vector2.up * deltaPosition.y;
PerformMovement(move, true);
}
void PerformMovement(Vector2 move, bool yMovement)
{
var distance = move.magnitude;
if (distance > minMoveDistance)
{
//check if we hit anything in current direction of travel
var count = body.Cast(move, contactFilter, hitBuffer, distance + shellRadius);
for (var i = 0; i < count; i++)
{
var currentNormal = hitBuffer[i].normal;
//is this surface flat enough to land on?
if (currentNormal.y > minGroundNormalY)
{
IsGrounded = true;
// if moving up, change the groundNormal to new surface normal.
if (yMovement)
{
groundNormal = currentNormal;
currentNormal.x = 0;
}
}
if (IsGrounded)
{
//how much of our velocity aligns with surface normal?
var projection = Vector2.Dot(velocity, currentNormal);
if (projection < 0)
{
//slower velocity if moving against the normal (up a hill).
velocity = velocity - projection * currentNormal;
}
}
else
{
//We are airborne, but hit something, so cancel vertical up and horizontal velocity.
velocity.x *= 0;
velocity.y = Mathf.Min(velocity.y, 0);
}
//remove shellDistance from actual move distance.
var modifiedDistance = hitBuffer[i].distance - shellRadius;
distance = modifiedDistance < distance ? modifiedDistance : distance;
}
}
body.position = body.position + move.normalized * distance;
}
}
}
@@ -0,0 +1,38 @@
using UnityEngine;
namespace Platformer.Mechanics
{
public partial class PatrolPath
{
/// <summary>
/// The Mover class oscillates between start and end points of a path at a defined speed.
/// </summary>
public class Mover
{
PatrolPath path;
float p = 0;
float duration;
float startTime;
public Mover(PatrolPath path, float speed)
{
this.path = path;
this.duration = (path.endPosition - path.startPosition).magnitude / speed;
this.startTime = Time.time;
}
/// <summary>
/// Get the position of the mover for the current frame.
/// </summary>
/// <value></value>
public Vector2 Position
{
get
{
p = Mathf.InverseLerp(0, duration, Mathf.PingPong(Time.time - startTime, duration));
return path.transform.TransformPoint(Vector2.Lerp(path.startPosition, path.endPosition, p));
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// This component is used to create a patrol path, two points which enemies will move between.
/// </summary>
public partial class PatrolPath : MonoBehaviour
{
/// <summary>
/// One end of the patrol path.
/// </summary>
public Vector2 startPosition, endPosition;
/// <summary>
/// Create a Mover instance which is used to move an entity along the path at a certain speed.
/// </summary>
/// <param name="speed"></param>
/// <returns></returns>
public Mover CreateMover(float speed = 1) => new Mover(this, speed);
void Reset()
{
startPosition = Vector3.left;
endPosition = Vector3.right;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// This class allows an audio clip to be played during an animation state.
/// </summary>
public class PlayAudioClip : StateMachineBehaviour
{
/// <summary>
/// The point in normalized time where the clip should play.
/// </summary>
public float t = 0.5f;
/// <summary>
/// If greater than zero, the normalized time will be (normalizedTime % modulus).
/// This is used to repeat the audio clip when the animation state loops.
/// </summary>
public float modulus = 0f;
/// <summary>
/// The audio clip to be played.
/// </summary>
public AudioClip clip;
float last_t = -1f;
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
var nt = stateInfo.normalizedTime;
if (modulus > 0f) nt %= modulus;
if (nt >= t && last_t < t)
AudioSource.PlayClipAtPoint(clip, animator.transform.position);
last_t = nt;
}
}
@@ -0,0 +1,151 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Platformer.Gameplay;
using static Platformer.Core.Simulation;
using Platformer.Model;
using Platformer.Core;
using UnityEngine.InputSystem;
namespace Platformer.Mechanics
{
/// <summary>
/// This is the main class used to implement control of the player.
/// It is a superset of the AnimationController class, but is inlined to allow for any kind of customisation.
/// </summary>
public class PlayerController : KinematicObject
{
public AudioClip jumpAudio;
public AudioClip respawnAudio;
public AudioClip ouchAudio;
/// <summary>
/// Max horizontal speed of the player.
/// </summary>
public float maxSpeed = 7;
/// <summary>
/// Initial jump velocity at the start of a jump.
/// </summary>
public float jumpTakeOffSpeed = 7;
public JumpState jumpState = JumpState.Grounded;
private bool stopJump;
/*internal new*/ public Collider2D collider2d;
/*internal new*/ public AudioSource audioSource;
public Health health;
public bool controlEnabled = true;
bool jump;
Vector2 move;
SpriteRenderer spriteRenderer;
internal Animator animator;
readonly PlatformerModel model = Simulation.GetModel<PlatformerModel>();
private InputAction m_MoveAction;
private InputAction m_JumpAction;
public Bounds Bounds => collider2d.bounds;
void Awake()
{
health = GetComponent<Health>();
audioSource = GetComponent<AudioSource>();
collider2d = GetComponent<Collider2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
animator = GetComponent<Animator>();
m_MoveAction = InputSystem.actions.FindAction("Player/Move");
m_JumpAction = InputSystem.actions.FindAction("Player/Jump");
m_MoveAction.Enable();
m_JumpAction.Enable();
}
protected override void Update()
{
if (controlEnabled)
{
move.x = m_MoveAction.ReadValue<Vector2>().x;
if (jumpState == JumpState.Grounded && m_JumpAction.WasPressedThisFrame())
jumpState = JumpState.PrepareToJump;
else if (m_JumpAction.WasReleasedThisFrame())
{
stopJump = true;
Schedule<PlayerStopJump>().player = this;
}
}
else
{
move.x = 0;
}
UpdateJumpState();
base.Update();
}
void UpdateJumpState()
{
jump = false;
switch (jumpState)
{
case JumpState.PrepareToJump:
jumpState = JumpState.Jumping;
jump = true;
stopJump = false;
break;
case JumpState.Jumping:
if (!IsGrounded)
{
Schedule<PlayerJumped>().player = this;
jumpState = JumpState.InFlight;
}
break;
case JumpState.InFlight:
if (IsGrounded)
{
Schedule<PlayerLanded>().player = this;
jumpState = JumpState.Landed;
}
break;
case JumpState.Landed:
jumpState = JumpState.Grounded;
break;
}
}
protected override void ComputeVelocity()
{
if (jump && IsGrounded)
{
velocity.y = jumpTakeOffSpeed * model.jumpModifier;
jump = false;
}
else if (stopJump)
{
stopJump = false;
if (velocity.y > 0)
{
velocity.y = velocity.y * model.jumpDeceleration;
}
}
if (move.x > 0.01f)
spriteRenderer.flipX = false;
else if (move.x < -0.01f)
spriteRenderer.flipX = true;
animator.SetBool("grounded", IsGrounded);
animator.SetFloat("velocityX", Mathf.Abs(velocity.x) / maxSpeed);
targetVelocity = move * maxSpeed;
}
public enum JumpState
{
Grounded,
PrepareToJump,
Jumping,
InFlight,
Landed
}
}
}
+12
View File
@@ -0,0 +1,12 @@
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// Marks a gameobject as a spawnpoint in a scene.
/// </summary>
public class SpawnPoint : MonoBehaviour
{
}
}
@@ -0,0 +1,71 @@
using UnityEngine;
namespace Platformer.Mechanics
{
/// <summary>
/// This class animates all token instances in a scene.
/// This allows a single update call to animate hundreds of sprite
/// animations.
/// If the tokens property is empty, it will automatically find and load
/// all token instances in the scene at runtime.
/// </summary>
public class TokenController : MonoBehaviour
{
[Tooltip("Frames per second at which tokens are animated.")]
public float frameRate = 12;
[Tooltip("Instances of tokens which are animated. If empty, token instances are found and loaded at runtime.")]
public TokenInstance[] tokens;
float nextFrameTime = 0;
[ContextMenu("Find All Tokens")]
void FindAllTokensInScene()
{
tokens = UnityEngine.Object.FindObjectsByType<TokenInstance>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
}
void Awake()
{
//if tokens are empty, find all instances.
//if tokens are not empty, they've been added at editor time.
if (tokens.Length == 0)
FindAllTokensInScene();
//Register all tokens so they can work with this controller.
for (var i = 0; i < tokens.Length; i++)
{
tokens[i].tokenIndex = i;
tokens[i].controller = this;
}
}
void Update()
{
//if it's time for the next frame...
if (Time.time - nextFrameTime > (1f / frameRate))
{
//update all tokens with the next animation frame.
for (var i = 0; i < tokens.Length; i++)
{
var token = tokens[i];
//if token is null, it has been disabled and is no longer animated.
if (token != null)
{
token._renderer.sprite = token.sprites[token.frame];
if (token.collected && token.frame == token.sprites.Length - 1)
{
token.gameObject.SetActive(false);
tokens[i] = null;
}
else
{
token.frame = (token.frame + 1) % token.sprites.Length;
}
}
}
//calculate the time of the next frame.
nextFrameTime += 1f / frameRate;
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// This class contains the data required for implementing token collection mechanics.
/// It does not perform animation of the token, this is handled in a batch by the
/// TokenController in the scene.
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class TokenInstance : MonoBehaviour
{
public AudioClip tokenCollectAudio;
[Tooltip("If true, animation will start at a random position in the sequence.")]
public bool randomAnimationStartTime = false;
[Tooltip("List of frames that make up the animation.")]
public Sprite[] idleAnimation, collectedAnimation;
internal Sprite[] sprites = new Sprite[0];
internal SpriteRenderer _renderer;
//unique index which is assigned by the TokenController in a scene.
internal int tokenIndex = -1;
internal TokenController controller;
//active frame in animation, updated by the controller.
internal int frame = 0;
internal bool collected = false;
void Awake()
{
_renderer = GetComponent<SpriteRenderer>();
if (randomAnimationStartTime)
frame = Random.Range(0, sprites.Length);
sprites = idleAnimation;
}
void OnTriggerEnter2D(Collider2D other)
{
//only exectue OnPlayerEnter if the player collides with this token.
var player = other.gameObject.GetComponent<PlayerController>();
if (player != null) OnPlayerEnter(player);
}
void OnPlayerEnter(PlayerController player)
{
if (collected) return;
//disable the gameObject and remove it from the controller update list.
frame = 0;
sprites = collectedAnimation;
if (controller != null)
collected = true;
//send an event into the gameplay system to perform some behaviour.
var ev = Schedule<PlayerTokenCollision>();
ev.token = this;
ev.player = player;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// Marks a trigger as a VictoryZone, usually used to end the current game level.
/// </summary>
public class VictoryZone : MonoBehaviour
{
void OnTriggerEnter2D(Collider2D collider)
{
var p = collider.gameObject.GetComponent<PlayerController>();
if (p != null)
{
var ev = Schedule<PlayerEnteredVictoryZone>();
ev.victoryZone = this;
}
}
}
}