diff --git a/src/js/beat-saber/NotePile.tsx b/src/js/beat-saber/NotePile.tsx index efd26bc..f5d9fa8 100644 --- a/src/js/beat-saber/NotePile.tsx +++ b/src/js/beat-saber/NotePile.tsx @@ -1,28 +1,35 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DataDisplayProps } from './DataDisplay'; -import { Engine, Composite, World, Render, Runner, Bodies } from 'matter-js'; +import { + Bodies, + Composite, + Engine, + Events, + IEventTimestamped, + Runner, + World +} from 'matter-js'; import { useCurrentScreenSize } from '../hooks/use-current-screen-size'; import { ColorType, ELiveDataEventTriggers, NoteCutDirection } from '../model/bsdp'; +import { getNoteSprite, Render } from '../utils/note-pile'; const DEFAULT_LEFT_SABER_COLOR = '#a82020'; const DEFAULT_RIGHT_SABER_COLOR = '#2064a8'; /** - * Renders a pile of notes affected by physics for each missed note (WIP) - * - * TODO Add sprites for dot/directional notes - * TODO Add a clear sequence after level completion + * Renders a pile of notes affected by physics for each missed note */ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) => { const [notesPileCanvasRef, setNotesPileCanvasRef] = useState(null); const screenSize = useCurrentScreenSize(); const engineRef = useRef(null); const amounts = useMemo(() => ({ - walls: screenSize.width * 0.012, - blocks: screenSize.width * 0.015, - baseTorque: screenSize.width * 4, - randomTorqueMax: screenSize.width * 0.4, + wallSize: screenSize.width * 0.012, + blockSize: screenSize.width * 0.015, + baseTorque: 1e5, horizontalForceMax: screenSize.width * 0.04, + clearThrowForceY: screenSize.width / 20, + clearThrowForceXMax: 50, }), [screenSize.width]); const colors: Record = useMemo(() => ({ @@ -30,24 +37,23 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = [ColorType.ColorB]: dataSource.mapData?.rightSaberColor ?? DEFAULT_RIGHT_SABER_COLOR, }), [dataSource.mapData?.leftSaberColor, dataSource.mapData?.rightSaberColor]); - const addBox = useCallback( + const addBlock = useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Will be used later (cutDirection: number = NoteCutDirection.None, color: number = ColorType.None) => { const engine = engineRef.current; if (!engine || color === ColorType.None) return; const screenCenterX = screenSize.width / 2; - const spawnY = amounts.blocks * -2; + const spawnY = amounts.blockSize * -2; const density = 1; - const box = Bodies.rectangle(screenCenterX, spawnY, amounts.blocks, amounts.blocks, { + const box = Bodies.rectangle(screenCenterX + (Math.random() - 0.5) * amounts.blockSize * 5, spawnY, amounts.blockSize, amounts.blockSize, { chamfer: { radius: 5 }, - torque: amounts.baseTorque + (Math.random() - 0.5) * amounts.randomTorqueMax, + torque: (Math.random() - 0.5) * amounts.baseTorque, render: { fillStyle: colors[color], - lineWidth: amounts.blocks * 0.075, + lineWidth: amounts.blockSize * 0.075, strokeStyle: 'rgba(0,0,0,.5)', - // TODO Find a way to render both sprite AND fill - // sprite: getNoteSprite(cutDirection) + sprite: getNoteSprite(cutDirection) }, density, frictionAir: .02, @@ -58,9 +64,50 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = }); Composite.add(engine.world, [box]); }, - [screenSize.width, amounts.blocks, amounts.baseTorque, amounts.randomTorqueMax, amounts.horizontalForceMax, colors] + [screenSize.width, amounts.blockSize, amounts.baseTorque, amounts.horizontalForceMax, colors] ); + /** + * Removing the bodies when they go outside the screen + */ + const cleanupBodies = useCallback((event: IEventTimestamped) => { + if (event.name !== 'afterUpdate') { + return; + } + const engine = engineRef.current; + if (!engine) return; + const yThreshold = screenSize.height * 2; + if (yThreshold === 0) return; + + for (const body of engine.world.bodies) { + if (body.position.y > yThreshold) { + World.remove(engine.world, body); + } + } + }, [screenSize.height]); + + /** + * Throw blocks into the ari a bit and apply a collision filter, + * so they can fall below the cleanup threshold + */ + const clearBlocks = useCallback(() => { + const engine = engineRef.current; + if (!engine) return; + + Composite.allBodies(engine.world).forEach((body) => { + if (!body.render.visible) return; + + body.collisionFilter = { + mask: 1, + group: 1, + }; + body.force = { + x: amounts.clearThrowForceXMax * (Math.random() - 0.5), + y: -amounts.clearThrowForceY, + }; + }); + }, [amounts.clearThrowForceXMax, amounts.clearThrowForceY]); + const trigger = dataSource.liveData?.trigger; const cutDirection = dataSource.liveData?.cutDirection; const color = dataSource.liveData?.color; @@ -70,8 +117,14 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = } // If the event was fired due to a miss - addBox(cutDirection, color); - }, [addBox, color, cutDirection, trigger]); + addBlock(cutDirection, color); + }, [addBlock, color, cutDirection, trigger]); + + useEffect(() => { + if (dataSource.mapData?.inLevel !== true) { + clearBlocks(); + } + }, [clearBlocks, dataSource.mapData?.inLevel, dataSource.readyState]); useEffect(() => { if (!notesPileCanvasRef) return; @@ -100,16 +153,16 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = const screenCenterY = screenSize.height / 2; // create a ground - const groundSizeHalf = amounts.walls / 2; - const ground = Bodies.rectangle(screenCenterX, screenSize.height + groundSizeHalf, screenSize.width, amounts.walls, { + const groundSizeHalf = amounts.wallSize / 2; + const ground = Bodies.rectangle(screenCenterX, screenSize.height + groundSizeHalf, screenSize.width, amounts.wallSize, { isStatic: true, render: { visible: false } }); - const leftWall = Bodies.rectangle(0 - groundSizeHalf, screenCenterY, amounts.walls, screenSize.height, { + const leftWall = Bodies.rectangle(0 - groundSizeHalf, screenCenterY, amounts.wallSize, screenSize.height, { isStatic: true, render: { visible: false } }); - const rightWall = Bodies.rectangle(screenSize.width + groundSizeHalf, screenCenterY, amounts.walls, screenSize.height, { + const rightWall = Bodies.rectangle(screenSize.width + groundSizeHalf, screenCenterY, amounts.wallSize, screenSize.height, { isStatic: true, render: { visible: false } }); @@ -126,6 +179,9 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = // run the engine Runner.run(runner, engine); + // Adding the event listener + Events.on(engine, 'afterUpdate', cleanupBodies); + // unmount return () => { // destroy Matter @@ -134,8 +190,9 @@ export const NotePile: FC<{ dataSource: DataDisplayProps }> = ({ dataSource }) = Engine.clear(engine); render.textures = {}; engineRef.current = null; + window.removeEventListener('contextmenu', clearBlocks); }; - }, [addBox, notesPileCanvasRef, screenSize.height, screenSize.width, amounts.walls]); + }, [addBlock, notesPileCanvasRef, screenSize.height, screenSize.width, amounts.wallSize, clearBlocks, cleanupBodies]); return {
-

🚧 Missed Notes Pile 🚧

+

Missed Notes Pile

-

Physics-based pile of missed notes accumulated on the screen (still under - construction)

+

Physics-based pile of missed notes accumulated on the screen

-

Only works with the DataPuller data source.

+

Only works with the DataPuller data source, and requires at least mod version + 2.1.9

1 ? 1 : 0; k < body.parts.length; k++) { + const part = body.parts[k]; + + if (!part.render.visible) + continue; + + if (typeof part.render.opacity === 'number') { + if (options.showSleeping && body.isSleeping) { + c.globalAlpha = 0.5 * part.render.opacity; + } else if (part.render.opacity !== 1) { + c.globalAlpha = part.render.opacity; + } + } + + // part polygon + if (part.circleRadius) { + c.beginPath(); + c.arc(part.position.x, part.position.y, part.circleRadius, 0, 2 * Math.PI); + } else { + c.beginPath(); + c.moveTo(part.vertices[0].x, part.vertices[0].y); + + for (let j = 1; j < part.vertices.length; j++) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (!part.vertices[j - 1].isInternal || showInternalEdges) { + c.lineTo(part.vertices[j].x, part.vertices[j].y); + } else { + c.moveTo(part.vertices[j].x, part.vertices[j].y); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (part.vertices[j].isInternal && !showInternalEdges) { + c.moveTo(part.vertices[(j + 1) % part.vertices.length].x, part.vertices[(j + 1) % part.vertices.length].y); + } + } + + c.lineTo(part.vertices[0].x, part.vertices[0].y); + c.closePath(); + } + + if (!options.wireframes) { + if (part.render.fillStyle) { + c.fillStyle = part.render.fillStyle; + } + + if (part.render.lineWidth) { + c.lineWidth = part.render.lineWidth; + if (part.render.strokeStyle) { + c.strokeStyle = part.render.strokeStyle; + } + c.stroke(); + } + + c.fill(); + } else { + c.lineWidth = 1; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + c.strokeStyle = render.options.wireframeStrokeStyle; + c.stroke(); + } + + const sprite = part.render.sprite; + if (sprite && sprite.texture && !options.wireframes) { + // part sprite + const texture = getTexture(render, sprite.texture); + + c.translate(part.position.x, part.position.y); + c.rotate(part.angle); + + c.drawImage( + texture, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + texture.width * -sprite.xOffset * sprite.xScale, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + texture.height * -sprite.yOffset * sprite.yScale, + texture.width * sprite.xScale, + texture.height * sprite.yScale + ); + + // revert translation, hopefully faster than save / restore + c.rotate(-part.angle); + c.translate(-part.position.x, -part.position.y); + } + + c.globalAlpha = 1; + } + } + } +});