scoring section

pull/1/head
Evan Hemsley 2019-06-04 17:59:56 -07:00
parent 0fe2a13c63
commit 5b4a9acee2
10 changed files with 489 additions and 1 deletions

View File

@ -99,7 +99,7 @@ Let's try it!
npm run love
```
<video width="640" height="360" autoplay="autoplay" muted="muted" loop="loop" style="display: block; margin: 0 auto; width: 640;">
<video width="75%" height="360" autoplay="autoplay" muted="muted" loop="loop" style="display: block; margin: 0 auto;">
<source src="/images/bouncing.webm" type="video/webm">
</video>

View File

@ -0,0 +1,282 @@
---
title: "Ball Respawn"
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.
We can easily implement a timer by using a Component.
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.
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.
Let's start with a vector with an _x_ component of 0 and a _y_ component of our desired speed. You can think of this as an arrow pointing straight downward. 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.
![pi over 2](/images/pi-over-2.png)
You can see that if we rotate our downward vector by pi/2 radians, it will face towards the left paddle. Now, what we want is for the ball to be served at angles like this:
![serve area](/images/serve-area.png)
The non-shaded area represents the angles that we want the ball to be served at. What angle is that exactly?
Well, a lot of what we do in game math is guesstimation. "Close enough" can be a powerful phrase! We can always easily tweak the exact values later if we architect our game properly.
![serve area angles](/images/serve-area-angles.png)
If we draw it out, we know that a quarter-circle rotation is pi/2 radians. The start of our serve range seems to be roughly half that. So that would be pi/4 radians. Sounds reasonable as a starting angle to me. What about the ending angle?
![three quarters circle](/images/three-quarters.png)
When we draw it out, we see that it is 3 eighth-circle rotations. So we get 3 * pi/4.
So now we need to 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.
Let's create **game/helpers/math.ts**:
```ts
export class MathHelper {
public static randomFloat(low: number, high: number): number {
return love.math.random() * high + low;
}
}
```
_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_.
One last note about rotations. What if the rotation is _negative_? Well, our positive rotations have been going clockwise - so negative rotations go counter-clockwise!
Now we can construct a formula for our random serve direction.
```ts
const direction = MathHelper.randomFloat(math.pi / 4, math.pi * 3 / 4) *
(love.math.random() > 0.5 ? 1 : -1);
```
What's that last bit on the second line? Remember, _love.math.random()_ returns a random number between 0 and 1. It has a 50% chance of being greater than 0.5. So that last 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 rotation 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!
Also, let's remember to destroy our timer entity at the end so it doesn't keep firing events. That would be bad!
Let's put it all together.
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 min_serve_angle: number;
private max_serve_angle: number;
private middle: number;
private height: number;
public initialize(
ball_size: number,
ball_speed: number,
min_serve_angle: number,
max_serve_angle: number,
middle: number,
height: number,
) {
this.ball_size = ball_size;
this.ball_speed = ball_speed;
this.min_serve_angle = min_serve_angle;
this.max_serve_angle = max_serve_angle;
this.middle = middle;
this.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.min_serve_angle,
this.max_serve_angle,
) * (love.math.random() > 0.5 ? 1 : -1);
[
ball_spawn_message.x_velocity,
ball_spawn_message.y_velocity,
] = vectorlight.rotate(direction, 0, this.ball_speed);
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.
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,
);
```
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.
```ts
const ball_timer_spawn_message = world_builder.emit_message(BallSpawnTimerSpawnMessage);
ball_timer_spawn_message.time = 1;
```
The moment of truth...
<video width="640" height="360" autoplay="autoplay" muted="muted" loop="loop" style="display: block; margin: 0 auto; width: 640;">
<source src="/images/ball_respawn.webm" type="video/webm">
</video>
Not bad. The angle is probably a bit generous as it is but we can leave it for now.

View File

