Basic groundwork for replay viewer

This commit is contained in:
nise.moe 2024-02-29 03:02:14 +01:00
parent 45cb5afc0a
commit dcdd60c318
12 changed files with 742 additions and 30 deletions

View File

@ -21,6 +21,7 @@
"chart.js": "^4.4.1",
"date-fns": "^3.3.1",
"lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
@ -8081,6 +8082,11 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/lzma-web": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/lzma-web/-/lzma-web-3.0.1.tgz",
"integrity": "sha512-sb5cdfd+PLNljK/HUgYzvnz4G7r0GFK8sonyGrqJS0FVyUQjFYcnmU2LqTWFi6r48lH1ZBstnxyLWepKM/t7QA=="
},
"node_modules/magic-string": {
"version": "0.30.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",

View File

@ -24,6 +24,7 @@
"chart.js": "^4.4.1",
"date-fns": "^3.3.1",
"lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",

View File

@ -1,31 +1,3 @@
<div class="header">
<div>
<a [routerLink]="['/']">
<img src="/assets/keisatsu-chan.png" class="header-image" alt="keisatsu-chan~!">
</a>
</div>
<div>
<h2>/nise.moe/</h2>
<ul style="font-size: 15px; line-height: 19px;">
<li><a [routerLink]="['/']">./home</a></li>
<li><a [routerLink]="['/sus']">./suspicious-scores</a></li>
<li><a [routerLink]="['/stolen']">./stolen-replays</a></li>
<li><a [routerLink]="['/search']">./advanced-search</a></li>
</ul>
<form (ngSubmit)="onSubmit()">
<input type="text" [(ngModel)]="term" [ngModelOptions]="{standalone: true}" id="nise-osu-username" required minlength="2" maxlength="50" placeholder="Search for users...">
</form>
<div style="margin-top: 6px">
<ng-container *ngIf="this.userService.isUserLoggedIn()">
hi, <span class="user-details" [title]="this.userService.currentUser?.username">{{this.userService.currentUser?.username}}</span> <a [href]="this.userService.getLogoutUrl()">Logout</a>
</ng-container>
<ng-container *ngIf="!this.userService.isUserLoggedIn()">
<a [href]="this.userService.getLoginUrl()">Login</a>
</ng-container>
</div>
</div>
</div>
<router-outlet></router-outlet>
<div class="text-center version">
v20240225
<app-replay-viewer></app-replay-viewer>
</div>

View File

@ -3,6 +3,7 @@ import {Router, RouterLink, RouterOutlet} from "@angular/router";
import {UserService} from "../corelib/service/user.service";
import {NgIf} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {ReplayViewerComponent} from "../corelib/components/replay-viewer/replay-viewer.component";
@Component({
selector: 'app-root',
@ -10,7 +11,7 @@ import {FormsModule} from '@angular/forms';
imports: [
RouterLink,
FormsModule,
NgIf, RouterOutlet
NgIf, RouterOutlet, ReplayViewerComponent
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']

View File

@ -0,0 +1,57 @@
export enum HitObjectType {
HitCircle = 1,
Slider = 2,
Spinner = 8
}
export type HitObject = {
x: number;
y: number;
time: number;
type: number;
hitSound: number;
objectParams: string;
hitSample: string;
};
export function parseHitObjects(beatmap: string): any[] {
const lines = beatmap.split('\n');
let recording = false;
const hitObjects = [];
for (const line of lines) {
if (line.trim() === '[HitObjects]') {
recording = true;
continue;
}
if (!recording) continue;
if (line.startsWith('[')) break;
const parts = line.split(',');
if (parts.length < 5) continue;
const type = parseInt(parts[3], 10);
const hitObject = {
x: parseInt(parts[0], 10),
y: parseInt(parts[1], 10),
time: parseInt(parts[2], 10),
type: getTypeFromFlag(type),
hitSound: parseInt(parts[4], 10),
objectParams: parts[5],
hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:'
};
hitObjects.push(hitObject);
}
return hitObjects;
}
function getTypeFromFlag(flag: number): HitObjectType | null {
if (flag & HitObjectType.HitCircle) return HitObjectType.HitCircle;
if (flag & HitObjectType.Slider) return HitObjectType.Slider;
if (flag & HitObjectType.Spinner) return HitObjectType.Spinner;
return null;
}

View File

@ -0,0 +1,70 @@
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
import LZMA from 'lzma-web'
export async function getEvents(replayString: string): Promise<ReplayEvent[]> {
const decompressedData = await decompressData(replayString);
const replayDataStr = new TextDecoder("utf-8").decode(decompressedData);
const trimmedReplayDataStr = replayDataStr.endsWith(',') ? replayDataStr.slice(0, -1) : replayDataStr;
return processEvents(trimmedReplayDataStr);
}
function processEvents(replayDataStr: string): ReplayEvent[] {
const eventStrings = replayDataStr.split(",");
const playData: ReplayEvent[] = [];
eventStrings.forEach((eventStr, index) => {
const event = createReplayEvent(eventStr.split('|'), index, eventStrings.length);
if (event) playData.push(event);
});
return playData;
}
function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null {
const timeDelta = parseInt(eventParts[0], 10);
const x = parseFloat(eventParts[1]);
const y = parseFloat(eventParts[2]);
const rawKey = parseInt(eventParts[3], 10);
if (timeDelta == -12345 && index == totalEvents - 1) {
return null;
}
if (index < 2 && x == 256.0 && y == -500.0) {
return null;
}
// Safely cast the integer value to the KeyPress enum
let keyPress = KeyPress[rawKey as unknown as keyof typeof KeyPress];
if (keyPress === undefined) {
// TODO: Fix
console.error("Unknown key press:", rawKey);
keyPress = KeyPress.Smoke;
}
return {
timeDelta,
x,
y,
key: keyPress
};
}
async function decompressData(base64Data: string): Promise<Uint8Array> {
const lzma = new LZMA();
const binaryString = atob(base64Data);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const result = await lzma.decompress(bytes);
if (typeof result === 'string') {
return new TextEncoder().encode(result);
} else {
return result;
}
}

View File

@ -0,0 +1,72 @@
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
export class ReplayEventProcessed {
x: number;
y: number;
t: number; // Time
key: KeyPress;
constructor(x: number, y: number, t: number, key: KeyPress) {
this.x = x;
this.y = y;
this.t = t;
this.key = key;
}
}
export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
if (events.length === 0) throw new Error("This replay's replay data was empty. It indicates a misbehaved replay.");
if (events[0].timeDelta === 0 && events.length > 1) events.shift();
const pEvents: ReplayEventProcessed[] = [];
let cumulativeTimeDelta = events[0].timeDelta;
let highestTimeDelta = Number.NEGATIVE_INFINITY;
let lastPositiveFrame: ReplayEvent | null = null;
let wasInNegativeSection = false;
const lastPositiveFrameData: [number, [number, number]][] = [];
events.slice(1).forEach((currentFrame, index) => {
const previousCumulativeTime = cumulativeTimeDelta;
cumulativeTimeDelta += currentFrame.timeDelta;
highestTimeDelta = Math.max(highestTimeDelta, cumulativeTimeDelta);
const isInNegativeSection = cumulativeTimeDelta < highestTimeDelta;
if (isInNegativeSection) {
if (!wasInNegativeSection) {
lastPositiveFrame = index > 0 ? events[index] : null;
}
} else {
if (wasInNegativeSection && lastPositiveFrame) {
const lastPositiveTime = lastPositiveFrameData.length > 0 ? lastPositiveFrameData[lastPositiveFrameData.length - 1][0] : previousCumulativeTime;
const ratio = (lastPositiveTime - previousCumulativeTime) / (cumulativeTimeDelta - previousCumulativeTime);
const interpolatedX = lastPositiveFrame.x + ratio * (currentFrame.x - lastPositiveFrame.x);
const interpolatedY = lastPositiveFrame.y + ratio * (currentFrame.y - lastPositiveFrame.y);
pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.key));
}
wasInNegativeSection = false;
}
wasInNegativeSection = isInNegativeSection;
if (!isInNegativeSection) {
pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.key));
}
if (!isInNegativeSection) {
lastPositiveFrameData.push([cumulativeTimeDelta, [currentFrame.x, currentFrame.y]]);
}
});
// Ensuring uniqueness based on time to avoid duplicates
return pEvents.filter((event, index, self) =>
index === self.findIndex((t) => (
t.t === event.t
))
);
}

