Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
e5b09b1
pointed to the branch and made tests
popuz Feb 10, 2026
464090c
add main helper for application of several impulses
popuz Feb 20, 2026
0e57d5d
add first physics force helpers and separated into files
popuz Feb 20, 2026
3c9e55c
small clean-up
popuz Feb 20, 2026
1af3fd0
adjusted to new renamings and removed Space
popuz Feb 25, 2026
c6a5bf4
apply renaming to parameter of the main helpers
popuz Feb 25, 2026
afd5481
more clean-up
popuz Feb 26, 2026
3581dd8
removed unneeded advanceFrame
popuz Feb 26, 2026
947bef4
removed unneeded advanceFrame
popuz Feb 26, 2026
9e3b256
changed overload to be specifically direction instead of vector
popuz Feb 26, 2026
810fb57
updated comments
popuz Feb 26, 2026
00fe871
made helpers for vector operations
popuz Feb 26, 2026
ca73eb8
update namings
popuz Feb 26, 2026
b6395b2
updated protocol
popuz Feb 26, 2026
07cba38
add localToWorld helper
popuz Feb 26, 2026
fa4aa30
add knockback
popuz Feb 26, 2026
31572ea
add force for duration
popuz Feb 26, 2026
ca5f044
add repulsion force
popuz Feb 26, 2026
127a652
make build
popuz Mar 2, 2026
3e2d5dc
pointed to @experimental
popuz Mar 2, 2026
2fdbc5c
pointed to @next
popuz Mar 2, 2026
5becb68
make build
popuz Mar 2, 2026
116edd0
fixed linter issues
popuz Mar 3, 2026
4a59913
Merge branch 'main' into feat/physics-force-and-impulse
popuz Mar 3, 2026
6c5d66c
updated snapshots
popuz Mar 3, 2026
75639bd
Merge remote-tracking branch 'origin/feat/physics-force-and-impulse' …
popuz Mar 3, 2026
f8dcd2d
Merge branch 'main' into feat/physics-force-and-impulse
popuz Mar 5, 2026
281c5db
change getOrNull to Has
popuz Mar 5, 2026
fba7691
removed math.ts and used Vector3 helpers instead. Moved map up
popuz Mar 5, 2026
4eee635
don't add force for zero Vector
popuz Mar 5, 2026
8dc05f4
add a comment for fource sources
popuz Mar 5, 2026
318fddc
mirrored vector3 operations in separate file (to fix @dcl/ecs-math pa…
popuz Mar 9, 2026
994e83b
make build
popuz Mar 9, 2026
21f2ac6
updated snapshots
popuz Mar 9, 2026
1824cc5
Merge remote-tracking branch 'refs/remotes/origin/main' into feat/phy…
popuz Mar 9, 2026
5941e53
removed deps on ecs-math
popuz Mar 9, 2026
6d32683
fix for external forces in multiplayer
popuz Mar 16, 2026
42b972e
add forces to not sync
popuz Mar 16, 2026
c72d0d3
force clean-up
popuz Mar 16, 2026
bb6c6bc
make snapshot
popuz Mar 16, 2026
1659021
add trigger area result to non-sync component
popuz Mar 16, 2026
fdac09b
swap exception to error log
popuz Mar 16, 2026
99717c7
removed TriggerAreaResults from sync components
popuz Mar 17, 2026
e8e2906
Merge remote-tracking branch 'refs/remotes/origin/main' into feat/phy…
popuz Mar 18, 2026
9f87c97
another make snapshot
popuz Mar 18, 2026
52bf3bc
fixed test
popuz Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/@dcl/ecs/src/components/manual/Transform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { LastWriteWinElementSetComponentDefinition, IEngine } from '../../engine'
import { Entity } from '../../engine/entity'
import type { ISchema } from '../../schemas/ISchema'
import type { Vector3Type } from '../../schemas/custom/Vector3'
import { ByteBuffer } from '../../serialization/ByteBuffer'
// Use import * to safely handle circular dependency (tree.ts → components → Transform.ts → tree.ts).
// With import *, the namespace object's properties are resolved at access time (live bindings in ESM,
// getters via __importStar in CJS), so by the time methods are called all exports are available.
import * as treeHelpers from '../../runtime/helpers/tree'

/**
* @public
Expand All @@ -14,6 +19,19 @@ export type TransformComponent = LastWriteWinElementSetComponentDefinition<Trans
export interface TransformComponentExtended extends TransformComponent {
create(entity: Entity, val?: TransformTypeWithOptionals): TransformType
createOrReplace(entity: Entity, val?: TransformTypeWithOptionals): TransformType

/**
* Transforms a direction vector from an entity's local coordinate space
* to world space, accounting for the full parent hierarchy.
*
* This applies only rotation (not translation or scale) — suitable for
* direction vectors like force/impulse directions.
*
* @param entity - The source entity whose local space defines the direction
* @param localDirection - Direction vector in the entity's local coordinates
* @returns The direction vector in world coordinates
*/
localToWorldDirection(entity: Entity, localDirection: Vector3Type): Vector3Type
}