@ -0,0 +1,77 @@
---
title: "Drawing the Score"
date: 2019-06-04T17:21:52-07:00
weight: 40
---
Remember Renderers? Haven't thought about those in a while.
All we need to draw new elements to the screen are Renderers. Let's create a new GeneralRenderer.
But first, we're gonna need a font. I liked [this font](https://www.dafont.com/squared-display.font). But you can pick any font you like. It's your world and you can do whatever you like in it.
Place the font of your heart's desire into the directory **game/assets/fonts**. Then it can be used in your game.
Let's write our ScoreRenderer.
In **game/renderers/score.ts**:
```ts
import { Component, GeneralRenderer, Type } from "encompass-ecs";
import { GoalOneComponent } from "game/components/goal_one";
import { GoalTwoComponent } from "game/components/goal_two";
import { ScoreComponent } from "game/components/score";
export class ScoreRenderer extends GeneralRenderer {
public layer = 1;
private midpoint: number;
private score_font: Font;
private player_one_score_text: Text;
private player_two_score_text: Text;
public initialize(midpoint: number) {
this.midpoint = midpoint;
this.score_font = love.graphics.newFont("game/assets/fonts/Squared Display.ttf", 128);
this.player_one_score_text = love.graphics.newText(this.score_font, "0");
this.player_two_score_text = love.graphics.newText(this.score_font, "0");
}
public render() {
this.render_score(GoalTwoComponent, this.player_two_score_text, this.midpoint - 200, 30);
this.render_score(GoalOneComponent, this.player_one_score_text, this.midpoint + 200, 30);
}
private render_score(ComponentType: Type<Component>, score_text: Text, x: number, y: number) {
const goal_component = this.read_component(ComponentType);
if (goal_component) {
const entity = this.get_entity(goal_component.entity_id);
if (entity) {
const score_component = entity.get_component(ScoreComponent);
if (score_component) {
score_text.set(score_component.score.toString());
love.graphics.draw(score_text, x, y);
}
}
}
}
}
```
Basically, we find each goal component, grab its score component, and draw the score component's value to the screen as text.
If we create new LOVE Text object every frame, this is very performance heavy. So we want to create a Text on initialization and then set its contents instead.
It's also very expensive to create a new LOVE Font every frame. Like the Text objects, we store it on the Renderer.
Let's add our ScoreRenderer to the WorldBuilder.
```ts
world_builder.add_renderer(ScoreRenderer);
```
<video width="75%" autoplay="autoplay" muted="muted" loop="loop" style="display: block; margin: 0 auto;">
<source src="/images/score.webm" type="video/webm">
</video>
Look at that! It's starting to look and feel like a more complete game now.

View File

@ -0,0 +1,129 @@
---
title: "Tracking the Score"
date: 2019-06-04T16:49:50-07:00
weight: 30
---
Finally, we need to track the score and update it appropriately.
I think a scoring Component is appropriate.
```ts
import { Component } from "encompass-ecs";
export class ScoreComponent extends Component {
public score: number;
}
```
We should have two different scores, and they should update based on which specific goal is touched by the ball.
I think we need another tag component.
```ts
import { Component } from "encompass-ecs";
export class GoalOneComponent extends Component {}
```
```ts
import { Component } from "encompass-ecs";
export class GoalTwoComponent extends Component {}
```
I know what you're thinking. Why not just have one GoalComponent with an index on it? Encompass lets us retrieve components from the game state by type, and it does so very quickly, so it is good to create structures that let us use that feature. Consider:
```ts
const goal_one_component = this.read_component(GoalOneComponent);
```
vs.
```ts
const goal_components = this.read_components(GoalComponent);
let goal_component;
for (const component of goal_components.values()) {
if (component.goal_index === 1) {
goal_component = component;
break;
}
}
```
The second one is way worse to read right? It's also much slower, performance-wise. Make use of Encompass's component retrieval structure wherever you can.
You know the drill by now.
Let's add a new property to our GoalSpawnMessage so we can tell which one is which.
```ts
public goal_index: number;
```
Let's add this in our GoalSpawner:
```ts
const score_component = entity.add_component(ScoreComponent);
score_component.score = 0;
if (message.goal_index === 0) {
entity.add_component(GoalOneComponent);
} else if (message.goal_index === 1) {
entity.add_component(GoalTwoComponent);
}
```
Now, we can tell which goal needs to have its score updated.
Let's create a score update message.
In **game/messages/score.ts**:
```ts
import { ComponentMessage, Message } from "encompass-ecs";
import { ScoreComponent } from "game/components/score";
export class ScoreMessage extends Message implements ComponentMessage {
public component: Readonly<ScoreComponent>;
public delta: number;
}
```
Now we can update our BallGoalCollisionEngine.
```ts
const score_component = message.goal_entity.get_component(ScoreComponent);
const score_message = this.emit_component_message(ScoreMessage, score_component);
score_message.delta = 1;
```
And let's create an engine to update the score.
In **game/engines/score.ts**:
```ts
import { Engine, Mutates, Reads } from "encompass-ecs";
import { ScoreComponent } from "game/components/score";
import { ScoreMessage } from "game/messages/score";
@Reads(ScoreMessage)
@Mutates(ScoreComponent)
export class ScoreEngine extends Engine {
public update() {
for (const score_message of this.read_messages(ScoreMessage).values()) {
const score_component = this.make_mutable(score_message.component);
score_component.score += score_message.delta;
}
}
}
```
And add it to our WorldBuilder:
```ts
world_builder.add_engine(ScoreEngine);
```
Last, but not least, it would be nice to actually see the score being drawn on the screen.

Binary file not shown.

BIN
static/images/pi-over-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
static/images/score.webm Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB