This commit is contained in:
sfj 2025-09-08 13:13:28 +02:00
commit a1bf5f3421
26 changed files with 1418 additions and 0 deletions

BIN
assets/bullet1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

BIN
assets/bullet2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

BIN
assets/enemy1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

BIN
assets/enemy2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

BIN
assets/explosion1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

BIN
assets/explosion2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

BIN
assets/explosion3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

BIN
assets/explosion4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

BIN
assets/gameover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
assets/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/images/extract_all.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

53
docs/setup_danish.md Normal file
View File

@ -0,0 +1,53 @@
# Guide til at sætte kode-miljøet op
## Download VS Code
https://code.visualstudio.com/
Hent *Live Server*-udvidelsen til VS Code, fra udvidelses-menuen.
## Hent koden
Gå til https://git.sfja.dk/sfja/game-in-js og download koden som ZIP-fil.
![download the game-platform files](images/download_code_zip.png)
Find ZIP-mappen på computeren og udpak ZIP-mappen's filer. Vælg for eksempel at lægge filerne på Desktop/Skrivebordet.
![extract all](images/extract_all.png)
Herefter åben den ny-udpakkede mappe i VS Code.
![open with code](images/open_with_code.png)
## Start Live Server
Tryk på "Go Live" i bunden til højre i VS Code og åben http://localhost:5500/ i en web-browser som Chrome eller Firefox.
## Tegn en firkant
Åben filen `game.js`. Dette er allerede i filen:
```js
import * as lib from "./lib/lib.js"
function loop() {
lib.canvas.fillRect(100, 100, 200, 50, "red");
}
lib.startGame(loop);
```
Tilføj en linje i `loop`-funktionen:
```js
function loop() {
// ...
lib.canvas.fillRect(100, 200, 50, 50, "lightblue");
}
```
Der burde nu være 2 firkanter på skærmen.
![two rectangles](images/two_rectangles.png)

View File

