diff --git a/dcl-workspace.json b/dcl-workspace.json index 41b136b..8bb535c 100644 --- a/dcl-workspace.json +++ b/dcl-workspace.json @@ -123,6 +123,9 @@ { "path": "scenes/1,0-visibility-comp-propagation" }, + { + "path": "scenes/0,7-physics-impulse" + }, { "path": "scenes/0,6-ui-zindex-and-opacity" }, diff --git a/scenes/0,7-physics-impulse/.dclignore b/scenes/0,7-physics-impulse/.dclignore new file mode 100644 index 0000000..7499ff3 --- /dev/null +++ b/scenes/0,7-physics-impulse/.dclignore @@ -0,0 +1,20 @@ +.* +bin/*.map +package-lock.json +yarn-lock.json +build.json +export +tsconfig.json +tslint.json +node_modules +*.ts +*.tsx +.vscode +Dockerfile +dist +README.md +*.blend +*.fbx +*.zip +*.rar +src diff --git a/scenes/0,7-physics-impulse/.gitignore b/scenes/0,7-physics-impulse/.gitignore new file mode 100644 index 0000000..895e577 --- /dev/null +++ b/scenes/0,7-physics-impulse/.gitignore @@ -0,0 +1,10 @@ +package-lock.json +*.js +node_modules +bin/ +.DS_Store +**/.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.editor \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/.vscode/extensions.json b/scenes/0,7-physics-impulse/.vscode/extensions.json new file mode 100644 index 0000000..1612a87 --- /dev/null +++ b/scenes/0,7-physics-impulse/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": [] +} diff --git a/scenes/0,7-physics-impulse/.vscode/launch.json b/scenes/0,7-physics-impulse/.vscode/launch.json new file mode 100644 index 0000000..a3b7699 --- /dev/null +++ b/scenes/0,7-physics-impulse/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use the Decentraland Editor extension of VSCode to debug the scene + // in chrome from VSCode + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Debug Decentraland in Chrome", + "url": "${command:decentraland-sdk7.commands.getDebugURL}", + "webRoot": "${workspaceFolder}/bin", + "sourceMapPathOverrides": { + "dcl:///*": "${workspaceFolder}/*" + } + } + ] +} diff --git a/scenes/0,7-physics-impulse/PhysicsForces/.dclignore b/scenes/0,7-physics-impulse/PhysicsForces/.dclignore new file mode 100644 index 0000000..0d2cd82 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/.dclignore @@ -0,0 +1,21 @@ +.* +bin/*.map +package-lock.json +yarn-lock.json +build.json +export +tsconfig.json +tslint.json +node_modules +*.ts +*.tsx +.vscode +Dockerfile +dist +README.md +*.blend +*.fbx +*.zip +*.rar +*.md +src diff --git a/scenes/0,7-physics-impulse/PhysicsForces/.gitignore b/scenes/0,7-physics-impulse/PhysicsForces/.gitignore new file mode 100644 index 0000000..895e577 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/.gitignore @@ -0,0 +1,10 @@ +package-lock.json +*.js +node_modules +bin/ +.DS_Store +**/.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.editor \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/.vscode/extensions.json b/scenes/0,7-physics-impulse/PhysicsForces/.vscode/extensions.json new file mode 100644 index 0000000..1612a87 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": [] +} diff --git a/scenes/0,7-physics-impulse/PhysicsForces/.vscode/launch.json b/scenes/0,7-physics-impulse/PhysicsForces/.vscode/launch.json new file mode 100644 index 0000000..a3b7699 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use the Decentraland Editor extension of VSCode to debug the scene + // in chrome from VSCode + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Debug Decentraland in Chrome", + "url": "${command:decentraland-sdk7.commands.getDebugURL}", + "webRoot": "${workspaceFolder}/bin", + "sourceMapPathOverrides": { + "dcl:///*": "${workspaceFolder}/*" + } + } + ] +} diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/README.md b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/README.md new file mode 100644 index 0000000..7c5ea43 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/README.md @@ -0,0 +1 @@ +# sdk-empty-scene-template \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/assets/scene/main.composite b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/assets/scene/main.composite new file mode 100644 index 0000000..ef00e80 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/assets/scene/main.composite @@ -0,0 +1,4 @@ +{ + "version": 1, + "components": [] +} diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursor.config b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursor.config new file mode 100644 index 0000000..99bcad1 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursor.config @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/getcursor/cursor-config/main/schema/cursor.schema.json", + "context": { + "include": [ + "https://github.com/decentraland/documentation/blob/main/ai-sdk-context/sdk7-complete-reference.md", + "https://github.com/decentraland/documentation/blob/main/ai-sdk-context/sdk7-examples.mdc", + "https://github.com/decentraland/documentation/tree/main/ai-sdk-context/libraries/*.ts" + ], + "exclude": [ + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*" + ] + }, + "metadata": { + "name": "dcl-cursorai-template", + "description": "A starter context setup for Cursor AI to use Decentraland SDK7 docs and curated code examples.", + "version": "1.0.0", + "tags": ["decentraland", "cursor", "sdk7", "ai-context", "examples"], + "authors": ["@drkrillo", "@decentraland"] + } +} \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursorignore b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursorignore new file mode 100644 index 0000000..c95fbd4 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/cursorignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +build/ +context/**/__tests__/ +*.test.* +*.spec.* \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/package.json b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/package.json new file mode 100644 index 0000000..61d165d --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/package.json @@ -0,0 +1,26 @@ +{ + "name": "dcl-project", + "version": "1.0.0", + "description": "SDK7 Template scene", + "scripts": { + "start": "sdk-commands start", + "deploy": "sdk-commands deploy", + "build": "sdk-commands build", + "upgrade-sdk": "npm install --save-dev @dcl/sdk@latest", + "upgrade-sdk:next": "npm install --save-dev @dcl/sdk@next" + }, + "devDependencies": { + "@dcl/js-runtime": "latest", + "@dcl/sdk": "latest" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=6.0.0" + }, + "prettier": { + "semi": false, + "singleQuote": true, + "printWidth": 120, + "trailingComma": "none" + } +} diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/scene.json b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/scene.json new file mode 100644 index 0000000..84af230 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/scene.json @@ -0,0 +1,52 @@ +{ + "ecs7": true, + "runtimeVersion": "7", + "display": { + "title": "Empty Scene Template", + "description": "Empty Scene Template", + "favicon": "favicon_asset" + }, + "owner": "", + "contact": { + "name": "SDK", + "email": "" + }, + "main": "bin/index.js", + "tags": [], + "scene": { + "parcels": [ + "0,0" + ], + "base": "0,0" + }, + "spawnPoints": [ + { + "name": "spawn1", + "default": true, + "position": { + "x": [ + 0, + 3 + ], + "y": [ + 0, + 0 + ], + "z": [ + 0, + 3 + ] + }, + "cameraTarget": { + "x": 8, + "y": 1, + "z": 8 + } + } + ], + "requiredPermissions": [ + "ALLOW_TO_TRIGGER_AVATAR_EMOTE", + "ALLOW_TO_MOVE_PLAYER_INSIDE_SCENE" + ], + "featureToggles": {} +} \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/index.ts b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/index.ts new file mode 100644 index 0000000..30d0918 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/index.ts @@ -0,0 +1,12 @@ +import {} from '@dcl/sdk/math' +import { engine } from '@dcl/sdk/ecs' +import { setupUi } from './ui' + + +export function main() { + // uncomment the line below to initialize UI from ui.tsx + //setupUi() + + // your scene code here +} + diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/ui.tsx b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/ui.tsx new file mode 100644 index 0000000..7478135 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/src/ui.tsx @@ -0,0 +1,12 @@ +import ReactEcs, { ReactEcsRenderer, UiEntity } from "@dcl/sdk/react-ecs" + + +export function setupUi() { + ReactEcsRenderer.setUiRenderer(uiMenu, { virtualWidth: 1920, virtualHeight: 1080 }) +} + +// draw your UI here +export const uiMenu = () => ( + + +) \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/tsconfig.json b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/tsconfig.json new file mode 100644 index 0000000..910d105 --- /dev/null +++ b/scenes/0,7-physics-impulse/PhysicsForces/decentraland-sdk-empty-scene-template-2519f16/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true, + "baseUrl": ".", + "paths": { + "@dcl/asset-packs/*": ["node_modules/@dcl/inspector/node_modules/@dcl/asset-packs/*"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "assets/**/*.ts", + "assets/**/*.tsx" + ], + "extends": "@dcl/sdk/types/tsconfig.ecs7.json" +} diff --git a/scenes/0,7-physics-impulse/README.md b/scenes/0,7-physics-impulse/README.md new file mode 100644 index 0000000..d1e3142 --- /dev/null +++ b/scenes/0,7-physics-impulse/README.md @@ -0,0 +1,88 @@ +# Physics Impulse Test Scene + +Прототипная сцена для тестирования `Physics.applyImpulseToPlayer` и `Physics.applyForceToPlayer` +в разных игровых механиках (кубы, маятники, карусель, настраиваемые зоны). + +## Что сейчас в сцене + +- Сравнение Force vs Impulse в туннелях. +- Impulse Cube и Repulsion Cube. +- Pendulum Bridge с ударными маятниками. +- Chain Carousel с: + - вертикальными колебаниями (можно поставить на паузу), + - настраиваемым максимальным углом наклона сидений, + - настраиваемой скоростью вращения. +- Grapple Anchors playground: + - подвесные точки зацепа, + - pointer interaction для активации, + - отрицательный `applyKnockbackToPlayer` для притягивания к якорю. + +## Архитектурная заметка (ADR-lite): Reusable Impulse Face Behavior + +### Контекст + +Сейчас face-триггеры для сидений карусели реализованы рабочим прототипом: + +- вынесены немного наружу от граней, чтобы снизить пропуски `onTriggerEnter`; +- имеют общий кулдаун; +- агрегируют несколько попаданий за кадр в одно итоговое направление; +- применяют один импульс фиксированной величины (как от одиночного удара гранью). + +Этот подход уже решает проблему многократных срабатываний и нестабильных касаний, но логика пока +привязана к текущему сценарию. + +### Проблема + +Нужно переиспользуемое поведение для будущих объектов: + +- сегодня это «пульсирующий куб»; +- завтра это «кресло карусели»; +- потом это может быть любой другой объект. + +### Решение (цель) + +Вынести текущую логику в поведенческий модуль, который можно навесить на любую сущность: + +`attachImpulseFacesBehavior(entity, config)` + +где `config` описывает: + +- `boxSize` +- `getMagnitude()` +- `cooldownSec` +- `thickness` +- `outset` +- `visuals` (базовые/кулдаун цвета) +- опционально `groupId` (если нужно объединять несколько сущностей в общий агрегатор/кулдаун) + +### Ожидаемое поведение модуля + +- Создаёт face-триггеры вокруг target-сущности. +- Собирает все срабатывания за кадр в агрегатор. +- Применяет один импульс в конце кадра: + - направление = сумма направлений триггеров; + - величина = как у одного «обычного» удара (без суммирования силы). +- Запускает кулдаун на группу/сущность. + +### Почему это не полноценный ADR прямо сейчас + +Сцена находится в фазе активного прототипирования, параметры и UX часто меняются. +Поэтому здесь используется формат **ADR-lite** внутри `README`: + +- есть явный контекст, решение и последствия; +- но без формальной фиксации статуса/версий как в строгом ADR-процессе. + +Когда поведение стабилизируется и начнёт использоваться в нескольких сценах/проектах, стоит +вынести это в отдельный формальный ADR. + +### Последствия и плюсы + +- Переиспользуемость: одно поведение для разных сущностей. +- Локальность состояния: кулдаун и агрегатор живут рядом с объектом/группой. +- Простота расширения: можно добавлять пресеты, разные режимы реакции, разные политики группировки. + +### TODO (следующий шаг) + +1. Вынести текущий код из `src/faceTriggers.ts` в явный reusable behavior API. +2. Подключить карусель через этот API без изменения внешнего поведения. +3. Добавить минимальный debug-режим (визуализация направления итогового импульса). diff --git a/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/CityTile.glb b/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/CityTile.glb new file mode 100644 index 0000000..8f6c414 Binary files /dev/null and b/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/CityTile.glb differ diff --git a/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/composite.json b/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/composite.json new file mode 100644 index 0000000..5901269 --- /dev/null +++ b/scenes/0,7-physics-impulse/assets/asset-packs/glossy_aetherea_tiles/composite.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "components": [ + { + "name": "core::GltfContainer", + "data": { + "0": { + "json": { + "src": "{assetPath}/CityTile.glb" + } + } + } + } + ] +} \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/assets/scene/entity-names.ts b/scenes/0,7-physics-impulse/assets/scene/entity-names.ts new file mode 100644 index 0000000..4455138 --- /dev/null +++ b/scenes/0,7-physics-impulse/assets/scene/entity-names.ts @@ -0,0 +1,10 @@ +// Auto-generated entity names from the scene + + +/** + * Object containing all entity names in the scene for autocomplete support. + */ +export enum EntityNames { + Ground = "Ground", + Tile_1 = "Tile 1", +} diff --git a/scenes/0,7-physics-impulse/assets/scene/main.composite b/scenes/0,7-physics-impulse/assets/scene/main.composite new file mode 100644 index 0000000..40b08d1 --- /dev/null +++ b/scenes/0,7-physics-impulse/assets/scene/main.composite @@ -0,0 +1,73 @@ +{ + "version": 1, + "components": [ + { + "name": "composite::root", + "jsonSchema": { + "type": "object", + "properties": { + "src": { + "type": "string", + "serializationType": "utf8-string" + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "integer", + "serializationType": "entity" + }, + "dest": { + "type": "integer", + "serializationType": "entity" + } + }, + "serializationType": "map" + }, + "serializationType": "array" + } + }, + "serializationType": "map" + }, + "data": {} + }, + { + "name": "core::Transform", + "jsonSchema": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "z": { "type": "number" } + } + }, + "scale": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "z": { "type": "number" } + } + }, + "rotation": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "z": { "type": "number" }, + "w": { "type": "number" } + } + }, + "parent": { "type": "integer" } + }, + "serializationType": "transform" + }, + "data": {} + } + ] +} diff --git a/scenes/0,7-physics-impulse/main.crdt b/scenes/0,7-physics-impulse/main.crdt new file mode 100644 index 0000000..6fb6fab Binary files /dev/null and b/scenes/0,7-physics-impulse/main.crdt differ diff --git a/scenes/0,7-physics-impulse/package.json b/scenes/0,7-physics-impulse/package.json new file mode 100644 index 0000000..a97c97e --- /dev/null +++ b/scenes/0,7-physics-impulse/package.json @@ -0,0 +1,14 @@ +{ + "name": "physics-impulse", + "description": "SDK7 test scene for PhysicsImpulse component", + "version": "1.0.0", + "dependencies": { + "@dcl/js-runtime": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/physics-impulse/@dcl/js-runtime/dcl-js-runtime-7.20.4-22484576356.commit-14072e0.tgz", + "@dcl/sdk": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/physics-force-and-impulse/dcl-sdk-7.21.1-23158348941.commit-4547b8f.tgz" + }, + "scripts": { + "build": "sdk-commands build --skip-install", + "start": "sdk-commands start", + "deploy": "sdk-commands deploy" + } +} diff --git a/scenes/0,7-physics-impulse/scene.json b/scenes/0,7-physics-impulse/scene.json new file mode 100644 index 0000000..40476bc --- /dev/null +++ b/scenes/0,7-physics-impulse/scene.json @@ -0,0 +1,53 @@ +{ + "ecs7": true, + "runtimeVersion": "7", + "display": { + "title": "physics-impulse", + "description": "Test", + "navmapThumbnail": "images/scene-thumbnail.png", + "favicon": "favicon_asset" + }, + "owner": "", + "contact": { + "name": "Vit", + "email": "" + }, + "main": "bin/game.js", + "tags": [], + "spawnPoints": [ + { + "name": "spawn1", + "default": true, + "position": { + "x": [ + 0, + 3 + ], + "y": [ + 0, + 0 + ], + "z": [ + 0, + 3 + ] + }, + "cameraTarget": { + "x": 8, + "y": 1, + "z": 8 + } + } + ], + "featureToggles": {}, + "scene": { + "base": "0,7", + "parcels": [ + "-1,7", + "0,7", + "1,7", + "1,8" + ] + }, + "name": "physics-impulse" +} \ No newline at end of file diff --git a/scenes/0,7-physics-impulse/src/carousel.ts b/scenes/0,7-physics-impulse/src/carousel.ts new file mode 100644 index 0000000..b9aa641 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/carousel.ts @@ -0,0 +1,186 @@ +import { + engine, + Material, + MeshCollider, + MeshRenderer, + Transform, + TextShape, + Entity +} from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' +import { createChildFaceTriggers } from './faceTriggers' + +const CENTER_X = 24 +const CENTER_Z = 24 + +const POLE_MAX_HEIGHT = 7 +const POLE_RADIUS = 0.35 +const DISK_RADIUS = 2.5 +const DISK_HEIGHT = 0.15 + +const SEAT_COUNT = 6 +const CHAIN_LENGTH = 7 +const CHAIN_RADIUS = 0.04 +const SEAT_SIZE = 0.9 +const POLE_MIN_HEIGHT = SEAT_SIZE / 2 +const LIFT_PERIOD_SECONDS = 8 +const DIRECTION_MARKER_LENGTH = CHAIN_LENGTH +const DIRECTION_MARKER_RADIUS = CHAIN_RADIUS + +const CAROUSEL_IMPULSE_MAG = 20 +const CAROUSEL_MAX_TILT_DEG = 50 +const CAROUSEL_SPEED_RPM = 1 + +const POLE_COLOR = Color4.create(0.5, 0.5, 0.55, 1) +const DISK_COLOR = Color4.create(0.2, 0.5, 0.8, 1) +const CHAIN_COLOR = Color4.create(0.6, 0.6, 0.6, 1) +const SEAT_COLOR = Color4.create(0.9, 0.7, 0.15, 1) +const MARKER_ATTACH_COLOR = Color4.create(0.9, 0.9, 0.9, 0.9) +const MARKER_NORMAL_COLOR = Color4.create(0.2, 1, 0.95, 0.9) + +export function setupCarousel() { + const movingPole = engine.addEntity() + Transform.create(movingPole, { + position: Vector3.create(CENTER_X, POLE_MAX_HEIGHT / 2, CENTER_Z), + scale: Vector3.create(POLE_RADIUS * 2, POLE_MAX_HEIGHT, POLE_RADIUS * 2) + }) + MeshRenderer.setCylinder(movingPole) + MeshCollider.setCylinder(movingPole) + Material.setPbrMaterial(movingPole, { albedoColor: POLE_COLOR }) + + const diskPivot = engine.addEntity() + Transform.create(diskPivot, { + position: Vector3.create(CENTER_X, POLE_MAX_HEIGHT, CENTER_Z) + }) + + const disk = engine.addEntity() + Transform.create(disk, { + parent: diskPivot, + scale: Vector3.create(DISK_RADIUS * 2, DISK_HEIGHT, DISK_RADIUS * 2) + }) + MeshRenderer.setCylinder(disk) + Material.setPbrMaterial(disk, { albedoColor: DISK_COLOR }) + + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create(CENTER_X, POLE_MAX_HEIGHT + 3, CENTER_Z) + }) + TextShape.create(label, { + text: `Chain Carousel\n(impulse=${CAROUSEL_IMPULSE_MAG})`, + fontSize: 2 + }) + + const chainTilts: Entity[] = [] + for (let i = 0; i < SEAT_COUNT; i++) { + createChainSeat(diskPivot, i, chainTilts) + } + + let yawDeg = 0 + let liftPhase = 0 + + engine.addSystem((dt) => { + const rpm = CAROUSEL_SPEED_RPM + yawDeg = (yawDeg - rpm * 6 * dt) % 360 + + liftPhase += (Math.PI * 2 * dt) / LIFT_PERIOD_SECONDS + const liftT = (Math.sin(liftPhase) + 1) / 2 + const liftY = POLE_MIN_HEIGHT + (POLE_MAX_HEIGHT - POLE_MIN_HEIGHT) * liftT + + const maxTilt = clamp(CAROUSEL_MAX_TILT_DEG, 0, 89) + const minTiltDeg = -90 + const dynamicTiltDeg = minTiltDeg + (90 - maxTilt) * liftT + const tiltRotation = Quaternion.fromEulerDegrees(dynamicTiltDeg, 0, 0) + + const pivot = Transform.getMutable(diskPivot) + pivot.position = Vector3.create(CENTER_X, liftY, CENTER_Z) + pivot.rotation = Quaternion.fromEulerDegrees(0, yawDeg, 0) + + const pole = Transform.getMutable(movingPole) + pole.position = Vector3.create(CENTER_X, liftY / 2, CENTER_Z) + pole.scale = Vector3.create(POLE_RADIUS * 2, Math.max(0.05, liftY), POLE_RADIUS * 2) + + for (const chainTilt of chainTilts) { + Transform.getMutable(chainTilt).rotation = tiltRotation + } + }) +} + +function clamp(value: number, min: number, max: number) { + if (value < min) return min + if (value > max) return max + return value +} + +function createChainSeat(diskPivot: Entity, index: number, chainTilts: Entity[]) { + const angle = (360 / SEAT_COUNT) * index + + const chainAnchor = engine.addEntity() + Transform.create(chainAnchor, { + parent: diskPivot, + rotation: Quaternion.fromEulerDegrees(0, angle, 0), + position: Vector3.create(0, 0, 0) + }) + + const chainTilt = engine.addEntity() + Transform.create(chainTilt, { + parent: chainAnchor, + position: Vector3.create(0, 0, DISK_RADIUS), + rotation: Quaternion.fromEulerDegrees(-CAROUSEL_MAX_TILT_DEG, 0, 0) + }) + chainTilts.push(chainTilt) + + const chain = engine.addEntity() + Transform.create(chain, { + parent: chainTilt, + position: Vector3.create(0, -CHAIN_LENGTH / 2, 0), + scale: Vector3.create(CHAIN_RADIUS * 2, CHAIN_LENGTH, CHAIN_RADIUS * 2) + }) + MeshRenderer.setCylinder(chain) + Material.setPbrMaterial(chain, { albedoColor: CHAIN_COLOR }) + + const seat = engine.addEntity() + Transform.create(seat, { + parent: chainTilt, + position: Vector3.create(0, -CHAIN_LENGTH, 0), + scale: Vector3.create(SEAT_SIZE, SEAT_SIZE, SEAT_SIZE) + }) + MeshRenderer.setBox(seat) + MeshCollider.setBox(seat) + Material.setPbrMaterial(seat, { albedoColor: SEAT_COLOR }) + + createSeatDirectionMarker( + seat, + Vector3.create(0, DIRECTION_MARKER_LENGTH / 2, 0), + Quaternion.fromEulerDegrees(0, 0, 0), + MARKER_ATTACH_COLOR + ) + createSeatDirectionMarker( + seat, + Vector3.create(0, 0, DIRECTION_MARKER_LENGTH / 2), + Quaternion.fromEulerDegrees(90, 0, 0), + MARKER_NORMAL_COLOR + ) + + createChildFaceTriggers(seat, SEAT_SIZE, () => CAROUSEL_IMPULSE_MAG) +} + +function createSeatDirectionMarker( + seat: Entity, + position: Vector3, + rotation: Quaternion, + color: Color4 +) { + const marker = engine.addEntity() + Transform.create(marker, { + parent: seat, + position, + rotation, + scale: Vector3.create( + DIRECTION_MARKER_RADIUS * 2, + DIRECTION_MARKER_LENGTH, + DIRECTION_MARKER_RADIUS * 2 + ) + }) + MeshRenderer.setCylinder(marker) + Material.setPbrMaterial(marker, { albedoColor: color }) +} diff --git a/scenes/0,7-physics-impulse/src/configUi.tsx b/scenes/0,7-physics-impulse/src/configUi.tsx new file mode 100644 index 0000000..5648d31 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/configUi.tsx @@ -0,0 +1 @@ +// UI removed for simplified QA testing diff --git a/scenes/0,7-physics-impulse/src/configurableTunnels.ts b/scenes/0,7-physics-impulse/src/configurableTunnels.ts new file mode 100644 index 0000000..4926d8d --- /dev/null +++ b/scenes/0,7-physics-impulse/src/configurableTunnels.ts @@ -0,0 +1,99 @@ +import { + engine, + Entity, + ColliderLayer, + Material, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics +} from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' + +const FORCE_COLOR = Color4.create(0.1, 0.4, 0.8, 0.25) +const IMPULSE_COLOR = Color4.create(0.8, 0.15, 0.1, 0.25) + +const HORIZONTAL_MAG = 20 +const VERTICAL_MAG = 50 + +export function setupConfigurableTunnels() { + const labelRot = Quaternion.fromEulerDegrees(0, 180, 0) + + // Horizontal impulse tunnel (red) + createImpulseTunnel( + Vector3.create(-10, 1.5, 8), Vector3.create(2, 3, 12), + Vector3.create(0, 0, HORIZONTAL_MAG), 'Impulse forward', labelRot + ) + + // Horizontal force tunnel (blue) + createForceTunnel( + Vector3.create(-6, 1.5, 8), Vector3.create(2, 3, 12), + Vector3.create(0, 0, HORIZONTAL_MAG), 'Force forward', labelRot + ) + + // Vertical impulse tunnel (red) + createImpulseTunnel( + Vector3.create(-10, 5, 2), Vector3.create(2, 10, 2), + Vector3.create(0, VERTICAL_MAG, 0), 'Impulse up', labelRot + ) + + // Vertical force tunnel (blue) + createForceTunnel( + Vector3.create(-6, 5, 2), Vector3.create(2, 10, 2), + Vector3.create(0, VERTICAL_MAG, 0), 'Force up', labelRot + ) +} + +function createLabel(position: Vector3, size: Vector3, text: string, rotation: Quaternion) { + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create(position.x, position.y + size.y / 2 + 0.5, position.z - size.z / 2), + rotation + }) + TextShape.create(label, { text, fontSize: 2 }) +} + +function createForceTunnel( + position: Vector3, size: Vector3, + direction: Vector3, label: string, labelRotation: Quaternion +): Entity { + const tunnel = engine.addEntity() + Transform.create(tunnel, { position, scale: size }) + MeshRenderer.setBox(tunnel) + Material.setPbrMaterial(tunnel, { albedoColor: FORCE_COLOR }) + TriggerArea.setBox(tunnel, ColliderLayer.CL_PLAYER) + + createLabel(position, size, label, labelRotation) + + triggerAreaEventsSystem.onTriggerEnter(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return + Physics.applyForceToPlayer(tunnel, direction) + }) + + triggerAreaEventsSystem.onTriggerExit(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return + Physics.removeForceFromPlayer(tunnel) + }) + + return tunnel +} + +function createImpulseTunnel( + position: Vector3, size: Vector3, + direction: Vector3, label: string, labelRotation: Quaternion +) { + const tunnel = engine.addEntity() + Transform.create(tunnel, { position, scale: size }) + MeshRenderer.setBox(tunnel) + Material.setPbrMaterial(tunnel, { albedoColor: IMPULSE_COLOR }) + TriggerArea.setBox(tunnel, ColliderLayer.CL_PLAYER) + + createLabel(position, size, label, labelRotation) + + triggerAreaEventsSystem.onTriggerStay(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return + Physics.applyImpulseToPlayer(direction) + }) +} diff --git a/scenes/0,7-physics-impulse/src/configurableZone.ts b/scenes/0,7-physics-impulse/src/configurableZone.ts new file mode 100644 index 0000000..d6b4a49 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/configurableZone.ts @@ -0,0 +1 @@ +// Removed for simplified QA testing diff --git a/scenes/0,7-physics-impulse/src/faceTriggers.ts b/scenes/0,7-physics-impulse/src/faceTriggers.ts new file mode 100644 index 0000000..5af6d50 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/faceTriggers.ts @@ -0,0 +1,200 @@ +import { + engine, + ColliderLayer, + Material, + MeshRenderer, + Transform, + TriggerArea, + triggerAreaEventsSystem, + Physics, + Entity +} from '@dcl/sdk/ecs' +import { Color4, Vector3 } from '@dcl/sdk/math' + +const TRIGGER_THICKNESS = 0.3 +const TRIGGER_OUTSET = 0.06 +const IMPULSE_COOLDOWN_SECONDS = 1 +const FACE_TRIGGER_COOLDOWN_ENABLED = false +const COOLDOWN_COLOR = Color4.create(0.45, 0.45, 0.45, 0.25) + +type TriggerVisual = { + entity: Entity + baseColor: Color4 +} + +const allChildFaceTriggers: TriggerVisual[] = [] +let cooldownRemainingSec = 0 +let cooldownVisualApplied = false +let cooldownSystemInitialized = false + +// Aggregator state: collects all trigger hits during frame, applies one impulse. +let pendingDirX = 0 +let pendingDirY = 0 +let pendingDirZ = 0 +let pendingMagnitude = 0 +let hasPendingImpulse = false + +interface FaceDef { + /** Position offset relative to parent center */ + localOffset: Vector3 + /** Trigger box dimensions */ + localSize: Vector3 + /** Unit-length push direction in parent-local space */ + localNormal: Vector3 + /** Default semi-transparent color */ + color: Color4 +} + +/** + * Builds 6 face trigger definitions for a box of given size. + * Faces: +X (red), -X (blue), +Z (red), -Z (blue), +Y (green), -Y (yellow). + */ +function buildFaceDefs(boxSize: number): FaceDef[] { + const half = boxSize / 2 + const off = half + TRIGGER_THICKNESS / 2 + TRIGGER_OUTSET + + return [ + { + localOffset: Vector3.create(off, 0, 0), + localSize: Vector3.create(TRIGGER_THICKNESS, boxSize, boxSize), + localNormal: Vector3.create(1, 0, 0), + color: Color4.create(1, 0.3, 0.3, 0.4) + }, + { + localOffset: Vector3.create(-off, 0, 0), + localSize: Vector3.create(TRIGGER_THICKNESS, boxSize, boxSize), + localNormal: Vector3.create(-1, 0, 0), + color: Color4.create(0.3, 0.3, 1, 0.4) + }, + { + localOffset: Vector3.create(0, 0, off), + localSize: Vector3.create(boxSize, boxSize, TRIGGER_THICKNESS), + localNormal: Vector3.create(0, 0, 1), + color: Color4.create(1, 0.3, 0.3, 0.4) + }, + { + localOffset: Vector3.create(0, 0, -off), + localSize: Vector3.create(boxSize, boxSize, TRIGGER_THICKNESS), + localNormal: Vector3.create(0, 0, -1), + color: Color4.create(0.3, 0.3, 1, 0.4) + }, + { + localOffset: Vector3.create(0, off, 0), + localSize: Vector3.create(boxSize, TRIGGER_THICKNESS, boxSize), + localNormal: Vector3.create(0, 1, 0), + color: Color4.create(0.3, 1, 0.3, 0.4) + }, + { + localOffset: Vector3.create(0, -off, 0), + localSize: Vector3.create(boxSize, TRIGGER_THICKNESS, boxSize), + localNormal: Vector3.create(0, -1, 0), + color: Color4.create(1, 0.9, 0.2, 0.4) + } + ] +} + +/** + * Creates 5 visible face-trigger boxes as **children** of `parent`. + * + * Each trigger uses `Transform.localToWorldDirection` to resolve the + * push direction through the full parent hierarchy — this is the key + * difference from the static repulsion cube which uses world-space normals. + */ +export function createChildFaceTriggers( + parent: Entity, + boxSize: number, + getMag: () => number +) { + ensureCooldownSystem() + const faces = buildFaceDefs(boxSize) + + for (const face of faces) { + const trigger = engine.addEntity() + Transform.create(trigger, { + parent, + position: face.localOffset, + scale: face.localSize + }) + MeshRenderer.setBox(trigger) + Material.setPbrMaterial(trigger, { albedoColor: face.color }) + TriggerArea.setBox(trigger, ColliderLayer.CL_PLAYER) + allChildFaceTriggers.push({ entity: trigger, baseColor: face.color }) + + triggerAreaEventsSystem.onTriggerEnter(trigger, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return + if (FACE_TRIGGER_COOLDOWN_ENABLED && cooldownRemainingSec > 0) return + + const worldDir = Transform.localToWorldDirection(trigger, face.localNormal) + pendingDirX += worldDir.x + pendingDirY += worldDir.y + pendingDirZ += worldDir.z + if (!hasPendingImpulse) { + pendingMagnitude = getMag() + hasPendingImpulse = true + } + }) + + triggerAreaEventsSystem.onTriggerExit(trigger, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return + if (FACE_TRIGGER_COOLDOWN_ENABLED && cooldownRemainingSec > 0) return + Material.setPbrMaterial(trigger, { albedoColor: face.color }) + }) + } +} + +function ensureCooldownSystem() { + if (cooldownSystemInitialized) return + cooldownSystemInitialized = true + + engine.addSystem((dt) => { + if (FACE_TRIGGER_COOLDOWN_ENABLED && cooldownRemainingSec > 0) { + cooldownRemainingSec = Math.max(0, cooldownRemainingSec - dt) + if (!cooldownVisualApplied) applyCooldownVisual() + return + } + + if (FACE_TRIGGER_COOLDOWN_ENABLED && cooldownVisualApplied) { + restoreBaseVisuals() + } + + if (hasPendingImpulse) { + const len = Math.sqrt( + pendingDirX * pendingDirX + + pendingDirY * pendingDirY + + pendingDirZ * pendingDirZ + ) + + const impulseDir = len > 0 + ? Vector3.create(pendingDirX / len, pendingDirY / len, pendingDirZ / len) + : Vector3.create(0, 1, 0) + + // Keep magnitude exactly like a single regular side hit. + Physics.applyImpulseToPlayer(impulseDir, pendingMagnitude) + + pendingDirX = 0 + pendingDirY = 0 + pendingDirZ = 0 + pendingMagnitude = 0 + hasPendingImpulse = false + + if (FACE_TRIGGER_COOLDOWN_ENABLED) { + cooldownRemainingSec = IMPULSE_COOLDOWN_SECONDS + applyCooldownVisual() + } + } + }) +} + +function applyCooldownVisual() { + cooldownVisualApplied = true + for (const trigger of allChildFaceTriggers) { + Material.setPbrMaterial(trigger.entity, { albedoColor: COOLDOWN_COLOR }) + } +} + +function restoreBaseVisuals() { + cooldownVisualApplied = false + for (const trigger of allChildFaceTriggers) { + Material.setPbrMaterial(trigger.entity, { albedoColor: trigger.baseColor }) + } +} diff --git a/scenes/0,7-physics-impulse/src/forceDurationZone.ts b/scenes/0,7-physics-impulse/src/forceDurationZone.ts new file mode 100644 index 0000000..d6b4a49 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/forceDurationZone.ts @@ -0,0 +1 @@ +// Removed for simplified QA testing diff --git a/scenes/0,7-physics-impulse/src/forceTunnel.ts b/scenes/0,7-physics-impulse/src/forceTunnel.ts new file mode 100644 index 0000000..76a4ac3 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/forceTunnel.ts @@ -0,0 +1,68 @@ +import { + engine, + ColliderLayer, + Material, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics +} from '@dcl/sdk/ecs' +import { Color4, Vector3 } from '@dcl/sdk/math' + +export interface ForceTunnelConfig { + position: Vector3 + size: Vector3 + forceDirection: Vector3 + label: string + color?: Color4 +} + +/** + * A tunnel-shaped trigger area that applies PhysicsForce (continuous). + * Force is added on enter, removed on exit. + */ +export function setupForceTunnel(config: ForceTunnelConfig) { + const defaultColor = config.color ?? Color4.create(0.1, 0.4, 0.8, 0.25) + const activeColor = Color4.create(0.2, 1, 0.2, 0.25) + + const tunnel = engine.addEntity() + Transform.create(tunnel, { + position: config.position, + scale: config.size + }) + MeshRenderer.setBox(tunnel) + Material.setPbrMaterial(tunnel, { albedoColor: defaultColor }) + TriggerArea.setBox(tunnel, ColliderLayer.CL_PLAYER) + + // Label + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create( + config.position.x, + config.position.y + config.size.y / 2 + 0.5, + config.position.z - config.size.z / 2 + ) + }) + TextShape.create(label, { + text: config.label, + fontSize: 3 + }) + + // On enter: apply force (tunnel entity is the source key in the registry) + triggerAreaEventsSystem.onTriggerEnter(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + console.log(`Force tunnel "${config.label}": force applied`) + Physics.applyForceToPlayer(tunnel, config.forceDirection) + Material.setPbrMaterial(tunnel, { albedoColor: activeColor }) + }) + + // On exit: remove force + triggerAreaEventsSystem.onTriggerExit(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + console.log(`Force tunnel "${config.label}": force removed`) + Physics.removeForceFromPlayer(tunnel) + Material.setPbrMaterial(tunnel, { albedoColor: defaultColor }) + }) +} diff --git a/scenes/0,7-physics-impulse/src/impulseCube.ts b/scenes/0,7-physics-impulse/src/impulseCube.ts new file mode 100644 index 0000000..f305169 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/impulseCube.ts @@ -0,0 +1,56 @@ +import { + engine, + ColliderLayer, + Material, + MeshCollider, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics +} from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' + +const IMPULSE_MAGNITUDE = 20 +const IMPULSE_DIRECTION = Vector3.create(0, 1, 1) +const LABEL_ROT = Quaternion.fromEulerDegrees(0, 180, 0) + +export function setupImpulseCube(position: Vector3) { + const cube = engine.addEntity() + Transform.create(cube, { + position: Vector3.create(position.x, position.y + 0.5, position.z), + scale: Vector3.create(2, 1, 2) + }) + MeshRenderer.setBox(cube) + MeshCollider.setBox(cube) + Material.setPbrMaterial(cube, { + albedoColor: Color4.create(0.2, 0.4, 0.9, 1) + }) + + const trigger = engine.addEntity() + Transform.create(trigger, { + position: Vector3.create(position.x, position.y + 1.5, position.z), + scale: Vector3.create(3, 3, 3) + }) + MeshRenderer.setBox(trigger) + Material.setPbrMaterial(trigger, { + albedoColor: Color4.create(1, 0.2, 0.2, 0.3) + }) + TriggerArea.setBox(trigger, ColliderLayer.CL_PLAYER) + + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create(position.x, position.y + 3.5, position.z), + rotation: LABEL_ROT + }) + TextShape.create(label, { + text: `Impulse Cube\n(magnitude=${IMPULSE_MAGNITUDE})`, + fontSize: 2 + }) + + triggerAreaEventsSystem.onTriggerEnter(trigger, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + Physics.applyImpulseToPlayer(IMPULSE_DIRECTION, IMPULSE_MAGNITUDE) + }) +} diff --git a/scenes/0,7-physics-impulse/src/impulsePendulumBridge.ts b/scenes/0,7-physics-impulse/src/impulsePendulumBridge.ts new file mode 100644 index 0000000..f63b663 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/impulsePendulumBridge.ts @@ -0,0 +1,145 @@ +import { + engine, + ColliderLayer, + Material, + MeshCollider, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics, + Tween, + TweenSequence, + TweenLoop, + EasingFunction, + Entity +} from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' + +const PENDULUM_MAGNITUDE = 20 + +const BRIDGE_X = 24 +const BRIDGE_Z_START = 2 +const BRIDGE_Z_END = 14 +const BRIDGE_Y = 1 +const BRIDGE_WIDTH = 1.5 + +const ARM_LENGTH = 4 +const HAMMER_SIZE = 1.2 +const HAMMER_DEPTH = 0.5 +const ROD_THICKNESS = 0.1 +const TRIGGER_THICKNESS = 0.2 +const SWING_ANGLE = 60 + +export function setupPendulumBridge() { + const bridgeLength = BRIDGE_Z_END - BRIDGE_Z_START + + const bridge = engine.addEntity() + Transform.create(bridge, { + position: Vector3.create(BRIDGE_X, BRIDGE_Y, (BRIDGE_Z_START + BRIDGE_Z_END) / 2), + scale: Vector3.create(BRIDGE_WIDTH, 0.2, bridgeLength) + }) + MeshRenderer.setBox(bridge) + MeshCollider.setBox(bridge) + Material.setPbrMaterial(bridge, { + albedoColor: Color4.create(0.4, 0.35, 0.3, 1) + }) + + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create(BRIDGE_X, BRIDGE_Y + 3, BRIDGE_Z_START - 1) + }) + TextShape.create(label, { + text: `Pendulum bridge\n(magnitude=${PENDULUM_MAGNITUDE})`, + fontSize: 2 + }) + + const pendulumCount = 4 + const spacing = bridgeLength / (pendulumCount + 1) + + for (let i = 0; i < pendulumCount; i++) { + const z = BRIDGE_Z_START + spacing * (i + 1) + const startsLeft = i % 2 === 0 + const duration = 2000 + i * 300 + createPendulum(z, startsLeft, duration) + } +} + +function createPendulum(z: number, startsLeft: boolean, duration: number) { + const pivotY = BRIDGE_Y + ARM_LENGTH + HAMMER_SIZE / 2 + 0.3 + const pivot = engine.addEntity() + Transform.create(pivot, { + position: Vector3.create(BRIDGE_X, pivotY, z) + }) + + const startAngle = startsLeft ? -SWING_ANGLE : SWING_ANGLE + const endAngle = startsLeft ? SWING_ANGLE : -SWING_ANGLE + + Tween.create(pivot, { + mode: Tween.Mode.Rotate({ + start: Quaternion.fromEulerDegrees(0, 0, startAngle), + end: Quaternion.fromEulerDegrees(0, 0, endAngle) + }), + duration, + easingFunction: EasingFunction.EF_EASEOUTSINE + }) + TweenSequence.create(pivot, { sequence: [], loop: TweenLoop.TL_YOYO }) + + const rod = engine.addEntity() + Transform.create(rod, { + parent: pivot, + position: Vector3.create(0, -ARM_LENGTH / 2, 0), + scale: Vector3.create(ROD_THICKNESS, ARM_LENGTH, ROD_THICKNESS) + }) + MeshRenderer.setBox(rod) + Material.setPbrMaterial(rod, { + albedoColor: Color4.create(0.5, 0.5, 0.5, 1) + }) + + const hammer = engine.addEntity() + Transform.create(hammer, { + parent: pivot, + position: Vector3.create(0, -ARM_LENGTH, 0), + scale: Vector3.create(HAMMER_SIZE, HAMMER_SIZE, HAMMER_DEPTH) + }) + MeshRenderer.setBox(hammer) + MeshCollider.setBox(hammer) + Material.setPbrMaterial(hammer, { + albedoColor: Color4.create(0.9, 0.15, 0.15, 0.9) + }) + + const triggerOffsetX = HAMMER_SIZE / 2 + TRIGGER_THICKNESS / 2 + + const LEFT_LOCAL_NORMAL = Vector3.create(-1, 0, 0) + const RIGHT_LOCAL_NORMAL = Vector3.create(1, 0, 0) + + createTriggerFace(pivot, Vector3.create(-triggerOffsetX, -ARM_LENGTH, 0), LEFT_LOCAL_NORMAL, + Color4.create(0.3, 0.3, 1, 0.4)) + + createTriggerFace(pivot, Vector3.create(triggerOffsetX, -ARM_LENGTH, 0), RIGHT_LOCAL_NORMAL, + Color4.create(1, 0.3, 0.3, 0.4)) +} + +function createTriggerFace( + pivot: Entity, + localPosition: Vector3, + localNormal: Vector3, + color: Color4 +) { + const trigger = engine.addEntity() + Transform.create(trigger, { + parent: pivot, + position: localPosition, + scale: Vector3.create(TRIGGER_THICKNESS, HAMMER_SIZE, HAMMER_DEPTH) + }) + MeshRenderer.setBox(trigger) + Material.setPbrMaterial(trigger, { albedoColor: color }) + TriggerArea.setBox(trigger, ColliderLayer.CL_PLAYER) + + triggerAreaEventsSystem.onTriggerEnter(trigger, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + const worldNormal = Transform.localToWorldDirection(pivot, localNormal) + Physics.applyImpulseToPlayer(worldNormal, PENDULUM_MAGNITUDE) + }) +} diff --git a/scenes/0,7-physics-impulse/src/impulseRepulsionCube.ts b/scenes/0,7-physics-impulse/src/impulseRepulsionCube.ts new file mode 100644 index 0000000..4ed118b --- /dev/null +++ b/scenes/0,7-physics-impulse/src/impulseRepulsionCube.ts @@ -0,0 +1,110 @@ +import { + engine, + ColliderLayer, + Material, + MeshCollider, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics +} from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' + +const TRIGGER_THICKNESS = 0.3 +const REPULSION_MAGNITUDE = 20 + +interface FaceDef { + offset: Vector3 + size: Vector3 + normal: Vector3 + color: Color4 +} + +const LABEL_ROT = Quaternion.fromEulerDegrees(0, 180, 0) + +export function setupRepulsionCube(position: Vector3, cubeSize: number = 2) { + const half = cubeSize / 2 + const triggerOffset = half + TRIGGER_THICKNESS / 2 + + const cube = engine.addEntity() + Transform.create(cube, { + position: Vector3.create(position.x, position.y + half, position.z), + scale: Vector3.create(cubeSize, cubeSize, cubeSize) + }) + MeshRenderer.setBox(cube) + MeshCollider.setBox(cube) + Material.setPbrMaterial(cube, { + albedoColor: Color4.create(0.6, 0.6, 0.6, 1) + }) + + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create(position.x, position.y + cubeSize + 1, position.z), + rotation: LABEL_ROT + }) + TextShape.create(label, { + text: `Repulsion cube\n(magnitude=${REPULSION_MAGNITUDE})`, + fontSize: 2 + }) + + const cubeCenter = Vector3.create(position.x, position.y + half, position.z) + + const faces: FaceDef[] = [ + { + offset: Vector3.create(triggerOffset, 0, 0), + size: Vector3.create(TRIGGER_THICKNESS, cubeSize, cubeSize), + normal: Vector3.create(1, 0, 0), + color: Color4.create(1, 0.3, 0.3, 0.4) + }, + { + offset: Vector3.create(-triggerOffset, 0, 0), + size: Vector3.create(TRIGGER_THICKNESS, cubeSize, cubeSize), + normal: Vector3.create(-1, 0, 0), + color: Color4.create(0.3, 0.3, 1, 0.4) + }, + { + offset: Vector3.create(0, 0, triggerOffset), + size: Vector3.create(cubeSize, cubeSize, TRIGGER_THICKNESS), + normal: Vector3.create(0, 0, 1), + color: Color4.create(1, 0.3, 0.3, 0.4) + }, + { + offset: Vector3.create(0, 0, -triggerOffset), + size: Vector3.create(cubeSize, cubeSize, TRIGGER_THICKNESS), + normal: Vector3.create(0, 0, -1), + color: Color4.create(0.3, 0.3, 1, 0.4) + }, + { + offset: Vector3.create(0, triggerOffset, 0), + size: Vector3.create(cubeSize, TRIGGER_THICKNESS, cubeSize), + normal: Vector3.create(0, 1, 0), + color: Color4.create(0.3, 1, 0.3, 0.4) + } + ] + + for (const face of faces) { + createFaceTrigger(cubeCenter, face) + } +} + +function createFaceTrigger(cubeCenter: Vector3, face: FaceDef) { + const trigger = engine.addEntity() + Transform.create(trigger, { + position: Vector3.create( + cubeCenter.x + face.offset.x, + cubeCenter.y + face.offset.y, + cubeCenter.z + face.offset.z + ), + scale: face.size + }) + MeshRenderer.setBox(trigger) + Material.setPbrMaterial(trigger, { albedoColor: face.color }) + TriggerArea.setBox(trigger, ColliderLayer.CL_PLAYER) + + triggerAreaEventsSystem.onTriggerEnter(trigger, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + Physics.applyImpulseToPlayer(face.normal, REPULSION_MAGNITUDE) + }) +} diff --git a/scenes/0,7-physics-impulse/src/impulseTunnel.ts b/scenes/0,7-physics-impulse/src/impulseTunnel.ts new file mode 100644 index 0000000..1621542 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/impulseTunnel.ts @@ -0,0 +1,73 @@ +import { + engine, + ColliderLayer, + Material, + MeshRenderer, + Transform, + TextShape, + TriggerArea, + triggerAreaEventsSystem, + Physics +} from '@dcl/sdk/ecs' +import { Color4, Vector3 } from '@dcl/sdk/math' + +export interface TunnelConfig { + position: Vector3 + size: Vector3 + impulseDirection: Vector3 + label: string +} + +/** + * A tunnel-shaped trigger area. + * While the player stays inside, a physics impulse is applied every frame. + */ +export function setupImpulseTunnel(config: TunnelConfig) { + const tunnel = engine.addEntity() + Transform.create(tunnel, { + position: config.position, + scale: config.size + }) + MeshRenderer.setBox(tunnel) + Material.setPbrMaterial(tunnel, { + albedoColor: Color4.create(0.8, 0.5, 0.1, 0.25) + }) + TriggerArea.setBox(tunnel, ColliderLayer.CL_PLAYER) + + // Label near the tunnel entrance + const label = engine.addEntity() + Transform.create(label, { + position: Vector3.create( + config.position.x, + config.position.y + config.size.y / 2 + 0.5, + config.position.z - config.size.z / 2 + ) + }) + TextShape.create(label, { + text: config.label, + fontSize: 2 + }) + + // Continuous impulse every frame while inside + triggerAreaEventsSystem.onTriggerStay(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + Physics.applyImpulseToPlayer(config.impulseDirection) + }) + + // Visual feedback + triggerAreaEventsSystem.onTriggerEnter(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + console.log(`Tunnel "${config.label}": impulse started`) + Material.setPbrMaterial(tunnel, { + albedoColor: Color4.create(0.2, 1, 0.2, 0.25) + }) + }) + + triggerAreaEventsSystem.onTriggerExit(tunnel, (result) => { + if (result.trigger?.entity !== engine.PlayerEntity) return; + console.log(`Tunnel "${config.label}": impulse stopped`) + Material.setPbrMaterial(tunnel, { + albedoColor: Color4.create(0.8, 0.5, 0.1, 0.25) + }) + }) +} diff --git a/scenes/0,7-physics-impulse/src/index.ts b/scenes/0,7-physics-impulse/src/index.ts new file mode 100644 index 0000000..48b1abf --- /dev/null +++ b/scenes/0,7-physics-impulse/src/index.ts @@ -0,0 +1,21 @@ +import { Vector3 } from '@dcl/sdk/math' +import { setupImpulseCube } from './impulseCube' +import { setupRepulsionCube } from './impulseRepulsionCube' +import { setupPendulumBridge } from './impulsePendulumBridge' +import { setupCarousel } from './carousel' +import { setupConfigurableTunnels } from './configurableTunnels' + +export function main() { + // === Parcel -1,7 (X: -16–0) — Force vs Impulse tunnels === + setupConfigurableTunnels() + + // === Parcel 0,7 (X: 0–16) === + setupImpulseCube(Vector3.create(4, 0, 8)) + setupRepulsionCube(Vector3.create(12, 0, 8)) + + // === Parcel 1,7 (X: 16–32) — Pendulum bridge === + setupPendulumBridge() + + // === Parcel 1,8 (X: 16–32, Z: 16–32) — Chain Carousel === + setupCarousel() +} diff --git a/scenes/0,7-physics-impulse/src/knockbackGrappleAnchors.ts b/scenes/0,7-physics-impulse/src/knockbackGrappleAnchors.ts new file mode 100644 index 0000000..d6b4a49 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/knockbackGrappleAnchors.ts @@ -0,0 +1 @@ +// Removed for simplified QA testing diff --git a/scenes/0,7-physics-impulse/src/knockbackSphereLab.ts b/scenes/0,7-physics-impulse/src/knockbackSphereLab.ts new file mode 100644 index 0000000..d6b4a49 --- /dev/null +++ b/scenes/0,7-physics-impulse/src/knockbackSphereLab.ts @@ -0,0 +1 @@ +// Removed for simplified QA testing diff --git a/scenes/0,7-physics-impulse/tsconfig.json b/scenes/0,7-physics-impulse/tsconfig.json new file mode 100644 index 0000000..d84fa28 --- /dev/null +++ b/scenes/0,7-physics-impulse/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "extends": "@dcl/sdk/types/tsconfig.ecs7.json" +} \ No newline at end of file