From 435b683558c7944536637149d6ea368fce62f0ad Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 10 Mar 2026 10:31:45 -0500 Subject: [PATCH 1/7] add basic support for boost events --- .../app/templates/events/boost-track.tsx | 64 +++++++++ src/components/app/templates/events/grid.tsx | 3 + src/helpers/events.helpers.ts | 7 +- src/helpers/packaging.helpers.ts | 4 + src/store/actions.ts | 3 + src/store/features/clipboard.slice.ts | 4 +- .../entities/lightshow/boost.slice.ts | 129 ++++++++++++++++++ .../features/entities/lightshow/index.ts | 7 +- src/store/middleware/history.middleware.ts | 27 +++- src/store/selectors.ts | 45 +++++- src/types/beatmap/app/beatmap.ts | 2 + 11 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 src/components/app/templates/events/boost-track.tsx create mode 100644 src/store/features/entities/lightshow/boost.slice.ts 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..1f7c57be --- /dev/null +++ b/src/components/app/templates/events/boost-track.tsx @@ -0,0 +1,64 @@ +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 { selectAllBoostEventsInWindow, selectEventEditorStartAndEndBeat, selectEventsEditorMirrorLock, selectEventTracksForEnvironment } from "$/store/selectors"; +import { isColorDark } from "$/utils"; +import { token } from "$:styled-system/tokens"; + +interface Props { + trackId: number; +} +function BoostEventTrack({ trackId, ...rest }: Assign, Props>) { + const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); + + const dispatch = useAppDispatch(); + const { startBeat, endBeat } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); + const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); + const boostEvents = useAppSelector((state) => selectAllBoostEventsInWindow(state, sid)); + const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); + + const resolveEventData = useCallback((time: number, norm: number) => createColorBoostEvent({ time, toggle: norm <= 0.5 }), []); + + const api = EventGrid.useContext(); + + 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]); + + const resolveEventStyle = useCallback((_: wrapper.IWrapColorBoostEvent) => { + const value = token("colors.pink.500"); + return { "--event-color": value, background: value, color: isColorDark(value) ? "white" : "black" }; + }, []); + + return ( + + x.time >= startBeat && x.time < endBeat)}> + {(data) => ( + + {data.toggle ? "1" : "0"} + + )} + + + ); +} + +export default BoostEventTrack; 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/helpers/events.helpers.ts b/src/helpers/events.helpers.ts index 103aadb1..8e0c4147 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}`; } diff --git a/src/helpers/packaging.helpers.ts b/src/helpers/packaging.helpers.ts index 9bc4cd77..b5abdf7b 100644 --- a/src/helpers/packaging.helpers.ts +++ b/src/helpers/packaging.helpers.ts @@ -213,6 +213,7 @@ export const { serialize: serializeBeatmapContents, deserialize: deserializeBeat const bombs = data.bombs?.map(shiftByOffset({ editorOffsetInBeats })); const obstacles = data.obstacles?.map(shiftByOffset({ editorOffsetInBeats })); const basicEvents = data.basicEvents?.map(shiftByOffset({ editorOffsetInBeats })); + const boostEvents = data.boostEvents?.map(shiftByOffset({ editorOffsetInBeats })); const bookmarks = data.bookmarks?.map(shiftByOffset({ editorOffsetInBeats })); return createBeatmap({ @@ -227,6 +228,7 @@ export const { serialize: serializeBeatmapContents, deserialize: deserializeBeat }, lightshow: { basicEvents: basicEvents, + colorBoostEvents: boostEvents, customData: ensureObject({ _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/boost.slice.ts b/src/store/features/entities/lightshow/boost.slice.ts new file mode 100644 index 00000000..e9b58d23 --- /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 { start, end, view } = action.payload; + if (view !== View.LIGHTSHOW) return state; + return updateAll(state, (x) => ({ selected: x.time >= start - 0.01 && x.time < end })); + }); + builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { + const { tracks, selectionBoxInBeats, metadata } = action.payload; + const allEntities = selectAll(state); + const allTracks = Object.keys(tracks); + 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/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..7bb67031 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"; @@ -295,6 +296,38 @@ export const selectAllBasicEventsForTrackInWindow = createDraftSafeSelector( { 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 selectAllBoostEventsInWindow = createDraftSafeSelector( + [selectEventEditorStartAndEndBeat, selectAllBoostEvents], + ({ startBeat, endBeat }, boostEvents) => { + const beforeIdx = boostEvents.findIndex((e) => e.time >= startBeat); + const afterIdx = boostEvents.findIndex((e) => e.time >= endBeat); + + const inWindow = beforeIdx === -1 ? [] : boostEvents.slice(beforeIdx, afterIdx === -1 ? boostEvents.length : afterIdx); + + return inWindow.concat(beforeIdx > 0 ? [boostEvents[beforeIdx - 1]] : [], afterIdx !== -1 ? [boostEvents[afterIdx]] : []).sort(sortObjectFn); + }, + { memoizeOptions: { resultEqualityCheck: shallowEqual } }, +); + export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment, (state: RootState, _songId: SongId, _beatmapId: BeatmapId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], ({ startBeat }, tracks, events): ILightState => { const basicEventsInWindow = events.filter((event) => event.time <= startBeat); const lastBasicEvent = basicEventsInWindow[basicEventsInWindow.length - 1]; @@ -308,13 +341,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 +360,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 +371,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[]; } From 28c5933aeeac7dccad05fe2f55c74456ead05e41 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 10 Mar 2026 11:43:17 -0500 Subject: [PATCH 2/7] apply color boost to light state resolution --- .../app/templates/events/basic-track.tsx | 37 +++++++--- .../templates/events/track.helpers.test.ts | 12 ++-- .../app/templates/events/track.helpers.ts | 68 +++++++++++-------- src/store/helpers.ts | 2 +- 4 files changed, 75 insertions(+), 44 deletions(-) diff --git a/src/components/app/templates/events/basic-track.tsx b/src/components/app/templates/events/basic-track.tsx index be1d602c..4c6d9c84 100644 --- a/src/components/app/templates/events/basic-track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -10,16 +10,28 @@ 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 { + selectAllBasicEventsForTrackInWindow, + selectAllBoostEventsInWindow, + 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); +function resolveBackgroundForEvent(data: wrapper.IWrapBasicEvent, options: Parameters[1] & { isBoosted: boolean; tracks: IEventTracks }) { const eventEffect = 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(eventEffect, options)) : resolveColorForItem(eventEffect, options); const brightColor = `color-mix(in srgb, ${color}, white 30%)`; const semiTransparentColor = `color-mix(in srgb, ${color}, black 30%)`; @@ -53,6 +65,7 @@ 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 boostEvents = useAppSelector((state) => selectAllBoostEventsInWindow(state, sid)); const colorScheme = useAppSelector((state) => selectColorScheme(state, sid, bid)); const selectedTool = useAppSelector(selectEventsEditorTool); const selectedColorType = useAppSelector(selectEventsEditorColor); @@ -61,8 +74,8 @@ function BasicEventTrack({ trackId, ...rest }: Assign { - 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 resolveEventData = useCallback( (time: number, norm: number) => { @@ -118,12 +131,18 @@ function BasicEventTrack({ trackId, ...rest }: Assign { + return (data: wrapper.IWrapBasicEvent) => { + return selectToggleAtBeat(state, { trackId: 5, beforeBeat: data.time + 0.001 }); + }; + }); + const resolveEventStyle = useCallback( (data: wrapper.IWrapBasicEvent) => { - const { style, value } = resolveBackgroundForEvent(data, { tracks, colorScheme }); + const { style, value } = resolveBackgroundForEvent(data, { tracks, colorScheme, isBoosted: isEventBoosted(data) }); return { "--event-color": style, background: style, color: isColorDark(value) ? "white" : "black" }; }, - [tracks, colorScheme], + [tracks, colorScheme, isEventBoosted], ); return ( diff --git a/src/components/app/templates/events/track.helpers.test.ts b/src/components/app/templates/events/track.helpers.test.ts index 6d044008..fedecd3d 100644 --- a/src/components/app/templates/events/track.helpers.test.ts +++ b/src/components/app/templates/events/track.helpers.test.ts @@ -45,7 +45,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 +57,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); }); @@ -76,7 +76,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 +107,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); }); @@ -139,7 +139,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,7 +182,7 @@ 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); }); diff --git a/src/components/app/templates/events/track.helpers.ts b/src/components/app/templates/events/track.helpers.ts index 715230ac..b363c378 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([Math.max(startBeat, offsetInBeats ?? 0), ...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/store/helpers.ts b/src/store/helpers.ts index 1e5076e0..a2ea3b29 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[0] ? (selector(state[0]) ?? fallback) : fallback; }); }, }; From 2a971b88cba41f38bc31ffef7fa6e5bc0cc356b0 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 10 Mar 2026 11:52:55 -0500 Subject: [PATCH 3/7] integrate color boost in event preview --- src/components/scene/hooks/environment.hooks.ts | 9 +++++---- src/components/scene/hooks/use-event-track.ts | 14 +++++++++++++- .../scene/templates/environment/back-lasers.tsx | 5 +++-- .../scene/templates/environment/large-rings.tsx | 5 +++-- .../scene/templates/environment/primary-lights.tsx | 5 +++-- .../scene/templates/environment/side-lasers.tsx | 5 +++-- 6 files changed, 30 insertions(+), 13 deletions(-) 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]); From fc5c258836de53a99e4f005efdb608fe54d9e857 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 10 Mar 2026 12:02:14 -0500 Subject: [PATCH 4/7] integrate gradients for event color toggle groups --- .../app/templates/events/controls.tsx | 20 +++++++++++-------- src/components/icons/event-effect.tsx | 14 +++++++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) 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/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 ( - + + + + + + + ); } From 43254ba965f3ae187abaf129ccdb49687f838524 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 10 Mar 2026 12:25:05 -0500 Subject: [PATCH 5/7] add docs + changeset --- .yarn/versions/69337c4c.yml | 2 ++ src/content/docs/manual/events/index.mdx | 18 +++++++++++++++++- .../media/images/boost-event-track.png | Bin 0 -> 5263 bytes 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .yarn/versions/69337c4c.yml create mode 100644 src/content/media/images/boost-event-track.png 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/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 0000000000000000000000000000000000000000..a8f9962872a09796d0c2e0184f04d6b43c7c5ffd GIT binary patch literal 5263 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 Date: Tue, 10 Mar 2026 12:28:44 -0500 Subject: [PATCH 6/7] make timeline more deterministic for background boxes --- src/components/app/templates/events/track.helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/app/templates/events/track.helpers.ts b/src/components/app/templates/events/track.helpers.ts index b363c378..dd02646d 100644 --- a/src/components/app/templates/events/track.helpers.ts +++ b/src/components/app/templates/events/track.helpers.ts @@ -80,7 +80,7 @@ export function createBackgroundBoxes(trackId: number, { tracks, basicEvents, bo 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), ...boostEvents.map((e) => e.time).filter((t) => t >= startBeat && t < endBeat), endBeat])).sort((a, b) => a - b); + const timeline = Array.from(new Set([offsetInBeats ?? 0, 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[] = []; From ade45ef7dd1c3703dfdb7abba09f5d0a0ad9eac2 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Sat, 14 Mar 2026 12:19:46 -0500 Subject: [PATCH 7/7] last minute bugfixes for critical oversights + test cases --- .../app/templates/events/basic-track.tsx | 85 +++++---- .../app/templates/events/boost-track.tsx | 48 +++-- .../templates/events/track.helpers.test.ts | 177 +++++++++++++++++- .../app/templates/events/track.helpers.ts | 2 +- .../media/images/boost-event-track.png | Bin 5263 -> 31654 bytes src/helpers/events.helpers.ts | 4 +- .../entities/lightshow/basic.slice.ts | 2 +- .../entities/lightshow/boost.slice.ts | 6 +- src/store/helpers.ts | 2 +- src/store/selectors.ts | 24 --- 10 files changed, 259 insertions(+), 91 deletions(-) diff --git a/src/components/app/templates/events/basic-track.tsx b/src/components/app/templates/events/basic-track.tsx index 4c6d9c84..89abb142 100644 --- a/src/components/app/templates/events/basic-track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -11,8 +11,8 @@ import { isBasicLightEvent, isBasicValueEvent, resolveBasicEventColor, resolveBa import { addBasicEvent, bulkAddBasicEvent, bulkRemoveEvent, deselectEvent, mirrorBasicEvent, removeEvent, selectEvent, updateBasicEvent } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { - selectAllBasicEventsForTrackInWindow, - selectAllBoostEventsInWindow, + selectAllBasicEventsForTrack, + selectAllBoostEvents, selectColorScheme, selectCurrentLightStateForTrack, selectEditorOffsetInBeats, @@ -28,33 +28,59 @@ import { clamp, isColorDark, normalize } from "$/utils"; import { createBackgroundBoxes, resolveColorForLightState } from "./track.helpers"; function resolveBackgroundForEvent(data: wrapper.IWrapBasicEvent, options: Parameters[1] & { isBoosted: boolean; tracks: IEventTracks }) { - const eventEffect = resolveBasicEventEffect(data, options.tracks); + const effect = resolveBasicEventEffect(data, options.tracks); const key = resolveColorForLightState({ color: resolveBasicEventColor(data), isBoosted: options.isBoosted }, options); - const color = isBasicLightEvent(data, options.tracks) ? (key ?? resolveColorForItem(eventEffect, options)) : resolveColorForItem(eventEffect, 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; } @@ -62,21 +88,23 @@ 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 boostEvents = useAppSelector((state) => selectAllBoostEventsInWindow(state, sid)); + 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, boostEvents, initialLightState, startBeat, endBeat }); }, [initialLightState, trackId, tracks, colorScheme, offsetInBeats, basicEvents, boostEvents, startBeat, endBeat]); + const api = EventGrid.useContext(); + const resolveEventData = useCallback( (time: number, norm: number) => { switch (tracks[trackId].type) { @@ -101,8 +129,6 @@ function BasicEventTrack({ trackId, ...rest }: Assign>(() => { return { onCreate: resolveEventData, @@ -131,31 +157,10 @@ function BasicEventTrack({ trackId, ...rest }: Assign { - return (data: wrapper.IWrapBasicEvent) => { - return selectToggleAtBeat(state, { trackId: 5, beforeBeat: data.time + 0.001 }); - }; - }); - - const resolveEventStyle = useCallback( - (data: wrapper.IWrapBasicEvent) => { - const { style, value } = resolveBackgroundForEvent(data, { tracks, colorScheme, isBoosted: isEventBoosted(data) }); - return { "--event-color": style, background: style, color: isColorDark(value) ? "white" : "black" }; - }, - [tracks, colorScheme, isEventBoosted], - ); - 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 index 1f7c57be..5799af48 100644 --- a/src/components/app/templates/events/boost-track.tsx +++ b/src/components/app/templates/events/boost-track.tsx @@ -9,10 +9,35 @@ 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 { selectAllBoostEventsInWindow, selectEventEditorStartAndEndBeat, selectEventsEditorMirrorLock, selectEventTracksForEnvironment } from "$/store/selectors"; +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; } @@ -20,15 +45,15 @@ function BoostEventTrack({ trackId, ...rest }: Assign selectEventEditorStartAndEndBeat(state, sid)); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); - const boostEvents = useAppSelector((state) => selectAllBoostEventsInWindow(state, sid)); - const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); - - const resolveEventData = useCallback((time: number, norm: number) => createColorBoostEvent({ time, toggle: norm <= 0.5 }), []); + 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, @@ -43,20 +68,9 @@ function BoostEventTrack({ trackId, ...rest }: Assign { - const value = token("colors.pink.500"); - return { "--event-color": value, background: value, color: isColorDark(value) ? "white" : "black" }; - }, []); - return ( - x.time >= startBeat && x.time < endBeat)}> - {(data) => ( - - {data.toggle ? "1" : "0"} - - )} - + x.time >= startBeat && x.time < endBeat)}>{(data) => } ); } diff --git a/src/components/app/templates/events/track.helpers.test.ts b/src/components/app/templates/events/track.helpers.test.ts index fedecd3d..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, () => { @@ -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[] = [ { @@ -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, @@ -186,4 +200,163 @@ describe(createBackgroundBoxes.name, () => { 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 dd02646d..cae1a82d 100644 --- a/src/components/app/templates/events/track.helpers.ts +++ b/src/components/app/templates/events/track.helpers.ts @@ -80,7 +80,7 @@ export function createBackgroundBoxes(trackId: number, { tracks, basicEvents, bo color: resolveBasicEventColor(data), })); - const timeline = Array.from(new Set([offsetInBeats ?? 0, 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 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[] = []; diff --git a/src/content/media/images/boost-event-track.png b/src/content/media/images/boost-event-track.png index a8f9962872a09796d0c2e0184f04d6b43c7c5ffd..b8a84ebbc9f920c5bd173296fae3572b5b1d5233 100644 GIT binary patch literal 31654 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 literal 5263 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: 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; @@ -91,7 +91,7 @@ export function resolveBasicEventEffect { 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 index e9b58d23..eafc8e4f 100644 --- a/src/store/features/entities/lightshow/boost.slice.ts +++ b/src/store/features/entities/lightshow/boost.slice.ts @@ -76,14 +76,14 @@ const slice = createSlice({ return updateAll(state, () => ({ selected: false })); }); builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { start, end, view } = action.payload; + const { startBeat, endBeat, view } = action.payload; if (view !== View.LIGHTSHOW) return state; - return updateAll(state, (x) => ({ selected: x.time >= start - 0.01 && x.time < end })); + 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); + const allTracks = Object.keys(tracks).concat("5"); if (!selectionBoxInBeats.withPrevious) { const allSelected = allEntities.filter((x) => x.selected); adapter.updateMany( diff --git a/src/store/helpers.ts b/src/store/helpers.ts index a2ea3b29..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 state[0] ? (selector(state[0]) ?? fallback) : fallback; + return state[state.length - 1] ? (selector(state[state.length - 1]) ?? fallback) : fallback; }); }, }; diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 7bb67031..47f665a4 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -283,18 +283,6 @@ 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, @@ -315,18 +303,6 @@ export const { selectAll: selectFutureBoostEvents } = boostEvents.getSelectors( (state) => state?.boostEvents ?? boostEvents.getInitialState(), ), ); -export const selectAllBoostEventsInWindow = createDraftSafeSelector( - [selectEventEditorStartAndEndBeat, selectAllBoostEvents], - ({ startBeat, endBeat }, boostEvents) => { - const beforeIdx = boostEvents.findIndex((e) => e.time >= startBeat); - const afterIdx = boostEvents.findIndex((e) => e.time >= endBeat); - - const inWindow = beforeIdx === -1 ? [] : boostEvents.slice(beforeIdx, afterIdx === -1 ? boostEvents.length : afterIdx); - - return inWindow.concat(beforeIdx > 0 ? [boostEvents[beforeIdx - 1]] : [], afterIdx !== -1 ? [boostEvents[afterIdx]] : []).sort(sortObjectFn); - }, - { memoizeOptions: { resultEqualityCheck: shallowEqual } }, -); export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment, (state: RootState, _songId: SongId, _beatmapId: BeatmapId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], ({ startBeat }, tracks, events): ILightState => { const basicEventsInWindow = events.filter((event) => event.time <= startBeat);