@ -0,0 +1,571 @@
# Guide til at lave et Space Invaders-spil
Denne guide viser dig, hvordan du kan lave en Space Invaders-spil.
![game](images/space_invaders_game.gif)
I Space Invaders er man et rumskip, er kan bevæge sig fra side til side. Man kigger op ad mod himlen.
Oppe i himlen nærmer der sig nogle rumskibe. Dem skal man forsøge at skyde ned.
## 1 Opsætning
Følg [Opsætningsguiden](setup_danish.md) og start med denne kode.
```js
import * as lib from "./lib/lib.js"
function loop() {
lib.canvas.fillRect(100, 100, 200, 50, "red");
}
lib.startGame(loop);
```
Den første import-linje henter kode-biblioteket, `lib` er en forkortelse for *library*.
Funktionen `loop` er et stykke kode, der bliver kørt hver frame (dvs. det bliver kørt igen og igen).
Linjen `lib.canvas.fillRect(100, 100, 200, 50, "red")` tegner en rød firkant på skærmen, med kordinaterne `(x: 100, y: 100)` og størrelsen `(width: 200, height: 50)`.
## 2 Tegn spilleren
Hent spiller-sprite'en.
```js
const playerSprite = await lib.texture.loadImage("assets/player.png", 64, 64);
```
Giv spriten'en en orange farve.
```js
playerSprite.adjustColor(1, 0.5, 0);
```
`(1, 0.5, 0)` betyder 100% rød, 50% grøn og 0% blå.
Lav en variabel til spillerens x-position.
```js
let playerX = 0;
```
Tegn spilleren ved at erstatte koden i `loop` med:
```js
function loop() {
const cx = lib.canvas;
cx.clear("black");
cx.putTexture(playerSprite, playerX, 300);
}
```
Der burde nu være en orange spiller på skærmen.
![orange player on screen](images/orange_player_on_screen.png)
## 3 Kontroller spillerens bevægelse
Spilleren skal kunne flytte sig fra side til side med piletasterne.
Tilføj en konstant med spillerens fart.
```js
const playerSpeed = 300;
```
Tilføj en funktion til at opdatere spiller-bevægelse.
```js
function loop() {
updatePlayerMovement();
// ...
}
function updatePlayerMovement() {
}
```
`// ...` betyder resten af koden fra før.
Find ud af om piletasterne (← og →) bliver trykket ned.
```js
function updatePlayerMovement() {
const goLeft = lib.isPressed("ArrowLeft");
const goRight = lib.isPressed("ArrowRight");
}
```
Lav en if-statement, som tjekker, om spilleren skal til højre eller venstre.
```js
function updatePlayerMovement() {
// ...
if (goLeft && !goRight) {
// player should go left
} else if (goRight && !goLeft) {
// player should go right
}
}
```
Flyt spilleren, hvis spilleren skal flytte.
```js
function updatePlayerMovement() {
// ...
if (goLeft && !goRight) {
// player should go left
playerX -= playerSpeed * lib.frameDeltaT;
} else if (goRight && !goLeft) {
// player should go right
playerX += playerSpeed * lib.frameDeltaT;
}
}
```
Hvis spilleren går for langt til venstre eller for langt til højre, skal spilleren blive ved kanten. Tjek og spilleren går ud af banen og flyt spilleren tilbage.
```js
function updatePlayerMovement() {
// ...
if (goLeft && !goRight) {
// ...
if (playerX < 0) {
playerX = 0;
}
} else if (goRight && !goLeft) {
// ...
if (playerX >= lib.canvas.width - playerSprite.width) {
playerX = lib.canvas.width - playerSprite.width;
}
}
}
```
Spilleren burde nu kunne flytte sig fra side til side på skærmen. (Sørg for at canvas'en er i focus).
## 4 Skyd
Spilleren skal kunne affyre skud ved at trykke på mellemrumstasten. Et skud bliver affyret fra spillerens rumskip og flyver opad.
Importer skud-sprite'en.
```js
const bulletSprite = await lib.texture.loadImage("assets/bullet1.png", 24, 24);
```
Roter sprite'en 180° (det samme som 1 × π i [radianer](https://da.wikipedia.org/wiki/Radian)).
```js
bulletSprite.rotate(Math.PI);
```
Lav en konstant for skud-hastighed.
```js
const bulletSpeed = 400;
```
Lav en variable med et tomt array af skud.
```js
let bullets = [];
```
Lav en cooldown-timer til skud med en konstant og en variabel.
```js
const bulletCoolDown = 0.5;
// the first shot should not have cooldown
let bulletCooldownTimer = bulletCoolDown;
```
Lav en *event handler* til når man trykker mellemrum.
```js
lib.onPress(" ", () => {
});
```
Lav timer-håndteringen.
```js
function loop() {
bulletCooldownTimer += lib.frameDeltaT;
// ..
}
lib.onPress(" ", () => {
if (bulletCooldownTimer >= bulletCoolDown) {
bulletCooldownTimer = 0;
}
});
```
Tilføj et skud til `bullets`-array'et. Skuddet *spawner* ved spillerens position.
```js
lib.onPress(" ", () => {
if (bulletCooldownTimer >= bulletCoolDown) {
// ...
bullets.push({ x: playerX + 20, y: 300 });
}
});
```
Tilføj en funktion til at opdatere skud.
```js
function loop() {
// ...
updateBulletMovement();
// ...
}
function updateBulletMovement() {
}
```
Loop igennem `bullets`-array'et.
```js
function updateBulletMovement() {
for (const bullet of bullets) {
}
}
```
Flyt hvert skud.
```js
function updateBulletMovement() {
for (const bullet of bullets) {
bullet.y -= bulletSpeed * lib.frameDeltaT;
}
}
```
Tegn hvert skud med et loop.
```js
function loop() {
// ...
for (const bullet of bullets) {
cx.putTexture(bulletSprite, bullet.x, bullet.y);
}
}
```
Spilleren burde nu kunne skyde ved at trykke på mellemrumstasten. Skuddene burde starte ved spilleren og flyve opad.
## 5 Fjender
Spilleren spiller mod fjender, som er rumskibe der kommer oppefra. Der er flere rækker fjender, som spilleren skal skyde ned.
Hent fjende-sprite'en og giv den en blå farve.
```js
const enemySprite = await lib.texture.loadImage("assets/enemy1.png", 48, 48);
enemySprite.adjustColor(0.2, 0.2, 1);
```
Lav et `enemies`-array.
```js
let enemies = [];
```
Lav en funktion til at spawne fjender.
```js
function loop() {
// ...
if (enemies.length === 0) {
spawnEnemies();
}
// ...
}
function spawnEnemies() {
}
```
Loop igennem en 2d-plan med rækker og kolonner.
```js
function spawnEnemies() {
const rows = 2;
const columns = 5;
for (let y = 0; y < rows; ++y) {
for (let x = 0; x < columns; ++x) {
}
}
}
```
Spawn en fjende for hvert position i 2d-planet.
```js
function spawnEnemies() {
// ...
for (let y = 0; y < rows; ++y) {
for (let x = 0; x < columns; ++x) {
enemies.push({
x: x * 100 + 10,
y: y * 80 + 10,
});
}
}
}
```
Tegn fjenderne i `enemies`-array'et.
```js
function loop() {
// ...
for (const enemy of enemies) {
cx.putTexture(enemySprite, enemy.x, enemy.y);
}
}
```
## 6 Skyd fjender
Når en spiller skyder et skud og skuddet rammer en fjende, så "dør" fjenden, hvilket betyder at fjenden forsvinder. Skuddet skal også selv forsvinde.
Tilføj en funktion til at håndtere skud-kollisioner.
```js
function loop() {
// ...
handleBulletCollisions();
// ...
}
function handleBulletCollisions() {
}
```
Man kan tjekke om 2 rektangler overlapper hinanden med en matematisk formel. Lav en funktion til at tjekke rektangel-kollision.
```js
function rectsAreColliding(
aX, aY, aWidth, aHeight,
bX, bY, bWidth, bHeight,
) {
return aX < bX + bWidth
&& aX + aWidth >= bX
&& aY < bY + bHeight
&& aY + aHeight >= bY;
}
```
I `handleBulletCollisions`, lav 2 arrays til døde fjender og døde skud.
```js
function handleBulletCollisions() {
let deadEnemies = [];
let deadBullets = [];
}
```
Lav et loop, der løber igennem hver fjende, med fjenden's *index* i `enemies`-array'et
```js
function handleBulletCollisions() {
//...
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
const enemy = enemies[enemyIdx];
}
}
```
Inde i dette loop, lav et nyt loop, der løber igennem alle skud i `bullets`-array'et.
```js
function handleBulletCollisions() {
//...
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
//...
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
const bullet = bullets[bulletIdx];
}
}
}
```
Inde i det inderste loop, tjek om et skud kollidere med en fjende.
```js
function handleBulletCollisions() {
//...
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
//...
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
//...
const isColliding = rectsAreColliding(
bullet.x, bullet.y, 24, 24,
enemy.x, enemy.y, 48, 48,
);
if (isColliding) {
}
}
}
}
```
Hvis de kollidere, gem *index* for skuddet og fjenden.
```js
function handleBulletCollisions() {
//...
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
//...
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
//...
if (isColliding) {
deadBullets.push(bulletIdx);
deadEnemies.push(enemyIdx);
}
}
}
}
```
Til sidst, fjern alle døde skud og fjender fra `bullets`- og `enemies`-array'ene.
```js
function handleBulletCollisions() {
//...
for (const i of deadEnemies.toReversed()) {
enemies.splice(i, 1)
}
for (const i of deadBullets.toReversed()) {
bullets.splice(i, 1)
}
}
```
Nu burde fjender kunne skydes, så de forsvinder fra skærmen. Skuddet der rammer fjenden, burde også blive fjernet. Når alle fjender er skudt, bliver nye fjender spawnet.
## 7 Animer eksplisioner
Når man rammer en fjende, skal fjenden eksplodere med en animation.
Hent eksplosions-sprites og giv dem en gul-agtig farve.
```js
const explosionSprites = [
await lib.texture.loadImage(`assets/explosion1.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion2.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion3.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion4.png`, 64, 64),
];
for (const texture of explosionSprites) {
texture.adjustColor(1, 0.75, 0);
}
```
Lav en variabel med et `explosions`-array og en konstant med varigheden af animationen.
```js
const explosionDuration = 0.5;
let explosions = [];
```
Når en fjende bliver ramt, skal der spawnes en eksplosion. I `handleBulletCollisions`-funktionen, tilføj en eksplosion, når der sker en kollision.
```js
function handleBulletCollisions() {
// ...
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
// ...
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
// ...
if (isColliding) {
// ...
explosions.push({
x: enemy.x + 24,
y: enemy.y + 24,
time: 0,
});
}
}
}
// ...
}
```
Lav en `updateExplosions`-funktion til at opdatere eksplosioner.
```js
function loop() {
// ...
updateExplosions();
// ...
}
function updateExplosions() {
}
```
Lav et array til eksplosioner, som er blevet færdige i dens animation.
```js
function updateExplosions() {
let deadExplosions = [];
}
```
Lav et loop, der løber igennem hvert *index* i `explosion`-array'et.
```js
function updateExplosions() {
let deadExplosions = [];
for (let i = 0; i < explosions.length; ++i) {
const explosion = explosions[i];
}
}
```
Håndter animationens timer.
```js
function updateExplosions() {
// ...
for (let i = 0; i < explosions.length; ++i) {
// ...
explosion.time += lib.frameDeltaT;
}
}
```
Tilføj eksplosionen til `deadExplosions`-array'et, hvis timeren er udløbet.
```js
function updateExplosions() {
// ...
for (let i = 0; i < explosions.length; ++i) {
// ...
if (explosion.time > explosionDuration) {
deadExplosions.push(i);
}
}
}
```
Fjern *døde* eksplosioner.
```js
function updateExplosions() {
// ...
for (const i of deadExplosions.toReversed()) {
explosions.splice(i, 1)
}
}
```
Til sidst, tegn eksplosionerne.
```js
function loop() {
// ...
for (const explosion of explosions) {
// goes from 0.0 to 1.0
const animationFraction = explosion.time / explosionDuration;
// goes from 0.0 to 4.0
const spriteIdxFraction = animationFraction * explosionSprites.length;
// is either of 0, 1, 2 or 3
const spriteIdx = Math.floor(spriteIdxFraction);
const texture = explosionSprites[spriteIdx];
cx.putTexture(texture, explosion.x - 32, explosion.y - 32);
}
}
```
Nu burde der være eksplosions-animationer, når en fjende bliver skudt.

312
game.js Normal file
View File

@ -0,0 +1,312 @@
"use strict";
import * as lib from "./lib/lib.js"
// === SPRITES ===
const playerSprite = await lib.texture.loadImage("assets/player.png", 64, 64);
playerSprite.adjustColor(1, 0.5, 0);
const enemySprite = await lib.texture.loadImage("assets/enemy1.png", 48, 48);
enemySprite.adjustColor(0.2, 0.2, 1);
const bulletSprite = await lib.texture.loadImage("assets/bullet1.png", 24, 24);
bulletSprite.rotate(Math.PI);
const enemybulletSprite = await lib.texture.loadImage("assets/bullet1.png", 24, 24)
const powerupSprite = await lib.texture.loadImage("assets/player.png", 64, 64);
powerupSprite.adjustColor(0, 1, 0);
const explosionSprites = [
await lib.texture.loadImage(`assets/explosion1.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion2.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion3.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion4.png`, 64, 64),
];
for (const texture of explosionSprites) {
texture.adjustColor(1, 0.75, 0);
}
const gameoverSprite = await lib.texture.loadImage("assets/gameover.png", 480, 360);
// === DATA ===
let firstUpdate = true;
let gamerunning = true;
let playerspeedstart = 300
let playerSpeed = playerspeedstart;
let player = { x: 0, y: 360 - 64 - 10 };
let enemies = [];
const bulletSpeed = 400;
let bullets = [];
const bulletCoolDown = 0.5;
let bulletCooldownTimer = 0;
const explosionDuration = 0.5;
let explosions = [];
let enemybullets = [];
const enemybulletSpeedStart = 400
let enemybulletSpeed = enemybulletSpeedStart;
const enemybulletCoolDownStart = 1.5;
let enemybulletCoolDown = enemybulletCoolDownStart;
let enemybulletCooldownTimer = bulletCoolDown;
let lvl = 1;
const gameoverScreenDelay = 1;
let gameoverScreenTimer = 0;
// === LOGIC ===
function loop() {
if (gamerunning) {
tick();
}
render();
gameoverScreenTimer += lib.frameDeltaT;
}
function tick() {
bulletCooldownTimer += lib.frameDeltaT;
enemybulletCooldownTimer -= lib.frameDeltaT;
updatePlayerMovement();
updateBulletMovement();
updateEnemyBulletMovement();
spawnenemybullets()
handleBulletCollisions();
handleenemyBulletCollisions()
updateExplosions();
if (firstUpdate) {
spawnEnemies();
}
if (enemies.length === 0) {
spawnEnemies();
newLevel();
}
firstUpdate = false;
}
function newLevel() {
lvl = lvl + 1
enemybulletSpeed = enemybulletSpeed + 50
enemybulletCoolDown = enemybulletCoolDown * 0.9
playerSpeed = playerSpeed + 35
}
function resetLevel() {
enemies = [];
bullets = [];
bulletCooldownTimer = 0;
enemybullets = [];
enemybulletCooldownTimer = enemybulletCoolDownStart;
lvl = 1;
playerSpeed = playerspeedstart
enemybulletSpeed = enemybulletSpeedStart;
enemybulletCoolDown = enemybulletCoolDownStart;
gamerunning = true;
firstUpdate = true;
}
function gameOver() {
console.log("ej du dårlig. nåede du kun til lvl " + lvl + "? 😂")
gameoverScreenTimer = 0;
gamerunning = false
}
function render() {
const cx = lib.canvas;
cx.clear("black");
cx.putTexture(playerSprite, player.x, player.y);
// cx.strokeRect(player.x, player.y, 64, 64, "red");
for (const bullet of bullets) {
cx.putTexture(bulletSprite, bullet.x, bullet.y);
}
for (const enemy of enemies) {
cx.putTexture(enemySprite, enemy.x, enemy.y);
}
for (const bullet of enemybullets) {
cx.putTexture(enemybulletSprite, bullet.x, bullet.y);
// cx.strokeRect(bullet.x, bullet.y, 24, 24, "red");
}
for (const explosion of explosions) {
const animationFraction = explosion.time / explosionDuration;
const spriteIdxFraction = animationFraction * explosionSprites.length;
const spriteIdx = Math.floor(spriteIdxFraction);
const texture = explosionSprites[spriteIdx];
cx.putTexture(texture, explosion.x - 32, explosion.y - 32);
}
cx.putText("LvL "+lvl, 0, 0, "white");
if (!gamerunning) {
if (gameoverScreenTimer >= gameoverScreenDelay) {
cx.putTexture(gameoverSprite, 0, 0);
cx.putText("ej du dårlig.", 150 - 3, 150 - 3, "black");
cx.putText("ej du dårlig.", 150, 150, "white");
cx.putText("nåede du kun til lvl " + lvl + "? 😂", 70 - 3, 174 - 3, "black");
cx.putText("nåede du kun til lvl " + lvl + "? 😂", 70, 174, "white");
}
}
}
function spawnEnemies() {
const rows = 2;
const columns = 5;
for (let y = 0; y < rows; ++y) {
for (let x = 0; x < columns; ++x) {
enemies.push({
x: x * 100 + 10,
y: y * 80 + 10,
});
}
}
}
function updatePlayerMovement() {
const goleft = lib.isPressed("ArrowLeft") || lib.isPressed("a");
const goright = lib.isPressed("ArrowRight") || lib.isPressed("d");
if (goleft && !goright) {
player.x -= playerSpeed * lib.frameDeltaT;
if (player.x < 0) {
player.x = 0;
}
}
else if (goright && !goleft) {
player.x += playerSpeed * lib.frameDeltaT;
if (player.x >= lib.canvas.width - playerSprite.width) {
player.x = lib.canvas.width - playerSprite.width;
}
}
}
function updateBulletMovement() {
for (const bullet of bullets) {
bullet.y -= bulletSpeed * lib.frameDeltaT;
}
}
function updateEnemyBulletMovement() {
for (const bullet of enemybullets) {
bullet.y += enemybulletSpeed * lib.frameDeltaT;
}
}
function spawnenemybullets() {
if (enemybulletCooldownTimer < 0) {
enemybulletCooldownTimer = enemybulletCoolDown / 2
+ (enemybulletCoolDown / 2) * Math.random();
let enemyidx = Math.floor(enemies.length * Math.random())
let enemy = enemies[enemyidx]
enemybullets.push({ x: enemy.x + 12, y: enemy.y + 12 });
}
}
function handleBulletCollisions() {
let deadEnemies = [];
let deadBullets = [];
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
const enemy = enemies[enemyIdx];
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
const bullet = bullets[bulletIdx];
const isColliding = rectsAreColliding(
bullet.x, bullet.y, 24, 24,
enemy.x, enemy.y, 48, 48,
);
if (isColliding) {
deadBullets.push(bulletIdx);
deadEnemies.push(enemyIdx);
explosions.push({
x: enemy.x + 24,
y: enemy.y + 24,
time: 0,
});
}
}
}
for (const i of deadEnemies.toReversed()) {
enemies.splice(i, 1)
}
for (const i of deadBullets.toReversed()) {
bullets.splice(i, 1)
}
}
function handleenemyBulletCollisions() {
for (let bulletIdx = 0; bulletIdx < enemybullets.length; ++bulletIdx) {
const bullet = enemybullets[bulletIdx];
const isColliding = rectsAreColliding(
bullet.x, bullet.y, 24, 24,
player.x, player.y, 64, 64,
);
if (isColliding) {
gameOver();
}
}
}
function updateExplosions() {
let deadExplosions = [];
for (let i = 0; i < explosions.length; ++i) {
const explosion = explosions[i];
explosion.time += lib.frameDeltaT;
if (explosion.time > explosionDuration) {
deadExplosions.push(i);
}
}
for (const i of deadExplosions.toReversed()) {
explosions.splice(i, 1)
}
}
function rectsAreColliding(
aX, aY, aWidth, aHeight,
bX, bY, bWidth, bHeight,
) {
return aX < bX + bWidth
&& aX + aWidth >= bX
&& aY < bY + bHeight
&& aY + aHeight >= bY;
}
lib.onPress(" ", () => {
if (!gamerunning) {
resetLevel();
return;
}
if (bulletCooldownTimer >= bulletCoolDown) {
bulletCooldownTimer = 0;
bullets.push({ x: player.x + 20, y: 300 });
}
});
lib.onPress("r", () => {
resetLevel();
});
lib.startGame(loop);