/**
Expand Down Expand Up @@ -132,13 +150,18 @@ export function defineTransformComponent(
engine: Pick<IEngine, 'defineComponentFromSchema'>
): TransformComponentExtended {
const transformDef = engine.defineComponentFromSchema('core::Transform', TransformSchema)

return {
...transformDef,
create(entity: Entity, val?: TransformTypeWithOptionals) {
return transformDef.create(entity, TransformSchema.extend!(val))
},
createOrReplace(entity: Entity, val?: TransformTypeWithOptionals) {
return transformDef.createOrReplace(entity, TransformSchema.extend!(val))
},
localToWorldDirection(entity: Entity, localDirection: Vector3Type): Vector3Type {
const worldRotation = treeHelpers.getWorldRotation(engine as treeHelpers.WorldTransformEngine, entity)
return treeHelpers.rotateVectorByQuaternion(localDirection, worldRotation)
}
}
}
1 change: 1 addition & 0 deletions packages/@dcl/ecs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './systems/assetLoad'
export * from './systems/async-task'
export * from './systems/tween'
export * from './systems/triggerArea'
export * from './systems/physics'
export * from './engine/entity'
export * from './components/types'

Expand Down
1 change: 1 addition & 0 deletions packages/@dcl/ecs/src/runtime/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './coordinates'
export * from './math'
export * from './tree'
export { createTimers, Timers, TimerId, TimerCallback } from './timers'
38 changes: 38 additions & 0 deletions packages/@dcl/ecs/src/runtime/helpers/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Vector3Type } from '../../schemas'

/** @public */
export function isZeroVector(v: Vector3Type): boolean {
Comment thread
popuz marked this conversation as resolved.
Outdated
return v.x === 0 && v.y === 0 && v.z === 0
}

/** @public */
export function normalizeVector(v: Vector3Type): Vector3Type {
const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
if (len === 0) return { x: 0, y: 0, z: 0 }
return { x: v.x / len, y: v.y / len, z: v.z / len }
}

/** @public */
export function scaleVector(v: Vector3Type, s: number): Vector3Type {
return { x: v.x * s, y: v.y * s, z: v.z * s }
}

/** @public */
export function addVectors(a: Vector3Type, b: Vector3Type): Vector3Type {
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }
}

/** @public */
export function subtractVectors(a: Vector3Type, b: Vector3Type): Vector3Type {
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }
}

/** @public */
export function vectorLength(v: Vector3Type): number {
return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
}

/** @public */
export function vectorsEqual(a: Vector3Type, b: Vector3Type): boolean {
return a.x === b.x && a.y === b.y && a.z === b.z
}
4 changes: 2 additions & 2 deletions packages/@dcl/ecs/src/runtime/helpers/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function multiplyQuaternions(q1: QuaternionType, q2: QuaternionType): Quaternion
}

