Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarn/versions/bbd33299.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
beatmapper: minor
22 changes: 21 additions & 1 deletion src/components/app/forms/settings/controls.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import { createListCollection } from "@ark-ui/react/collection";
import { toPascalCase } from "@std/text/to-pascal-case";

import { Field, RadioGroup } from "$/components/ui/compositions";
import { updateObstaclePlacementMode } from "$/store/actions";
import { useAppDispatch, useAppSelector } from "$/store/hooks";
import { selectUserObstaclePlacementMode } from "$/store/selectors";
import { ObstaclePlacementMode } from "$/types";
import { Stack, Wrap } from "$:styled-system/jsx";

const OBSTACLE_PLACEMENT_MODE_COLLECTION = createListCollection({
items: [ObstaclePlacementMode.LEGACY, ObstaclePlacementMode.MODERN, ObstaclePlacementMode.VISUAL],
itemToString: toPascalCase,
});

function AppControlsSettings() {
const dispatch = useAppDispatch();
const obstaclePlacementMode = useAppSelector(selectUserObstaclePlacementMode);

return (
<Stack gap={4}>
<Wrap gap={2}>Soon™</Wrap>
<Wrap gap={2}>
<Field label="Obstacle Placement Mode" helperText="Determines the behavior of how obstacles are placed along the grid. [Learn more](/docs/manual/notes#obstacle-placement-modes)">
<RadioGroup collection={OBSTACLE_PLACEMENT_MODE_COLLECTION} value={obstaclePlacementMode} onValueChange={(x) => dispatch(updateObstaclePlacementMode({ value: x.value as ObstaclePlacementMode }))} />
</Field>
</Wrap>
</Stack>
);
}
Expand Down
1 change: 0 additions & 1 deletion src/components/app/forms/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ const collection = createListCollection({
{ value: "advanced", label: "Advanced", render: () => <AppAdvancedSettings /> },
//
],
isItemDisabled: (item) => !["user", "graphics", "audio", "advanced"].includes(item.value),
});

function AppSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function GridActionPanelGroup({ finishTweakingGrid }: Props) {
const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" });

const dispatch = useAppDispatch();
const { numRows, numCols, colWidth, rowHeight } = useAppSelector((state) => selectGridSize(state, sid));
const { numRows, numCols, colWidth, rowHeight, colOffset, rowOffset } = useAppSelector((state) => selectGridSize(state, sid));

const { trigger: triggerSaveGridPreset } = usePrompt({
title: "Save Grid Preset",
Expand Down Expand Up @@ -45,6 +45,12 @@ function GridActionPanelGroup({ finishTweakingGrid }: Props) {
<Field label="Cell Height">
<FieldInput type="number" min={0.1} step={0.1} value={rowHeight} onKeyDown={(ev) => ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { rowHeight: details.valueAsNumber } }))} />
</Field>
<Field label="X Offset">
<FieldInput type="number" step={0.25} value={colOffset} onKeyDown={(ev) => ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { colOffset: details.valueAsNumber } }))} />
</Field>
<Field label="Y Offset">
<FieldInput type="number" step={0.25} value={rowOffset} onKeyDown={(ev) => ev.stopPropagation()} onValueChange={(details) => sid && dispatch(updateGridSize({ songId: sid, changes: { rowOffset: details.valueAsNumber } }))} />
</Field>
</ActionPanelGroup.ActionGroup>
<ActionPanelGroup.ActionGroup>
<Button variant="subtle" size="sm" unfocusOnPress onClick={triggerSaveGridPreset}>
Expand Down
2 changes: 2 additions & 0 deletions src/components/scene/layouts/placement-grid/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { NoteDirection } from "bsmap";
import type { Vector2Like } from "three";

import type { NotePlacementMode } from "$/types";
import { convertCartesianToPolar, convertRadiansToDegrees, normalizeAngle } from "$/utils";

const CHUNK_SIZE = 45; // We have 8 possible directions in a 360-degree circle, so each direction gets a 45-degree wedge.
const CHUNK_DIRECTIONS = [NoteDirection.RIGHT, NoteDirection.DOWN_RIGHT, NoteDirection.DOWN, NoteDirection.DOWN_LEFT, NoteDirection.LEFT, NoteDirection.UP_LEFT, NoteDirection.UP, NoteDirection.UP_RIGHT];