32
index.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Game</title>
<link rel="icon" type="image/png" href="assets/player.png" />
<script type="module" src="game.js" defer></script>
<style>
* {
box-sizing: border-box;
color-scheme: light dark;
}
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#game {
background-color: black;
}
</style>
</head>
<body>
<canvas id="game" width="480" height="360"></canvas>
<script></script>
</body>
</html>

69
lib/canvas.js Normal file
View File

@ -0,0 +1,69 @@
import { Texture } from "./texture.js";
/** @type {HTMLCanvasElement} */
const htmlCanvas = document.querySelector("#game");
const ctx = htmlCanvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
export const width = htmlCanvas.width;
export const height = htmlCanvas.height;
/**
* Clear canvas with a color.
* @param {string} color
*/
export function clear(color) {
ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height);
}
/**
* Draw a filled rectangle.
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
* @param {string} color
*/
export function fillRect(x, y, width, height, color) {
ctx.fillStyle = color;
ctx.fillRect(x, y, width, height);
}
/**
* Draw a rectangle outline.
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
* @param {string} color
*/
export function strokeRect(x, y, width, height, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
}
/**
*
* @param {Texture} texture
* @param {number} x
* @param {number} y
*/
export function putTexture(texture, x, y) {
texture.draw(ctx, x, y);
}
/**
*
* @param {string} text
* @param {number} x
* @param {number} y
* @param {string} color
*/
export function putText(text, x, y, color) {
const fontSize = 24;
ctx.fillStyle = color;
ctx.font = `bold ${fontSize}px monospace`;
ctx.fillText(text, x, y + fontSize);
}

