Skip to content

Commit

Permalink
Minimal working multiplayer implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Marvin Schürz committed Jan 17, 2025
1 parent fc17d95 commit 5c1c73a
Show file tree
Hide file tree
Showing 35 changed files with 315 additions and 73 deletions.
1 change: 1 addition & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
reverse_proxy /beatmaps/* https://assets.ppy.sh {
header_up Host {upstream_hostport}
}

header >Cache-Control "public, max-age=3600"
reverse_proxy http://localhost:5173
}
9 changes: 5 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
}
},
"dependencies": {
"audiobuffer-to-wav": "^1.0.0",
"bezier-easing": "^2.1.0",
"unzipit": "^1.4.3",
"@osucad/framework": "workspace:*",
"@osucad/multiplayer": "workspace:*",
"@osucad/resources": "workspace:*",
"@osucad/multiplayer": "workspace:*"
"audiobuffer-to-wav": "^1.0.0",
"bezier-easing": "^2.1.0",
"socket.io-client": "^4.8.1",
"unzipit": "^1.4.3"
},
"devDependencies": {
"@types/audiobuffer-to-wav": "^1.0.4"
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/beatmap/Beatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class Beatmap<T extends HitObject = HitObject> extends SharedStaticObject

override get childObjects(): readonly SharedStructure<any>[] {
return [
this.beatmapInfo,
this.controlPoints,
this.hitObjects,
];
Expand Down Expand Up @@ -116,7 +117,7 @@ export class Beatmap<T extends HitObject = HitObject> extends SharedStaticObject

override initializeFromSummary(summary: any): void {
this.beatmapInfo.initializeFromSummary(summary.beatmapInfo);
this.hitObjects.initializeFromSummary(summary.hitObjects);
this.controlPoints.initializeFromSummary(summary.controlPoints);
this.hitObjects.initializeFromSummary(summary.hitObjects);
}
}
18 changes: 16 additions & 2 deletions packages/core/src/beatmap/BeatmapInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,24 @@ export class BeatmapInfo extends SharedObject {

path?: string;

override createSummary() {
return {
...super.createSummary(),
difficulty: this.difficulty.createSummary(),
metadata: this.metadata.createSummary(),
};
}

override initializeFromSummary(summary: any) {
super.initializeFromSummary(summary);
this.difficulty.initializeFromSummary(summary.difficulty);
this.metadata.initializeFromSummary(summary.metadata);
}

override get childObjects(): readonly SharedStructure<any>[] {
return [
// this.difficulty,
// this.metadata
this.difficulty,
this.metadata,
];
}
}
11 changes: 8 additions & 3 deletions packages/core/src/controlPoints/ControlPoint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SharedProperty } from '@osucad/multiplayer';
import type { ObjectSummary, SharedProperty } from '@osucad/multiplayer';
import type { IControlPoint } from './IControlPoint';
import { Action, Comparer } from '@osucad/framework';
import { SharedObject } from '@osucad/multiplayer';
Expand All @@ -20,7 +20,8 @@ export abstract class ControlPoint extends SharedObject implements IControlPoint

protected constructor(time: number) {
super();
this.#time = this.property('time', time);

this.time = time;
}

abstract get controlPointName(): string;
Expand All @@ -30,7 +31,7 @@ export abstract class ControlPoint extends SharedObject implements IControlPoint
raiseChanged() {
}

readonly #time: SharedProperty<number>;
readonly #time = this.property('time', 0);

get timeBindable() {
return this.#time.bindable;
Expand Down Expand Up @@ -60,4 +61,8 @@ export abstract class ControlPoint extends SharedObject implements IControlPoint
super.onPropertyChanged(property, oldValue, submitEvents);
this.changed.emit(this);
}

override initializeFromSummary(summary: ObjectSummary) {
super.initializeFromSummary(summary);
}
}
3 changes: 3 additions & 0 deletions packages/core/src/controlPoints/ControlPointList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export class ControlPointList<T extends ControlPoint> extends ObservableSortedLi
}

controlPointIndexAt(time: number): number {
if (this.length === 0)
return -1;

let index = this.binarySearch({ time } as unknown as T);

if (index >= 0)
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/controlPoints/DifficultyPoint.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { SharedProperty } from '@osucad/multiplayer';
import { ControlPoint } from './ControlPoint';

export class DifficultyPoint extends ControlPoint {
constructor(time: number = 0, sliderVelocity: number = 1) {
super(time);

this.#sliderVelocity = this.property('sliderVelocity', sliderVelocity);
this.sliderVelocity = sliderVelocity;
}

static default = new DifficultyPoint(1);
Expand All @@ -14,7 +13,7 @@ export class DifficultyPoint extends ControlPoint {
return 'Difficulty Point';
}

readonly #sliderVelocity: SharedProperty<number>;
readonly #sliderVelocity = this.property('sliderVelocity', 1);

get sliderVelocityBindable() {
return this.#sliderVelocity.bindable;
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/controlPoints/EffectPoint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { SharedProperty } from '@osucad/multiplayer';
import { ControlPoint } from './ControlPoint';

export interface EffectPointPatch {
Expand All @@ -9,7 +8,7 @@ export class EffectPoint extends ControlPoint {
constructor(time: number = 0, kiaiMode: boolean = false) {
super(time);

this.#kiaiMode = this.property('kiaiMode', kiaiMode);
this.kiaiMode = kiaiMode;
}

static default = new EffectPoint(0);
Expand All @@ -18,7 +17,7 @@ export class EffectPoint extends ControlPoint {
return 'Effect Point';
}

readonly #kiaiMode: SharedProperty<boolean>;
readonly #kiaiMode = this.property('kiaiMode', false);

get kiaiModeBindable() {
return this.#kiaiMode.bindable;
Expand Down
12 changes: 5 additions & 7 deletions packages/core/src/controlPoints/TimingPoint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { SharedProperty } from '@osucad/multiplayer';
import { ControlPoint } from './ControlPoint';

export class TimingPoint extends ControlPoint {
Expand All @@ -9,9 +8,8 @@ export class TimingPoint extends ControlPoint {
) {
super(time);

this.#beatLength = this.property('beatLength', beatLength);
this.#meter = this.property('meter', meter);
this.#omitFirstBarLine = this.property('omitFirstBarLine', false);
this.beatLength = beatLength;
this.meter = meter;
}

static readonly DEFAULT_BEAT_LENGTH = 60_000 / 120;
Expand All @@ -22,7 +20,7 @@ export class TimingPoint extends ControlPoint {
return 'Timing Point';
}

readonly #beatLength: SharedProperty<number>;
readonly #beatLength = this.property('beatLength', TimingPoint.DEFAULT_BEAT_LENGTH);

get beatLengthBindable() {
return this.#beatLength.bindable;
Expand All @@ -48,7 +46,7 @@ export class TimingPoint extends ControlPoint {
this.beatLength = 60_000 / value;
}

readonly #meter: SharedProperty<number>;
readonly #meter = this.property('meter', 4);

get meterBindable() {
return this.#meter.bindable;
Expand All @@ -63,7 +61,7 @@ export class TimingPoint extends ControlPoint {
this.raiseChanged();
}

readonly #omitFirstBarLine: SharedProperty<boolean>;
readonly #omitFirstBarLine = this.property('omitFirstBarLine', false);

get#omitFirstBarLineBindable() {
return this.#omitFirstBarLine.bindable;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/editor/EditorBeatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ export class EditorBeatmap<T extends HitObject = HitObject> extends Component {
return true;
}

protected createUpdateHandler() {
return new UpdateHandler(this.beatmap);
}

protected override async loadAsync(dependencies: ReadonlyDependencyContainer): Promise<void> {
await super.loadAsync(dependencies);

this.addInternal(this.updateHandler = new UpdateHandler(this.beatmap));
this.addInternal(this.updateHandler = this.createUpdateHandler());

await Promise.all([
this.loadTrack(dependencies.resolve(IResourcesProvider)),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export * from './EditorBeatmap';
export * from './EditorClock';
export * from './EditorNavigation';
export * from './EditorSafeArea';
export * from './multiplayer';
export * from './screens';
export * from './ui';
102 changes: 102 additions & 0 deletions packages/core/src/editor/multiplayer/MultiplayerClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ClientMessages, IMutation, InitialStateServerMessage, MutationContext, MutationsSubmittedMessage, ServerMessages } from '@osucad/multiplayer';
import type { Socket } from 'socket.io-client';
import type { Beatmap } from '../../beatmap/Beatmap';
import type { FileStore } from '../../beatmap/io/FileStore';
import { Component } from '@osucad/framework';
import { MutationSource, UpdateHandler } from '@osucad/multiplayer';
import { io } from 'socket.io-client';
import { SimpleFile } from '../../beatmap/io/SimpleFile';
import { StaticFileStore } from '../../beatmap/io/StaticFileStore';
import { RulesetStore } from '../../rulesets/RulesetStore';

export class MultiplayerClient extends Component {
constructor(url: string) {
super();
this.ws = io(url, {
autoConnect: false,
transports: ['websocket'],
});

this.ws.on('mutationsSubmitted', msg => this.mutationSubmitted(msg));
}

updateHandler!: UpdateHandler;

readonly ws: Socket<ServerMessages, ClientMessages>;

clientId!: number;

beatmap!: Beatmap<any>;

fileStore!: FileStore;

protected override loadComplete() {
super.loadComplete();

this.scheduler.addDelayed(() => this.flush(), 25, true);
}

async connect() {
this.ws.connect();

const initialState = await new Promise<InitialStateServerMessage>((resolve, reject) => {
this.ws.once('connect_error', reject);
this.ws.once('initialData', resolve);
});

this.clientId = initialState.clientId;

const rulesetInfo = RulesetStore.getByShortName(initialState.beatmap.ruleset);
if (!rulesetInfo || !rulesetInfo.available)
throw new Error(`Ruleset "${initialState.beatmap.ruleset}" not available`);

const ruleset = rulesetInfo.createInstance();

this.beatmap = ruleset.createBeatmap();
this.beatmap.beatmapInfo.ruleset = rulesetInfo;
this.beatmap.initializeFromSummary(initialState.beatmap.data);

this.updateHandler = new UpdateHandler(this.beatmap);
this.updateHandler.attach(this.beatmap);

const assets = initialState.assets.map(it => new SimpleFile(
it.path,
() => fetch(`http://localhost:3000/assets/${it.id}`).then(it => it.arrayBuffer()),
));

this.fileStore = new StaticFileStore(assets);

this.updateHandler.commandApplied.addListener(mutation => this.onLocalMutation(mutation));
}

private mutationSubmitted(msg: MutationsSubmittedMessage) {
const ctx: MutationContext = {
version: msg.version,
source: msg.clientId === this.clientId
? MutationSource.Ack
: MutationSource.Remote,
};

console.log(msg.mutations);

for (const mutation of msg.mutations)
this.updateHandler.apply(mutation, ctx);
}

bufferedMutations: IMutation[] = [];

onLocalMutation(mutation: IMutation) {
this.bufferedMutations.push(mutation);
}

flush() {
if (this.bufferedMutations.length === 0)
return;

this.ws.emit('submitMutations', {
version: this.updateHandler.version++,
mutations: this.bufferedMutations,
});
this.bufferedMutations = [];
}
}
15 changes: 15 additions & 0 deletions packages/core/src/editor/multiplayer/MultiplayerEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ReadonlyDependencyContainer } from '@osucad/framework';
import type { MultiplayerEditorBeatmap } from './MultiplayerEditorBeatmap';
import { Editor } from '../Editor';

export class MultiplayerEditor extends Editor {
constructor(editorBeatmap: MultiplayerEditorBeatmap) {
super(editorBeatmap);
}

protected override async loadAsync(dependencies: ReadonlyDependencyContainer): Promise<void> {
this.addInternal((this.editorBeatmap as MultiplayerEditorBeatmap).client);

return await super.loadAsync(dependencies);
}
}
15 changes: 15 additions & 0 deletions packages/core/src/editor/multiplayer/MultiplayerEditorBeatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { UpdateHandler } from '@osucad/multiplayer';
import type { MultiplayerClient } from './MultiplayerClient';
import { EditorBeatmap } from '../EditorBeatmap';

export class MultiplayerEditorBeatmap extends EditorBeatmap {
constructor(
readonly client: MultiplayerClient,
) {
super(client.beatmap, client.fileStore);
}

protected override createUpdateHandler(): UpdateHandler {
return this.client.updateHandler;
}
}
3 changes: 3 additions & 0 deletions packages/core/src/editor/multiplayer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './MultiplayerClient';
export * from './MultiplayerEditor';
export * from './MultiplayerEditorBeatmap';
6 changes: 5 additions & 1 deletion packages/core/src/rulesets/Ruleset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { IKeyBinding, NoArgsConstructor } from '@osucad/framework';
import type { Beatmap } from '../beatmap/Beatmap';
import type { BeatmapConverter } from '../beatmap/BeatmapConverter';
import type { IBeatmap } from '../beatmap/IBeatmap';
import type { StableHitObjectParser } from '../beatmap/io/StableHitObjectParser';
Expand All @@ -12,6 +11,7 @@ import type { BeatmapVerifier } from '../verifier/BeatmapVerifier';
import type { DifficultyCalculator } from './difficulty/DifficultyCalculator';
import type { DrawableRuleset } from './DrawableRuleset';
import type { EditorRuleset } from './EditorRuleset';
import { Beatmap } from '../beatmap/Beatmap';
import { RulesetInfo } from './RulesetInfo';

export abstract class Ruleset {
Expand All @@ -23,6 +23,10 @@ export abstract class Ruleset {
return null;
}

createBeatmap(): Beatmap<any> {
return new Beatmap();
}

abstract getHitObjectClasses(): Record<string, NoArgsConstructor<HitObject>>;

abstract createDrawableRulesetWith(beatmap: IBeatmap): DrawableRuleset;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/rulesets/RulesetStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export class RulesetStore {
static getByLegacyId(legacyId: number) {
return this.#legacyRulesets.get(legacyId) ?? null;
}

static getByShortName(shortName: string) {
return this.#rulesets.get(shortName);
}
}
Loading

0 comments on commit 5c1c73a

Please sign in to comment.