interface Options {
mode: NotePlacementMode;
usePrecisionPlacement: boolean;
threshold?: number;
selectedDirection?: NoteDirection;
Expand Down
16 changes: 10 additions & 6 deletions src/components/scene/layouts/placement-grid/machine.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ThreeEvent } from "@react-three/fiber";
import { createMachine, type MachineSchema, type Service } from "@zag-js/core";

import type { IGrid, IGridCell, ObjectPlacementMode } from "$/types";
import type { IGrid, IGridCell, NotePlacementMode, ObstaclePlacementMode } from "$/types";
import type { ThreeProps } from "$/types/vendor";
import { isMetaKeyPressed } from "$/utils";
import { BLOCK_CELL_SIZE } from "../../constants";
Expand All @@ -15,7 +15,8 @@ export interface IPlacementContext {

export interface PlacementGridSchema extends MachineSchema {
props: {
mode: ObjectPlacementMode;
notePlacementMode: NotePlacementMode;
obstaclePlacementMode: ObstaclePlacementMode;
grid: IGrid;
onPointerUp?: (event: PointerEvent, payload: Pick<IPlacementContext, "cellDownAt" | "cellOverAt" | "direction">) => void;
onCellPointerDown?: (event: ThreeEvent<PointerEvent>, payload: Pick<IPlacementContext, "cellDownAt">) => void;
Expand Down Expand Up @@ -48,14 +49,16 @@ export const machine = createMachine<PlacementGridSchema>({
});

export function connect({ prop, context, refs }: Service<PlacementGridSchema>) {
const mode = prop("mode");
const notePlacementMode = prop("notePlacementMode");
const obstaclePlacementMode = prop("obstaclePlacementMode");
const grid = prop("grid");
const mouseDownAt = refs.get("mouseDownAt");

const scaleIndex = (value: number) => value * BLOCK_CELL_SIZE;

return {
mode,
notePlacementMode,
obstaclePlacementMode,
grid,
mouseDownAt,
cellDownAt: context.get("cellDownAt"),
Expand All @@ -65,8 +68,8 @@ export function connect({ prop, context, refs }: Service<PlacementGridSchema>) {
getCellProps: ({ colIndex, rowIndex }: IGridCell): ThreeProps<"group"> => {
const currentCell = { colIndex, rowIndex };

const x = scaleIndex((grid.numCols * -0.5 + 0.5 + colIndex) * grid.colWidth);
const y = scaleIndex(rowIndex * grid.rowHeight - 1);
const x = scaleIndex((grid.numCols * -0.5 + 0.5 + colIndex + grid.colOffset) * grid.colWidth);
const y = scaleIndex(rowIndex * grid.rowHeight - 1 + grid.rowOffset);

return {
"position-x": x,
Expand Down Expand Up @@ -110,6 +113,7 @@ export function connect({ prop, context, refs }: Service<PlacementGridSchema>) {
mouseDownAt,
{ x: event.pageX, y: event.pageY },
{
mode: notePlacementMode,
usePrecisionPlacement: isMetaKeyPressed(event),
},
);
Expand Down
11 changes: 6 additions & 5 deletions src/components/scene/layouts/placement-grid/tentative-object.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { type ReactNode, useMemo } from "react";

import type { IGrid, ObjectPlacementMode } from "$/types";
import type { IGrid, NotePlacementMode, ObstaclePlacementMode } from "$/types";
import { usePlacementGridContext } from "./context";
import type { IPlacementContext } from "./machine";

interface Props<T> {
createObject: (ctx: IPlacementContext, mode: ObjectPlacementMode, grid: IGrid) => T | null;
interface Props<T, TMode extends NotePlacementMode | ObstaclePlacementMode> {
mode: TMode;
createObject: (ctx: IPlacementContext, mode: TMode, grid: IGrid) => T | null;
children: (data: NonNullable<T>) => ReactNode;
}
function TentativeObject<T>({ createObject, children }: Props<T>) {
const { mode, grid, mouseDownAt, cellDownAt, cellOverAt, direction } = usePlacementGridContext();
function TentativeObject<T, TMode extends NotePlacementMode | ObstaclePlacementMode>({ mode, createObject, children }: Props<T, TMode>) {
const { grid, mouseDownAt, cellDownAt, cellOverAt, direction } = usePlacementGridContext();

const data = useMemo(() => {
if (mouseDownAt?.button !== 0) return null;
Expand Down
20 changes: 11 additions & 9 deletions src/components/scene/templates/placement-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ import { createBombNoteFromMouseEvent, createColorNoteFromMouseEvent } from "$/h
import { createObstacleFromMouseEvent } from "$/helpers/obstacles.helpers";
import { addObstacle, addToCell } from "$/store/actions";
import { useAppDispatch, useAppSelector } from "$/store/hooks";
import { selectBeatDepth, selectColorScheme, selectDefaultObstacleDuration, selectGridSize, selectNotesEditorDirection, selectNotesEditorSelectionMode, selectNotesEditorTool, selectPlacementMode } from "$/store/selectors";
import { selectBeatDepth, selectColorScheme, selectDefaultObstacleDuration, selectGridSize, selectNotePlacementMode, selectNotesEditorDirection, selectNotesEditorSelectionMode, selectNotesEditorTool, selectObstaclePlacementMode } from "$/store/selectors";
import { ObjectTool } from "$/types";

function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign<ComponentProps<"group">, Pick<PlacementGrid.Schema["props"], "onCellPointerDown" | "onCellWheel">>) {
const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" });

const dispatch = useAppDispatch();
const selectionMode = useAppSelector(selectNotesEditorSelectionMode);
const mode = useAppSelector((state) => selectPlacementMode(state, sid));
const notePlacementMode = useAppSelector((state) => selectNotePlacementMode(state, sid));
const obstaclePlacementMode = useAppSelector((state) => selectObstaclePlacementMode(state, sid));
const grid = useAppSelector((state) => selectGridSize(state, sid));
const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid));
const selectedTool = useAppSelector(selectNotesEditorTool);
Expand All @@ -30,7 +31,8 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign
const beatDepth = useAppSelector(selectBeatDepth);

const service = useMachine(PlacementGrid.machine, {
mode,
notePlacementMode,
obstaclePlacementMode,
grid,
onCellPointerDown,
onCellWheel,
Expand All @@ -40,17 +42,17 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign
switch (selectedTool) {
case ObjectTool.LEFT_NOTE:
case ObjectTool.RIGHT_NOTE: {
const note = createColorNoteFromMouseEvent(ctx, mode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) });
const note = createColorNoteFromMouseEvent(ctx, notePlacementMode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) });
if (note) return dispatch(addToCell({ songId: sid, tool: selectedTool, posX: note.posX, posY: note.posY, direction: note.direction }));
break;
}
case ObjectTool.BOMB_NOTE: {
const note = createBombNoteFromMouseEvent(ctx, mode, grid);
const note = createBombNoteFromMouseEvent(ctx, notePlacementMode, grid);
if (note) return dispatch(addToCell({ songId: sid, tool: selectedTool, posX: note.posX, posY: note.posY }));
break;
}
case ObjectTool.OBSTACLE: {
const obstacle = createObstacleFromMouseEvent(ctx, mode, grid, { duration: defaultObstacleDuration });
const obstacle = createObstacleFromMouseEvent(ctx, obstaclePlacementMode, grid, { duration: defaultObstacleDuration });
if (obstacle) return dispatch(addObstacle({ songId: sid, obstacle }));
break;
}
Expand All @@ -63,17 +65,17 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign
<PlacementGrid.Layout>{(cell) => <PlacementGrid.Cell key={`${cell.colIndex}-${cell.rowIndex}`} data={cell} layers={!selectionMode ? 1 : 2} />}</PlacementGrid.Layout>
<Switch>
<Match when={!selectionMode && (selectedTool === ObjectTool.LEFT_NOTE || selectedTool === ObjectTool.RIGHT_NOTE)}>
<PlacementGrid.TentativeObject createObject={(ctx, mode, grid) => createColorNoteFromMouseEvent(ctx, mode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) })}>
<PlacementGrid.TentativeObject mode={notePlacementMode} createObject={(ctx, mode, grid) => createColorNoteFromMouseEvent(ctx, mode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) })}>
{(data) => <ColorNote data={data} position={resolvePositionForGridObject(data, { beatDepth, zOffset: SONG_OFFSET })} color={resolveColorForItem(selectedTool, { colorScheme })} />}
</PlacementGrid.TentativeObject>
</Match>
<Match when={!selectionMode && selectedTool === ObjectTool.BOMB_NOTE}>
<PlacementGrid.TentativeObject createObject={(ctx, mode, grid) => createBombNoteFromMouseEvent(ctx, mode, grid)}>
<PlacementGrid.TentativeObject mode={notePlacementMode} createObject={(ctx, mode, grid) => createBombNoteFromMouseEvent(ctx, mode, grid)}>
{(data) => <BombNote data={data} position={resolvePositionForGridObject(data, { beatDepth, zOffset: SONG_OFFSET })} color={resolveColorForItem(selectedTool, { colorScheme })} />}
</PlacementGrid.TentativeObject>
</Match>
<Match when={!selectionMode && selectedTool === ObjectTool.OBSTACLE}>
<PlacementGrid.TentativeObject createObject={(ctx, mode, grid) => createObstacleFromMouseEvent(ctx, mode, grid, { duration: defaultObstacleDuration })}>
<PlacementGrid.TentativeObject mode={obstaclePlacementMode} createObject={(ctx, mode, grid) => createObstacleFromMouseEvent(ctx, mode, grid, { duration: defaultObstacleDuration })}>
{(data) => <Obstacle data={data} beatDepth={beatDepth} position={resolvePositionForObstacle(data, { beatDepth, zOffset: SONG_OFFSET })} color={resolveColorForItem(selectedTool, { colorScheme })} />}
</PlacementGrid.TentativeObject>
</Match>
Expand Down
2 changes: 2 additions & 0 deletions src/constants/editor.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const DEFAULT_GRID = {
numCols: DEFAULT_NUM_COLS,
colWidth: DEFAULT_COL_WIDTH,
rowHeight: DEFAULT_ROW_HEIGHT,
colOffset: 0,
rowOffset: 0,
} as const;

export const SNAPPING_INCREMENTS = [
Expand Down
23 changes: 16 additions & 7 deletions src/content/docs/manual/notes/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,29 @@ You can also cycle through tools with <Shortcut>tab</Shortcut> and <Shortcut>shi

## Placing Obstacles

Obstacles are a bit different - they take up multiple cells at the same time, and come in two variants: **full-height** and **crouch** walls.
Obstacles are a bit different - they deplete your health when your head collides with them,
encouraging the player to physically dodge or crouch in order to avoid them.

![](../../../media/images/obstacle.png)

To place a full-height wall, click and drag across the _bottom 2 rows_ in the placement grid.

Crouch walls are placed the same way, but by clicking on the top row of squares. You can flip between ceilings and walls by moving the mouse up and down, before releasing:
To create an obstacle, simply click and drag across the cells of the placement grid.

![](../../../media/images/place-obstacle.gif)

### Obstacle Placement Modes

Three unique placement behaviors are available for obstacles, which you can swap between via the Controls tab of the [Settings](/docs/manual/navigation#app-settings) menu:

![](../../../media/images/free-obstacle-placement.gif)

- For "Legacy" placement mode, you will be restricted to placement of full-height and crouch-height walls (as shown above). Useful if you're targeting legacy map formats.
- For "Modern" placement mode, obstacles may be placed freely *within the constraints of the default placement grid*. If you're coming from the official editor, this mode should feel right at home.
- For "Visual" placement mode, the placement grid will be re-adjusted to match the vanilla boundaries for obstacle placement.
This mode also adds two additional columns to each side, just for good measure.

> [!note]
> You'll notice that for full-height walls, you're limited to placing walls that are 1 or 2 columns wide, no wider.
> This is a safety precaution; 3-column-thick walls can be hazardous, as folks try to leap out of the way.
> If you really want to place a super-wide wall, you'll need to do it in another editor.
> For all placement modes, you may notice the inability to create a "dodge" obstacle that obstructs line of sight across the two center lanes.
> This is an intentional design choice, which primarily serves as a safety precaution for players.

### Tweaking Duration

Expand Down
3 changes: 2 additions & 1 deletion src/content/docs/mods/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ Maybe you want a section of the song to be in a 5×4 grid, or maybe you want to
Once Mapping Extensions has been enabled for your map, a new button appears in the "Beatmap" view's right-hand sidebar: `Customize Grid`.

Clicking this button swaps the side-panel to one that allows you to customize the number of rows and columns in the grid.
You can also change the width and height of each cell, so that a larger number of columns doesn't need to take up more physical space in the world!
You can also change the width and height of each cell, as well as the X and Y offset of the entire placement grid itself,
so that a larger number of columns doesn't need to take up more physical space in the world!

![](../../media/images/customize-grid.gif)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions src/helpers/grid.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import type { IGrid, IGridCell } from "$/types";
// For example, in an 8x3 grid (2 extra columns on each side), the top-left corner would have a position of [0,2] in our custom grid,
// but that translates to a position of [-2,2] in our natural game grid.

function transformIndex(index: number, scale: number, pivot: number, shift: number = 0) {
return (index - shift - pivot) * scale + pivot;
function transformIndex(index: number, scale: number, pivot: number, shift: number = 0, offset = 0) {
return (index - shift - pivot) * scale + pivot + offset;
}

export function convertGridColumn(colIndex: number, { numCols, colWidth }: Pick<IGrid, "numCols" | "colWidth">) {
return transformIndex(colIndex, colWidth, (DEFAULT_NUM_COLS - 1) / 2, (numCols - DEFAULT_NUM_COLS) / 2);
export function convertGridColumn(colIndex: number, { numCols, colWidth, colOffset }: Pick<IGrid, "numCols" | "colWidth" | "colOffset">) {
return transformIndex(colIndex, colWidth, (DEFAULT_NUM_COLS - 1) / 2, (numCols - DEFAULT_NUM_COLS) / 2, colOffset);
}
export function convertGridRow(rowIndex: number, { rowHeight }: Pick<IGrid, "numRows" | "rowHeight">) {
return transformIndex(rowIndex, rowHeight, 0, 0);
export function convertGridRow(rowIndex: number, { rowHeight, rowOffset }: Pick<IGrid, "numRows" | "rowHeight" | "rowOffset">) {
return transformIndex(rowIndex, rowHeight, 0, 0, rowOffset);
}

export function convertGridCell({ colIndex, rowIndex }: IGridCell, grid: IGrid = DEFAULT_GRID) {
Expand Down
6 changes: 3 additions & 3 deletions src/helpers/notes.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createBombNote, createColorNote } from "bsmap";
import type { wrapper } from "bsmap/types";

import type { IPlacementContext } from "$/components/scene/layouts/placement-grid/machine";
import { type IGrid, ObjectPlacementMode } from "$/types";
import { type IGrid, NotePlacementMode } from "$/types";
import { convertGridCell } from "./grid.helpers";
import { serializeCoordinate } from "./item.helpers";

Expand All @@ -20,10 +20,10 @@ export function resolveNoteId<T extends Pick<wrapper.IWrapBaseNote, "time" | "po
}

function createNotePlacementFactory<T extends wrapper.IWrapBaseNote>(createNote: (data: Partial<T>) => T) {
return ({ cellDownAt }: IPlacementContext, mode: ObjectPlacementMode, grid: IGrid, data: Partial<T> = {}) => {
return ({ cellDownAt }: IPlacementContext, mode: NotePlacementMode, grid: IGrid, data: Partial<T> = {}) => {
if (!cellDownAt) return null;

const isExtended = mode === ObjectPlacementMode.EXTENSIONS;
const isExtended = mode === NotePlacementMode.EXTENSIONS;

const { colIndex, rowIndex } = convertGridCell(cellDownAt, grid);

Expand Down
Loading
Loading