67
lib/lib.js Normal file
View File

@ -0,0 +1,67 @@
export * as canvas from "./canvas.js"
export * as texture from "./texture.js"
export let frameDeltaT = 0;
/**
* Start a game.
*
* @param {() => void} loopFunction Run every frame
*/
export function startGame(loopFunction) {
let before = Date.now();
setInterval(() => {
const now = Date.now();
frameDeltaT = (now - before) / 1000;
before = now;
loopFunction();
}, 16);
}
const keysPressed = new Set();
const keyPressHandlers = new Map();
const keyReleaseHandlers = new Map();
document.body.addEventListener("keydown", (ev) => {
keysPressed.add(ev.key);
keyPressHandlers.get(ev.key)?.();
});
document.body.addEventListener("keyup", (ev) => {
keysPressed.delete(ev.key);
keyReleaseHandlers.get(ev.key)?.();
});
/**
* If a key is currently being pressed.
* @param {string} key
* @returns {boolean}
*/
export function isPressed(key) {
return keysPressed.has(key);
}
/**
* When a key is pressed (key down).
* @param {string} key
* @param {() => void} handlerFunction
*/
export function onPress(key, handlerFunction) {
keyPressHandlers.set(key, handlerFunction);
}
/**
* When a key is released (key up).
* @param {string} key
* @param {() => void} handlerFunction
*/
export function onRelease(key, handlerFunction) {
keyReleaseHandlers.set(key, handlerFunction);
}

