SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Port of BrowserQuest client/js/{entity,character}.js (MPL-2.0).
import {
Orientations,
getKindAsString,
getOrientationAsString,
isMob,
isPlayer,
type Orientation,
} from '../shared/gametypes.js';
import { Animation, Timer, Transition } from './timing.js';
import type { DrawableSprite, Sprite } from './sprite.js';
export type Path = [number, number][];
export class Entity {
id: number;
name = '';
sprite: DrawableSprite | null = null;
normalSprite: DrawableSprite | null = null;
hurtSprite: DrawableSprite | null = null;
flipSpriteX = false;
flipSpriteY = false;
animations: Record<string, Animation> | null = null;
currentAnimation: Animation | null = null;
shadowOffsetY = 0;
x = 0;
y = 0;
gridX = 0;
gridY = 0;
isLoaded = false;
isHighlighted = false;
visible = true;
isFading = false;
fadingAlpha = 1;
startFadingTime = 0;
isDirty = true;
private blinking: number | null = null;
private readyFunc: (() => void) | null = null;
constructor(
id: number,
public kind: number,
) {
this.id = id;
this.setGridPosition(0, 0);
}
setName(name: string): void {
this.name = name;
}
setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
}
setGridPosition(x: number, y: number): void {
this.gridX = x;
this.gridY = y;
this.setPosition(x * 16, y * 16);
}
setSprite(sprite: Sprite | undefined): void {
if (!sprite) throw new Error(`${this.id}: sprite is null`);
if (this.sprite && (this.sprite as Sprite).name === sprite.name) return;
this.sprite = sprite;
this.normalSprite = sprite;
if (isMob(this.kind) || isPlayer(this.kind)) this.hurtSprite = sprite.getHurtSprite();
this.animations = sprite.createAnimations();
this.isLoaded = true;
this.readyFunc?.();
}
getSprite(): DrawableSprite | null {
return this.sprite;
}
getSpriteName(): string {
return getKindAsString(this.kind) ?? '';
}
getAnimationByName(name: string): Animation | null {
return this.animations?.[name] ?? null;
}
setAnimation(name: string, speed: number, count?: number, onEndCount?: () => void): void {
if (!this.isLoaded) return;
if (this.currentAnimation && this.currentAnimation.name === name) return;
const a = this.getAnimationByName(name);
if (a) {
this.currentAnimation = a;
if (name.substring(0, 3) === 'atk') a.reset();
a.setSpeed(speed);
a.setCount(count ? count : 0, onEndCount || (() => this.idle()));
}
}
idle(_orientation?: Orientation): void {}
hasShadow(): boolean {
return false;
}
ready(f: () => void): void {
this.readyFunc = f;
}
clean(): void {
this.stopBlinking();
}
setHighlight(value: boolean): void {
const base = this.normalSprite as Sprite | null;
if (value && base?.silhouetteSprite) {
this.sprite = base.silhouetteSprite;
this.isHighlighted = true;
} else {
this.sprite = this.normalSprite;
this.isHighlighted = false;
}
}
setVisible(value: boolean): void {
this.visible = value;
}
isVisible(): boolean {
return this.visible;
}
toggleVisibility(): void {
this.setVisible(!this.visible);
}
getDistanceToEntity(entity: Entity): number {
const distX = Math.abs(entity.gridX - this.gridX);
const distY = Math.abs(entity.gridY - this.gridY);
return distX > distY ? distX : distY;
}
isCloseTo(entity: Entity | null): boolean {
if (!entity) return false;
return Math.abs(entity.gridX - this.gridX) < 30 && Math.abs(entity.gridY - this.gridY) < 14;
}
/** Returns true if the entity is adjacent to the given one. */
isAdjacent(entity: Entity | null): boolean {
return entity ? this.getDistanceToEntity(entity) <= 1 : false;
}
isAdjacentNonDiagonal(entity: Entity | null): boolean {
return (
!!entity &&
this.isAdjacent(entity) &&
!(this.gridX !== entity.gridX && this.gridY !== entity.gridY)
);
}
isDiagonallyAdjacent(entity: Entity): boolean {
return this.isAdjacent(entity) && !this.isAdjacentNonDiagonal(entity);
}
forEachAdjacentNonDiagonalPosition(callback: (x: number, y: number, o: Orientation) => void): void {
callback(this.gridX - 1, this.gridY, Orientations.LEFT);
callback(this.gridX, this.gridY - 1, Orientations.UP);
callback(this.gridX + 1, this.gridY, Orientations.RIGHT);
callback(this.gridX, this.gridY + 1, Orientations.DOWN);
}
fadeIn(currentTime: number): void {
this.isFading = true;
this.fadingAlpha = 0;
this.startFadingTime = currentTime;
}
blink(speed: number): void {
this.stopBlinking();
this.blinking = window.setInterval(() => this.toggleVisibility(), speed);
}
stopBlinking(): void {
if (this.blinking !== null) window.clearInterval(this.blinking);
this.blinking = null;
this.setVisible(true);
}
setDirty(): void {
this.isDirty = true;
}
}
export class Character extends Entity {
nextGridX = -1;
nextGridY = -1;
orientation: Orientation = Orientations.DOWN;
atkSpeed = 50;
moveSpeed = 120;
walkSpeed = 100;
idleSpeed = 450;
attackCooldown = new Timer(800);
movement = new Transition();
path: Path | null = null;
step = 0;
newDestination: { x: number; y: number } | null = null;
destination: { gridX: number; gridY: number } | null = null;
adjacentTiles: Record<number, boolean> = {};
target: Entity | null = null;
unconfirmedTarget: Entity | null = null;
previousTarget: Character | null = null;
attackers: Record<number, Character> = {};
hitPoints = 0;
maxHitPoints = 0;
isDead = false;
isDying = false;
attackingMode = false;
followingMode = false;
interrupted = false;
private hurting: number | null = null;
private requestPathCallback: ((x: number, y: number) => Path) | null = null;
private startPathingCallback: ((path: Path) => void) | null = null;
private stopPathingCallback: ((x: number, y: number) => void) | null = null;
private beforeStepCallback: (() => void) | null = null;
private stepCallback: (() => void) | null = null;
private aggroCallback: ((character: Character) => void) | null = null;
private checkAggroCallback: (() => void) | null = null;
private deathCallback: (() => void) | null = null;
private hasMovedCallback: ((c: Character) => void) | null = null;
clean(): void {
this.forEachAttacker((attacker) => {
attacker.disengage();
attacker.idle();
});
}
setMaxHitPoints(hp: number): void {
this.maxHitPoints = hp;
this.hitPoints = hp;
}
setDefaultAnimation(): void {
this.idle();
}
hasWeapon(): boolean {
return false;
}
getWeaponName(): string | null {
return null;
}
hasShadow(): boolean {
return true;
}
animate(animation: string, speed: number, count?: number, onEndCount?: () => void): void {
const oriented = ['atk', 'walk', 'idle'];
const o = this.orientation;
// don't change animation if the character is dying
if (!(this.currentAnimation && this.currentAnimation.name === 'death')) {
this.flipSpriteX = false;
this.flipSpriteY = false;
if (oriented.includes(animation)) {
animation += `_${o === Orientations.LEFT ? 'right' : getOrientationAsString(o)}`;
this.flipSpriteX = this.orientation === Orientations.LEFT;
}
this.setAnimation(animation, speed, count, onEndCount);
}
}
turnTo(orientation: Orientation): void {
this.orientation = orientation;
this.idle();
}
setOrientation(orientation?: Orientation | null): void {
if (orientation) this.orientation = orientation;
}
idle(orientation?: Orientation): void {
this.setOrientation(orientation);
this.animate('idle', this.idleSpeed);
}
hit(orientation?: Orientation): void {
this.setOrientation(orientation);
this.animate('atk', this.atkSpeed, 1);
}
walk(orientation?: Orientation): void {
this.setOrientation(orientation);
this.animate('walk', this.walkSpeed);
}
moveTo_(x: number, y: number): void {
this.destination = { gridX: x, gridY: y };
this.adjacentTiles = {};
if (this.isMoving()) {
this.continueTo(x, y);
} else {
this.followPath(this.requestPathfindingTo(x, y));
}
}
requestPathfindingTo(x: number, y: number): Path {
return this.requestPathCallback ? this.requestPathCallback(x, y) : [];
}
onRequestPath(callback: (x: number, y: number) => Path): void {
this.requestPathCallback = callback;
}
onStartPathing(callback: (path: Path) => void): void {
this.startPathingCallback = callback;
}
onStopPathing(callback: (x: number, y: number) => void): void {
this.stopPathingCallback = callback;
}
followPath(path: Path): void {
// Length of 1 means the player has clicked on himself
if (path.length > 1) {
this.path = path;
this.step = 0;
if (this.followingMode) path.pop(); // following a character
this.startPathingCallback?.(path);
this.nextStep();
}
}
continueTo(x: number, y: number): void {
this.newDestination = { x, y };
}
updateMovement(): void {
const p = this.path!;
const i = this.step;
if (p[i][0] < p[i - 1][0]) this.walk(Orientations.LEFT);
if (p[i][0] > p[i - 1][0]) this.walk(Orientations.RIGHT);
if (p[i][1] < p[i - 1][1]) this.walk(Orientations.UP);
if (p[i][1] > p[i - 1][1]) this.walk(Orientations.DOWN);
}
updatePositionOnGrid(): void {
const [x, y] = this.path![this.step];
this.setGridPosition(x, y);
}
nextStep(): void {
if (!this.isMoving()) return;
let stop = false;
this.beforeStepCallback?.();
this.updatePositionOnGrid();
this.checkAggro();
if (this.interrupted) {
// if Character.stop() has been called
stop = true;
this.interrupted = false;
} else {
if (this.hasNextStep()) {
this.nextGridX = this.path![this.step + 1][0];
this.nextGridY = this.path![this.step + 1][1];
}
this.stepCallback?.();
if (this.hasChangedItsPath()) {
const { x, y } = this.newDestination!;
const path = this.requestPathfindingTo(x, y);
this.newDestination = null;
if (path.length < 2) stop = true;
else this.followPath(path);
} else if (this.hasNextStep()) {
this.step += 1;
this.updateMovement();
} else {
stop = true;
}
}
if (stop) {
// Path is complete or has been interrupted
this.path = null;
this.idle();
this.stopPathingCallback?.(this.gridX, this.gridY);
}
}
onBeforeStep(callback: () => void): void {
this.beforeStepCallback = callback;
}
onStep(callback: () => void): void {
this.stepCallback = callback;
}
isMoving(): boolean {
return this.path !== null;
}
hasNextStep(): boolean {
return this.path!.length - 1 > this.step;
}
hasChangedItsPath(): boolean {
return this.newDestination !== null;
}
isNear(character: Entity, distance: number): boolean {
return (
Math.abs(this.gridX - character.gridX) <= distance &&
Math.abs(this.gridY - character.gridY) <= distance
);
}
onAggro(callback: (character: Character) => void): void {
this.aggroCallback = callback;
}
onCheckAggro(callback: () => void): void {
this.checkAggroCallback = callback;
}
checkAggro(): void {
this.checkAggroCallback?.();
}
aggro(character: Character): void {
this.aggroCallback?.(character);
}
onDeath(callback: () => void): void {
this.deathCallback = callback;
}
/** Changes the character's orientation so that it is facing its target. */
lookAtTarget(): void {
if (this.target) this.turnTo(this.getOrientationTo(this.target));
}
go(x: number, y: number): void {
if (this.isAttacking()) {
this.disengage();
} else if (this.followingMode) {
this.followingMode = false;
this.target = null;
}
this.moveTo_(x, y);
}
/** Makes the character follow another one. */
follow(entity: Entity | null): void {
if (entity) {
this.followingMode = true;
this.moveTo_(entity.gridX, entity.gridY);
}
}
/** Stops a moving character. */
stop(): void {
if (this.isMoving()) this.interrupted = true;
}
/** Same as follow but with an auto-attacking behavior. */
engage(character: Entity): void {
this.attackingMode = true;
this.setTarget(character);
this.follow(character);
}
disengage(): void {
this.attackingMode = false;
this.followingMode = false;
this.removeTarget();
}
isAttacking(): boolean {
return this.attackingMode;
}
getOrientationTo(character: Entity): Orientation {
if (this.gridX < character.gridX) return Orientations.RIGHT;
if (this.gridX > character.gridX) return Orientations.LEFT;
if (this.gridY > character.gridY) return Orientations.UP;
return Orientations.DOWN;
}
isAttackedBy(character: Entity): boolean {
return character.id in this.attackers;
}
addAttacker(character: Character): void {
if (!this.isAttackedBy(character)) this.attackers[character.id] = character;
}
removeAttacker(character: Character): void {
if (this.isAttackedBy(character)) delete this.attackers[character.id];
}
forEachAttacker(callback: (attacker: Character) => void): void {
for (const attacker of Object.values(this.attackers)) callback(attacker);
}
setTarget(character: Entity): void {
if (this.target !== character) {
if (this.hasTarget()) this.removeTarget(); // Cleanly remove the previous one
this.unconfirmedTarget = null;
this.target = character;
}
}
removeTarget(): void {
if (this.target) {
if (this.target instanceof Character) this.target.removeAttacker(this);
this.target = null;
}
}
hasTarget(): boolean {
return this.target !== null;
}
waitToAttack(character: Entity): void {
this.unconfirmedTarget = character;
}
isWaitingToAttack(character: Entity): boolean {
return this.unconfirmedTarget === character;
}
canAttack(time: number): boolean {
return this.canReachTarget() && this.attackCooldown.isOver(time);
}
canReachTarget(): boolean {
return this.hasTarget() && this.isAdjacentNonDiagonal(this.target);
}
die(): void {
this.removeTarget();
this.isDead = true;
this.deathCallback?.();
}
onHasMoved(callback: (c: Character) => void): void {
this.hasMovedCallback = callback;
}
hasMoved(): void {
this.setDirty();
this.hasMovedCallback?.(this);
}
hurt(): void {
this.stopHurting();
if (this.hurtSprite) this.sprite = this.hurtSprite;
this.hurting = window.setTimeout(() => this.stopHurting(), 75);
}
stopHurting(): void {
this.sprite = this.normalSprite;
if (this.hurting !== null) window.clearTimeout(this.hurting);
this.hurting = null;
}
setAttackRate(rate: number): void {
this.attackCooldown = new Timer(rate);
}
}
