1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Pabloader 1e4be0ff2c Add moving animation 2025-06-27 12:51:16 +00:00
Pabloader b3574a8acf Refactor Character -> Player, add separate character class 2025-06-27 12:31:12 +00:00
6 changed files with 234 additions and 179 deletions

View File

@ -48,6 +48,7 @@ export function* zip(...args: Iterable<any>[]) {
export const range = (size: number | string) => Object.keys((new Array(+size)).fill(0)).map(k => +k); export const range = (size: number | string) => Object.keys((new Array(+size)).fill(0)).map(k => +k);
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const lerp = (start: number, end: number, t: number) => (start + (end - start) * t);
export const prevent = (e: Event) => (e.preventDefault(), false); export const prevent = (e: Event) => (e.preventDefault(), false);

View File

@ -1,114 +1,51 @@
import Item, { ItemType, type ItemTypeImage } from "./item"; import { lerp } from "@common/utils";
import { SpinnerAction } from "./spinner"; import Item from "./item";
import Tile from "./tile"; import Tile from "./tile";
const MOVE_DURATION = .1;
export default class Character extends Item { export default class Character extends Item {
public health: number; public tile = new Tile([0, 0], 1);
public inventory: Item[] = []; private path: Tile[] = [];
public tile: Tile = new Tile([0, 0], 1); private pathProgress = 0;
public lastDoor: Tile | undefined;
constructor(type: ItemTypeImage) { public get displayPosition(): [number, number] {
super(type); if (this.path.length > 1) {
this.health = this.maxHealth; const numTransitions = this.path.length - 1;
const defaultItem = this.defaultItem; const progress = this.pathProgress * numTransitions;
if (defaultItem) { const currentTileIdx = Math.floor(progress);
this.inventory.push(this.defaultItem);
const currentTile = this.path[currentTileIdx];
const nextTile = this.path[currentTileIdx + 1];
if (!nextTile) {
return [currentTile.centerX, currentTile.centerY];
}
const transitionFraction = progress - currentTileIdx;
return [
lerp(currentTile.centerX, nextTile.centerX, transitionFraction),
lerp(currentTile.centerY, nextTile.centerY, transitionFraction),
]
}
return [this.tile.centerX, this.tile.centerY];
}
public moveTo(tile: Tile, path: Tile[]) {
this.pathProgress = 0;
this.tile = tile;
this.path = path;
}
public update(dt: number) {
if (this.path.length > 1) {
if (this.pathProgress >= 1) {
this.path = [];
this.pathProgress = 0;
} else {
this.pathProgress += dt / (MOVE_DURATION * (this.path.length - 1));
}
} }
} }
}
get maxHealth() {
return this.type === ItemType.CHAR_BIG ? 7 : 5;
}
get defaultItem() {
if (this.type === ItemType.CHAR_NINJA) {
return new Item(ItemType.WEAPON_KNIFE);
}
if (this.type === ItemType.CHAR_POLICE) {
return new Item(ItemType.WEAPON_PISTOL);
}
return null;
}
get healingAmount() {
return this.type === ItemType.CHAR_NURSE ? 2 : 1;
}
get moveBonus() {
return this.type === ItemType.CHAR_RUNNER ? 1 : 0;
}
get isDead() {
return this.health <= 0;
}
get meleeWeapon() {
return this.inventory.find(i => i.isMeleeWeapon);
}
get gun() {
return this.inventory.find(i => i.isShootingWeapon);
}
get grenade() {
return this.inventory.find(i => i.type === ItemType.WEAPON_GRENADE);
}
get rocketLauncher() {
return this.inventory.find(i => i.type === ItemType.WEAPON_ROCKET_LAUNCHER);
}
public heal(character: Character) {
character.health += this.healingAmount;
}
public removeItem(item: Item | null | undefined): boolean {
if (!item) {
return false;
}
const itemIndex = this.inventory.findIndex(i => i === item);
if (itemIndex >= 0) {
this.inventory.splice(itemIndex, 1);
return true;
}
return false;
}
public hasItem(item: Item | null | undefined): boolean {
if (!item) {
return false;
}
const itemIndex = this.inventory.findIndex(i => i === item);
return itemIndex >= 0;
}
/** @returns true, if action was performed */
public handleSpin(action: SpinnerAction): boolean {
switch (action) {
case SpinnerAction.RUN:
return true;
case SpinnerAction.BITE:
this.health -= 1;
return true;
case SpinnerAction.MELEE:
return this.meleeWeapon != null && !this.tile.enemy?.isBoss;
case SpinnerAction.SHOOT:
return this.removeItem(this.gun) && !this.tile.enemy?.isBoss;
}
}
}
export const Characters = {
BIG: new Character(ItemType.CHAR_BIG),
NINJA: new Character(ItemType.CHAR_NINJA),
NURSE: new Character(ItemType.CHAR_NURSE),
POLICE: new Character(ItemType.CHAR_POLICE),
RUNNER: new Character(ItemType.CHAR_RUNNER),
} as const;

View File

@ -1,9 +1,7 @@
export default abstract class Entity { export default abstract class Entity {
public hovered = false; public hovered = false;
constructor(public position: [number, number], public size: [number, number]) { constructor(public position: [number, number], public size: [number, number]) { }
}
public render(ctx: CanvasRenderingContext2D) { public render(ctx: CanvasRenderingContext2D) {
ctx.save(); ctx.save();

View File

@ -1,12 +1,12 @@
import { range } from "@common/utils"; import { range } from "@common/utils";
import type Character from "./character"; import type Player from "./player";
import { Characters } from "./character"; import { Players } from "./player";
import Entity from "./entity"; import Entity from "./entity";
import Tile from "./tile"; import Tile from "./tile";
import type Item from "./item"; import type Item from "./item";
import type TileMap from "./tilemap"; import type TileMap from "./tilemap";
export type UseListener = (character: Character, item: Item) => void; export type UseListener = (player: Player, item: Item) => void;
export default class Inventory extends Entity { export default class Inventory extends Entity {
private tiles: Tile[][]; private tiles: Tile[][];
@ -14,26 +14,26 @@ export default class Inventory extends Entity {
constructor(public readonly map: TileMap, position: [number, number], size: [number, number]) { constructor(public readonly map: TileMap, position: [number, number], size: [number, number]) {
super(position, size); super(position, size);
const numCharacters = Object.keys(Characters).length; const numPlayers = Object.keys(Players).length;
const tileSize = this.width / numCharacters; const tileSize = this.width / numPlayers;
const numTiles = Math.floor(this.height / tileSize) - 2; const numTiles = Math.floor(this.height / tileSize) - 2;
this.tiles = range(numCharacters).map((x) => this.tiles = range(numPlayers).map((x) =>
range(numTiles).map((y) => range(numTiles).map((y) =>
new Tile([x * tileSize, (2 + y) * tileSize], tileSize) new Tile([x * tileSize, (2 + y) * tileSize], tileSize)
) )
); );
} }
private get characters() { private get players() {
return this.map.characters; return this.map.players;
} }
private drawCharacter(ctx: CanvasRenderingContext2D, idx: number) { private drawPlayer(ctx: CanvasRenderingContext2D, idx: number) {
const character = this.characters[idx]; const player = this.players[idx];
ctx.drawImage(character.type, 0.1, 0.1, 0.8, 0.8); ctx.drawImage(player.type, 0.1, 0.1, 0.8, 0.8);
ctx.fillText(`💖 ${character.health}`, 0.5, 1.5); ctx.fillText(`💖 ${player.health}`, 0.5, 1.5);
if (character === this.map.character) { if (player === this.map.player) {
ctx.strokeStyle = 'black'; ctx.strokeStyle = 'black';
ctx.lineWidth = 0.03; ctx.lineWidth = 0.03;
@ -41,16 +41,16 @@ export default class Inventory extends Entity {
} }
let y = 2; let y = 2;
for (const item of character.inventory) { for (const item of player.inventory) {
ctx.drawImage(item.type, 0.1, 0.1 + y, 0.8, 0.8); ctx.drawImage(item.type, 0.1, 0.1 + y, 0.8, 0.8);
y += 1; y += 1;
} }
} }
public override handleClick(x: number, y: number): void { public override handleClick(x: number, y: number): void {
for (const { tile, item, character } of this.activeTiles) { for (const { tile, item, player } of this.activeTiles) {
if (tile.isPointInBounds(x - this.left, y - this.top)) { if (tile.isPointInBounds(x - this.left, y - this.top)) {
this.map.handleItemUse(character, item); this.map.handleItemUse(player, item);
break; break;
} }
} }
@ -61,20 +61,20 @@ export default class Inventory extends Entity {
} }
private get activeTiles() { private get activeTiles() {
return this.tiles.slice(0, this.characters.length) return this.tiles.slice(0, this.players.length)
.flatMap((column, characterIndex) => .flatMap((column, playerIndex) =>
column.slice(0, this.characters[characterIndex].inventory.length) column.slice(0, this.players[playerIndex].inventory.length)
.map((tile, tileIndex) => ({ .map((tile, tileIndex) => ({
tile, tile,
character: this.characters[characterIndex], player: this.players[playerIndex],
item: this.characters[characterIndex].inventory[tileIndex], item: this.players[playerIndex].inventory[tileIndex],
})) }))
.filter(({ item }) => item.isConsumable) .filter(({ item }) => item.isConsumable)
); );
} }
protected override draw(ctx: CanvasRenderingContext2D): void { protected override draw(ctx: CanvasRenderingContext2D): void {
const step = 1 / Object.keys(Characters).length; const step = 1 / Object.keys(Players).length;
const columnWidth = this.width * step; const columnWidth = this.width * step;
ctx.save(); ctx.save();
@ -82,8 +82,8 @@ export default class Inventory extends Entity {
ctx.font = `0.3px Arial`; ctx.font = `0.3px Arial`;
ctx.fillStyle = 'black'; ctx.fillStyle = 'black';
for (const i of range(this.characters.length)) { for (const i of range(this.players.length)) {
this.drawCharacter(ctx, i); this.drawPlayer(ctx, i);
ctx.translate(1, 0); ctx.translate(1, 0);
} }
ctx.restore(); ctx.restore();

114
src/games/zombies/player.ts Normal file
View File

@ -0,0 +1,114 @@
import Item, { ItemType, type ItemTypeImage } from "./item";
import Character from "./character";
import { SpinnerAction } from "./spinner";
import Tile from "./tile";
export default class Player extends Character {
public health: number;
public inventory: Item[] = [];
public lastDoor: Tile | undefined;
constructor(type: ItemTypeImage) {
super(type);
this.health = this.maxHealth;
const defaultItem = this.defaultItem;
if (defaultItem) {
this.inventory.push(this.defaultItem);
}
}
get maxHealth() {
return this.type === ItemType.CHAR_BIG ? 7 : 5;
}
get defaultItem() {
if (this.type === ItemType.CHAR_NINJA) {
return new Item(ItemType.WEAPON_KNIFE);
}
if (this.type === ItemType.CHAR_POLICE) {
return new Item(ItemType.WEAPON_PISTOL);
}
return null;
}
get healingAmount() {
return this.type === ItemType.CHAR_NURSE ? 2 : 1;
}
get moveBonus() {
return this.type === ItemType.CHAR_RUNNER ? 1 : 0;
}
get isDead() {
return this.health <= 0;
}
get meleeWeapon() {
return this.inventory.find(i => i.isMeleeWeapon);
}
get gun() {
return this.inventory.find(i => i.isShootingWeapon);
}
get grenade() {
return this.inventory.find(i => i.type === ItemType.WEAPON_GRENADE);
}
get rocketLauncher() {
return this.inventory.find(i => i.type === ItemType.WEAPON_ROCKET_LAUNCHER);
}
public heal(player: Player) {
player.health += this.healingAmount;
}
public removeItem(item: Item | null | undefined): boolean {
if (!item) {
return false;
}
const itemIndex = this.inventory.findIndex(i => i === item);
if (itemIndex >= 0) {
this.inventory.splice(itemIndex, 1);
return true;
}
return false;
}
public hasItem(item: Item | null | undefined): boolean {
if (!item) {
return false;
}
const itemIndex = this.inventory.findIndex(i => i === item);
return itemIndex >= 0;
}
/** @returns true, if action was performed */
public handleSpin(action: SpinnerAction): boolean {
switch (action) {
case SpinnerAction.RUN:
return true;
case SpinnerAction.BITE:
this.health -= 1;
return true;
case SpinnerAction.MELEE:
return this.meleeWeapon != null && !this.tile.enemy?.isBoss;
case SpinnerAction.SHOOT:
return this.removeItem(this.gun) && !this.tile.enemy?.isBoss;
}
}
}
export const Players = {
BIG: new Player(ItemType.CHAR_BIG),
NINJA: new Player(ItemType.CHAR_NINJA),
NURSE: new Player(ItemType.CHAR_NURSE),
POLICE: new Player(ItemType.CHAR_POLICE),
RUNNER: new Player(ItemType.CHAR_RUNNER),
} as const;

View File

@ -3,7 +3,7 @@ import Entity from "./entity";
import Tile, { TileType } from "./tile"; import Tile, { TileType } from "./tile";
import Pathfinding from "./pathfinding"; import Pathfinding from "./pathfinding";
import Item, { ItemType } from "./item"; import Item, { ItemType } from "./item";
import Character, { Characters } from "./character"; import Player, { Players } from "./player";
import { SpinnerAction } from "./spinner"; import { SpinnerAction } from "./spinner";
enum GameState { enum GameState {
@ -15,14 +15,14 @@ export default class TileMap extends Entity {
private tiles: Tile[] = []; private tiles: Tile[] = [];
public startTile: Tile; public startTile: Tile;
public readonly characters: Character[]; public readonly players: Player[];
private currentCharacterIdx = 0; private currentPlayerIdx = 0;
private state = GameState.NORMAL; private state = GameState.NORMAL;
private availableTiles: Tile[] = []; private availableTiles: Tile[] = [];
constructor(position: [number, number], private mapSize: number, private tileSize: number, numPlayers: number = 2) { constructor(position: [number, number], private mapSize: number, private tileSize: number, numPlayers: number = 2) {
super(position, [mapSize * tileSize, mapSize * tileSize]); super(position, [mapSize * tileSize, mapSize * tileSize]);
this.characters = shuffle(Object.values(Characters)).slice(0, numPlayers); this.players = shuffle(Object.values(Players)).slice(0, numPlayers);
this.startTile = this.createMap(); this.startTile = this.createMap();
this.findAvailableTiles(); this.findAvailableTiles();
} }
@ -128,7 +128,7 @@ export default class TileMap extends Entity {
this.startTile = startTile; this.startTile = startTile;
this.fillItems(); this.fillItems();
this.characters.forEach(c => c.tile = startTile); this.players.forEach(c => c.tile = startTile);
return startTile; return startTile;
} }
@ -164,8 +164,8 @@ export default class TileMap extends Entity {
} }
} }
for (const char of Object.values(Characters)) { for (const char of Object.values(Players)) {
if (this.characters.includes(char)) continue; if (this.players.includes(char)) continue;
items.push(char); items.push(char);
} }
@ -187,19 +187,19 @@ export default class TileMap extends Entity {
for (const [tile, item] of zip(fillableTiles, shuffle(items))) { for (const [tile, item] of zip(fillableTiles, shuffle(items))) {
tile.items.push(item); tile.items.push(item);
if (item instanceof Character) { if (item instanceof Player) {
item.tile = tile; item.tile = tile;
} }
}; };
} }
get character() { get player() {
return this.characters[this.currentCharacterIdx]; return this.players[this.currentPlayerIdx];
} }
get activeTiles() { get activeTiles() {
return this.tiles.filter(tile => ( return this.tiles.filter(tile => (
tile.items.length > 0 tile.items.length > 0
|| tile.type === TileType.LOCKED_DOOR || tile.type === TileType.LOCKED_DOOR
|| tile.hovered || tile.hovered
|| (this.state === GameState.NORMAL && this.availableTiles.includes(tile)) || (this.state === GameState.NORMAL && this.availableTiles.includes(tile))
@ -216,21 +216,21 @@ export default class TileMap extends Entity {
} }
for (const tile of this.availableTiles) { for (const tile of this.availableTiles) {
if (tile.isPointInBounds(x - this.left, y - this.top)) { if (tile.isPointInBounds(x - this.left, y - this.top)) {
const path = Pathfinding.findPath(this.character.tile, tile); const path = Pathfinding.findPath(this.player.tile, tile);
if (path.length > 1) { if (path.length > 1) {
this.character.lastDoor = path.find(t => t.type === TileType.DOOR); this.player.lastDoor = path.find(t => t.type === TileType.DOOR);
this.character.tile = tile; this.player.moveTo(tile, path);
tile.open(); tile.open();
for (const item of tile.items.slice()) { // iterate remaining items for (const item of tile.items.slice()) { // iterate remaining items
if (item.isPickable) { if (item.isPickable) {
if (!tile.enemy) { if (!tile.enemy) {
tile.removeItem(item); tile.removeItem(item);
this.character.inventory.push(item); this.player.inventory.push(item);
} }
} else if (item instanceof Character) { } else if (item instanceof Player) {
tile.removeItem(item); tile.removeItem(item);
this.characters.push(item); this.players.push(item);
} else if (item.isBoss && this.character.removeItem(this.character.rocketLauncher)) { } else if (item.isBoss && this.player.removeItem(this.player.rocketLauncher)) {
tile.killEnemy(); tile.killEnemy();
} else if (item.isEnemy) { } else if (item.isEnemy) {
this.state = GameState.FIGHT; this.state = GameState.FIGHT;
@ -240,7 +240,7 @@ export default class TileMap extends Entity {
} }
} }
if (this.state === GameState.NORMAL) { if (this.state === GameState.NORMAL) {
this.nextCharacter(); this.nextPlayer();
} }
} }
@ -256,14 +256,14 @@ export default class TileMap extends Entity {
this.findAvailableTiles(action); this.findAvailableTiles(action);
break; break;
case GameState.FIGHT: case GameState.FIGHT:
const success = this.character.handleSpin(action); const success = this.player.handleSpin(action);
if (success) { if (success) {
switch (action) { switch (action) {
case SpinnerAction.BITE: case SpinnerAction.BITE:
if (this.character.isDead) { if (this.player.isDead) {
this.character.tile.items.push(...this.character.inventory); this.player.tile.items.push(...this.player.inventory);
this.characters.splice(this.currentCharacterIdx, 1); this.players.splice(this.currentPlayerIdx, 1);
this.currentCharacterIdx = this.currentCharacterIdx % this.characters.length; this.currentPlayerIdx = this.currentPlayerIdx % this.players.length;
this.setNormalState(); this.setNormalState();
} }
break; break;
@ -280,29 +280,29 @@ export default class TileMap extends Entity {
} }
} }
public handleItemUse(character: Character, item: Item) { public handleItemUse(player: Player, item: Item) {
let success = character.hasItem(item); let success = player.hasItem(item);
if (success) { if (success) {
if (item.type === ItemType.ITEM_HEAL) { if (item.type === ItemType.ITEM_HEAL) {
character.heal(character); player.heal(player);
} else if (item.type === ItemType.WEAPON_GRENADE && this.state === GameState.FIGHT) { } else if (item.type === ItemType.WEAPON_GRENADE && this.state === GameState.FIGHT) {
this.killEnemy(); this.killEnemy();
} else if (item.type === ItemType.ITEM_PLANKS && character.lastDoor && !character.lastDoor.enemy) { } else if (item.type === ItemType.ITEM_PLANKS && player.lastDoor && !player.lastDoor.enemy) {
character.lastDoor.type = TileType.LOCKED_DOOR; player.lastDoor.type = TileType.LOCKED_DOOR;
} else { } else {
success = false; success = false;
console.warn(`Dont know how to use ${item}`); console.warn(`Dont know how to use ${item}`);
} }
} }
if (success) { if (success) {
character.removeItem(item); player.removeItem(item);
} }
} }
private findAvailableTiles(moveDistance: number = 1) { private findAvailableTiles(moveDistance: number = 1) {
const characterTiles = new Set(this.characters.map(c => c.tile)); const playerTiles = new Set(this.players.map(c => c.tile));
this.availableTiles = Pathfinding.findPossibleMoves(this.character.tile, moveDistance + this.character.moveBonus) this.availableTiles = Pathfinding.findPossibleMoves(this.player.tile, moveDistance + this.player.moveBonus)
.filter(t => !characterTiles.has(t)); .filter(t => !playerTiles.has(t));
} }
private setNormalState() { private setNormalState() {
@ -310,19 +310,19 @@ export default class TileMap extends Entity {
this.findAvailableTiles(); this.findAvailableTiles();
} }
private nextCharacter() { private nextPlayer() {
this.currentCharacterIdx = (this.currentCharacterIdx + 1) % this.characters.length; this.currentPlayerIdx = (this.currentPlayerIdx + 1) % this.players.length;
} }
private killEnemy() { private killEnemy() {
this.character.tile.killEnemy(); this.player.tile.killEnemy();
this.character.tile.items.forEach((item) => { this.player.tile.items.slice().forEach((item) => {
if (item.isPickable) { if (item.isPickable) {
this.character.inventory.push(item); this.player.inventory.push(item);
this.character.tile.removeItem(item); this.player.tile.removeItem(item);
} }
}); });
this.nextCharacter(); this.nextPlayer();
this.setNormalState(); this.setNormalState();
} }
@ -339,15 +339,20 @@ export default class TileMap extends Entity {
} }
const w = this.tileSize * 0.8; const w = this.tileSize * 0.8;
[...this.characters, this.character].forEach(c => [...this.players, this.player].forEach((player) => {
ctx.drawImage(c.type, c.tile.centerX - w / 2, c.tile.centerY - w / 2, w, w) const [x, y] = player.displayPosition;
); ctx.drawImage(player.type, x - w / 2, y - w / 2, w, w);
});
this.activeTiles.forEach(t => t.render(ctx)); this.activeTiles.forEach(t => t.render(ctx));
ctx.lineWidth = 5; ctx.lineWidth = 5;
ctx.strokeStyle = 'yellow'; ctx.strokeStyle = 'yellow';
ctx.strokeRect(this.character.tile.centerX - w / 2, this.character.tile.centerY - w / 2, w, w); ctx.strokeRect(this.player.tile.centerX - w / 2, this.player.tile.centerY - w / 2, w, w);
}
public update(dt: number) {
this.players.forEach((player) => player.update(dt));
} }
} }