120
lib/texture.js Normal file
View File

@ -0,0 +1,120 @@
export class Texture {
/**
*
* @param {HTMLCanvasElement} canvas
*/
constructor(canvas) {
this.canvas = canvas;
}
/**
*
* @param {HTMLImageElement} imageElement
* @returns {Texture}
*/
static fromImage(imageElement) {
const canvas = new OffscreenCanvas(imageElement.width, imageElement.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(imageElement, 0, 0);
return new Texture(canvas);
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
set width(value) {
const newCanvas = new OffscreenCanvas(value, this.canvas.height);
const ctx = newCanvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.canvas, 0, 0, value, this.canvas.height);
this.canvas = newCanvas;
}
set height(value) {
const newCanvas = new OffscreenCanvas(this.canvas.width, value);
const ctx = newCanvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.canvas, 0, 0, this.canvas.width, value);
this.canvas = newCanvas;
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
*/
draw(ctx, x, y) {
ctx.drawImage(this.canvas, x, y);
}
/**
*
* @param {number} red
* @param {number} green
* @param {number} blue
*/
adjustColor(red, green, blue) {
const ctx = this.canvas.getContext("2d");
const data = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < data.height; ++y) {
for (let x = 0; x < data.width; ++x) {
const idx = y * (data.width * 4) + x * 4;
data.data[idx] *= red;
data.data[idx + 1] *= green;
data.data[idx + 2] *= blue;
}
}
ctx.putImageData(data, 0, 0);
}
rotate(angle) {
const { width, height } = this;
const newCanvas = new OffscreenCanvas(width, height);
const newCtx = newCanvas.getContext("2d");
newCtx.imageSmoothingEnabled = false;
newCtx.save();
newCtx.translate(width / 2, height / 2)
newCtx.rotate(angle)
newCtx.drawImage(this.canvas, -width / 2, -height / 2, width, height);
newCtx.restore();
this.canvas = newCanvas;
}
copy() {
const { width, height } = this;
const newCanvas = new OffscreenCanvas(width, height);
const newCtx = newCanvas.getContext("2d");
newCtx.imageSmoothingEnabled = false;
newCtx.drawImage(this.canvas, -width / 2, -height / 2, width, height);
return new Texture(newCanvas);
}
}
/**
*
* @param {string} path
* @param {number} [width]
* @param {number} [height]
* @returns {Promise<Texture>}
*/
export function loadImage(path, width, height) {
const image = document.createElement("img");
image.src = path;
return new Promise((resolve) => {
image.onload = () => {
const texture = Texture.fromImage(image);
if (width !== undefined) {
texture.width = width;
}
if (height !== undefined) {
texture.height = height;
}
resolve(texture);
}
});
}

