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/{player,warrior,mob,mobs,npc,npcs,item,items,chest,entityfactory}.js (MPL-2.0).
import {
Entities,
Orientations,
getArmorRank,
getKindAsString,
getKindFromString,
getWeaponRank,
isArmor,
type Orientation,
} from '../shared/gametypes.js';
import { Character, Entity } from './entity.js';
import { Timer } from './timing.js';
import type { Sprite } from './sprite.js';
export class LootException extends Error {}
export class Player extends Character {
nameOffsetY = -10;
spriteName = 'clotharmor';
weaponName: string | null = 'sword1';
/** Self-claimed Nostr pubkey announced in HELLO (hex). */
nostrPubkey = '';
isLootMoving = false;
isSwitchingWeapon = false;
isSwitchingArmor = false;
invincible = false;
isOnPlateau = false;
currentArmorSprite: Sprite | null = null;
lastCheckpoint: { id: number | string } | null = null;
private invincibleTimeout: number | null = null;
private weaponBlanking: number | null = null;
private armorBlanking: number | null = null;
private armorLootCallback: ((armorName: string) => void) | null = null;
private switchCallback: (() => void) | null = null;
private invincibleCallback: (() => void) | null = null;
constructor(id: number, name: string) {
super(id, Entities.WARRIOR);
this.name = name;
}
loot(item: Item | null): void {
if (!item) return;
let rank: number | undefined;
let currentRank: number | undefined;
let msg = '';
const currentArmorName = this.currentArmorSprite ? this.currentArmorSprite.name : this.spriteName;
if (item.type === 'armor') {
rank = getArmorRank(item.kind);
currentRank = getArmorRank(getKindFromString(currentArmorName) ?? -1);
msg = 'You are wearing a better armor';
} else if (item.type === 'weapon') {
rank = getWeaponRank(item.kind);
currentRank = getWeaponRank(getKindFromString(this.weaponName ?? '') ?? -1);
msg = 'You are wielding a better weapon';
}
// Same truthiness test as the original: rank 0 (cloth/sword1) skips the check.
if (rank && currentRank) {
if (rank === currentRank) throw new LootException(`You already have this ${item.type}`);
if (rank <= currentRank) throw new LootException(msg);
}
if (isArmor(item.kind) && this.invincible) this.stopInvincibility();
item.onLoot(this);
}
isMovingToLoot(): boolean {
return this.isLootMoving;
}
getSpriteName(): string {
return this.spriteName;
}
setSpriteName(name: string): void {
this.spriteName = name;
}
getArmorName(): string {
return (this.getArmorSprite() as Sprite).id;
}
getArmorSprite(): Sprite {
return (this.invincible ? this.currentArmorSprite : this.sprite) as Sprite;
}
getWeaponName(): string | null {
return this.weaponName;
}
setWeaponName(name: string | null): void {
this.weaponName = name;
}
hasWeapon(): boolean {
return this.weaponName !== null;
}
switchWeapon(newWeaponName: string): void {
let count = 14;
let value = false;
if (newWeaponName === this.getWeaponName()) return;
if (this.weaponBlanking !== null) window.clearInterval(this.weaponBlanking);
this.isSwitchingWeapon = true;
this.weaponBlanking = window.setInterval(() => {
value = !value;
this.setWeaponName(value ? newWeaponName : null);
count -= 1;
if (count === 1) {
window.clearInterval(this.weaponBlanking!);
this.weaponBlanking = null;
this.setWeaponName(newWeaponName);
this.isSwitchingWeapon = false;
this.switchCallback?.();
}
}, 90);
}
switchArmor(newArmorSprite: Sprite | undefined): void {
let count = 14;
let value = false;
if (!newArmorSprite || newArmorSprite.id === this.getSpriteName()) return;
if (this.armorBlanking !== null) window.clearInterval(this.armorBlanking);
this.isSwitchingArmor = true;
this.setSprite(newArmorSprite);
this.setSpriteName(newArmorSprite.id);
this.armorBlanking = window.setInterval(() => {
value = !value;
this.setVisible(value);
count -= 1;
if (count === 1) {
window.clearInterval(this.armorBlanking!);
this.armorBlanking = null;
this.setVisible(true);
this.isSwitchingArmor = false;
this.switchCallback?.();
}
}, 90);
}
onArmorLoot(callback: (armorName: string) => void): void {
this.armorLootCallback = callback;
}
armorLoot(name: string): void {
this.armorLootCallback?.(name);
}
onSwitchItem(callback: () => void): void {
this.switchCallback = callback;
}
onInvincible(callback: () => void): void {
this.invincibleCallback = callback;
}
startInvincibility(): void {
if (!this.invincible) {
this.currentArmorSprite = this.getSprite() as Sprite;
this.invincible = true;
this.invincibleCallback?.();
} else if (this.invincibleTimeout !== null) {
// If the player already has invincibility, just reset its duration.
window.clearTimeout(this.invincibleTimeout);
}
this.invincibleTimeout = window.setTimeout(() => {
this.stopInvincibility();
this.idle();
}, 15000);
}
stopInvincibility(): void {
this.invincibleCallback?.();
this.invincible = false;
if (this.currentArmorSprite) {
this.setSprite(this.currentArmorSprite);
this.setSpriteName(this.currentArmorSprite.id);
this.currentArmorSprite = null;
}
if (this.invincibleTimeout !== null) window.clearTimeout(this.invincibleTimeout);
this.invincibleTimeout = null;
}
dispose(): void {
if (this.invincibleTimeout !== null) window.clearTimeout(this.invincibleTimeout);
if (this.weaponBlanking !== null) window.clearInterval(this.weaponBlanking);
if (this.armorBlanking !== null) window.clearInterval(this.armorBlanking);
this.stopBlinking();
}
}
export class Mob extends Character {
aggroRange = 1;
isAggressive = true;
/** Deathknight and boss face down when idle without a target. */
idleFacesDown = false;
idle(orientation?: Orientation): void {
super.idle(this.idleFacesDown && !this.hasTarget() ? Orientations.DOWN : orientation);
}
}
interface MobTuning {
moveSpeed?: number;
atkSpeed?: number;
idleSpeed?: number;
walkSpeed?: number;
shadowOffsetY?: number;
attackRate?: number;
aggroRange?: number;
isAggressive?: boolean;
idleFacesDown?: boolean;
}
// Values from client/js/mobs.js.
const mobTuning: Record<number, MobTuning> = {
[Entities.RAT]: { moveSpeed: 350, idleSpeed: 700, shadowOffsetY: -2, isAggressive: false },
[Entities.SKELETON]: { moveSpeed: 350, atkSpeed: 100, idleSpeed: 800, shadowOffsetY: 1, attackRate: 1300 },
[Entities.SKELETON2]: {
moveSpeed: 200,
atkSpeed: 100,
idleSpeed: 800,
walkSpeed: 200,
shadowOffsetY: 1,
attackRate: 1300,
},
[Entities.SPECTRE]: {
moveSpeed: 150,
atkSpeed: 50,
idleSpeed: 200,
walkSpeed: 200,
shadowOffsetY: 1,
attackRate: 900,
},
[Entities.DEATHKNIGHT]: {
atkSpeed: 50,
moveSpeed: 220,
walkSpeed: 100,
idleSpeed: 450,
attackRate: 800,
aggroRange: 3,
idleFacesDown: true,
},
[Entities.GOBLIN]: { moveSpeed: 150, atkSpeed: 60, idleSpeed: 600, attackRate: 700 },
[Entities.OGRE]: { moveSpeed: 300, atkSpeed: 100, idleSpeed: 600 },
[Entities.CRAB]: { moveSpeed: 200, atkSpeed: 40, idleSpeed: 500 },
[Entities.SNAKE]: { moveSpeed: 200, atkSpeed: 40, idleSpeed: 250, walkSpeed: 100, shadowOffsetY: -4 },
[Entities.EYE]: { moveSpeed: 200, atkSpeed: 40, idleSpeed: 50 },
[Entities.BAT]: { moveSpeed: 120, atkSpeed: 90, idleSpeed: 90, walkSpeed: 85, isAggressive: false },
[Entities.WIZARD]: { moveSpeed: 200, atkSpeed: 100, idleSpeed: 150 },
[Entities.BOSS]: {
moveSpeed: 300,
atkSpeed: 50,
idleSpeed: 400,
attackRate: 2000,
aggroRange: 3,
idleFacesDown: true,
},
};
function createMob(id: number, kind: number): Mob {
const mob = new Mob(id, kind);
const t = mobTuning[kind] ?? {};
if (t.moveSpeed !== undefined) mob.moveSpeed = t.moveSpeed;
if (t.atkSpeed !== undefined) mob.atkSpeed = t.atkSpeed;
if (t.idleSpeed !== undefined) mob.idleSpeed = t.idleSpeed;
if (t.walkSpeed !== undefined) mob.walkSpeed = t.walkSpeed;
if (t.shadowOffsetY !== undefined) mob.shadowOffsetY = t.shadowOffsetY;
if (t.attackRate !== undefined) mob.setAttackRate(t.attackRate);
if (t.aggroRange !== undefined) mob.aggroRange = t.aggroRange;
if (t.isAggressive !== undefined) mob.isAggressive = t.isAggressive;
if (t.idleFacesDown !== undefined) mob.idleFacesDown = t.idleFacesDown;
return mob;
}
/**
* NPC dialogue from client/js/npc.js. Lines that quoted songs or films, pointed
* at the 2012 hosting stack, or belonged to third-party characters were rewritten
* as original text for this port; the rest are the original game's text.
*/
const NpcTalk: Record<string, string[]> = {
// Original lines (the 2012 text quoted a film).
guard: [
'Hello there',
'Halt! ...oh, never mind, you look friendly enough.',
'I guard this spot. It is a very important spot.',
'Nothing to see here. Off you go...',
],
// Original lines: the 2012 game's king riffed on a commercial song.
king: [
"Hi, I'm the King",
'I run this place',
'Mostly from this very comfortable chair',
'Today I signed three royal decrees',
'All of them about lunch',
'This crown is heavier than it looks, you know',
'Ruling is exhausting work',
'Now leave me alone',
'The royal nap will not take itself',
],
villagegirl: [
'Hi there, adventurer!',
'How do you like this game?',
"It's all happening in a single web page! Isn't it crazy?",
'Other adventurers reach you straight through your browser, peer to peer.',
"I don't know much about it, after all I'm just a program.",
'Your name and face come from your Nostr profile, if you have one.',
],
villager: [
'Howdy stranger. Do you like poetry?',
'Roses are red, violets are blue...',
'I like hunting rats, and so do you...',
'The rats are dead, now what to do?',
'To be honest, I have no clue.',
'Maybe the forest, could interest you...',
'or instead, cook a rat stew.',
],
// Original lines (the 2012 text quoted a film).
agent: [
'Psst. Over here.',
'I am not really a villager, you know.',
'I am watching for glitches in this world.',
'If you see a rat walk past twice... tell no one.',
],
// Original lines: the 2012 game quoted a commercial song here.
rick: [
"Oh! You caught me rehearsing for the village talent show.",
'Do, re, mi... no, too high.',
'La la laaa... hold on, let me warm up.',
'Singing is all about breathing from the belly.',
'Now you try. Louder! From the toes!',
'There! Lesson over. Same time tomorrow?',
],
scientist: [
'Greetings.',
'I am the inventor of these two potions.',
'The red one will replenish your health points...',
'The orange one will wreathe you in flames and make you invincible...',
'But it only lasts for a short while.',
'So make good use of it!',
"Now if you'll excuse me, I need to get back to my experiments...",
],
// This NPC uses village art in the port; its lines are original.
nyan: [
'Shh. I am watching the clouds.',
'That one looks like a sheep.',
'That one looks like a cake.',
'I may have been staring at the sky for too long.',
],
beachnpc: [
"Don't mind me, I'm just here on vacation.",
'I have to say...',
'These giant crabs are somewhat annoying.',
'Could you please get rid of them for me?',
],
forestnpc: ['lorem ipsum dolor sit amet', 'consectetur adipisicing elit, sed do eiusmod tempor'],
desertnpc: [
'Few who climb into these mountains ever come back down...',
'An ancient undead lord is said to dwell here.',
'Nobody knows exactly what he looks like...',
'...for none has lived to tell the tale.',
"It's not too late to turn around and go home, kid.",
],
lavanpc: ['lorem ipsum dolor sit amet', 'consectetur adipisicing elit, sed do eiusmod tempor'],
priest: [
'Oh, hello, young man.',
"Wisdom is everything, so I'll share a few guidelines with you.",
'You are free to go wherever you like in this world',
'but beware of the many foes that await you.',
'You can find many weapons and armors by killing enemies.',
'The tougher the enemy, the higher the potential rewards.',
'You can also unlock achievements by exploring and hunting.',
'Click on the small cup icon to see a list of all the achievements.',
'Please stay a while and enjoy the many surprises of Browser Side Quest',
'Farewell, young friend.',
],
sorcerer: [
'Ah... I had foreseen you would come to see me.',
'Well? How do you like my new staff?',
'Pretty cool, eh?',
'Where did I get it, you ask?',
"I understand. It's easy to get envious.",
'I actually crafted it myself, using my mad wizard skills.',
'But let me tell you one thing...',
'There are lots of items in this game.',
'Some more powerful than others.',
'In order to find them, exploration is key.',
'Good luck.',
],
// This NPC uses village art in the port; its lines are original.
octocat: [
'Welcome to Browser Side Quest!',
'This world was first built in 2012 and shared with everyone.',
'Its code is open source, so anyone can bring it back to life.',
'Like we just did!',
],
coder: [
'Hi! Do you know that you can also play Browser Side Quest on your tablet or mobile?',
"That's the beauty of HTML5!",
'Give it a try...',
],
};
/**
* Two original NPCs depicted third-party characters; the port draws them with
* other village sprites instead of shipping that art.
*/
const npcSpriteOverrides: Record<number, string> = {
[Entities.OCTOCAT]: 'coder',
[Entities.NYAN]: 'villager',
};
export function spriteNameForKind(kind: number): string {
return npcSpriteOverrides[kind] ?? getKindAsString(kind) ?? '';
}
export class Npc extends Character {
readonly itemKind: string;
talkCount: number;
talkIndex = 0;
constructor(id: number, kind: number) {
super(id, kind);
this.itemKind = getKindAsString(kind) ?? 'othernpc';
this.talkCount = (NpcTalk[this.itemKind] ?? []).length;
if (kind === Entities.NYAN) this.idleSpeed = 50;
if (kind === Entities.SORCERER) this.idleSpeed = 150;
}
getSpriteName(): string {
return spriteNameForKind(this.kind);
}
talk(): string | null {
let msg: string | null = null;
if (this.talkIndex > this.talkCount) this.talkIndex = 0;
if (this.talkIndex < this.talkCount) msg = NpcTalk[this.itemKind][this.talkIndex];
this.talkIndex += 1;
return msg;
}
}
export type ItemType = 'weapon' | 'armor' | 'object';
const itemDefs: Record<number, [ItemType, string]> = {
[Entities.SWORD2]: ['weapon', 'You pick up a steel sword'],
[Entities.AXE]: ['weapon', 'You pick up an axe'],
[Entities.REDSWORD]: ['weapon', 'You pick up a blazing sword'],
[Entities.BLUESWORD]: ['weapon', 'You pick up a magic sword'],
[Entities.GOLDENSWORD]: ['weapon', 'You pick up the ultimate sword'],
[Entities.MORNINGSTAR]: ['weapon', 'You pick up a morning star'],
[Entities.LEATHERARMOR]: ['armor', 'You equip a leather armor'],
[Entities.MAILARMOR]: ['armor', 'You equip a mail armor'],
[Entities.PLATEARMOR]: ['armor', 'You equip a plate armor'],
[Entities.REDARMOR]: ['armor', 'You equip a ruby armor'],
[Entities.GOLDENARMOR]: ['armor', 'You equip a golden armor'],
[Entities.FLASK]: ['object', 'You drink a health potion'],
[Entities.CAKE]: ['object', 'You eat a cake'],
[Entities.BURGER]: ['object', 'You can haz rat burger'],
[Entities.FIREPOTION]: ['object', 'You feel the power of the flame!'],
};
export class Item extends Entity {
readonly itemKind: string;
readonly type: ItemType;
readonly lootMessage: string;
wasDropped = false;
playersInvolved: number[] = [];
constructor(id: number, kind: number) {
super(id, kind);
const [type, message] = itemDefs[kind] ?? ['object', ''];
this.itemKind = getKindAsString(kind) ?? '';
this.type = type;
this.lootMessage = message;
}
hasShadow(): boolean {
return true;
}
onLoot(player: Player): void {
if (this.kind === Entities.FIREPOTION) {
player.startInvincibility();
} else if (this.type === 'weapon') {
player.switchWeapon(this.itemKind);
} else if (this.type === 'armor') {
player.armorLoot(this.itemKind);
}
}
getSpriteName(): string {
return `item-${this.itemKind}`;
}
getLootMessage(): string {
return this.lootMessage;
}
}
export class Chest extends Entity {
private openCallback: (() => void) | null = null;
constructor(id: number) {
super(id, Entities.CHEST);
}
getSpriteName(): string {
return 'chest';
}
isMoving(): boolean {
return false;
}
open(): void {
this.openCallback?.();
}
onOpen(callback: () => void): void {
this.openCallback = callback;
}
}
export function createEntity(kind: number, id: number, name = ''): Entity {
if (kind === Entities.WARRIOR) return new Player(id, name);
if (kind in mobTuning) return createMob(id, kind);
if (kind === Entities.CHEST) return new Chest(id);
if (kind in itemDefs) return new Item(id, kind);
if (getKindAsString(kind) && kind >= Entities.GUARD && kind <= Entities.CODER) return new Npc(id, kind);
throw new Error(`${kind} is not a valid Entity type`);
}
export { Timer };
