Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion client/src/dolls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ const preview = await makeDoll(scene, x, y, 'draft', await flatDoll(appearance),
a job painted. `isFigure` tells the two apart.
- **`makeFigure(scene, x, y, id, figure, height?)`** draws a `SpriteFigure` once its atlas has
loaded. Its origin is on the ground under the middle of its head, and `height` is how many
pixels the standing figure is.
pixels the standing figure is. **`wear(figure)`** draws another figure the same size once its
atlas has loaded.
- **The player with bionics** (`bionics.ts`): `playerFigureId(body)` is the player's figure for
what a `Body` has fitted, `player` with nothing and `player_legs_datajack_eye` with everything.
The eye is painted where it shows, so either socket is the same figure. `PlayerBody` keeps the
last body and says when its figure changes: `world/assets.ts` feeds one from `player_state`,
`dollFor('player')` picks the player's figure by it, and the side view's player wears the new
figure when a bionic is fitted.
- **`SpriteFigure.setPose(pose, facing, phase, options)`** is `PaperDoll`'s: `pose` is the sim's
`Pose`, or the player's `'jumping'` or `'landing'`, and `phase` how far through its cycle it is,
any number. Each pose plays its painted frames by phase (`sprite.ts`):
Expand Down
7 changes: 7 additions & 0 deletions client/src/dolls/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ describe('the committed figures', () => {
'mirela',
'odalys',
'player',
'player_datajack',
'player_datajack_eye',
'player_eye',
'player_legs',
'player_legs_datajack',
'player_legs_datajack_eye',
'player_legs_eye',
'priya',
'raymond',
'tobias',
Expand Down
71 changes: 71 additions & 0 deletions client/src/dolls/bionics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import type { Body } from '../net/protocol';
import { dolls } from './assets';
import { FITTED, isPlayerFigure, PlayerBody, playerFigureId } from './bionics';

const whole: Body = {
legs: 'present',
vertebra: 'present',
left_eye: 'present',
right_eye: 'present',
left_kidney: 'present',
right_kidney: 'present',
};

describe('the player’s figure', () => {
it.each<[Partial<Body>, string]>([
[{}, 'player'],
[{ left_kidney: 'sold', right_eye: 'sold' }, 'player'],
[{ legs: 'hydraulic_legs' }, 'player_legs'],
[{ vertebra: 'datajack' }, 'player_datajack'],
[{ left_eye: 'infrared_eye' }, 'player_eye'],
[{ right_eye: 'infrared_eye', legs: 'hydraulic_legs' }, 'player_legs_eye'],
[{ right_eye: 'infrared_eye', vertebra: 'datajack', legs: 'hydraulic_legs' }, 'player_legs_datajack_eye'],
])('with %o fitted is %s', (fitted, id) => {
expect(playerFigureId({ ...whole, ...fitted })).toBe(id);
});

it('is the whole player before the body is known', () => {
expect(playerFigureId()).toBe('player');
});

it('is committed for every combination of bionics, with the same frames and poses', () => {
const combinations = FITTED.reduce<string[][]>((all, part) => [...all, ...all.map((c) => [...c, part])], [[]]);
for (const parts of combinations) {
const body: Body = { ...whole };
if (parts.includes('hydraulic_legs')) body.legs = 'hydraulic_legs';
if (parts.includes('datajack')) body.vertebra = 'datajack';
if (parts.includes('infrared_eye')) body.left_eye = 'infrared_eye';
const figure = dolls[playerFigureId(body)];
expect(figure, playerFigureId(body)).toBeDefined();
expect(Object.keys(figure.frames).sort()).toEqual(Object.keys(dolls.player.frames).sort());
expect(figure.poses).toEqual(dolls.player.poses);
expect(figure.height).toBe(dolls.player.height);
}
});

it('tells the player’s figures from the residents’', () => {
expect(isPlayerFigure('player')).toBe(true);
expect(isPlayerFigure('player_legs_eye')).toBe(true);
expect(isPlayerFigure('priya')).toBe(false);
expect(isPlayerFigure(undefined)).toBe(false);
});
});

describe('PlayerBody', () => {
it('says when the figure changes, and only then', () => {
const body = new PlayerBody();
const heard = vi.fn();
const stop = body.onChange(heard);

body.set(whole);
body.set({ ...whole, left_kidney: 'sold' });
body.set({ ...whole, legs: 'hydraulic_legs' });
body.set({ ...whole, legs: 'hydraulic_legs', right_eye: 'infrared_eye' });
stop();
body.set(whole);

expect(heard.mock.calls).toEqual([['player_legs'], ['player_legs_eye']]);
expect(body.figure).toBe('player');
});
});
41 changes: 41 additions & 0 deletions client/src/dolls/bionics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Bionic, Body } from '../net/protocol';

export const PLAYER_FIGURE = 'player';
/** In the order a figure's id names them. */
export const FITTED: readonly Bionic[] = ['hydraulic_legs', 'datajack', 'infrared_eye'];
const NAMES: Record<Bionic, string> = { hydraulic_legs: 'legs', datajack: 'datajack', infrared_eye: 'eye' };

/** The bionics a body has fitted, in `FITTED`'s order. */
export function fitted(body?: Body): Bionic[] {
const parts = new Set<string>(body ? Object.values(body) : []);
return FITTED.filter((bionic) => parts.has(bionic));
}

/** The player's figure for a body: `player` with nothing fitted, `player_legs_eye` and so on. */
export const playerFigureId = (body?: Body): string =>
[PLAYER_FIGURE, ...fitted(body).map((bionic) => NAMES[bionic])].join('_');

export const isPlayerFigure = (id?: string): boolean =>
id === PLAYER_FIGURE || !!id?.startsWith(`${PLAYER_FIGURE}_`);

/** The player's body as last heard, and who to tell when what's fitted changes. */
export class PlayerBody {
private body?: Body;
private readonly listeners = new Set<(figure: string) => void>();

get figure(): string {
return playerFigureId(this.body);
}

set(body: Body): void {
const before = this.figure;
this.body = body;
if (this.figure !== before) this.listeners.forEach((listener) => listener(this.figure));
}

/** Calls `listener` with the new figure's id whenever it changes. */
onChange(listener: (figure: string) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
113 changes: 113 additions & 0 deletions client/src/dolls/phaser.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// What a SpriteFigure uses of Phaser, for tests in Node: vi.mock('phaser', () => import('./phaser.fixture')).
import type Phaser from 'phaser';

export class FakeImage {
key: string;
frame?: string;
flipX = false;
tint = -1;

constructor(_scene: unknown, _x: number, _y: number, key: string, frame?: string) {
this.key = key;
this.frame = frame;
}

setTexture(key: string, frame?: string): this {
this.key = key;
this.frame = frame;
return this;
}

setFlipX(flip: boolean): this {
this.flipX = flip;
return this;
}

setTint(tint: number): this {
this.tint = tint;
return this;
}

setOrigin(): this {
return this;
}

setPosition(): this {
return this;
}

setAngle(): this {
return this;
}

setScale(): this {
return this;
}
}

class FakeContainer {
scene?: unknown;
readonly list: FakeImage[] = [];
scale = 1;
private readonly destroyed: (() => void)[] = [];

constructor(scene: unknown) {
this.scene = scene;
}

add(child: FakeImage): this {
this.list.push(child);
return this;
}

setScale(scale: number): this {
this.scale = scale;
return this;
}

setPosition(): this {
return this;
}

once(_event: string, listener: () => void): this {
this.destroyed.push(listener);
return this;
}

destroy(): void {
this.destroyed.forEach((listener) => listener());
this.scene = undefined;
}
}

export default {
GameObjects: { Container: FakeContainer, Image: FakeImage, Events: { DESTROY: 'destroy' } },
Textures: { FilterMode: { LINEAR: 0 } },
};

/** A scene whose textures are the atlases `loaded` and any added since. */
export function fakeScene(loaded: string[] = []): Phaser.Scene {
const keys = new Set(loaded);
const textures = {
exists: (key: string) => keys.has(key),
addImage: (key: string) => (keys.add(key), { setFilter: () => undefined, add: () => undefined }),
};
return { textures } as unknown as Phaser.Scene;
}

/** The image a figure draws. */
export const drawnImage = (figure: object): FakeImage => (figure as { list: FakeImage[] }).list[0];

/** Browser images whose loads the test finishes, in any order, by calling `onload`. */
export function loadingImages(): { onload: () => void }[] {
const started: { onload: () => void }[] = [];
class LoadingImage {
onload = () => undefined;
onerror = () => undefined;
set src(_url: string) {
started.push(this);
}
}
globalThis.Image = LoadingImage as unknown as typeof Image;
return started;
}
53 changes: 53 additions & 0 deletions client/src/dolls/sprite-figure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';
import { dolls } from './assets';
import { drawnImage, fakeScene, loadingImages } from './phaser.fixture';
import { shown } from './sprite';
import { SpriteFigure } from './sprite-figure';

vi.mock('phaser', () => import('./phaser.fixture'));

const atlases = Object.keys(dolls).map((id) => `figure-${id}`);

describe('wearing another figure', () => {
it('draws it the same size, in the pose it was in, once its atlas has loaded', async () => {
const figure = new SpriteFigure(fakeScene(atlases), 0, 0, dolls.player, 'figure-player', 245);
figure.setPose('walking', 'west', 0.3);

await figure.wear(dolls.player_legs);

expect(figure.figure).toBe(dolls.player_legs);
const image = drawnImage(figure);
expect(image.key).toBe('figure-player_legs');
expect(image.frame).toBe(shown(dolls.player_legs, 'walking', 'west', 0.3).frame);
expect(image.flipX).toBe(true);
expect((figure as unknown as { scale: number }).scale).toBeCloseTo(245 / dolls.player_legs.height);
});

it('wears the last of several asked for while their atlases load, whichever loads last', async () => {
const loads = loadingImages();
const figure = new SpriteFigure(fakeScene(['figure-player']), 0, 0, dolls.player, 'figure-player');

const legs = figure.wear(dolls.player_legs);
const eye = figure.wear(dolls.player_eye);
loads[1].onload();
await eye;
loads[0].onload();
await legs;

expect(figure.figure).toBe(dolls.player_eye);
expect(drawnImage(figure).key).toBe('figure-player_eye');
});

it('keeps its figure if it was destroyed while the atlas loaded', async () => {
const loads = loadingImages();
const scene = fakeScene(['figure-player']);
const figure = new SpriteFigure(scene, 0, 0, dolls.player, 'figure-player');

const legs = figure.wear(dolls.player_legs);
figure.destroy();
loads[0].onload();
await legs;

expect(figure.figure).toBe(dolls.player);
});
});
20 changes: 18 additions & 2 deletions client/src/dolls/sprite-figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ export function addFigureAtlas(scene: Phaser.Scene, key: string, figure: FigureM
/** A resident painted whole, its origin on the ground under the middle of its head. `setPose`
* is `PaperDoll`'s: each pose shows its painted frames by phase, mirrored to face west. */
export class SpriteFigure extends Phaser.GameObjects.Container {
readonly figure: FigureManifest;
private worn: FigureManifest;
private readonly image: Phaser.GameObjects.Image;
private key: string;
private size = 1;
private posedAs?: Parameters<SpriteFigure['setPose']>;
private drawn?: Shown;
private asked?: FigureManifest;

constructor(
scene: Phaser.Scene,
Expand All @@ -43,14 +44,18 @@ export class SpriteFigure extends Phaser.GameObjects.Container {
height = figure.height,
) {
super(scene, x, y);
this.figure = figure;
this.worn = figure;
this.key = key;
this.image = new Phaser.GameObjects.Image(scene, 0, 0, key, figure.poses.stand[0]);
this.add(this.image);
this.setDollHeight(height);
this.setPose('standing', 'east');
}

get figure(): FigureManifest {
return this.worn;
}

/** Scales the figure so it stands `height` pixels tall. */
setDollHeight(height: number): this {
this.size = height / this.figure.height;
Expand All @@ -66,6 +71,17 @@ export class SpriteFigure extends Phaser.GameObjects.Container {
return this;
}

/** Draws another figure the same size once its atlas has loaded; the last one asked for wins. */
async wear(figure: FigureManifest, key = `figure-${figure.id}`): Promise<void> {
this.asked = figure;
await addFigureAtlas(this.scene, key, figure);
if (this.asked !== figure || !this.scene) return;
const height = this.size * this.worn.height;
this.worn = figure;
this.setDollHeight(height);
this.paint(key);
}

setPose(pose: DollPose, facing: Facing, phase = 0, options: PoseOptions = {}): this {
this.posedAs = [pose, facing, phase, options];
return this.draw(shown(this.figure, pose, facing, phase, options));
Expand Down
2 changes: 1 addition & 1 deletion client/src/side/poses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const AS: Record<SpritePose, [DollPose, PoseOptions]> = {

/** Allocates nothing once each step of a pose has been drawn. */
export class PoseCache {
private readonly figure: FigureManifest;
readonly figure: FigureManifest;
private readonly shown: (Shown | undefined)[] = [];
private readonly options: PoseOptions = {};

Expand Down
Loading
Loading