194
reference/space_invaders.js Normal file
View File

@ -0,0 +1,194 @@
import * as lib from "./lib/lib.js"
const playerSprite = await lib.texture.loadImage("assets/player.png", 64, 64);
playerSprite.adjustColor(1, 0.5, 0);
const bulletSprite = await lib.texture.loadImage("assets/bullet1.png", 24, 24);
bulletSprite.rotate(Math.PI);
const enemySprite = await lib.texture.loadImage("assets/enemy1.png", 48, 48);
enemySprite.adjustColor(0.2, 0.2, 1);
const explosionSprites = [
await lib.texture.loadImage(`assets/explosion1.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion2.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion3.png`, 64, 64),
await lib.texture.loadImage(`assets/explosion4.png`, 64, 64),
];
for (const texture of explosionSprites) {
texture.adjustColor(1, 0.75, 0);
}
const playerSpeed = 300;
let playerX = 0;
const bulletSpeed = 400;
const bulletCoolDown = 0.5;
let bullets = [];
// the first shot should not have cooldown
let bulletCooldownTimer = bulletCoolDown;
let enemies = [];
const explosionDuration = 0.5;
let explosions = [];
function loop() {
bulletCooldownTimer += lib.frameDeltaT;
updatePlayerMovement();
updateBulletMovement();
handleBulletCollisions();
updateExplosions();
if (enemies.length === 0) {
spawnEnemies();
}
const cx = lib.canvas;
cx.clear("black");
cx.putTexture(playerSprite, playerX, 300);
for (const bullet of bullets) {
cx.putTexture(bulletSprite, bullet.x, bullet.y);
}
for (const enemy of enemies) {
cx.putTexture(enemySprite, enemy.x, enemy.y);
}
for (const explosion of explosions) {
// goes from 0.0 to 1.0
const animationFraction = explosion.time / explosionDuration;
// goes from 0.0 to 4.0
const spriteIdxFraction = animationFraction * explosionSprites.length;
// is either of 0, 1, 2 or 3
const spriteIdx = Math.floor(spriteIdxFraction);
const texture = explosionSprites[spriteIdx];
cx.putTexture(texture, explosion.x - 32, explosion.y - 32);
}
}
function spawnEnemies() {
const rows = 2;
const columns = 5;
for (let y = 0; y < rows; ++y) {
for (let x = 0; x < columns; ++x) {
enemies.push({
x: x * 100 + 10,
y: y * 80 + 10,
});
}
}
}
function updatePlayerMovement() {
const goLeft = lib.isPressed("ArrowLeft");
const goRight = lib.isPressed("ArrowRight");
if (goLeft && !goRight) {
// player should go left
playerX -= playerSpeed * lib.frameDeltaT;
if (playerX < 0) {
playerX = 0;
}
} else if (goRight && !goLeft) {
// player should go right
playerX += playerSpeed * lib.frameDeltaT;
if (playerX >= lib.canvas.width - playerSprite.width) {
playerX = lib.canvas.width - playerSprite.width;
}
}
}
function updateBulletMovement() {
for (const bullet of bullets) {
bullet.y -= bulletSpeed * lib.frameDeltaT;
}
}
function handleBulletCollisions() {
let deadEnemies = [];
let deadBullets = [];
for (let enemyIdx = 0; enemyIdx < enemies.length; ++enemyIdx) {
const enemy = enemies[enemyIdx];
for (let bulletIdx = 0; bulletIdx < bullets.length; ++bulletIdx) {
const bullet = bullets[bulletIdx];
const isColliding = rectsAreColliding(
bullet.x, bullet.y, 24, 24,
enemy.x, enemy.y, 48, 48,
);
if (isColliding) {
deadBullets.push(bulletIdx);
deadEnemies.push(enemyIdx);
explosions.push({
x: enemy.x + 24,
y: enemy.y + 24,
time: 0,
});
}
}
}
for (const i of deadEnemies.toReversed()) {
enemies.splice(i, 1)
}
for (const i of deadBullets.toReversed()) {
bullets.splice(i, 1)
}
}
function rectsAreColliding(
aX, aY, aWidth, aHeight,
bX, bY, bWidth, bHeight,
) {
return aX < bX + bWidth
&& aX + aWidth >= bX
&& aY < bY + bHeight
&& aY + aHeight >= bY;
}
function updateExplosions() {
let deadExplosions = [];
for (let i = 0; i < explosions.length; ++i) {
const explosion = explosions[i];
explosion.time += lib.frameDeltaT;
if (explosion.time > explosionDuration) {
deadExplosions.push(i);
}
}
for (const i of deadExplosions.toReversed()) {
explosions.splice(i, 1)
}
}
lib.onPress(" ", () => {
if (bulletCooldownTimer >= bulletCoolDown) {
bulletCooldownTimer = 0;
bullets.push({ x: playerX + 20, y: 300 });
}
});
lib.startGame(loop);