View File

@ -0,0 +1,213 @@
import { ElementRef, Injectable } from '@angular/core';
import {HitObject, HitObjectType} from "./decode-beatmap";
import {ReplayEventProcessed} from "./process-replay";
@Injectable({
providedIn: 'root',
})
export class ReplayService {
private hitObjects: HitObject[] = [];
private replayEvents: ReplayEventProcessed[] = [];
currentTime = 0;
private totalDuration = 0;
private lastRenderTime = 0;
private isPlaying = false;
private requestId: number | null = null;
private replayCanvas: ElementRef<HTMLCanvasElement> | null = null;
private ctx: CanvasRenderingContext2D | null = null;
constructor() {}
setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) {
this.replayCanvas = canvas;
}
setCanvasContext(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
setHitObjects(hitObjects: HitObject[]) {
this.hitObjects = hitObjects;
console.log('Hit objects:', this.hitObjects);
}
setEvents(events: ReplayEventProcessed[]) {
this.replayEvents = events;
this.calculateTotalDuration();
console.log('Replay events:', this.replayEvents);
}
private calculateTotalDuration() {
if (this.replayEvents.length === 0) {
this.totalDuration = 0;
return;
}
const lastEvent = this.replayEvents[this.replayEvents.length - 1];
this.totalDuration = lastEvent.t;
}
start() {
this.isPlaying = true;
if(this.currentTime >= this.totalDuration) {
this.currentTime = 0;
}
this.animate();
}
pause() {
this.isPlaying = false;
if (this.requestId) {
cancelAnimationFrame(this.requestId);
this.requestId = null;
}
}
seek(time: number) {
this.currentTime = Math.min(Math.max(time, 0), this.totalDuration);
this.lastRenderTime = 0;
this.isPlaying = true;
this.animate();
this.isPlaying = false;
console.log('Seeking to:', this.currentTime);
}
private animate(currentTimestamp: number = 0) {
if (!this.isPlaying) return;
if (!this.lastRenderTime) {
this.lastRenderTime = currentTimestamp;
}
const elapsedTime = currentTimestamp - this.lastRenderTime;
// Check if enough time has passed for the next frame (approximately 16.67ms for 60 FPS)
if (elapsedTime < 16.67) {
// Request the next animation frame and return early
this.requestId = requestAnimationFrame(this.animate.bind(this));
return;
}
// Assuming elapsedTime is sufficient for 1 frame, update currentTime for real-time playback
this.currentTime += elapsedTime;
if (this.currentTime > this.totalDuration) {
this.currentTime = this.totalDuration;
this.pause();
return;
}
this.drawCurrentEventPosition();
this.drawHitCircles();
this.drawSliders();
this.lastRenderTime = currentTimestamp;
// Request the next frame
this.requestId = requestAnimationFrame(this.animate.bind(this));
}
public getCurrentReplayEvent(): ReplayEventProcessed | null {
let currentEvent: ReplayEventProcessed | null = null;
for (const event of this.replayEvents) {
if (event.t <= this.currentTime) {
currentEvent = event;
} else {
break; // Exit the loop once an event exceeds the current time
}
}
return currentEvent;
}
private drawCurrentEventPosition() {
const currentEvent = this.getCurrentReplayEvent();
if (currentEvent) {
this.updateCanvas(currentEvent.x, currentEvent.y);
}
}
private drawHitCircles() {
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
const visibleHitCircles = this.hitObjects.filter(obj =>
obj.type === HitObjectType.HitCircle &&
this.currentTime >= obj.time - 200 && // Start showing 200ms before
this.currentTime <= obj.time // Hide after its time
);
visibleHitCircles.forEach(hitCircle => {
const opacity = this.calculateOpacity(hitCircle.time);
this.drawHitCircle(hitCircle.x, hitCircle.y, opacity);
});
}
private drawSliders() {
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
const visibleSliders = this.hitObjects.filter(obj =>
obj.type === HitObjectType.Slider &&
this.currentTime >= obj.time - 200 && // Start showing 200ms before
this.currentTime <= obj.time // Hide after its time
);
visibleSliders.forEach(slider => {
const opacity = this.calculateOpacity(slider.time);
this.drawSlider(slider, opacity);
});
}
private drawSlider(slider: HitObject, opacity: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
this.ctx!.beginPath();
this.ctx!.arc(slider.x, slider.y, 25, 0, 2 * Math.PI); // Assuming a radius of 5px
this.ctx!.fill();
}
private calculateOpacity(circleTime: number): number {
const timeDifference = circleTime - this.currentTime;
if (timeDifference < 0) {
return 0; // Circle time has passed
}
// Calculate fade-in effect (0 to 1 over 200ms)
return Math.min(1, (200 - timeDifference) / 200);
}
private drawHitCircle(x: number, y: number, opacity: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
this.ctx!.beginPath();
this.ctx!.arc(x, y, 25, 0, 2 * Math.PI); // Assuming a radius of 50px
this.ctx!.fill();
}
private updateCanvas(x: number, y: number) {
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height);
this.ctx.fillStyle = '#FFFFFF';
this.ctx.beginPath();
this.ctx.arc(x, y, 5, 0, 2 * Math.PI);
this.ctx.fill();
}
getTotalDuration() {
return this.totalDuration;
}
getIsPlaying() {
return this.isPlaying;
}
}

