diff --git a/.yarn/versions/69337c4c.yml b/.yarn/versions/69337c4c.yml new file mode 100644 index 00000000..5b878624 --- /dev/null +++ b/.yarn/versions/69337c4c.yml @@ -0,0 +1,2 @@ +releases: + beatmapper: minor diff --git a/src/components/app/templates/events/basic-track.tsx b/src/components/app/templates/events/basic-track.tsx index be1d602c..89abb142 100644 --- a/src/components/app/templates/events/basic-track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -10,39 +10,77 @@ import { resolveColorForItem } from "$/helpers/colors.helpers"; import { isBasicLightEvent, isBasicValueEvent, resolveBasicEventColor, resolveBasicEventEffect, resolveEventId, serializeBasicEventValue } from "$/helpers/events.helpers"; import { addBasicEvent, bulkAddBasicEvent, bulkRemoveEvent, deselectEvent, mirrorBasicEvent, removeEvent, selectEvent, updateBasicEvent } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectAllBasicEventsForTrackInWindow, selectColorScheme, selectCurrentLightStateForTrack, selectEditorOffsetInBeats, selectEventEditorStartAndEndBeat, selectEventsEditorColor, selectEventsEditorMirrorLock, selectEventsEditorTool, selectEventTracksForEnvironment } from "$/store/selectors"; +import { + selectAllBasicEventsForTrack, + selectAllBoostEvents, + selectColorScheme, + selectCurrentLightStateForTrack, + selectEditorOffsetInBeats, + selectEventEditorStartAndEndBeat, + selectEventsEditorColor, + selectEventsEditorMirrorLock, + selectEventsEditorTool, + selectEventTracksForEnvironment, + selectToggleAtBeat, +} from "$/store/selectors"; import { App, type IEventTracks, TrackType } from "$/types"; import { clamp, isColorDark, normalize } from "$/utils"; -import { createBackgroundBoxes } from "./track.helpers"; +import { createBackgroundBoxes, resolveColorForLightState } from "./track.helpers"; -function resolveBackgroundForEvent(data: wrapper.IWrapBasicEvent, options: Parameters[1] & { tracks: IEventTracks }) { - const eventColor = resolveBasicEventColor(data); - const eventEffect = resolveBasicEventEffect(data, options.tracks); +function resolveBackgroundForEvent(data: wrapper.IWrapBasicEvent, options: Parameters[1] & { isBoosted: boolean; tracks: IEventTracks }) { + const effect = resolveBasicEventEffect(data, options.tracks); - const color = resolveColorForItem(isBasicLightEvent(data, options.tracks) ? (eventColor ?? eventEffect) : eventEffect, options); + const key = resolveColorForLightState({ color: resolveBasicEventColor(data), isBoosted: options.isBoosted }, options); + const color = isBasicLightEvent(data, options.tracks) ? (key ?? resolveColorForItem(effect, options)) : resolveColorForItem(effect, options); - const brightColor = `color-mix(in srgb, ${color}, white 30%)`; - const semiTransparentColor = `color-mix(in srgb, ${color}, black 30%)`; + const toWhite = `color-mix(in srgb, ${color}, white 30%)`; + const toBlack = `color-mix(in srgb, ${color}, black 30%)`; - switch (eventEffect) { + switch (effect) { case App.BasicEventEffect.ON: { return { value: color, style: color }; } case App.BasicEventEffect.FLASH: { - return { value: color, style: `linear-gradient(90deg, ${semiTransparentColor}, ${brightColor})` }; + return { value: color, style: `linear-gradient(90deg, ${toBlack}, ${toWhite})` }; } case App.BasicEventEffect.FADE: { - return { value: color, style: `linear-gradient(-90deg, ${semiTransparentColor}, ${brightColor})` }; + return { value: color, style: `linear-gradient(-90deg, ${toBlack}, ${toWhite})` }; } case App.BasicEventEffect.TRANSITION: { - return { value: color, style: `linear-gradient(0deg, ${semiTransparentColor}, ${brightColor})` }; + return { value: color, style: `linear-gradient(0deg, ${toBlack}, ${toWhite})` }; } default: { - return { value: color, style: `linear-gradient(90deg, ${semiTransparentColor}, ${brightColor}, ${semiTransparentColor})` }; + return { value: color, style: `linear-gradient(90deg, ${toBlack}, ${toWhite}, ${toBlack})` }; } } } +function BasicEvent({ data, actions }: { data: App.IBasicEvent; actions: EventGrid.IPlacementActions }) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); + const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid)); + + const api = EventGrid.useContext(); + + const isEventBoosted = useAppSelector((state) => selectToggleAtBeat(state, { trackId: 5, beforeBeat: data.time + 0.001 })); + + const resolveEventStyle = useCallback( + (data: wrapper.IWrapBasicEvent) => { + const { style, value } = resolveBackgroundForEvent(data, { tracks, colorScheme, isBoosted: isEventBoosted }); + return { "--event-color": style, background: style, color: isColorDark(value) ? "white" : "black" }; + }, + [tracks, colorScheme, isEventBoosted], + ); + + return ( + + {isBasicLightEvent(data, tracks) && data.value !== 0 ? data.floatValue : undefined} + {isBasicValueEvent(data, tracks) && data.value} + + ); +} + interface Props { trackId: number; } @@ -50,19 +88,22 @@ function BasicEventTrack({ trackId, ...rest }: Assign selectEventEditorStartAndEndBeat(state, sid)); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); - const basicEvents = useAppSelector((state) => selectAllBasicEventsForTrackInWindow(state, sid, trackId)); + const basicEvents = useAppSelector((state) => selectAllBasicEventsForTrack(state, trackId)); + const boostEvents = useAppSelector((state) => selectAllBoostEvents(state)); const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid)); - const selectedTool = useAppSelector(selectEventsEditorTool); - const selectedColorType = useAppSelector(selectEventsEditorColor); const initialLightState = useAppSelector((state) => selectCurrentLightStateForTrack(state, sid, bid, trackId)); const offsetInBeats = useAppSelector((state) => selectEditorOffsetInBeats(state, sid)); - const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); const backgroundBoxes = useMemo(() => { - return createBackgroundBoxes(trackId, { tracks, colorScheme, offsetInBeats, basicEvents, initialLightState, startBeat, endBeat }); - }, [initialLightState, trackId, tracks, colorScheme, offsetInBeats, basicEvents, startBeat, endBeat]); + return createBackgroundBoxes(trackId, { tracks, colorScheme, offsetInBeats, basicEvents, boostEvents, initialLightState, startBeat, endBeat }); + }, [initialLightState, trackId, tracks, colorScheme, offsetInBeats, basicEvents, boostEvents, startBeat, endBeat]); + + const api = EventGrid.useContext(); const resolveEventData = useCallback( (time: number, norm: number) => { @@ -88,8 +129,6 @@ function BasicEventTrack({ trackId, ...rest }: Assign>(() => { return { onCreate: resolveEventData, @@ -118,25 +157,10 @@ function BasicEventTrack({ trackId, ...rest }: Assign { - const { style, value } = resolveBackgroundForEvent(data, { tracks, colorScheme }); - return { "--event-color": style, background: style, color: isColorDark(value) ? "white" : "black" }; - }, - [tracks, colorScheme], - ); - return ( {(box) => } - x.time >= startBeat && x.time < endBeat)}> - {(data) => ( - - {isBasicLightEvent(data, tracks) && data.value !== 0 ? data.floatValue : undefined} - {isBasicValueEvent(data, tracks) && data.value} - - )} - + x.time >= startBeat && x.time < endBeat)}>{(data) => } ); } diff --git a/src/components/app/templates/events/boost-track.tsx b/src/components/app/templates/events/boost-track.tsx new file mode 100644 index 00000000..5799af48 --- /dev/null +++ b/src/components/app/templates/events/boost-track.tsx @@ -0,0 +1,78 @@ +import type { Assign } from "@ark-ui/react"; +import { useParams } from "@tanstack/react-router"; +import { createColorBoostEvent } from "bsmap"; +import type { wrapper } from "bsmap/types"; +import { type ComponentProps, useCallback, useMemo } from "react"; + +import { EventGrid } from "$/components/app/layouts"; +import { For } from "$/components/ui/atoms"; +import { resolveEventId } from "$/helpers/events.helpers"; +import { addBoostEvent, bulkAddBoostEvent, bulkRemoveEvent, deselectEvent, removeEvent, selectEvent, updateBoostEvent } from "$/store/actions"; +import { useAppDispatch, useAppSelector } from "$/store/hooks"; +import { selectAllBoostEvents, selectEventEditorStartAndEndBeat, selectEventsEditorMirrorLock, selectEventTracksForEnvironment } from "$/store/selectors"; +import type { App } from "$/types"; +import { isColorDark } from "$/utils"; +import { token } from "$:styled-system/tokens"; + +function BoostEvent({ data, actions }: { data: App.IBoostEvent; actions: EventGrid.IPlacementActions }) { + const api = EventGrid.useContext(); + + const color = token("colors.pink.500"); + + const resolveEventStyle = useCallback( + (_: wrapper.IWrapColorBoostEvent) => { + const toWhite = `color-mix(in srgb, ${color}, white 30%)`; + const toBlack = `color-mix(in srgb, ${color}, black 30%)`; + + const style = `radial-gradient(${toBlack}, ${toWhite})`; + + return { "--event-color": style, background: style, color: isColorDark(color) ? "white" : "black" }; + }, + [color], + ); + + return ( + + {data.toggle ? "1" : "0"} + + ); +} + +interface Props { + trackId: number; +} +function BoostEventTrack({ trackId, ...rest }: Assign, Props>) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); + const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); + const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); + const boostEvents = useAppSelector((state) => selectAllBoostEvents(state)); + + const api = EventGrid.useContext(); + + const resolveEventData = useCallback((time: number, norm: number) => createColorBoostEvent({ time, toggle: norm <= 0.5 }), []); + + const actions = useMemo>(() => { + return { + onCreate: resolveEventData, + onPlace: (data, isBulk) => dispatch((isBulk ? bulkAddBoostEvent : addBoostEvent)({ query: data, data: data, tracks, areLasersLocked })), + onSelect: (data) => dispatch(selectEvent({ query: data, tracks, areLasersLocked })), + onDeselect: (data) => dispatch(deselectEvent({ query: data, tracks, areLasersLocked })), + onPick: () => {}, + onDelete: (data, isBulk) => dispatch((isBulk ? bulkRemoveEvent : removeEvent)({ query: data, tracks, areLasersLocked })), + onWheel: (data, delta) => { + return dispatch(updateBoostEvent({ query: data, tracks, areLasersLocked, changes: { toggle: delta > 0 } })); + }, + }; + }, [dispatch, resolveEventData, tracks, areLasersLocked]); + + return ( + + x.time >= startBeat && x.time < endBeat)}>{(data) => } + + ); +} + +export default BoostEventTrack; diff --git a/src/components/app/templates/events/controls.tsx b/src/components/app/templates/events/controls.tsx index 247e5fea..2d3ec01d 100644 --- a/src/components/app/templates/events/controls.tsx +++ b/src/components/app/templates/events/controls.tsx @@ -6,13 +6,14 @@ import { type ComponentProps, type CSSProperties, useMemo } from "react"; import { EventEffectIcon } from "$/components/icons"; import { Button, Field, Toggle, ToggleGroup, Tooltip } from "$/components/ui/compositions"; import { ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; -import { type ColorResolverOptions, resolveColorForItem } from "$/helpers/colors.helpers"; +import type { ColorResolverOptions } from "$/helpers/colors.helpers"; import { decrementEventsEditorZoom, incrementEventsEditorZoom, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectColorScheme, selectEventsEditorColor, selectEventsEditorEditMode, selectEventsEditorMirrorLock, selectEventsEditorTool, selectEventsEditorWindowLock, selectEventsEditorZoomLevel } from "$/store/selectors"; import { EventColor, EventEditMode, EventTool } from "$/types"; import { HStack, styled } from "$:styled-system/jsx"; import { hstack } from "$:styled-system/patterns"; +import { resolveColorForLightState } from "./track.helpers"; const EDIT_MODE_LIST_COLLECTION = createListCollection({ items: Object.values(EventEditMode).map((value, index) => { @@ -21,20 +22,23 @@ const EDIT_MODE_LIST_COLLECTION = createListCollection({ }), }); -interface EventListCollection extends ColorResolverOptions { - selectedColor?: EventColor; -} -function createEventColorListCollection({ colorScheme }: EventListCollection) { +function createEventColorListCollection({ colorScheme }: ColorResolverOptions) { return createListCollection({ items: Object.values(EventColor).map((value) => { - return { value, label: }; + const color = resolveColorForLightState({ color: value, isBoosted: false }, { colorScheme }); + const boostColor = resolveColorForLightState({ color: value, isBoosted: true }, { colorScheme }); + if (!color || !boostColor) return null; + return { value, label: }; }), }); } -function createEventEffectListCollection({ selectedColor, colorScheme }: EventListCollection) { +function createEventEffectListCollection({ selectedColor, colorScheme }: ColorResolverOptions & { selectedColor: EventColor | null }) { return createListCollection({ items: Object.values(EventTool).map((value) => { - return { value, label: }; + const color = resolveColorForLightState({ color: selectedColor, isBoosted: false }, { colorScheme }); + const boostColor = resolveColorForLightState({ color: selectedColor, isBoosted: true }, { colorScheme }); + if (!color || !boostColor) return null; + return { value, label: }; }), }); } diff --git a/src/components/app/templates/events/grid.tsx b/src/components/app/templates/events/grid.tsx index 128ded93..3a5c75f2 100644 --- a/src/components/app/templates/events/grid.tsx +++ b/src/components/app/templates/events/grid.tsx @@ -29,6 +29,7 @@ import { type IEventTrack, type IEventTracks, TrackType } from "$/types"; import { clamp } from "$/utils"; import { Stack, Wrap } from "$:styled-system/jsx"; import BasicEventTrack from "./basic-track"; +import BoostEventTrack from "./boost-track"; function EventGridEditor({ ...rest }: ComponentProps) { const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); @@ -168,11 +169,13 @@ function EventGridEditor({ ...rest }: ComponentProps) { )} + Color Boost {(_, id) => } + diff --git a/src/components/app/templates/events/track.helpers.test.ts b/src/components/app/templates/events/track.helpers.test.ts index 6d044008..79beb6d9 100644 --- a/src/components/app/templates/events/track.helpers.test.ts +++ b/src/components/app/templates/events/track.helpers.test.ts @@ -1,9 +1,10 @@ -import { createBasicEvent } from "bsmap"; +import { createBasicEvent, createColorBoostEvent } from "bsmap"; import type { wrapper } from "bsmap/types"; import { describe, expect, it } from "vitest"; import { serializeBasicEventValue } from "$/helpers/events.helpers"; import { App, ColorSchemeKey, type IBackgroundBox, type IColorScheme, type IEventTracks } from "$/types"; +import { lerp, lerpColor } from "$/utils"; import { createBackgroundBoxes } from "./track.helpers"; describe(createBackgroundBoxes.name, () => { @@ -45,7 +46,7 @@ describe(createBackgroundBoxes.name, () => { ]; const expectedResult: IBackgroundBox[] = []; - const actualResult = createBackgroundBoxes(12, { tracks, colorScheme, basicEvents, initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(12, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); @@ -57,7 +58,7 @@ describe(createBackgroundBoxes.name, () => { const basicEvents: wrapper.IWrapBasicEvent[] = []; const expectedResult: IBackgroundBox[] = []; - const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); @@ -66,7 +67,14 @@ describe(createBackgroundBoxes.name, () => { // R [________] const startBeat = 8; const numOfBeatsToShow = 8; - const basicEvents: wrapper.IWrapBasicEvent[] = []; + const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 0, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 1, + }), + ]; const expectedResult: IBackgroundBox[] = [ { @@ -76,7 +84,7 @@ describe(createBackgroundBoxes.name, () => { endState: { color: colorScheme.envColorLeft, brightness: 1 }, }, ]; - const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, initialLightState: { color: colorScheme.envColorLeft, brightness: 1 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: colorScheme.envColorLeft, brightness: 1 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); @@ -107,7 +115,7 @@ describe(createBackgroundBoxes.name, () => { endState: { color: colorScheme.envColorLeft, brightness: 1 }, }, ]; - const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); @@ -117,6 +125,12 @@ describe(createBackgroundBoxes.name, () => { const startBeat = 8; const numOfBeatsToShow = 8; const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 0, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 1, + }), createBasicEvent({ type: 2, time: 12, @@ -139,7 +153,7 @@ describe(createBackgroundBoxes.name, () => { endState: { color: colorScheme.envColorLeft, brightness: 1 }, }, ]; - const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, initialLightState: { color: colorScheme.envColorLeft, brightness: 1 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: colorScheme.envColorLeft, brightness: 1 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); @@ -182,8 +196,167 @@ describe(createBackgroundBoxes.name, () => { endState: { color: colorScheme.envColorRight, brightness: 1 }, }, ]; - const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); expect(actualResult).toEqual(expectedResult); }); + + it("handles brightness changes", () => { + // 0 [R---R___] + const startBeat = 0; + const numOfBeatsToShow = 8; + const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 0, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 0.5, + }), + createBasicEvent({ + type: 2, + time: 4, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 0.25, + }), + ]; + + const expectedResult: IBackgroundBox[] = [ + { + time: 0, + duration: 4, + startState: { color: colorScheme.envColorLeft, brightness: 0.5 }, + endState: { color: colorScheme.envColorLeft, brightness: 0.5 }, + }, + { + time: 4, + duration: 4, + startState: { color: colorScheme.envColorLeft, brightness: 0.25 }, + endState: { color: colorScheme.envColorLeft, brightness: 0.25 }, + }, + ]; + + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + + expect(actualResult).toEqual(expectedResult); + }); + + it("handles interpolation for transitions", () => { + // R [\\\b___] + const startBeat = 8; + const numOfBeatsToShow = 8; + + const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 16, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.TRANSITION, color: App.EventColor.SECONDARY }, { tracks }), + floatValue: 1.0, + }), + ]; + + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents: [], initialLightState: { color: colorScheme.envColorLeft, brightness: 0 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + + expect(actualResult[0]).toEqual({ + time: 8, + duration: 8, + startState: { + color: lerpColor(colorScheme.envColorLeft, colorScheme.envColorRight, 0.5), + brightness: lerp(0, 1.0, 0.5), + }, + endState: { + color: lerpColor(colorScheme.envColorLeft, colorScheme.envColorRight, 1), + brightness: lerp(0, 1.0, 1), + }, + }); + }); + + it("handles color boost", () => { + // 0 [R_!___._] + const startBeat = 8; + const numOfBeatsToShow = 8; + const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 8, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 1, + }), + ]; + const boostEvents: wrapper.IWrapColorBoostEvent[] = [{ time: 10, toggle: true } as wrapper.IWrapColorBoostEvent, { time: 14, toggle: false } as wrapper.IWrapColorBoostEvent]; + + const expectedResult: IBackgroundBox[] = [ + { + time: 8, + duration: 2, + startState: { color: colorScheme.envColorLeft, brightness: 1 }, + endState: { color: colorScheme.envColorLeft, brightness: 1 }, + }, + { + time: 10, + duration: 4, + startState: { color: colorScheme.envColorLeftBoost, brightness: 1 }, + endState: { color: colorScheme.envColorLeftBoost, brightness: 1 }, + }, + { + time: 14, + duration: 2, + startState: { color: colorScheme.envColorLeft, brightness: 1 }, + endState: { color: colorScheme.envColorLeft, brightness: 1 }, + }, + ]; + + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents, initialLightState: { color: null, brightness: null }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + + expect(actualResult).toEqual(expectedResult); + }); + + it("handles color boost during a transition", () => { + // 0 [\\\!\\\] + const startBeat = 0; + const numOfBeatsToShow = 8; + const initialColor = colorScheme.envColorLeft; + + const basicEvents: wrapper.IWrapBasicEvent[] = [ + createBasicEvent({ + type: 2, + time: 0, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.ON, color: App.EventColor.PRIMARY }, { tracks }), + floatValue: 0.0, + }), + createBasicEvent({ + type: 2, + time: 8, + value: serializeBasicEventValue({ effect: App.BasicEventEffect.TRANSITION, color: App.EventColor.SECONDARY }, { tracks }), + floatValue: 1.0, + }), + ]; + const boostEvents: wrapper.IWrapColorBoostEvent[] = [createColorBoostEvent({ time: 4, toggle: true })]; + + const actualResult = createBackgroundBoxes(2, { tracks, colorScheme, basicEvents, boostEvents, initialLightState: { color: initialColor, brightness: 0 }, startBeat, endBeat: startBeat + numOfBeatsToShow }); + + expect(actualResult[0]).toEqual({ + time: 0, + duration: 4, + startState: { + color: colorScheme.envColorLeft, + brightness: 0, + }, + endState: { + color: lerpColor(colorScheme.envColorLeft, colorScheme.envColorRight, 0.5), + brightness: 0.5, + }, + }); + expect(actualResult[1]).toEqual({ + time: 4, + duration: 4, + startState: { + color: lerpColor(colorScheme.envColorLeftBoost, colorScheme.envColorRightBoost, 0.5), + brightness: 0.5, + }, + endState: { + color: colorScheme.envColorRightBoost, + brightness: 1, + }, + }); + }); }); diff --git a/src/components/app/templates/events/track.helpers.ts b/src/components/app/templates/events/track.helpers.ts index 715230ac..cae1a82d 100644 --- a/src/components/app/templates/events/track.helpers.ts +++ b/src/components/app/templates/events/track.helpers.ts @@ -12,8 +12,13 @@ const COLOR_KEY_MAP = { [EventColor.SECONDARY]: [ColorSchemeKey.ENV_RIGHT], [EventColor.WHITE]: [ColorSchemeKey.ENV_WHITE], }; -export function resolveColorForLightState({ color }: { color: App.EventColor | null }, options: ColorResolverOptions): string | null { - const key = color !== null ? COLOR_KEY_MAP[color][0] : null; +const BOOST_COLOR_KEY_MAP = { + [EventColor.PRIMARY]: [ColorSchemeKey.BOOST_LEFT], + [EventColor.SECONDARY]: [ColorSchemeKey.BOOST_RIGHT], + [EventColor.WHITE]: [ColorSchemeKey.BOOST_WHITE], +}; +export function resolveColorForLightState({ color, isBoosted }: { color: App.EventColor | null; isBoosted: boolean }, options: ColorResolverOptions): string | null { + const key = color !== null ? (isBoosted ? BOOST_COLOR_KEY_MAP : COLOR_KEY_MAP)[color][0] : null; if (!key) return null; return resolveColorForItem(key, options); } @@ -22,75 +27,82 @@ interface StateResolverContext extends ColorResolverOptions { initialLightState: ILightState; offsetInBeats?: number; } -export function deriveLightStateAtBeat(targetBeat: number, sortedEvents: { data: wrapper.IWrapBasicEvent; effect: App.BasicEventEffect; color: EventColor | null }[], { initialLightState, offsetInBeats = 0, ...options }: StateResolverContext): IBackgroundBox["startState" | "endState"] { - const nextIdx = sortedEvents.findIndex((e) => e.data.time > targetBeat); - - const currentEvent = nextIdx === -1 ? sortedEvents.at(-1) : sortedEvents[nextIdx - 1]; - const nextEvent = nextIdx !== -1 ? sortedEvents[nextIdx] : null; +export function deriveLightStateAtBeat( + targetBeat: number, + currentEvent: { data: wrapper.IWrapBasicEvent; effect: App.BasicEventEffect; color: EventColor | null } | undefined, + nextEvent: { data: wrapper.IWrapBasicEvent; effect: App.BasicEventEffect; color: EventColor | null } | undefined, + { initialLightState, offsetInBeats = 0, isBoosted, ...options }: StateResolverContext & { isBoosted: boolean }, +): IBackgroundBox["startState" | "endState"] { + const isActive = currentEvent ? isLightEffectActive(currentEvent.effect) : false; const startTime = currentEvent?.data.time ?? offsetInBeats; - const startColor = currentEvent?.color ? resolveColorForLightState({ color: currentEvent?.color }, options) : initialLightState.color; - const startBrightness = currentEvent?.data.floatValue ?? initialLightState.brightness ?? 0; + const startBrightness = isActive ? (currentEvent?.data.floatValue ?? initialLightState.brightness ?? 0) : 0; + const startColor = currentEvent?.color ? resolveColorForLightState({ color: currentEvent.color, isBoosted }, options) : initialLightState.color; if (nextEvent?.effect === App.BasicEventEffect.TRANSITION) { const duration = nextEvent.data.time - startTime; const ratio = duration > 0 ? clamp((targetBeat - startTime) / duration, 0, 1) : 1; - const endColor = resolveColorForLightState({ color: nextEvent.color }, options); - const endBrightness = nextEvent.data.floatValue; - return { - color: lerpColor(startColor, endColor, ratio), - brightness: lerp(startBrightness, endBrightness, ratio), + color: lerpColor(startColor, resolveColorForLightState({ color: nextEvent.color, isBoosted }, options), ratio), + brightness: lerp(startBrightness, nextEvent.data.floatValue, ratio), }; } - const isActive = (currentEvent ? isLightEffectActive(currentEvent.effect) : startColor !== null) && startBrightness > 0; - return { color: isActive ? (startColor ?? "transparent") : "transparent", brightness: startBrightness, }; } +function deriveBoostStateAtBeat(targetBeat: number, boostEvents: wrapper.IWrapColorBoostEvent[], initialBoostState: boolean): boolean { + let activeBoost = initialBoostState; + for (const event of boostEvents) { + if (event.time > targetBeat) break; + activeBoost = event.toggle; + } + return activeBoost; +} + interface CreateBackgroundBoxesOptions extends StateResolverContext { tracks: IEventTracks; basicEvents: wrapper.IWrapBasicEvent[]; + boostEvents: wrapper.IWrapColorBoostEvent[]; startBeat: number; endBeat: number; } -export function createBackgroundBoxes(trackId: number, { tracks, basicEvents, startBeat, endBeat, offsetInBeats, ...rest }: CreateBackgroundBoxesOptions) { +export function createBackgroundBoxes(trackId: number, { tracks, basicEvents, boostEvents, startBeat, endBeat, offsetInBeats, ...rest }: CreateBackgroundBoxesOptions) { if (!isLightTrack(trackId, tracks)) return []; - const sortedEvents = basicEvents.sort(sortObjectFn).map((data) => ({ + const sortedEvents = [...basicEvents].sort(sortObjectFn).map((data) => ({ data, effect: resolveBasicEventEffect(data, tracks), color: resolveBasicEventColor(data), })); - const timeline = Array.from(new Set([Math.max(startBeat, offsetInBeats ?? 0), ...sortedEvents.map((e) => e.data.time).filter((t) => t >= startBeat && t < endBeat), endBeat])).sort((a, b) => a - b); - const statesForTimeline = timeline.map((t) => deriveLightStateAtBeat(t, sortedEvents, { ...rest, offsetInBeats })); + const timeline = Array.from(new Set([startBeat, ...sortedEvents.map((e) => e.data.time).filter((t) => t >= startBeat && t < endBeat), ...boostEvents.map((e) => e.time).filter((t) => t >= startBeat && t < endBeat), endBeat])).sort((a, b) => a - b); const backgroundBoxes: IBackgroundBox[] = []; - for (let i = 0; i < statesForTimeline.length - 1; i++) { + for (let i = 0; i < timeline.length - 1; i++) { const startPoint = timeline[i]; const endPoint = timeline[i + 1]; - const startState = statesForTimeline[i]; - const nextStartState = statesForTimeline[i + 1]; - - const nextEvent = sortedEvents.find((e) => e.data.time === endPoint); + const currentEvent = [...sortedEvents].reverse().find((e) => e.data.time <= startPoint); + const nextEvent = sortedEvents.find((e) => e.data.time > startPoint); const isTransition = nextEvent?.effect === App.BasicEventEffect.TRANSITION; - const endState = isTransition ? nextStartState : startState; + const isBoosted = deriveBoostStateAtBeat(startPoint, boostEvents, false); + + const startState = deriveLightStateAtBeat(startPoint, currentEvent, nextEvent, { ...rest, isBoosted }); + const endState = isTransition ? deriveLightStateAtBeat(endPoint, currentEvent, nextEvent, { ...rest, isBoosted }) : startState; if (startState.brightness > 0 || endState.brightness > 0) { backgroundBoxes.push({ time: startPoint, duration: endPoint - startPoint, - startState: startState, - endState: endState, + startState, + endState, }); } } diff --git a/src/components/icons/event-effect.tsx b/src/components/icons/event-effect.tsx index e01c6e2f..6f350631 100644 --- a/src/components/icons/event-effect.tsx +++ b/src/components/icons/event-effect.tsx @@ -1,5 +1,6 @@ import type { Assign } from "@ark-ui/react"; import type { LucideProps } from "lucide-react"; +import { useId } from "react"; import { EventTool } from "$/types"; import { token } from "$:styled-system/tokens"; @@ -23,12 +24,21 @@ function getPathForTool(tool: EventTool) { interface Props { tool: EventTool; + boostColor?: string; } -function EventToolIcon({ tool, color }: Assign) { +function EventToolIcon({ tool, color, boostColor }: Assign) { + const gradient = useId(); + return ( - + + + + + + + ); } diff --git a/src/components/scene/hooks/environment.hooks.ts b/src/components/scene/hooks/environment.hooks.ts index 321fd932..6c49283b 100644 --- a/src/components/scene/hooks/environment.hooks.ts +++ b/src/components/scene/hooks/environment.hooks.ts @@ -2,8 +2,8 @@ import { useParams } from "@tanstack/react-router"; import type { wrapper } from "bsmap/types"; import { useCallback, useMemo, useState } from "react"; +import { resolveColorForLightState } from "$/components/app/templates/events/track.helpers"; import { useUpdateEffect } from "$/components/hooks/use-update-effect"; -import { resolveColorForItem } from "$/helpers/colors.helpers"; import { resolveBasicEventColor, resolveBasicEventEffect, resolveEventId } from "$/helpers/events.helpers"; import { useAppSelector } from "$/store/hooks"; import { selectColorScheme, selectEventTracksForEnvironment, selectPlaying } from "$/store/selectors"; @@ -12,8 +12,9 @@ import { App, type ILightState } from "$/types"; interface UseLightEffectOptions { lastEvent: wrapper.IWrapBasicEvent | null; nextEvent: wrapper.IWrapBasicEvent | null; + lastBoostEvent: wrapper.IWrapColorBoostEvent | null; } -export function useLightEffect({ lastEvent, nextEvent }: UseLightEffectOptions) { +export function useLightEffect({ lastEvent, nextEvent, lastBoostEvent }: UseLightEffectOptions) { const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); @@ -61,11 +62,11 @@ export function useLightEffect({ lastEvent, nextEvent }: UseLightEffectOptions) const brightness = deriveBrightnessForEvent(event); return { - color: color ? resolveColorForItem(color, { colorScheme }) : "black", + color: color ? (resolveColorForLightState({ color, isBoosted: !!lastBoostEvent?.toggle }, { colorScheme }) ?? "black") : "black", brightness: brightness, }; }, - [deriveColorForEvent, deriveBrightnessForEvent, colorScheme], + [deriveColorForEvent, deriveBrightnessForEvent, lastBoostEvent, colorScheme], ); return useMemo(() => { diff --git a/src/components/scene/hooks/use-event-track.ts b/src/components/scene/hooks/use-event-track.ts index 8d16c525..3544b254 100644 --- a/src/components/scene/hooks/use-event-track.ts +++ b/src/components/scene/hooks/use-event-track.ts @@ -3,7 +3,7 @@ import type { wrapper } from "bsmap/types"; import { useMemo } from "react"; import { useAppSelector } from "$/store/hooks"; -import { selectAllBasicEventsForTrack, selectCursorPositionInBeats } from "$/store/selectors"; +import { selectAllBasicEventsForTrack, selectAllBoostEvents, selectCursorPositionInBeats } from "$/store/selectors"; function findLastEventInTrack(events: T[], currentBeat: number): [T | null, T | null] { for (let i = events.length - 1; i >= 0; i--) { @@ -30,3 +30,15 @@ export function useBasicEventTrack({ trackId }: UseBasicEventTrackOptions) { return findLastEventInTrack(basicEvents, currentBeat); }, [sid, basicEvents, currentBeat]); } + +export function useBoostEventTrack() { + const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const currentBeat = useAppSelector((state) => selectCursorPositionInBeats(state, sid)); + const boostEvents = useAppSelector((state) => selectAllBoostEvents(state)); + + return useMemo((): [lastEvent: wrapper.IWrapColorBoostEvent | null, nextEvent: wrapper.IWrapColorBoostEvent | null] => { + if (!sid || currentBeat === null) return [null, null] as const; + return findLastEventInTrack(boostEvents, currentBeat); + }, [sid, boostEvents, currentBeat]); +} diff --git a/src/components/scene/templates/environment/back-lasers.tsx b/src/components/scene/templates/environment/back-lasers.tsx index 9aaf89f9..223318e4 100644 --- a/src/components/scene/templates/environment/back-lasers.tsx +++ b/src/components/scene/templates/environment/back-lasers.tsx @@ -1,5 +1,5 @@ import { useLightEffect } from "$/components/scene/hooks/environment.hooks"; -import { useBasicEventTrack } from "$/components/scene/hooks/use-event-track"; +import { useBasicEventTrack, useBoostEventTrack } from "$/components/scene/hooks/use-event-track"; import { Environment } from "$/components/scene/layouts"; import { range } from "$/utils"; @@ -10,8 +10,9 @@ const DISTANCE_BETWEEN_BEAMS = 25; function BackLasers() { const [lastLightEvent, nextLightEvent] = useBasicEventTrack({ trackId: 0 }); + const [lastBoostEvent] = useBoostEventTrack(); - const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent }); + const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent, lastBoostEvent }); return sides.map((side) => { return Array.from(range(0, NUM_OF_BEAMS_PER_SIDE)).map((index) => { diff --git a/src/components/scene/templates/environment/large-rings.tsx b/src/components/scene/templates/environment/large-rings.tsx index 41f20ceb..0808e88f 100644 --- a/src/components/scene/templates/environment/large-rings.tsx +++ b/src/components/scene/templates/environment/large-rings.tsx @@ -1,7 +1,7 @@ import { useRouteContext } from "@tanstack/react-router"; import { useLightEffect } from "$/components/scene/hooks/environment.hooks"; -import { useBasicEventTrack } from "$/components/scene/hooks/use-event-track"; +import { useBasicEventTrack, useBoostEventTrack } from "$/components/scene/hooks/use-event-track"; import { useRenderScale } from "$/components/scene/hooks/use-render-scale"; import { Environment } from "$/components/scene/layouts"; @@ -14,8 +14,9 @@ function LargeRings() { const [lastLightEvent, nextLightEvent] = useBasicEventTrack({ trackId: 1 }); const [lastRotationEvent] = useBasicEventTrack({ trackId: 8 }); + const [lastBoostEvent] = useBoostEventTrack(); - const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent }); + const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent, lastBoostEvent }); const numOfRings = useRenderScale(16); diff --git a/src/components/scene/templates/environment/primary-lights.tsx b/src/components/scene/templates/environment/primary-lights.tsx index 923a651e..1f251c8e 100644 --- a/src/components/scene/templates/environment/primary-lights.tsx +++ b/src/components/scene/templates/environment/primary-lights.tsx @@ -2,15 +2,16 @@ import { Fragment } from "react"; import { SURFACE_WIDTH } from "$/components/scene/constants"; import { useLightEffect } from "$/components/scene/hooks/environment.hooks"; -import { useBasicEventTrack } from "$/components/scene/hooks/use-event-track"; +import { useBasicEventTrack, useBoostEventTrack } from "$/components/scene/hooks/use-event-track"; import { Environment } from "$/components/scene/layouts"; const SIDE_BEAM_LENGTH = 250; function PrimaryLights() { const [lastLightEvent, nextLightEvent] = useBasicEventTrack({ trackId: 4 }); + const [lastBoostEvent] = useBoostEventTrack(); - const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent }); + const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent, lastBoostEvent }); return ( diff --git a/src/components/scene/templates/environment/side-lasers.tsx b/src/components/scene/templates/environment/side-lasers.tsx index b3bec918..15627228 100644 --- a/src/components/scene/templates/environment/side-lasers.tsx +++ b/src/components/scene/templates/environment/side-lasers.tsx @@ -1,7 +1,7 @@ import { Fragment, useMemo } from "react"; import { useLightEffect } from "$/components/scene/hooks/environment.hooks"; -import { useBasicEventTrack } from "$/components/scene/hooks/use-event-track"; +import { useBasicEventTrack, useBoostEventTrack } from "$/components/scene/hooks/use-event-track"; import { Environment } from "$/components/scene/layouts"; import { useAppSelector } from "$/store/hooks"; import { selectCursorPosition } from "$/store/selectors"; @@ -44,8 +44,9 @@ function SideLasers({ side }: Props) { const [lastLightEvent, nextLightEvent] = useBasicEventTrack({ trackId: side === "left" ? 2 : 3 }); const [lastSpeedEvent] = useBasicEventTrack({ trackId: side === "left" ? 12 : 13 }); + const [lastBoostEvent] = useBoostEventTrack(); - const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent }); + const light = useLightEffect({ lastEvent: lastLightEvent, nextEvent: nextLightEvent, lastBoostEvent }); const factor = useMemo(() => (side === "left" ? -1 : 1), [side]); diff --git a/src/content/docs/manual/events/index.mdx b/src/content/docs/manual/events/index.mdx index 27d78ede..a3eea8de 100644 --- a/src/content/docs/manual/events/index.mdx +++ b/src/content/docs/manual/events/index.mdx @@ -83,7 +83,7 @@ You can use the keyboard shortcuts - & +. ## Event Tracks -Beatmapper supports 3 types of event tracks, and they're a little distinct in how they function. +Beatmapper supports a variety of event types, and they're a little distinct in how they function. ### Light Tracks @@ -123,6 +123,21 @@ Lasers with a speed of 0 won't move at all, and increasing the value will subseq > Decimals are not supported for value events; the value defined for an event must be an integer starting from 0. +### Color Boost Tracks + +![](../../../media/images/boost-event-track.png) + +**Color Boost** tracks will allow you to swap between two distinct sets of light colors, each of which are derived from your active color scheme. + +A value of 1 will enable the color boost effect, whereas a value of 0 will disable it. + +When the color boost effect is active, any light events placed after that point will change into their respective boost colors. +This effect will also be reflected in the interpolations for background boxes. + +> [!tip] +> For environments that do not support boost colors (or simply do not have them defined in their color scheme), these events will not produce a noticeable effect. +> However, players will still be able to experience any color boost effects for your map if they override the map's chosen environment or color scheme with one that has defined boost colors. + ## Placing and Modifying Events Clicking on a track will place an event at the current cursor position (the white vertical line that follows the mouse and snaps to the nearest increment). @@ -131,6 +146,7 @@ The height at where you click relative to the track will determine the values as - For Light tracks, the height will determine the brightness of the event, from a range of 0 to 1 with a step of 0.5. - For Value tracks, the height will determine the value of the event, from a range of 0 to 8 with a step of 1. +- For Color Boost tracks, the height will determine whether the color boost effect is enabled. You can also adjust the value for both of these event types by hovering over an event, holding option, and scrolling forwards/backwards. Updating the value in this manner will allow you to set values above the upper limit or at greater precision (in the case of Light events). diff --git a/src/content/media/images/boost-event-track.png b/src/content/media/images/boost-event-track.png new file mode 100644 index 00000000..b8a84ebb Binary files /dev/null and b/src/content/media/images/boost-event-track.png differ diff --git a/src/helpers/events.helpers.ts b/src/helpers/events.helpers.ts index 103aadb1..82b96c24 100644 --- a/src/helpers/events.helpers.ts +++ b/src/helpers/events.helpers.ts @@ -38,12 +38,17 @@ export function isBasicEvent(data: unknown): data is wrapper.IWrapBasicEvent { if (typeof data !== "object" || !data) return false; return "type" in data; } +export function isBoostEvent(data: unknown): data is wrapper.IWrapColorBoostEvent { + if (typeof data !== "object" || !data) return false; + return "toggle" in data; +} export function resolveTrackIdForEvent(data: unknown) { if (isBasicEvent(data)) return data.type; + if (isBoostEvent(data)) return 5; throw new Error("Invalid event data.", { cause: data }); } -export function resolveEventId>(x: T) { +export function resolveEventId | Pick>(x: T) { return `${resolveTrackIdForEvent(x)}/${x.time}`; } @@ -70,7 +75,7 @@ export function resolveBasicEventColor>(data: T, tracks: IEventTracks) { const trackId = resolveTrackIdForEvent(data); - switch (tracks[trackId].type) { + switch (tracks[trackId]?.type) { case TrackType.LIGHT: { if (data.value === 0) return App.BasicEventEffect.OFF; if (data.value % 4 === 1) return App.BasicEventEffect.ON; @@ -86,7 +91,7 @@ export function resolveBasicEventEffect({ _bookmarks: version === 2 ? ensureArray(bookmarks?.map((x) => serializeCustomBookmark(x, version, {})).sort(sortV2ObjectFn) ?? []) : undefined, bookmarks: version === 3 ? ensureArray(bookmarks?.map((x) => serializeCustomBookmark(x, version, {})).sort(sortV3ObjectFn) ?? []) : undefined, @@ -242,6 +244,7 @@ export const { serialize: serializeBeatmapContents, deserialize: deserializeBeat const bombs = data.difficulty.bombNotes; const obstacles = data.difficulty.obstacles; const basicEvents = data.lightshow.basicEvents; + const boostEvents = data.lightshow.colorBoostEvents; const bookmarks = distinctBy( [ @@ -258,6 +261,7 @@ export const { serialize: serializeBeatmapContents, deserialize: deserializeBeat bombs: bombs?.map(shiftByOffset({ editorOffsetInBeats: -editorOffsetInBeats })), obstacles: obstacles?.map(shiftByOffset({ editorOffsetInBeats: -editorOffsetInBeats })), basicEvents: basicEvents?.map(shiftByOffset({ editorOffsetInBeats: -editorOffsetInBeats })), + boostEvents: boostEvents?.map(shiftByOffset({ editorOffsetInBeats: -editorOffsetInBeats })), bookmarks: bookmarks.map(shiftByOffset({ editorOffsetInBeats: -editorOffsetInBeats })), }; }, diff --git a/src/store/actions.ts b/src/store/actions.ts index 7591c633..3f5a99e1 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -35,6 +35,7 @@ import notes from "./features/entities/beatmap/notes.slice"; import obstacles from "./features/entities/beatmap/obstacles.slice"; import bookmarks from "./features/entities/editor/bookmarks.slice"; import basicEvents from "./features/entities/lightshow/basic.slice"; +import boostEvents from "./features/entities/lightshow/boost.slice"; import global from "./features/global.slice"; import navigation from "./features/navigation.slice"; import songs from "./features/songs.slice"; @@ -293,6 +294,8 @@ export const redoObjects = createAction("redoObjects", (args: { songId: SongId } export const { addOne: addBasicEvent, addOne: bulkAddBasicEvent, updateOne: updateBasicEvent, updateColor: mirrorBasicEvent } = basicEvents.actions; +export const { addOne: addBoostEvent, addOne: bulkAddBoostEvent, updateOne: updateBoostEvent } = boostEvents.actions; + export const selectEvent = createAction("selectEvent", (args: { query: Parameters[0]; tracks: IEventTracks; areLasersLocked: boolean }) => { return { payload: { ...args } }; }); diff --git a/src/store/features/clipboard.slice.ts b/src/store/features/clipboard.slice.ts index 2e092a2b..02f3cf1b 100644 --- a/src/store/features/clipboard.slice.ts +++ b/src/store/features/clipboard.slice.ts @@ -28,6 +28,7 @@ const processSelection: CaseReducer { if (state.data.basicEvents) return state.data.basicEvents.length > 0; + if (state.data.boostEvents) return state.data.boostEvents.length > 0; }, selectEarliestBeat: (state) => { - return [...(state.data.notes ?? []), ...(state.data.bombs ?? []), ...(state.data.obstacles ?? []), ...(state.data.basicEvents ?? [])].sort(sortObjectFn)[0].time; + return [...(state.data.notes ?? []), ...(state.data.bombs ?? []), ...(state.data.obstacles ?? []), ...(state.data.basicEvents ?? []), ...(state.data.boostEvents ?? [])].sort(sortObjectFn)[0].time; }, }, reducers: (api) => { diff --git a/src/store/features/entities/lightshow/basic.slice.ts b/src/store/features/entities/lightshow/basic.slice.ts index 05a8108e..90fe91a3 100644 --- a/src/store/features/entities/lightshow/basic.slice.ts +++ b/src/store/features/entities/lightshow/basic.slice.ts @@ -94,7 +94,7 @@ const slice = createSlice({ builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { const { tracks, selectionBoxInBeats, metadata } = action.payload; const allEntities = selectAll(state); - const allTracks = Object.keys(tracks); + const allTracks = Object.keys(tracks).concat("5"); if (!selectionBoxInBeats.withPrevious) { const allSelected = allEntities.filter((x) => x.selected); adapter.updateMany( diff --git a/src/store/features/entities/lightshow/boost.slice.ts b/src/store/features/entities/lightshow/boost.slice.ts new file mode 100644 index 00000000..eafc8e4f --- /dev/null +++ b/src/store/features/entities/lightshow/boost.slice.ts @@ -0,0 +1,129 @@ +import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; +import { createColorBoostEvent, sortObjectFn } from "bsmap"; +import type { wrapper } from "bsmap/types"; + +import { isBoostEvent, resolveEventId, resolveTrackIdForEvent } from "$/helpers/events.helpers"; +import { nudgeItem } from "$/helpers/item.helpers"; +import { addSong, bulkRemoveEvent, cutSelection, deselectAllEntities, deselectEvent, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, removeAllSelectedEvents, removeEvent, selectAllEntities, selectAllEntitiesInRange, selectEvent, startLoadingMap } from "$/store/actions"; +import { createEditorObjectReducers, createEditorObjectSelectors, createEventReducerFactory, createEventSelectors } from "$/store/helpers"; +import { type App, View } from "$/types"; + +const adapter = createEntityAdapter, EntityId>({ + selectId: resolveEventId, + sortComparer: sortObjectFn, +}); + +const { selectAll } = adapter.getSelectors(); +const { selectAllSelected } = createEditorObjectSelectors(adapter); +const { createEventSelector } = createEventSelectors(adapter); +const { removeAllSelected, updateAll, updateAllSelected } = createEditorObjectReducers(adapter); + +const createEventReducer = createEventReducerFactory(adapter); + +const slice = createSlice({ + name: "basicEvents", + initialState: adapter.getInitialState(), + selectors: { + selectAll: selectAll, + selectAllSelected: selectAllSelected, + selectToggleAtBeat: createEventSelector((data) => data.toggle, false), + }, + reducers: () => { + return { + addOne: createEventReducer<{ data: wrapper.IWrapColorBoostEvent; overwrite?: boolean }>(({ match }, state, action) => { + const { data, overwrite } = action.payload; + if (!overwrite && match) return state; + if (!isBoostEvent(data)) return state; + return adapter.upsertOne(state, createColorBoostEvent({ ...data })); + }), + updateOne: createEventReducer<{ changes: Partial }>(({ match }, state, action) => { + if (!match) return state; + return adapter.updateOne(state, { id: adapter.selectId({ ...match }), changes: action.payload.changes }); + }), + }; + }, + extraReducers: (builder) => { + builder.addCase(loadBeatmapEntities, (state, action) => { + const { boostEvents } = action.payload; + return adapter.setAll(state, boostEvents ?? []); + }); + builder.addCase(removeAllSelectedEvents, (state) => { + return removeAllSelected(state); + }); + builder.addCase(cutSelection.fulfilled, (state, action) => { + const { view } = action.payload; + if (view !== View.LIGHTSHOW) return state; + return removeAllSelected(state); + }); + builder.addCase(pasteSelection.fulfilled, (state, action) => { + const { view, data, deltaBetweenPeriods } = action.payload; + if (view !== View.LIGHTSHOW) return state; + if (!data.boostEvents) return state; + updateAll(state, () => ({ selected: false })); + return adapter.upsertMany( + state, + data.boostEvents.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + ); + }); + builder.addCase(selectAllEntities.fulfilled, (state, action) => { + const { view, metadata } = action.payload; + if (view !== View.LIGHTSHOW || !metadata) return state; + return updateAll(state, () => ({ selected: true })); + }); + builder.addCase(deselectAllEntities, (state, action) => { + const { view } = action.payload; + if (view !== View.LIGHTSHOW) return state; + return updateAll(state, () => ({ selected: false })); + }); + builder.addCase(selectAllEntitiesInRange, (state, action) => { + const { startBeat, endBeat, view } = action.payload; + if (view !== View.LIGHTSHOW) return state; + return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); + }); + builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { + const { tracks, selectionBoxInBeats, metadata } = action.payload; + const allEntities = selectAll(state); + const allTracks = Object.keys(tracks).concat("5"); + if (!selectionBoxInBeats.withPrevious) { + const allSelected = allEntities.filter((x) => x.selected); + adapter.updateMany( + state, + allSelected.map((x) => ({ id: adapter.selectId(x), changes: { selected: false } })), + ); + } + const allVisible = allEntities.filter((x) => { + const isInWindow = x.time >= metadata.window.startBeat && x.time <= metadata.window.endBeat; + const isInVisibleTracks = x.time >= selectionBoxInBeats.startBeat && x.time <= selectionBoxInBeats.endBeat; + return isInWindow && isInVisibleTracks; + }); + for (const event of allVisible) { + const eventTrackIndex = allTracks.findIndex((id) => Number.parseInt(id, 10) === resolveTrackIdForEvent(event)); + const isInSelectionBox = eventTrackIndex >= selectionBoxInBeats.startTrackIndex && eventTrackIndex <= selectionBoxInBeats.endTrackIndex; + adapter.updateOne(state, { id: adapter.selectId(event), changes: { selected: isInSelectionBox || (selectionBoxInBeats.withPrevious && event.selected) } }); + } + }); + builder.addCase(nudgeSelection.fulfilled, (state, action) => { + const { view, direction, amount } = action.payload; + if (view !== View.LIGHTSHOW) return state; + return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); + }); + builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); + builder.addMatcher( + isAnyOf(removeEvent, bulkRemoveEvent), + createEventReducer(({ match }, state) => { + if (!match) return state; + return adapter.removeOne(state, adapter.selectId({ ...match })); + }), + ); + builder.addMatcher( + isAnyOf(selectEvent, deselectEvent), + createEventReducer(({ match }, state, action) => { + if (!match) return state; + return adapter.updateOne(state, { id: adapter.selectId({ ...match }), changes: { selected: selectEvent.match(action) } }); + }), + ); + builder.addDefaultCase((state) => state); + }, +}); + +export default slice; diff --git a/src/store/features/entities/lightshow/index.ts b/src/store/features/entities/lightshow/index.ts index 2eaa5651..85eaae89 100644 --- a/src/store/features/entities/lightshow/index.ts +++ b/src/store/features/entities/lightshow/index.ts @@ -1,11 +1,13 @@ import { combineReducers, type UnknownAction } from "@reduxjs/toolkit"; import undoable, { type FilterFunction, type GroupByFunction, groupByActionTypes, includeAction } from "redux-undo"; -import { addBasicEvent, bulkAddBasicEvent, bulkRemoveEvent, cutSelection, loadBeatmapEntities, mirrorBasicEvent, nudgeSelection, pasteSelection, redoEvents, removeAllSelectedEvents, removeEvent, undoEvents, updateBasicEvent } from "$/store/actions"; +import { addBasicEvent, addBoostEvent, bulkAddBasicEvent, bulkAddBoostEvent, bulkRemoveEvent, cutSelection, loadBeatmapEntities, mirrorBasicEvent, nudgeSelection, pasteSelection, redoEvents, removeAllSelectedEvents, removeEvent, undoEvents, updateBasicEvent, updateBoostEvent } from "$/store/actions"; import basicEvents from "./basic.slice"; +import boostEvents from "./boost.slice"; const reducer = combineReducers({ basicEvents: basicEvents.reducer, + boostEvents: boostEvents.reducer, }); const filter: FilterFunction, UnknownAction> = includeAction([ @@ -14,6 +16,9 @@ const filter: FilterFunction, UnknownAction> = includ bulkAddBasicEvent.type, updateBasicEvent.type, mirrorBasicEvent.type, + addBoostEvent.type, + bulkAddBoostEvent.type, + updateBoostEvent.type, removeEvent.type, bulkRemoveEvent.type, removeAllSelectedEvents.type, diff --git a/src/store/helpers.ts b/src/store/helpers.ts index 1e5076e0..bcf842db 100644 --- a/src/store/helpers.ts +++ b/src/store/helpers.ts @@ -93,7 +93,7 @@ export function createEventSelectors, Id }), createEventSelector: (selector: (data: T) => Value | undefined, fallback: Value) => { return createDraftSafeSelector(selectAllForTrackBeforeBeat, (state) => { - return selector(state[0]) ?? fallback; + return state[state.length - 1] ? (selector(state[state.length - 1]) ?? fallback) : fallback; }); }, }; diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 7e61a272..c8048784 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -6,7 +6,23 @@ import { resolveEventId } from "$/helpers/events.helpers"; import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; import { jumpToBeat, leaveEditor, redoEvents, redoObjects, undoEvents, undoObjects } from "$/store/actions"; -import { selectAllBasicEvents, selectAllBombNotes, selectAllColorNotes, selectAllObstacles, selectFutureBasicEvents, selectFutureBombNotes, selectFutureColorNotes, selectFutureObstacles, selectPastBasicEvents, selectPastBombNotes, selectPastColorNotes, selectPastObstacles } from "$/store/selectors"; +import { + selectAllBasicEvents, + selectAllBombNotes, + selectAllBoostEvents, + selectAllColorNotes, + selectAllObstacles, + selectFutureBasicEvents, + selectFutureBombNotes, + selectFutureBoostEvents, + selectFutureColorNotes, + selectFutureObstacles, + selectPastBasicEvents, + selectPastBombNotes, + selectPastBoostEvents, + selectPastColorNotes, + selectPastObstacles, +} from "$/store/selectors"; import type { RootState } from "$/store/setup"; import type { App, SongId } from "$/types/beatmap"; import { difference } from "$/utils"; @@ -22,10 +38,11 @@ function jumpToEarliestObject(api: ListenerEffectAPI, songI api.dispatch(jumpToBeat({ songId, value: earliestBeat, pauseTrack: true, animateJump: true })); } -function jumpToEarliestEvent(api: ListenerEffectAPI, songId: SongId, args: { [K in "basicEvents"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { - const relevantEvents = difference(args.basicEvents.before, args.basicEvents.after, resolveEventId); +function jumpToEarliestEvent(api: ListenerEffectAPI, songId: SongId, args: { [K in "basicEvents" | "boostEvents"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { + const relevantBasicEvents = difference(args.basicEvents.before, args.basicEvents.after, resolveEventId); + const relevantBoostEvents = difference(args.boostEvents.before, args.boostEvents.after, resolveEventId); - const relevantEntities = [...relevantEvents].sort(sortObjectFn); + const relevantEntities = [...relevantBasicEvents, ...relevantBoostEvents].sort(sortObjectFn); const earliestBeat = relevantEntities.reduce((beat, entity) => Math.min(beat, entity.time), relevantEntities[0].time); api.dispatch(jumpToBeat({ songId, value: earliestBeat, pauseTrack: true, animateJump: true })); @@ -76,6 +93,7 @@ export default function createHistoryMiddleware() { const { songId } = action.payload; jumpToEarliestEvent(api, songId, { basicEvents: { before: selectFutureBasicEvents(state), after: selectAllBasicEvents(state) }, + boostEvents: { before: selectFutureBoostEvents(state), after: selectAllBoostEvents(state) }, }); }, }); @@ -86,6 +104,7 @@ export default function createHistoryMiddleware() { const { songId } = action.payload; jumpToEarliestEvent(api, songId, { basicEvents: { before: selectPastBasicEvents(state), after: selectAllBasicEvents(state) }, + boostEvents: { before: selectPastBoostEvents(state), after: selectAllBoostEvents(state) }, }); }, }); diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 21ac0dbd..47f665a4 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -16,6 +16,7 @@ import notes from "./features/entities/beatmap/notes.slice"; import obstacles from "./features/entities/beatmap/obstacles.slice"; import bookmarks from "./features/entities/editor/bookmarks.slice"; import basicEvents from "./features/entities/lightshow/basic.slice"; +import boostEvents from "./features/entities/lightshow/boost.slice"; import global from "./features/global.slice"; import navigation from "./features/navigation.slice"; import songs from "./features/songs.slice"; @@ -282,17 +283,25 @@ export const { selectAll: selectFutureBasicEvents } = basicEvents.getSelectors( (state) => state?.basicEvents ?? basicEvents.getInitialState(), ), ); -export const selectAllBasicEventsForTrackInWindow = createDraftSafeSelector( - [selectEventEditorStartAndEndBeat, (state: RootState, _: SongId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], - ({ startBeat, endBeat }, basicEvents) => { - const beforeIdx = basicEvents.findIndex((e) => e.time >= startBeat); - const afterIdx = basicEvents.findIndex((e) => e.time >= endBeat); - const inWindow = beforeIdx === -1 ? [] : basicEvents.slice(beforeIdx, afterIdx === -1 ? basicEvents.length : afterIdx); - - return inWindow.concat(beforeIdx > 0 ? [basicEvents[beforeIdx - 1]] : [], afterIdx !== -1 ? [basicEvents[afterIdx]] : []).sort(sortObjectFn); - }, - { memoizeOptions: { resultEqualityCheck: shallowEqual } }, +export const { + selectAll: selectAllBoostEvents, + selectAllSelected: selectAllSelectedBoostEvents, + selectToggleAtBeat, +} = boostEvents.getSelectors((state: Pick) => { + return state.entities.lightshow.present.boostEvents; +}); +export const { selectAll: selectPastBoostEvents } = boostEvents.getSelectors( + selectHistory( + (state: Pick) => state.entities.lightshow.past, + (state) => state?.boostEvents ?? boostEvents.getInitialState(), + ), +); +export const { selectAll: selectFutureBoostEvents } = boostEvents.getSelectors( + selectHistory( + (state: Pick) => state.entities.lightshow.future, + (state) => state?.boostEvents ?? boostEvents.getInitialState(), + ), ); export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment, (state: RootState, _songId: SongId, _beatmapId: BeatmapId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], ({ startBeat }, tracks, events): ILightState => { @@ -308,13 +317,14 @@ export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEv }; }); -export const selectSelectedEvents = createSelector(selectAllSelectedBasicEvents, (basicEvents) => { +export const selectSelectedEvents = createSelector(selectAllSelectedBasicEvents, selectAllSelectedBoostEvents, (basicEvents, boostEvents) => { return { basicEvents: basicEvents.length > 0 ? basicEvents : undefined, + boostEvents: boostEvents.length > 0 ? boostEvents : undefined, }; }); -export const selectAllSelectedEvents = createSelector(selectAllSelectedBasicEvents, (basicEvents) => { - return [...basicEvents].sort(sortObjectFn); +export const selectAllSelectedEvents = createSelector(selectAllSelectedBasicEvents, selectAllSelectedBoostEvents, (basicEvents, boostEvents) => { + return [...basicEvents, ...boostEvents].sort(sortObjectFn); }); export const selectAnySelectedEvents = createSelector(selectAllSelectedEvents, (events) => { return events.length > 0; @@ -326,6 +336,7 @@ export const selectSelectedBeatmapEntities = createSelector([selectSelectedObjec bombs: view === View.BEATMAP ? objects.bombs : undefined, obstacles: view === View.BEATMAP ? objects.obstacles : undefined, basicEvents: view === View.LIGHTSHOW ? events.basicEvents : undefined, + boostEvents: view === View.LIGHTSHOW ? events.boostEvents : undefined, }; }); export const selectAllSelectedBeatmapEntities = createSelector([selectAllSelectedObjects, selectAllSelectedEvents], (objects, events) => { @@ -336,8 +347,8 @@ export const { selectAll: selectAllBookmarks } = bookmarks.getSelectors((state: return state.entities.editor.bookmarks; }); -export const selectBeatmapEntities = createSelector([selectAllColorNotes, selectAllBombNotes, selectAllObstacles, selectAllBasicEvents, selectAllBookmarks], (notes, bombs, obstacles, basicEvents, bookmarks): App.IBeatmapEntities => { - return { notes, bombs, obstacles, basicEvents, bookmarks }; +export const selectBeatmapEntities = createSelector([selectAllColorNotes, selectAllBombNotes, selectAllObstacles, selectAllBasicEvents, selectAllBoostEvents, selectAllBookmarks], (notes, bombs, obstacles, basicEvents, boostEvents, bookmarks): App.IBeatmapEntities => { + return { notes, bombs, obstacles, basicEvents, boostEvents, bookmarks }; }); export const { diff --git a/src/types/beatmap/app/beatmap.ts b/src/types/beatmap/app/beatmap.ts index f012279b..d15b1d49 100644 --- a/src/types/beatmap/app/beatmap.ts +++ b/src/types/beatmap/app/beatmap.ts @@ -9,6 +9,7 @@ export type IBombNote = IWrapEditorObject; export type IObstacle = IWrapEditorObject; export type IBasicEvent = IWrapEditorObject; +export type IBoostEvent = IWrapEditorObject; export interface IBookmark { time: number; @@ -21,5 +22,6 @@ export interface IBeatmapEntities { bombs: IBombNote[]; obstacles: IObstacle[]; basicEvents: IBasicEvent[]; + boostEvents: IBoostEvent[]; bookmarks: IBookmark[]; }