nise/nise-replay-viewer/src/osu/Drawer.ts
2024-03-10 15:59:33 +01:00

428 lines
12 KiB
TypeScript

import {Vector2} from "osu-classes";
import p5 from "p5";
import {loadImageAsync} from "@/utils";
import {Md5} from "ts-md5";
import {Judgements, OsuRenderer} from "@/osu/OsuRenderer";
export class Drawer {
private static imageCache: Record<string, p5.Graphics> = {};
static images = {
cursor: undefined as any as p5.Image,
cursor2: undefined as any as p5.Image,
cursortrail: undefined as any as p5.Image,
hitcircle: undefined as any as p5.Image,
hitcircleoverlay: undefined as any as p5.Image,
radix: undefined as any as p5.Image,
sliderfollowcircle: undefined as any as p5.Image,
sliderb0: undefined as any as p5.Image,
default0: undefined as any as p5.Image,
default1: undefined as any as p5.Image,
default2: undefined as any as p5.Image,
default3: undefined as any as p5.Image,
default4: undefined as any as p5.Image,
default5: undefined as any as p5.Image,
default6: undefined as any as p5.Image,
default7: undefined as any as p5.Image,
default8: undefined as any as p5.Image,
default9: undefined as any as p5.Image,
hitmiss: undefined as any as p5.Image,
hit50: undefined as any as p5.Image,
hit100: undefined as any as p5.Image
};
private static p: p5;
static setP(_p: p5) {
this.p = _p;
}
static async loadDefaultImages() {
const imageLoadPromises = Object.keys(Drawer.images).map(imageName =>
loadImageAsync(`/${imageName}.png`).then(
image => {
Drawer.images[imageName as keyof typeof Drawer.images] = image;
},
error => {
console.error(`Failed to load image ${imageName}:`, error);
}
)
);
return Promise.allSettled(imageLoadPromises)
}
static setImages(images: typeof this.images) {
this.images = images;
}
static setDrawingOpacity(opacity: number) {
//@ts-ignore
this.p.drawingContext.globalAlpha = opacity;
}
/**
* Draws the error bar for the given judgements.
* @param judgements
*/
static drawErrorBar(judgements: Judgements[]) {
let width = 512;
let height = 384;
// @https://osu.ppy.sh/wiki/en/Beatmap/Overall_difficulty
let hitWindow300 = 80 - 6 * OsuRenderer.beatmap.difficulty.overallDifficulty;
let hitWindow100 = 140 - 8 * OsuRenderer.beatmap.difficulty.overallDifficulty;
let hitWindow50 = 200 - 10 * OsuRenderer.beatmap.difficulty.overallDifficulty;
let barWidth = 512 * 0.8; // Width of the error bar, 80% of canvas width
let barHeight = 20; // The height of the error bar
let barX = (width - barWidth) / 2; // Center the bar on the canvas
let barY = height + 52;
// Draw the background for the 50's hit window (biggest)
Drawer.p.fill(217, 174, 71);
Drawer.p.rect(barX + barWidth / 2 - (hitWindow50), barY, hitWindow50 * 2, barHeight);
// Draw the background for the 100's hit window (middle)
Drawer.p.fill(87, 225, 19);
Drawer.p.rect(barX + barWidth / 2 - (hitWindow100), barY, hitWindow100 * 2, barHeight);
// Draw the background for the 300's hit window (smallest/closer to the center)
Drawer.p.fill(50, 188, 231);
Drawer.p.rect(barX + barWidth / 2 - (hitWindow300), barY, hitWindow300 * 2, barHeight);
let tickDensity = new Array(Math.ceil(barWidth / 5)).fill(0); // Example: Each segment is 5 pixels wide
judgements.forEach(judgement => {
let judgementX = barX + barWidth / 2 + judgement.error / 2;
this.drawJudgementTick(judgementX, barY, barHeight, tickDensity);
});
// Draw a vertical bar in the exact center of the hit error bar
// Drawer.p.push();
// Drawer.p.noFill();
// Drawer.p.stroke(255);
// Drawer.p.strokeWeight(2);
// Drawer.p.line(barX + barWidth / 2, barY - 5, barX + barWidth / 2, barY + barHeight + 5);
// Drawer.p.pop();
}
//@ts-ignore
static drawJudgementTick(x, y, height, tickDensity) {
let barWidth = 512 * 0.8;
let barX = (512 - barWidth) / 2; // Center the bar on the canvas
const segmentIndex = Math.floor((x - barX) / 5); // Assuming 5-pixel segments
tickDensity[segmentIndex]++;
const opacity = this.calculateOpacity(tickDensity[segmentIndex]);
Drawer.p.stroke(255, 255, 255, 255);
Drawer.p.strokeWeight(2);
Drawer.p.strokeCap(Drawer.p.SQUARE); // Slightly thicker ends for clarity
Drawer.p.colorMode(Drawer.p.RGB);
Drawer.p.stroke(Drawer.p.color(255, 255, 255, 255 * opacity)); // Apply opacity
Drawer.p.line(x, y, x, y + height);
}
/**
* Calculate the opacity of the judgement tick. Lower opacity means less amount.
*
* @param count The amount of ticks
* @returns The opacity of the tick (0-1)
*/
static calculateOpacity(count: number): number {
const MIN_OPACITY = 0.55; // Minimum opacity enforced
const MAX_OPACITY = 1; // Maximum opacity
const MAX_DENSITY = 50; // Opacity reaches maximum at this tick count
// Calculate linear opacity based on count
let opacity = (count / MAX_DENSITY) * (MAX_OPACITY - MIN_OPACITY) + MIN_OPACITY;
// Ensure opacity is at least MIN_OPACITY and at most MAX_OPACITY
opacity = Math.max(opacity, MIN_OPACITY);
opacity = Math.min(opacity, MAX_OPACITY);
return opacity;
}
static drawJudgement(
position: Vector2,
judgement: string,
opacity: number
) {
Drawer.p.push();
//@ts-ignore
Drawer.p.drawingContext.globalAlpha = opacity;
if (judgement === "ONE_HUNDRED") {
Drawer.p.image(
this.images.hit100,
position.x,
position.y,
this.images.hit100.width,
this.images.hit100.height
);
}
if (judgement === "FIFTY") {
Drawer.p.image(
this.images.hit50,
position.x,
position.y,
this.images.hit50.width,
this.images.hit50.height
);
}
if (judgement === "MISS") {
Drawer.p.image(
this.images.hitmiss,
position.x,
position.y,
this.images.hitmiss.width,
this.images.hitmiss.height
);
}
Drawer.p.pop();
}
static drawSliderFollowPoint(position: Vector2, radius: number) {
Drawer.p.image(
this.images.sliderfollowcircle,
position.x,
position.y,
radius * 2,
radius * 2
);
Drawer.p.push();
Drawer.p.fill(255, 255, 255, 18);
Drawer.p.circle(position.x, position.y, radius * 4);
Drawer.p.pop();
}
static drawHitCircle(position: Vector2, radius: number, comboNumber: number) {
Drawer.p.push();
Drawer.p.noStroke();
Drawer.p.fill(160);
Drawer.p.image(
this.images.hitcircle,
position.x,
position.y,
radius * 2,
radius * 2
);
Drawer.p.image(
this.images.hitcircleoverlay,
position.x,
position.y,
radius * 2,
radius * 2
);
this.drawNumberWithSprites(
comboNumber + 1,
new Vector2(position.x - 0.5, position.y),
radius * 0.4
);
Drawer.p.pop();
}
static drawApproachCircle(
position: Vector2,
radius: number,
arScale: number
) {
if (arScale == 1) return;
Drawer.p.push();
Drawer.p.noFill();
Drawer.p.stroke("white");
Drawer.p.strokeWeight(3);
Drawer.p.circle(position.x, position.y, radius * 2 * arScale);
Drawer.p.pop();
}
static drawSliderBody(origin: Vector2, path: Vector2[], radius: number) {
Drawer.p.push();
const cacheKey = Md5.hashStr(JSON.stringify(origin) + JSON.stringify(path) + JSON.stringify(radius));
if (!this.imageCache[cacheKey]) {
const g = Drawer.p.createGraphics(512 * 4, 384 * 4);
g.scale(2);
g.translate(512 - 256, 384 - 192);
//@ts-ignore
const ctx = g.drawingContext;
ctx.lineCap = "round";
ctx.lineJoin = "round";
g.translate(origin.x, origin.y);
g.noFill();
g.strokeWeight(radius * 2 - 10);
g.stroke(255);
g.beginShape();
for (const node of path) {
g.vertex(node.x, node.y);
}
g.endShape();
g.strokeWeight(radius * 2 - 17);
g.stroke(10);
g.beginShape();
for (const node of path) {
g.vertex(node.x, node.y);
}
g.endShape();
for (let i = 0; i < radius * 2 - 17; i += 2) {
g.strokeWeight(radius * 2 - 17 - i);
g.stroke(Math.round((i / (radius * 2 - 17)) * 45));
g.beginShape();
for (const node of path) {
g.vertex(node.x, node.y);
}
g.endShape();
}
this.imageCache[cacheKey] = g;
}
Drawer.p.imageMode(Drawer.p.CORNER);
Drawer.p.image(this.imageCache[cacheKey], -256, -192, 512 * 2, 384 * 2);
Drawer.p.pop();
}
static drawCursorPath(
cursorImage: p5.Image,
path: {
position: Vector2;
time: number;
button: {
mouseLeft1: boolean;
mouseLeft2: boolean;
mouseRight1: boolean;
mouseRight2: boolean;
};
}[],
cursor: {
position: Vector2;
button: {
mouseLeft1: boolean;
mouseLeft2: boolean;
mouseRight1: boolean;
mouseRight2: boolean;
};
}
) {
Drawer.p.push();
//@ts-ignore
const ctx = Drawer.p.drawingContext;
ctx.lineCap = "round";
ctx.lineJoin = "round";
Drawer.p.noFill();
Drawer.p.strokeWeight(1.5);
Drawer.p.stroke("white");
for (let i = 1; i < path.length; i++) {
const lastFrame = path[i - 1];
const frame = path[i];
if(!OsuRenderer.settings.showFutureCursorPath && frame.time > OsuRenderer.time) {
continue;
}
if(OsuRenderer.settings.showCursorPath) {
Drawer.p.stroke("white");
Drawer.p.line(
lastFrame.position.x,
lastFrame.position.y,
frame.position.x,
frame.position.y
);
}
}
if (cursor.position) {
Drawer.p.image(
cursorImage,
cursor.position.x,
cursor.position.y,
55,
55
);
for (let i = 1; i < path.length; i++) {
const lastFrame = path[i - 1];
const frame = path[i];
if(!OsuRenderer.settings.showFutureCursorPath && frame.time > OsuRenderer.time) {
continue;
}
if(OsuRenderer.settings.showKeyPress) {
if(!OsuRenderer.settings.showFutureCursorPath && lastFrame.time > OsuRenderer.time) {
continue;
}
let size = 4;
if (lastFrame.button.mouseLeft1 || lastFrame.button.mouseLeft2) {
Drawer.p.stroke("#BB6BD9");
Drawer.p.line(lastFrame.position.x - size, lastFrame.position.y - size, lastFrame.position.x + size, lastFrame.position.y + size);
Drawer.p.line(lastFrame.position.x + size, lastFrame.position.y - size, lastFrame.position.x - size, lastFrame.position.y + size);
}
if (lastFrame.button.mouseRight1 || lastFrame.button.mouseRight2) {
Drawer.p.stroke("#F2994A");
Drawer.p.line(lastFrame.position.x - size, lastFrame.position.y - size, lastFrame.position.x + size, lastFrame.position.y + size);
Drawer.p.line(lastFrame.position.x + size, lastFrame.position.y - size, lastFrame.position.x - size, lastFrame.position.y + size);
}
}
}
}
Drawer.p.pop();
}
static drawNumberWithSprites(
number: number,
position: Vector2,
size: number
) {
Drawer.p.push();
Drawer.p.imageMode(Drawer.p.CORNER);
const digits = number.toString().split("");
const digitWidth = size;
const digitHeight = size * 1.2;
const digitSpacing = -size * 0.1;
const totalWidth = digits.length * (digitWidth + digitSpacing);
const x = position.x - totalWidth / 2;
const y = position.y - digitHeight / 2;
digits.forEach((digit, index) => {
const indexer = `default${digit}`;
const image = this.images[indexer as keyof typeof this.images];
Drawer.p.image(
image,
x + index * (digitWidth + digitSpacing),
y,
digitWidth,
digitHeight
);
});
Drawer.p.pop();
}
static drawField() {
Drawer.p.noFill();
Drawer.p.stroke(255, 255, 255, 60);
Drawer.p.rect(0, 0, 512, 384, 4);
}
static beginDrawing() {
Drawer.p.push();
}
static endDrawing() {
Drawer.p.pop();
}
}