Skip to content

Commit

Permalink
Finalize implementation of missed notes on screen
Browse files Browse the repository at this point in the history
  • Loading branch information
DJDavid98 committed Jan 17, 2024
1 parent d133b99 commit c6d2d6b
Show file tree
Hide file tree
Showing 3 changed files with 212 additions and 30 deletions.
105 changes: 81 additions & 24 deletions src/js/beat-saber/NotePile.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,59 @@
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<HTMLCanvasElement | null>(null);
const screenSize = useCurrentScreenSize();
const engineRef = useRef<Engine | null>(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<number, string> = useMemo(() => ({
[ColorType.ColorA]: dataSource.mapData?.leftSaberColor ?? DEFAULT_LEFT_SABER_COLOR,
[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,
Expand All @@ -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<Engine>) => {
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 }
});
Expand All @@ -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
Expand All @@ -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 <canvas
ref={setNotesPileCanvasRef}
Expand Down
8 changes: 4 additions & 4 deletions src/js/settings/pages/SettingsPageBeatSaber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,13 @@ export const SettingsPageBeatSaber: FC = () => {
</details>
<details open>
<summary>
<h2>🚧 Missed Notes Pile 🚧</h2>
<h2>Missed Notes Pile</h2>
</summary>

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

<p>Only works with the DataPuller data source.</p>
<p>Only works with the DataPuller data source, and requires at least mod version
2.1.9</p>

<LabelledInput
type="checkbox"
Expand Down
129 changes: 127 additions & 2 deletions src/js/utils/note-pile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,133 @@ export const getNoteSprite = (cutDirection: number): IBodyRenderOptionsSprite |
return undefined;
}
return {
xScale: 1,
yScale: 1,
xScale: 0.35,
yScale: 0.35,
texture: texture.toString(),
};
};

import { Body, Render as MatterRender } from 'matter-js';

/**
* Gets the requested texture (an Image) via its path
*/
const getTexture = function (render: MatterRender, imagePath: string): HTMLImageElement {
let image = render.textures[imagePath];

if (image)
return image;

image = render.textures[imagePath] = new Image();
image.src = imagePath;

return image;
};

export const Render = Object.assign(MatterRender, {
bodies(render: MatterRender, bodies: Body[], context: CanvasRenderingContext2D) {
const c = context;
const options = render.options;
const showInternalEdges = options.showInternalEdges || !options.wireframes;

for (let i = 0; i < bodies.length; i++) {
const body = bodies[i];

if (!body.render.visible)
continue;

// handle compound parts
for (let k = body.parts.length > 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;
}
}
}
});

0 comments on commit c6d2d6b

Please sign in to comment.