ball respawn randomness
continuous-integration/drone/push Build is passing Details

main
Evan Hemsley 2020-07-15 18:24:37 -07:00
parent db68dfcc31
commit 5b4c9d10c1
2 changed files with 50 additions and 276 deletions

View File

@ -4,139 +4,15 @@ date: 2019-06-04T10:44:53-07:00
weight: 20
---
In Pong, when the ball collides with the goal, we want it to respawn after a set amount of time, fired from a random point in the center of the play area in a variable direction.
Right now the ball respawns using fixed magic values. That's pretty boring and bad!
We can easily implement a timer by using a Component.
When the ball is served in Pong, it fires from a random position along the center line at a random angle (with some constraints). Sound's like we're gonna need some vector math.
Let's create a new Entity: a timed ball spawner.
You should be getting fairly familiar with this process by now. We'll need a Component, a Message, and a Spawner.
In **game/components/ball_spawn_timer.ts**:
```ts
import { Component } from "encompass-ecs";
export class BallSpawnTimerComponent extends Component {
public time_remaining: number;
}
```
In **game/messages/ball_spawn_timer_spawn.ts**:
```ts
import { Message } from "encompass-ecs";
export class BallSpawnTimerSpawnMessage extends Message {
public time: number;
}
```
In **game/engines/spawners/ball_spawn_timer.ts**:
```ts
import { Reads, Spawner } from "encompass-ecs";
import { BallSpawnTimerComponent } from "game/components/ball_spawn_timer";
import { BallSpawnTimerSpawnMessage } from "game/messages/ball_spawn_timer_spawn";
@Reads(BallSpawnTimerSpawnMessage)
export class BallSpawnTimerSpawner extends Spawner {
public spawn_message_type = BallSpawnTimerSpawnMessage;
public spawn(message: BallSpawnTimerSpawnMessage) {
const entity = this.create_entity();
const component = entity.add_component(BallSpawnTimerComponent);
component.time_remaining = message.time;
}
}
```
Finally we need an Engine to control the timer behavior and the firing of the ball spawn message.
When we serve the ball in Pong, it fires from a random position along the center line at a random angle (with some constraints). Sound's like we're gonna need some vector math.
If you don't know anything about vectors, a 2D vector is simply a mathematical structure composed of an _x_ and _y_ component. We generally use them to represent both position and velocity. There are certain clever mathematical operations we can do on vectors that make them very useful for games.
It turns out there is a very useful Lua library for 2D vector math in a repository called HUMP. Download it [here](https://github.com/vrld/hump/blob/master/vector-light.lua).
Create a new directory, **lua-lib/hump**, and add vectorlight.lua to the directory. Let's write a declaration file to go along with it.
In **lua-lib/hump/vectorlight.d.ts**:
```ts
/** @noSelfInFile */
export function str(x: number, y: number): string;
/** @tupleReturn */
export function mul(s: number, x: number, y: number): [number, number];
/** @tupleReturn */
export function div(s: number, x: number, y: number): [number, number];
/** @tupleReturn */
export function add(x1: number, y1: number, x2: number, y2: number): [number, number];
/** @tupleReturn */
export function sub(x1: number, y1: number, x2: number, y2: number): [number, number];
/** @tupleReturn */
export function permul(x1: number, y1: number, x2: number, y2: number): [number, number];
export function dot(x1: number, y1: number, x2: number, y2: number): number;
export function det(x1: number, y1: number, x2: number, y2: number): number;
export function eq(x1: number, y1: number, x2: number, y2: number): boolean;
export function lt(x1: number, y1: number, x2: number, y2: number): boolean;
export function le(x1: number, y1: number, x2: number, y2: number): boolean;
export function len2(x: number, y: number): number;
export function len(x: number, y: number): number;
/** @tupleReturn */
export function fromPolar(angle: number, radius: number): [number, number];
/** @tupleReturn */
export function randomDirection(len_min: number, len_max: number): [number, number];
/** @tupleReturn */
export function toPolar(x: number, y: number): [number, number];
export function dist2(x1: number, y1: number, x2: number, y2: number): number;
export function dist(x1: number, y1: number, x2: number, y2: number): number;
/** @tupleReturn */
export function normalize(x: number, y: number): [number, number];
/** @tupleReturn */
export function rotate(phi: number, x: number, y: number): [number, number];
/** @tupleReturn */
export function perpendicular(x: number, y: number): [number, number];
/** @tupleReturn */
export function project(x: number, y: number, u: number, v: number): [number, number];
/** @tupleReturn */
export function mirror(x: number, y: number, u: number, v: number): [number, number];
/** @tupleReturn */
export function trim(maxLen: number, x: number, y: number): [number, number];
export function angleTo(x: number, y: number, u: number, v: number): number;
```
Now we can use all these useful vector math functions in our game.
We haven't talked about vectors much yet, but to recap, a 2D vector is simply a mathematical structure composed of an _x_ and _y_ component. There are various clever mathematical operations we can do on vectors that make them very useful for games. C# provides us with a useful **System.Numerics** library that contains vector math structures; right now we use Vector2 to represent velocity.
Let's break down some of the math here.
We want the ball to fire in a random direction, but we don't want its trajectory to be too vertical, or it will take forever to get to one of the paddles, which is boring. We also want it to travel at the same speed regardless of its direction.
We want the ball to fire in a random direction, but we don't want its trajectory to be too vertical, or it will take forever to get to one of the paddles, and it will also be very hard to hit. We also want it to travel at the same speed regardless of its direction.
Let's start with a vector with an _x_ component of our desired speed and a _y_ component of 0. You can think of this as an arrow pointing directly to the right. Now imagine that arrow as the hand of a clock. How can we describe the angle of the clock hand? As the hand rotates around it differs from its original orientation in an amount of units called _radians_. When that angle changes by 2 times pi, it ends up in the same position. So a rotation a quarter of the way around the clock would be pi divided by 2.
@ -156,142 +32,77 @@ If we draw it out, we know that a quarter-circle rotation is pi/2 radians. The a
What if the rotation is _negative_? Well, our positive rotations have been going clockwise - so negative rotations go counter-clockwise! That means our possible serve angle is somewhere between -pi/4 and pi/4.
So now we need to actually pick a random rotation within this range. How should we do that? TypeScript and Lua don't have anything built-in for this. I usually write a helper for this, since it's so common to want a random real number in a certain range.
So now we need to actually pick a random rotation within this range. How should we do that? C# doesn't have anything built-in for this so I usually write a helper, since it's so common to want a random real number in a certain range.
Let's create **game/helpers/math.ts**:
Let's create **PongFE/Utility/MathHelper.cs**:
```ts
export class MathHelper {
public static randomFloat(low: number, high: number): number {
return love.math.random() * high + low;
```cs
using System;
public static class MathHelper
{
private readonly static Random s_random = new Random();
public static double RandomDouble(double min, double max)
{
return (s_random.NextDouble() * (max - min)) + min;
}
}
```
_love.math.random()_ returns a random real number between 0 and 1. So our _randomFloat_ function will return a random real number between _low_ and _high_.
**Random.NextDouble()** returns a random real number between 0 and 1. So our **RandomDouble** function will return a random real number between _low_ and _high_.
Now we can construct a formula for our random serve direction.
While we're at it, let's add a few more math helper functions.
```ts
const direction = MathHelper.randomFloat(-math.pi / 4, math.pi / 4);
```cs
public static int Dice(int n)
{
return (int)Math.Floor(RandomDouble(0, n));
}
public static bool CoinFlip()
{
return Dice(2) == 0;
}
```
Now we need the ball to be able to be served left or right. To make our direction point left, we can make the _x_ component of the vector negative.
Now we can construct a formula for our random serve direction. First, let's change the BallSpawner to take a speed value instead of a specific velocity.
```ts
const horizontal_speed = this.ball_speed * (love.math.random() > 0.5 ? 1 : -1);
```
Remember, _love.math.random()_ returns a random number between 0 and 1. It has a 50% chance of being greater than 0.5. So this expression means there's a 50% chance of that value being equal to 1, and a 50% chance of it being equal to -1. If we multiply the direction by negative 1, we are reversing its direction, so we have an equal chance of the ball being served to the left or the right. Spiffy!
Now we can put it all together to get our final velocity.
```ts
[
ball_spawn_message.x_velocity,
ball_spawn_message.y_velocity,
] = vectorlight.rotate(direction, horizontal_speed, 0);
```
This is an example of a _destructuring assignment_. It lets us multiple variables at the same time, which is particularly useful for vector math. You can read more about it [on the official TypeScript documentation](https://www.typescriptlang.org/docs/handbook/variable-declarations.html).
{{% notice notice %}}
*Why aren't we just using an object to represent the vector?*
Good question! My personal reason is that in Lua objects need to be garbage collected once they are done being used, so I like to avoid creating them when possible to avoid frame spikes, which is when a frame takes significantly longer to render than other frames. Regular numbers are not garbage collected so there is no danger of this happening.
{{% notice tip %}}
The difference between speed and velocity is that velocity contains directional information, while speed does not.
{{% /notice %}}
Let's put it all together.
**BallSpawnMessage.cs**:
```cs
public float Speed { get; }
In **game/engines/ball_spawn_timer.ts**:
...
```ts
import { Emits, Engine, Mutates } from "encompass-ecs";
import { BallSpawnTimerComponent } from "game/components/ball_spawn_timer";
import { MathHelper } from "game/helpers/math";
import { BallSpawnMessage } from "game/messages/ball_spawn";
import * as vectorlight from "lua-lib/hump/vectorlight";
@Mutates(BallSpawnTimerComponent)
@Emits(BallSpawnMessage)
export class BallSpawnTimerEngine extends Engine {
private ball_size: number;
private ball_speed: number;
private serve_angle: number;
private middle: number;
private height: number;
public initialize(
ball_size: number,
ball_speed: number,
serve_angle: number,
middle: number,
height: number,
) {
this.ball_size = ball_size;
this.ball_speed = ball_speed;
this.serve_angle = serve_angle;
this.middle = middle;
this.height = height;
public BallSpawnMessage(Position2D position, float speed, int width, int height)
{
Position = position;
Speed = speed;
Width = width;
Height = height;
}
public update(dt: number) {
for (const component of this.read_components_mutable(BallSpawnTimerComponent).values()) {
component.time_remaining -= dt;
if (component.time_remaining <= 0) {
const ball_spawn_message = this.emit_message(BallSpawnMessage);
ball_spawn_message.x = this.middle;
ball_spawn_message.y = love.math.random() * this.height;
const direction = MathHelper.randomFloat(
-this.serve_angle,
this.serve_angle,
);
const horizontal_speed = this.ball_speed * (love.math.random() > 0.5 ? 1 : -1);
[
ball_spawn_message.x_velocity,
ball_spawn_message.y_velocity,
] = vectorlight.rotate(direction, horizontal_speed, 0);
ball_spawn_message.size = this.ball_size;
this.get_entity(component.entity_id)!.destroy();
}
}
}
}
```
Every frame we subtract the remaining time by the delta-time value. Once it less than or equal to zero, we fire a BallSpawnMessage and destroy the timer entity.
Now, in **BallSpawner.cs**:
Notice that we remembered to destroy our timer entity at the end so it doesn't keep firing new ball spawn messages every frame. That would be bad!
Don't forget to register our new Engines with the WorldBuilder.
```ts
world_builder.add_engine(BallSpawnTimerSpawner);
world_builder.add_engine(BallSpawnTimerEngine).initialize(
ball_size,
ball_speed,
math.pi / 4,
3 * math.pi / 4,
play_area_width * 0.5,
play_area_height,
);
```cs
var direction = (float)MathHelper.RandomDouble(-System.Math.PI / 4.0, System.Math.PI / 4.0);
var velocity = new Vector2(message.Speed * (MathHelper.CoinFlip() ? -1 : 1), 0).Rotate(direction);
AddComponent(ball, new VelocityComponent(velocity));
```
While we're in **game.ts**, let's also change our game start ball spawn to use our fancy new timer based ball spawn system.
We need the ball to be able to be served left or right. To make our direction point left, we can make the _x_ component of the vector negative. So the above formula gives a 50% chance of serving left or right.
```ts
const ball_timer_spawn_message = world_builder.emit_message(BallSpawnTimerSpawnMessage);
ball_timer_spawn_message.time = 1;
```
This maybe isn't the best place for this logic, but since right now we always want the ball to be served randomly, it's probably fine to have this logic in the BallSpawner. Sometimes it isn't the best use of time to write the most abstract-possible system all up front if you're pretty sure you aren't gonna need it. You will have to use your judgment to make these kinds of calls.
The moment of truth...
*insert video here*
<video width="75%" autoplay="autoplay" muted="muted" loop="loop" style="display: block; margin: 0 auto;">
<source src="/images/ball_respawn.webm" type="video/webm">
</video>

View File

@ -66,43 +66,6 @@ namespace PongFE.Messages
}
```
And **PongFE/Engines/DestroyEngine.cs**:
```cs
using Encompass;
using PongFE.Components;
using PongFE.Messages;
namespace PongFE.Engines
{
[Reads(typeof(SpawnBallAfterDestroyComponent))]
[Receives(typeof(DestroyMessage))]
[Sends(typeof(BallSpawnMessage))]
public class DestroyEngine : Engine
{
public override void Update(double dt)
{
foreach (ref readonly var message in ReadMessages<DestroyMessage>())
{
if (HasComponent<SpawnBallAfterDestroyComponent>(message.Entity))
{
ref readonly var respawnComponent = ref GetComponent<SpawnBallAfterDestroyComponent>(message.Entity);
SendMessage(new BallSpawnMessage(
new MoonTools.Structs.Position2D(640, 360),
new System.Numerics.Vector2(-200, 100),
16,
16
));
}
Destroy(message.Entity);
}
}
}
}
```
In **CollisionEngine.cs**:
```cs