View File

@ -0,0 +1,5 @@
<canvas #replayCanvas width="600" height="400"></canvas>
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div>
<div>Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}</div>
<button (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
<input type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">

View File

@ -0,0 +1,91 @@
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {getEvents} from "./decode-replay";
import {DecimalPipe, JsonPipe} from "@angular/common";
import {beatmapData, replayData} from "./sample-replay";
import {ReplayService} from "./replay-service";
import {FormsModule} from "@angular/forms";
import {parseHitObjects} from "./decode-beatmap";
import {processReplay} from "./process-replay";
export enum KeyPress {
M1 = 1,
M2 = 2,
K1 = 5,
K2 = 10,
Smoke = 16,
}
export interface ReplayEvent {
/**
* Time in milliseconds since the previous action
*/
timeDelta: number;
/**
* x-coordinate of the cursor from 0 - 512
*/
x: number;
/**
* y-coordinate of the cursor from 0 - 384
*/
y: number;
/**
* Key being pressed.
*/
key: KeyPress;
}
@Component({
selector: 'app-replay-viewer',
standalone: true,
imports: [
JsonPipe,
FormsModule,
DecimalPipe
],
templateUrl: './replay-viewer.component.html',
styleUrl: './replay-viewer.component.css'
})
export class ReplayViewerComponent implements OnInit, AfterViewInit {
@ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
constructor(public replayService: ReplayService) { }
ngOnInit() {
// Assume getEvents() method returns a promise of ReplayEvent[]
getEvents(replayData).then(events => {
this.replayService.setEvents(processReplay(events));
this.replayService.setHitObjects(parseHitObjects(beatmapData))
});
}
ngAfterViewInit() {
this.ctx = this.replayCanvas.nativeElement.getContext('2d')!;
this.replayService.setCanvasElement(this.replayCanvas);
this.replayService.setCanvasContext(this.ctx);
this.replayService.start(); // Start the animation loop
}
togglePlayPause() {
if (this.replayService.getIsPlaying()) {
this.replayService.pause();
} else {
this.replayService.start();
}
}
seek(time: number) {
this.replayService.seek(time);
if (!this.replayService.getIsPlaying()) {
// Redraw the canvas for the new current time without resuming playback
}
}
}

File diff suppressed because one or more lines are too long