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 = {}; 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(`/replay-viewer/${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(); } }