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
+32
View File
@@ -0,0 +1,32 @@
using UnityEngine;
namespace Platformer.Core
{
/// <summary>
/// Fuzzy provides methods for using values +- an amount of random deviation, or fuzz.
/// </summary>
static class Fuzzy
{
public static bool ValueLessThan(float value, float test, float fuzz = 0.1f)
{
var delta = value - test;
return delta < 0 ? true : Random.value > delta / (fuzz * test);
}
public static bool ValueGreaterThan(float value, float test, float fuzz = 0.1f)
{
var delta = value - test;
return delta < 0 ? Random.value > -1 * delta / (fuzz * test) : true;
}
public static bool ValueNear(float value, float test, float fuzz = 0.1f)
{
return Mathf.Abs(1f - (value / test)) < fuzz;
}
public static float Value(float value, float fuzz = 0.1f)
{
return value + value * Random.Range(-fuzz, +fuzz);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
namespace Platformer.Core
{
/// <summary>
/// HeapQueue provides a queue collection that is always ordered.
/// </summary>
/// <typeparam name="T"></typeparam>
public class HeapQueue<T> where T : IComparable<T>
{
List<T> items;
public int Count { get { return items.Count; } }
public bool IsEmpty { get { return items.Count == 0; } }
public T First { get { return items[0]; } }
public void Clear() => items.Clear();
public bool Contains(T item) => items.Contains(item);
public void Remove(T item) => items.Remove(item);
public T Peek() => items[0];
public HeapQueue()
{
items = new List<T>();
}
public void Push(T item)
{
//add item to end of tree to extend the list
items.Add(item);
//find correct position for new item.
SiftDown(0, items.Count - 1);
}
public T Pop()
{
//if there are more than 1 items, returned item will be first in tree.
//then, add last item to front of tree, shrink the list
//and find correct index in tree for first item.
T item;
var last = items[items.Count - 1];
items.RemoveAt(items.Count - 1);
if (items.Count > 0)
{
item = items[0];
items[0] = last;
SiftUp();
}
else
{
item = last;
}
return item;
}
int Compare(T A, T B) => A.CompareTo(B);
void SiftDown(int startpos, int pos)
{
//preserve the newly added item.
var newitem = items[pos];
while (pos > startpos)
{
//find parent index in binary tree
var parentpos = (pos - 1) >> 1;
var parent = items[parentpos];
//if new item precedes or equal to parent, pos is new item position.
if (Compare(parent, newitem) <= 0)
break;
//else move parent into pos, then repeat for grand parent.
items[pos] = parent;
pos = parentpos;
}
items[pos] = newitem;
}
void SiftUp()
{
var endpos = items.Count;
var startpos = 0;
//preserve the inserted item
var newitem = items[0];
var childpos = 1;
var pos = 0;
//find child position to insert into binary tree
while (childpos < endpos)
{
//get right branch
var rightpos = childpos + 1;
//if right branch should precede left branch, move right branch up the tree
if (rightpos < endpos && Compare(items[rightpos], items[childpos]) <= 0)
childpos = rightpos;
//move child up the tree
items[pos] = items[childpos];
pos = childpos;
//move down the tree and repeat.
childpos = 2 * pos + 1;
}
//the child position for the new item.
items[pos] = newitem;
SiftDown(startpos, pos);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Collections.Generic;
namespace Platformer.Core
{
public static partial class Simulation
{
/// <summary>
/// An event is something that happens at a point in time in a simulation.
/// The Precondition method is used to check if the event should be executed,
/// as conditions may have changed in the simulation since the event was
/// originally scheduled.
/// </summary>
/// <typeparam name="Event"></typeparam>
public abstract class Event : System.IComparable<Event>
{
internal float tick;
public int CompareTo(Event other)
{
return tick.CompareTo(other.tick);
}
public abstract void Execute();
public virtual bool Precondition() => true;
internal virtual void ExecuteEvent()
{
if (Precondition())
Execute();
}
/// <summary>
/// This method is generally used to set references to null when required.
/// It is automatically called by the Simulation when an event has completed.
/// </summary>
internal virtual void Cleanup()
{
}
}
/// <summary>
/// Event<T> adds the ability to hook into the OnExecute callback
/// whenever the event is executed. Use this class to allow functionality
/// to be plugged into your application with minimal or zero configuration.
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class Event<T> : Event where T : Event<T>
{
public static System.Action<T> OnExecute;
internal override void ExecuteEvent()
{
if (Precondition())
{
Execute();
OnExecute?.Invoke((T)this);
}
}
}
}
}
@@ -0,0 +1,16 @@
namespace Platformer.Core
{
public static partial class Simulation
{
/// <summary>
/// This class provides a container for creating singletons for any other class,
/// within the scope of the Simulation. It is typically used to hold the simulation
/// models and configuration classes.
/// </summary>
/// <typeparam name="T"></typeparam>
static class InstanceRegister<T> where T : class, new()
{
public static T instance = new T();
}
}
}
+140
View File
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Platformer.Core
{
/// <summary>
/// The Simulation class implements the discrete event simulator pattern.
/// Events are pooled, with a default capacity of 4 instances.
/// </summary>
public static partial class Simulation
{
static HeapQueue<Event> eventQueue = new HeapQueue<Event>();
static Dictionary<System.Type, Stack<Event>> eventPools = new Dictionary<System.Type, Stack<Event>>();
/// <summary>
/// Create a new event of type T and return it, but do not schedule it.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
static public T New<T>() where T : Event, new()
{
Stack<Event> pool;
if (!eventPools.TryGetValue(typeof(T), out pool))
{
pool = new Stack<Event>(4);
pool.Push(new T());
eventPools[typeof(T)] = pool;
}
if (pool.Count > 0)
return (T)pool.Pop();
else
return new T();
}
/// <summary>
/// Clear all pending events and reset the tick to 0.
/// </summary>
public static void Clear()
{
eventQueue.Clear();
}
/// <summary>
/// Schedule an event for a future tick, and return it.
/// </summary>
/// <returns>The event.</returns>
/// <param name="tick">Tick.</param>
/// <typeparam name="T">The event type parameter.</typeparam>
static public T Schedule<T>(float tick = 0) where T : Event, new()
{
var ev = New<T>();
ev.tick = Time.time + tick;
eventQueue.Push(ev);
return ev;
}
/// <summary>
/// Reschedule an existing event for a future tick, and return it.
/// </summary>
/// <returns>The event.</returns>
/// <param name="tick">Tick.</param>
/// <typeparam name="T">The event type parameter.</typeparam>
static public T Reschedule<T>(T ev, float tick) where T : Event, new()
{
ev.tick = Time.time + tick;
eventQueue.Push(ev);
return ev;
}
/// <summary>
/// Return the simulation model instance for a class.
/// </summary>
/// <typeparam name="T"></typeparam>
static public T GetModel<T>() where T : class, new()
{
return InstanceRegister<T>.instance;
}
/// <summary>
/// Set a simulation model instance for a class.
/// </summary>
/// <typeparam name="T"></typeparam>
static public void SetModel<T>(T instance) where T : class, new()
{
InstanceRegister<T>.instance = instance;
}
/// <summary>
/// Destroy the simulation model instance for a class.
/// </summary>
/// <typeparam name="T"></typeparam>
static public void DestroyModel<T>() where T : class, new()
{
InstanceRegister<T>.instance = null;
}
/// <summary>
/// Tick the simulation. Returns the count of remaining events.
/// If remaining events is zero, the simulation is finished unless events are
/// injected from an external system via a Schedule() call.
/// </summary>
/// <returns></returns>
static public int Tick()
{
var time = Time.time;
var executedEventCount = 0;
while (eventQueue.Count > 0 && eventQueue.Peek().tick <= time)
{
var ev = eventQueue.Pop();
var tick = ev.tick;
ev.ExecuteEvent();
if (ev.tick > tick)
{
//event was rescheduled, so do not return it to the pool.
}
else
{
// Debug.Log($"<color=green>{ev.tick} {ev.GetType().Name}</color>");
ev.Cleanup();
try
{
eventPools[ev.GetType()].Push(ev);
}
catch (KeyNotFoundException)
{
//This really should never happen inside a production build.
Debug.LogError($"No Pool for: {ev.GetType()}");
}
}
executedEventCount++;
}
return eventQueue.Count;
}
}
}
@@ -0,0 +1,19 @@
using Platformer.Core;
using Platformer.Model;
namespace Platformer.Gameplay
{
/// <summary>
/// This event is fired when user input should be enabled.
/// </summary>
public class EnablePlayerInput : Simulation.Event<EnablePlayerInput>
{
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
var player = model.player;
player.controlEnabled = true;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using Platformer.Core;
using Platformer.Mechanics;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the health component on an enemy has a hitpoint value of 0.
/// </summary>
/// <typeparam name="EnemyDeath"></typeparam>
public class EnemyDeath : Simulation.Event<EnemyDeath>
{
public EnemyController enemy;
public override void Execute()
{
enemy._collider.enabled = false;
enemy.control.enabled = false;
if (enemy._audio && enemy.ouch)
enemy._audio.PlayOneShot(enemy.ouch);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Platformer.Core;
using Platformer.Mechanics;
using static Platformer.Core.Simulation;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the player health reaches 0. This usually would result in a
/// PlayerDeath event.
/// </summary>
/// <typeparam name="HealthIsZero"></typeparam>
public class HealthIsZero : Simulation.Event<HealthIsZero>
{
public Health health;
public override void Execute()
{
Schedule<PlayerDeath>();
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.Collections;
using System.Collections.Generic;
using Platformer.Core;
using Platformer.Model;
using UnityEngine;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the player has died.
/// </summary>
/// <typeparam name="PlayerDeath"></typeparam>
public class PlayerDeath : Simulation.Event<PlayerDeath>
{
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
var player = model.player;
if (player.health.IsAlive)
{
player.health.Die();
model.virtualCamera.Follow = null;
model.virtualCamera.LookAt = null;
// player.collider.enabled = false;
player.controlEnabled = false;
if (player.audioSource && player.ouchAudio)
player.audioSource.PlayOneShot(player.ouchAudio);
player.animator.SetTrigger("hurt");
player.animator.SetBool("dead", true);
Simulation.Schedule<PlayerSpawn>(2);
}
}
}
}
@@ -0,0 +1,53 @@
using Platformer.Core;
using Platformer.Mechanics;
using Platformer.Model;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when a Player collides with an Enemy.
/// </summary>
/// <typeparam name="EnemyCollision"></typeparam>
public class PlayerEnemyCollision : Simulation.Event<PlayerEnemyCollision>
{
public EnemyController enemy;
public PlayerController player;
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
var willHurtEnemy = player.Bounds.center.y >= enemy.Bounds.max.y;
if (willHurtEnemy)
{
var enemyHealth = enemy.GetComponent<Health>();
if (enemyHealth != null)
{
enemyHealth.Decrement();
if (!enemyHealth.IsAlive)
{
Schedule<EnemyDeath>().enemy = enemy;
player.Bounce(2);
}
else
{
player.Bounce(7);
}
}
else
{
Schedule<EnemyDeath>().enemy = enemy;
player.Bounce(2);
}
}
else
{
Schedule<PlayerDeath>();
}
}
}
}
@@ -0,0 +1,22 @@
using Platformer.Core;
using Platformer.Mechanics;
using Platformer.Model;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when a player enters a trigger with a DeathZone component.
/// </summary>
/// <typeparam name="PlayerEnteredDeathZone"></typeparam>
public class PlayerEnteredDeathZone : Simulation.Event<PlayerEnteredDeathZone>
{
public DeathZone deathzone;
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
Simulation.Schedule<PlayerDeath>(0);
}
}
}
@@ -0,0 +1,24 @@
using Platformer.Core;
using Platformer.Mechanics;
using Platformer.Model;
namespace Platformer.Gameplay
{
/// <summary>
/// This event is triggered when the player character enters a trigger with a VictoryZone component.
/// </summary>
/// <typeparam name="PlayerEnteredVictoryZone"></typeparam>
public class PlayerEnteredVictoryZone : Simulation.Event<PlayerEnteredVictoryZone>
{
public VictoryZone victoryZone;
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
model.player.animator.SetTrigger("victory");
model.player.controlEnabled = false;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using Platformer.Core;
using Platformer.Mechanics;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the player performs a Jump.
/// </summary>
/// <typeparam name="PlayerJumped"></typeparam>
public class PlayerJumped : Simulation.Event<PlayerJumped>
{
public PlayerController player;
public override void Execute()
{
if (player.audioSource && player.jumpAudio)
player.audioSource.PlayOneShot(player.jumpAudio);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using Platformer.Core;
using Platformer.Mechanics;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the player character lands after being airborne.
/// </summary>
/// <typeparam name="PlayerLanded"></typeparam>
public class PlayerLanded : Simulation.Event<PlayerLanded>
{
public PlayerController player;
public override void Execute()
{
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Platformer.Core;
using Platformer.Mechanics;
using Platformer.Model;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the player is spawned after dying.
/// </summary>
public class PlayerSpawn : Simulation.Event<PlayerSpawn>
{
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
var player = model.player;
player.collider2d.enabled = true;
player.controlEnabled = false;
if (player.audioSource && player.respawnAudio)
player.audioSource.PlayOneShot(player.respawnAudio);
player.health.Increment();
player.Teleport(model.spawnPoint.transform.position);
player.jumpState = PlayerController.JumpState.Grounded;
player.animator.SetBool("dead", false);
model.virtualCamera.Follow = player.transform;
model.virtualCamera.LookAt = player.transform;
Simulation.Schedule<EnablePlayerInput>(2f);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using Platformer.Core;
using Platformer.Mechanics;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when the Jump Input is deactivated by the user, cancelling the upward velocity of the jump.
/// </summary>
/// <typeparam name="PlayerStopJump"></typeparam>
public class PlayerStopJump : Simulation.Event<PlayerStopJump>
{
public PlayerController player;
public override void Execute()
{
}
}
}
@@ -0,0 +1,24 @@
using Platformer.Core;
using Platformer.Mechanics;
using Platformer.Model;
using UnityEngine;
namespace Platformer.Gameplay
{
/// <summary>
/// Fired when a player collides with a token.
/// </summary>
/// <typeparam name="PlayerCollision"></typeparam>
public class PlayerTokenCollision : Simulation.Event<PlayerTokenCollision>
{
public PlayerController player;
public TokenInstance token;
PlatformerModel model = Simulation.GetModel<PlatformerModel>();
public override void Execute()
{
AudioSource.PlayClipAtPoint(token.tokenCollectAudio, token.transform.position);
}
}
}
@@ -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;
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
using Platformer.Mechanics;
using UnityEngine;
namespace Platformer.Model
{
/// <summary>
/// The main model containing needed data to implement a platformer style
/// game. This class should only contain data, and methods that operate
/// on the data. It is initialised with data in the GameController class.
/// </summary>
[System.Serializable]
public class PlatformerModel
{
/// <summary>
/// The virtual camera in the scene.
/// </summary>
public Unity.Cinemachine.CinemachineCamera virtualCamera;
/// <summary>
/// The main component which controls the player sprite, controlled
/// by the user.
/// </summary>
public PlayerController player;
/// <summary>
/// The spawn point in the scene.
/// </summary>
public Transform spawnPoint;
/// <summary>
/// A global jump modifier applied to all initial jump velocities.
/// </summary>
public float jumpModifier = 1.5f;
/// <summary>
/// A global jump modifier applied to slow down an active jump when
/// the user releases the jump input.
/// </summary>
public float jumpDeceleration = 0.5f;
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Platformer.UI
{
/// <summary>
/// A simple controller for switching between UI panels.
/// </summary>
public class MainUIController : MonoBehaviour
{
public GameObject[] panels;
public void SetActivePanel(int index)
{
for (var i = 0; i < panels.Length; i++)
{
var active = i == index;
var g = panels[i];
if (g.activeSelf != active) g.SetActive(active);
}
}
void OnEnable()
{
SetActivePanel(0);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using Platformer.Mechanics;
using Platformer.UI;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Platformer.UI
{
/// <summary>
/// The MetaGameController is responsible for switching control between the high level
/// contexts of the application, eg the Main Menu and Gameplay systems.
/// </summary>
public class MetaGameController : MonoBehaviour
{
/// <summary>
/// The main UI object which used for the menu.
/// </summary>
public MainUIController mainMenu;
/// <summary>
/// A list of canvas objects which are used during gameplay (when the main ui is turned off)
/// </summary>
public Canvas[] gamePlayCanvasii;
/// <summary>
/// The game controller.
/// </summary>
public GameController gameController;
bool showMainCanvas = false;
private InputAction m_MenuAction;
void OnEnable()
{
_ToggleMainMenu(showMainCanvas);
m_MenuAction = InputSystem.actions.FindAction("Player/Menu");
}
/// <summary>
/// Turn the main menu on or off.
/// </summary>
/// <param name="show"></param>
public void ToggleMainMenu(bool show)
{
if (this.showMainCanvas != show)
{
_ToggleMainMenu(show);
}
}
void _ToggleMainMenu(bool show)
{
if (show)
{
Time.timeScale = 0;
mainMenu.gameObject.SetActive(true);
foreach (var i in gamePlayCanvasii) i.gameObject.SetActive(false);
}
else
{
Time.timeScale = 1;
mainMenu.gameObject.SetActive(false);
foreach (var i in gamePlayCanvasii) i.gameObject.SetActive(true);
}
this.showMainCanvas = show;
}
void Update()
{
if (m_MenuAction.WasPressedThisFrame())
{
ToggleMainMenu(show: !showMainCanvas);
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Collections;
using System.Collections.Generic;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
using UnityEngine.Tilemaps;
namespace Platformer.View
{
[System.Serializable]
[CreateAssetMenu(fileName = "New Animated Tile", menuName = "Tiles/Animated Tile")]
public class AnimatedTile : TileBase
{
public Sprite[] m_AnimatedSprites;
public float m_MinSpeed = 1f;
public float m_MaxSpeed = 1f;
public float m_AnimationStartTime;
public Tile.ColliderType m_TileColliderType;
public override void GetTileData(Vector3Int location, ITilemap tileMap, ref TileData tileData)
{
tileData.transform = Matrix4x4.identity;
tileData.color = Color.white;
if (m_AnimatedSprites != null && m_AnimatedSprites.Length > 0)
{
tileData.sprite = m_AnimatedSprites[m_AnimatedSprites.Length - 1];
tileData.colliderType = m_TileColliderType;
}
}
public override bool GetTileAnimationData(Vector3Int location, ITilemap tileMap, ref TileAnimationData tileAnimationData)
{
if (m_AnimatedSprites.Length > 0)
{
tileAnimationData.animatedSprites = m_AnimatedSprites;
tileAnimationData.animationSpeed = Random.Range(m_MinSpeed, m_MaxSpeed);
tileAnimationData.animationStartTime = m_AnimationStartTime;
return true;
}
return false;
}
}
#if UNITY_EDITOR
[CustomEditor(typeof(AnimatedTile))]
public class AnimatedTileEditor : Editor
{
private AnimatedTile tile { get { return (target as AnimatedTile); } }
public override void OnInspectorGUI()
{
EditorGUI.BeginChangeCheck();
int count = EditorGUILayout.DelayedIntField("Number of Animated Sprites", tile.m_AnimatedSprites != null ? tile.m_AnimatedSprites.Length : 0);
if (count < 0)
count = 0;
if (tile.m_AnimatedSprites == null || tile.m_AnimatedSprites.Length != count)
{
System.Array.Resize<Sprite>(ref tile.m_AnimatedSprites, count);
}
if (count == 0)
return;
EditorGUILayout.LabelField("Place sprites shown based on the order of animation.");
EditorGUILayout.Space();
for (int i = 0; i < count; i++)
{
tile.m_AnimatedSprites[i] = (Sprite)EditorGUILayout.ObjectField("Sprite " + (i + 1), tile.m_AnimatedSprites[i], typeof(Sprite), false, null);
}
float minSpeed = EditorGUILayout.FloatField("Minimum Speed", tile.m_MinSpeed);
float maxSpeed = EditorGUILayout.FloatField("Maximum Speed", tile.m_MaxSpeed);
if (minSpeed < 0.0f)
minSpeed = 0.0f;
if (maxSpeed < 0.0f)
maxSpeed = 0.0f;
if (maxSpeed < minSpeed)
maxSpeed = minSpeed;
tile.m_MinSpeed = minSpeed;
tile.m_MaxSpeed = maxSpeed;
tile.m_AnimationStartTime = EditorGUILayout.FloatField("Start Time", tile.m_AnimationStartTime);
tile.m_TileColliderType = (Tile.ColliderType)EditorGUILayout.EnumPopup("Collider Type", tile.m_TileColliderType);
if (EditorGUI.EndChangeCheck())
EditorUtility.SetDirty(tile);
}
}
#endif
}
+29
View File
@@ -0,0 +1,29 @@
using UnityEngine;
namespace Platformer.View
{
/// <summary>
/// Used to move a transform relative to the main camera position with a scale factor applied.
/// This is used to implement parallax scrolling effects on different branches of gameobjects.
/// </summary>
public class ParallaxLayer : MonoBehaviour
{
/// <summary>
/// Movement of the layer is scaled by this value.
/// </summary>
public Vector3 movementScale = Vector3.one;
Transform _camera;
void Awake()
{
_camera = Camera.main.transform;
}
void LateUpdate()
{
transform.position = Vector3.Scale(_camera.position, movementScale);
}
}
}