Basic groundwork for replay viewer
This commit is contained in:
parent
45cb5afc0a
commit
dcdd60c318
6
nise-frontend/package-lock.json
generated
6
nise-frontend/package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
|
"lzma-web": "^3.0.1",
|
||||||
"ng2-charts": "^5.0.4",
|
"ng2-charts": "^5.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
@ -8081,6 +8082,11 @@
|
|||||||
"lz-string": "bin/bin.js"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.7",
|
"version": "0.30.7",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
|
"lzma-web": "^3.0.1",
|
||||||
"ng2-charts": "^5.0.4",
|
"ng2-charts": "^5.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|||||||
@ -1,31 +1,3 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<app-replay-viewer></app-replay-viewer>
|
||||||
<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
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {Router, RouterLink, RouterOutlet} from "@angular/router";
|
|||||||
import {UserService} from "../corelib/service/user.service";
|
import {UserService} from "../corelib/service/user.service";
|
||||||
import {NgIf} from '@angular/common';
|
import {NgIf} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {ReplayViewerComponent} from "../corelib/components/replay-viewer/replay-viewer.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@ -10,7 +11,7 @@ import {FormsModule} from '@angular/forms';
|
|||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIf, RouterOutlet
|
NgIf, RouterOutlet, ReplayViewerComponent
|
||||||
],
|
],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.css']
|
styleUrls: ['./app.component.css']
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)">
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user