/**
* @internal
* @public
* Rotate a vector by a quaternion
* Uses the formula: v' = q * v * q^(-1), optimized version
*/
function rotateVectorByQuaternion(v: Vector3Type, q: QuaternionType): Vector3Type {
export function rotateVectorByQuaternion(v: Vector3Type, q: QuaternionType): Vector3Type {
// Extract quaternion components
const qx = q.x,
qy = q.y,
Expand Down
8 changes: 8 additions & 0 deletions packages/@dcl/ecs/src/runtime/initialization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { pointerEventColliderChecker } from '../../systems/pointer-event-collide
import { createTriggerAreaEventsSystem, TriggerAreaEventsSystem } from '../../systems/triggerArea'
import { createTimers, Timers } from '../helpers/timers'
import { setGlobalPolyfill } from '../globals'
import { createPhysicsSystem, PhysicsSystem } from '../../systems/physics'

/**
* @public
Expand Down Expand Up @@ -95,6 +96,13 @@ setGlobalPolyfill('clearTimeout', timers.clearTimeout)
setGlobalPolyfill('setInterval', timers.setInterval)
setGlobalPolyfill('clearInterval', timers.clearInterval)

/**
* @public
* Physics helpers for applying impulses and forces to the player.
*/
export const Physics: PhysicsSystem = /* @__PURE__ */ createPhysicsSystem(engine)
export { PhysicsSystem }

/**
* Adds pointer event collider system only in DEV env
*/
Expand Down
198 changes: 198 additions & 0 deletions packages/@dcl/ecs/src/systems/physics-force.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import * as components from '../components'
import { Entity } from '../engine'
import { IEngine } from '../engine'
import { Vector3Type } from '../schemas'
import {
isZeroVector,
normalizeVector,
scaleVector,
addVectors,
subtractVectors,
vectorLength,
vectorsEqual,
createTimers
} from '../runtime/helpers'
import { KnockbackFalloff } from './physics-impulse'

/**
* @internal
*/
export interface PhysicsForceHelper {
applyForceToPlayer(source: Entity, vector: Vector3Type, magnitude?: number): void
removeForceFromPlayer(source: Entity): void
applyForceToPlayerForDuration(source: Entity, duration: number, vector: Vector3Type, magnitude?: number): void
applyRepulsionForceToPlayer(
source: Entity,
fromPosition: Vector3Type,
magnitude: number,
radius?: number,
falloff?: KnockbackFalloff
): void
}

/** @internal */
export function createPhysicsForceHelper(engine: IEngine): PhysicsForceHelper {
const PhysicsCombinedForce = components.PhysicsCombinedForce(engine)
const Transform = components.Transform(engine)

const forceSources = new Map<Entity, Vector3Type>()
const repulsionSources = new Map<
Entity,
{ fromPosition: Vector3Type; magnitude: number; radius: number; falloff: KnockbackFalloff }
>()
let lastWrittenForceVector: Vector3Type | null = null

function recalcForce(): void {
if (forceSources.size === 0) {
if (PhysicsCombinedForce.getOrNull(engine.PlayerEntity) !== null) {
Comment thread
popuz marked this conversation as resolved.
Outdated
PhysicsCombinedForce.deleteFrom(engine.PlayerEntity)
}
lastWrittenForceVector = null
return
}

const current = PhysicsCombinedForce.getOrNull(engine.PlayerEntity)
if (current && lastWrittenForceVector && current.vector) {
if (!vectorsEqual(current.vector, lastWrittenForceVector)) {
throw new Error(
'PBPhysicsCombinedForce was modified outside Physics helper. ' +
'Do not mix direct component access with Physics.applyForceToPlayer().'
)
}
}

let sum: Vector3Type = { x: 0, y: 0, z: 0 }
for (const v of forceSources.values()) {
Comment thread
popuz marked this conversation as resolved.
sum = addVectors(sum, v)
}

PhysicsCombinedForce.createOrReplace(engine.PlayerEntity, { vector: sum })
lastWrittenForceVector = sum
}

function applyForceToPlayer(source: Entity, vector: Vector3Type, magnitude?: number): void {
let finalVector: Vector3Type

if (typeof magnitude === 'number') {
if (isZeroVector(vector)) return
finalVector = scaleVector(normalizeVector(vector), magnitude)
} else {
finalVector = vector
Comment thread
popuz marked this conversation as resolved.
}

forceSources.set(source, finalVector)
recalcForce()
}

function removeForceFromPlayer(source: Entity): void {
repulsionSources.delete(source)
const timerId = durationTimers.get(source)
if (timerId !== undefined) {
timers.clearTimeout(timerId)
durationTimers.delete(source)
}
if (!forceSources.has(source)) return
forceSources.delete(source)
recalcForce()
}

const timers = createTimers(engine)
const durationTimers = new Map<Entity, number>()
Comment thread
popuz marked this conversation as resolved.
Outdated

function scheduleForceDuration(source: Entity, seconds: number): void {
const existing = durationTimers.get(source)
if (existing !== undefined) {
timers.clearTimeout(existing)
}

const timerId = timers.setTimeout(() => {
durationTimers.delete(source)
removeForceFromPlayer(source)
}, seconds * 1000)
durationTimers.set(source, timerId)
}

function applyForceToPlayerForDuration(
source: Entity,
duration: number,
vector: Vector3Type,
magnitude?: number
): void {
applyForceToPlayer(source, vector, magnitude)
scheduleForceDuration(source, duration)
}

function computeRepulsionVector(
fromPosition: Vector3Type,
magnitude: number,
radius: number,
falloff: KnockbackFalloff
): Vector3Type | null {
const diff = subtractVectors(Transform.get(engine.PlayerEntity).position, fromPosition)

if (isZeroVector(diff)) return { x: 0, y: magnitude, z: 0 }
Comment thread
popuz marked this conversation as resolved.
Outdated

// Fast path: default params — no need to compute distance
if (radius === Infinity && falloff === KnockbackFalloff.CONSTANT) {
return scaleVector(normalizeVector(diff), magnitude)
}

const distance = vectorLength(diff)
if (distance > radius) return null

let effectiveMagnitude: number
switch (falloff) {
case KnockbackFalloff.LINEAR:
effectiveMagnitude = magnitude * (1 - distance / radius)
break
case KnockbackFalloff.INVERSE_SQUARE:
effectiveMagnitude = magnitude / (distance * distance + 1)
break
case KnockbackFalloff.CONSTANT:
default:
effectiveMagnitude = magnitude
break
}

if (effectiveMagnitude === 0) return null

// normalize(diff) * effectiveMagnitude in one step
return scaleVector(diff, effectiveMagnitude / distance)
}

function applyRepulsionForceToPlayer(
source: Entity,
fromPosition: Vector3Type,
magnitude: number,
radius: number = Infinity,
falloff: KnockbackFalloff = KnockbackFalloff.CONSTANT
): void {
repulsionSources.set(source, { fromPosition, magnitude, radius, falloff })

const vector = computeRepulsionVector(fromPosition, magnitude, radius, falloff)
if (vector) {
forceSources.set(source, vector)
} else {
forceSources.delete(source)
}
recalcForce()
}

// Background system: recalculate repulsion vectors every tick
engine.addSystem(() => {
if (repulsionSources.size === 0) return

for (const [source, { fromPosition, magnitude, radius, falloff }] of repulsionSources) {
const vector = computeRepulsionVector(fromPosition, magnitude, radius, falloff)
if (vector) {
forceSources.set(source, vector)
} else {
forceSources.delete(source)
}
}

recalcForce()
})

return { applyForceToPlayer, removeForceFromPlayer, applyForceToPlayerForDuration, applyRepulsionForceToPlayer }
}
Loading
Loading