mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-12 02:02:42 +00:00
16-reviewer audit of ae815f489 (103 findings) remediated in six groups.
1. Lazy THREE init (runtimeobject.ts): scratch Box3/Matrix4/Euler allocated on
first use, so a 2D-only export (never loads three.js) boots.
2. Culling correctness: computeAABB uses |size|/2 half-extents and places the box
at the true rotation pivot C' = P + R*(C-P); custom-object children union
conservative boxes; the frustum is rebuilt in the boxes' space (right-multiply
_threeGroup.matrixWorld) with a numeric cache incl. camera-Z; hide/cull route
through setCullingVisible/applyRenderVisibility (no detached-carrier writes);
custom-object children and nested tile maps no longer cull with a wrong window.
3. Studio pipeline wedge (Utils.js + Studio/*): lock release over all calls,
working marks only editor calls, spawn failures reach the model, plan merge on
objects (JSON.parse bug), one persistent LoopGuard with real call_id +
re-arming + recordSuccess, RequestWriteGate self-retry + supersede-rejects,
UseStudioRuntime fire-and-forget gate write + recomputed plan + sent check,
FinalizeSubAgents terminal-'error' + executed-but-unsent + turn-cap + enriched
report, send-returns-boolean, BYOK cache write-through, suspension cascade,
turn-cap dispatch, stand-alone cannot spawn.
4. Instancing: useInstancing now serializes (C++); the pool grows without null.add
and collapses deleted slots; the instance matrix is normalized
(carrier * correction) and rewritten on rotation/hide; animated/skinned/morph/
multi-mesh models rejected; per-slot shadows; layer moves re-acquire.
5. BYOK role enforcement: studioRoleId persisted and re-applied every turn (tools +
prompt); dispatch filters tools outside the role; readOnly roles skip edit
approval.
6. Warnings + Info: mutating-tool classification, hasOwnProperty role guards,
cache-message merge, spawn arg caps, per-batch GDD note, fan-out cap, plan
dependsOn-omission + caps + duplicate-id + fail-closed + trimmed ids, tilemap
rotated/mirror bail + window margin + null-map guard, pool dispose wired to
unloadScene, schema description required, plus doc/comment corrections.
Verified: engine builds clean; boot + computeAABB (13/13) + Model3DInstancePool
(9/9) harnesses pass; Jest green for src/AiGeneration + src/AI (175), CustomAIClient
(27, incl. the critical second-turn role regression), PlanStore (25),
FinalizeSubAgents (21), LoopGuard (18), RequestWriteGate (11), Roles (9).
554 lines
17 KiB
TypeScript
554 lines
17 KiB
TypeScript
namespace gdjs {
|
|
type Model3DAnimation = { name: string; source: string; loop: boolean };
|
|
|
|
type Model3DObjectNetworkSyncDataType = {
|
|
mt: number;
|
|
op: LocationPoint | null;
|
|
cp: LocationPoint | null;
|
|
anis: Model3DAnimation[];
|
|
ai: integer;
|
|
ass: float;
|
|
aet: float;
|
|
ap: boolean;
|
|
cfd: float;
|
|
};
|
|
|
|
type Model3DObjectNetworkSyncData = Object3DNetworkSyncData &
|
|
Model3DObjectNetworkSyncDataType;
|
|
|
|
/**
|
|
* Base parameters for {@link gdjs.Model3DRuntimeObject}
|
|
* @category Objects > 3D Model
|
|
*/
|
|
export interface Model3DObjectData extends Object3DData {
|
|
/** The base parameters of the Model3D object */
|
|
content: Object3DDataContent & {
|
|
modelResourceName: string;
|
|
rotationX: number;
|
|
rotationY: number;
|
|
rotationZ: number;
|
|
keepAspectRatio: boolean;
|
|
materialType: 'Basic' | 'StandardWithoutMetalness' | 'KeepOriginal';
|
|
originLocation:
|
|
| 'ModelOrigin'
|
|
| 'ObjectCenter'
|
|
| 'BottomCenterZ'
|
|
| 'BottomCenterY'
|
|
| 'TopLeft';
|
|
centerLocation:
|
|
| 'ModelOrigin'
|
|
| 'ObjectCenter'
|
|
| 'CenteredOnZ'
|
|
| 'BottomCenterZ'
|
|
| 'BottomCenterY';
|
|
animations: Model3DAnimation[];
|
|
crossfadeDuration: float;
|
|
isCastingShadow: boolean;
|
|
isReceivingShadow: boolean;
|
|
/** Opt-in sharing of one `THREE.InstancedMesh` between identical models. */
|
|
useInstancing?: boolean;
|
|
};
|
|
}
|
|
|
|
type LocationPoint = [float | null, float | null, float | null];
|
|
|
|
const getPointForLocation = (location: string): LocationPoint => {
|
|
switch (location) {
|
|
case 'ModelOrigin':
|
|
return [null, null, null];
|
|
case 'CenteredOnZ':
|
|
return [null, null, 0.5];
|
|
case 'ObjectCenter':
|
|
return [0.5, 0.5, 0.5];
|
|
case 'BottomCenterZ':
|
|
return [0.5, 0.5, 0];
|
|
case 'BottomCenterY':
|
|
return [0.5, 1, 0.5];
|
|
case 'TopLeft':
|
|
return [0, 0, 0];
|
|
default:
|
|
return [null, null, null];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A 3D object which displays a 3D model.
|
|
* @category Objects > 3D Model
|
|
*/
|
|
export class Model3DRuntimeObject
|
|
extends gdjs.RuntimeObject3D
|
|
implements gdjs.Animatable
|
|
{
|
|
_renderer: gdjs.Model3DRuntimeObjectRenderer;
|
|
|
|
_modelResourceName: string;
|
|
_materialType: gdjs.Model3DRuntimeObject.MaterialType =
|
|
gdjs.Model3DRuntimeObject.MaterialType.Basic;
|
|
/**
|
|
* Whether this instance should share a `THREE.InstancedMesh` with the other
|
|
* instances of the same model resource, instead of cloning the model.
|
|
*
|
|
* The renderer decides whether the model actually allows it; see
|
|
* `Model3DRuntimeObject3DRenderer._canUseInstancing`.
|
|
*/
|
|
_useInstancing: boolean = false;
|
|
|
|
/**
|
|
* The local point of the model that will be at the object position.
|
|
*
|
|
* Coordinates are between 0 and 1.
|
|
*
|
|
* Its coordinates are `null` when the point is configured to `"ModelOrigin"`
|
|
* because the model origin needs to be evaluated according to the object
|
|
* configuration.
|
|
* @see gdjs.Model3DRuntimeObject3DRenderer.getOriginPoint
|
|
*/
|
|
_originPoint: LocationPoint;
|
|
/**
|
|
* The local point of the model that is used as rotation center.
|
|
*
|
|
* Coordinates are between 0 and 1.
|
|
*
|
|
* Its value is `null` when the point is configured to `"ModelOrigin"`
|
|
* because the model origin needs to be evaluated according to the object
|
|
* configuration.
|
|
* @see gdjs.Model3DRuntimeObject3DRenderer.getCenterPoint
|
|
*/
|
|
_centerPoint: LocationPoint;
|
|
|
|
_animations: Model3DAnimation[];
|
|
_currentAnimationIndex: integer = 0;
|
|
_animationSpeedScale: float = 1;
|
|
_animationPaused: boolean = false;
|
|
_crossfadeDuration: float = 0;
|
|
_isCastingShadow: boolean = true;
|
|
_isReceivingShadow: boolean = true;
|
|
_data: Model3DObjectData;
|
|
|
|
constructor(
|
|
instanceContainer: gdjs.RuntimeInstanceContainer,
|
|
objectData: Model3DObjectData,
|
|
instanceData?: InstanceData
|
|
) {
|
|
super(instanceContainer, objectData, instanceData);
|
|
this._data = objectData;
|
|
this._modelResourceName = objectData.content.modelResourceName;
|
|
this._animations = objectData.content.animations;
|
|
this._originPoint = getPointForLocation(
|
|
objectData.content.originLocation
|
|
);
|
|
this._centerPoint = getPointForLocation(
|
|
objectData.content.centerLocation
|
|
);
|
|
this._renderer = new gdjs.Model3DRuntimeObjectRenderer(
|
|
this,
|
|
instanceContainer
|
|
);
|
|
this._materialType = this._convertMaterialType(
|
|
objectData.content.materialType
|
|
);
|
|
this._crossfadeDuration = objectData.content.crossfadeDuration || 0;
|
|
this._useInstancing = !!objectData.content.useInstancing;
|
|
|
|
this.setIsCastingShadow(objectData.content.isCastingShadow);
|
|
this.setIsReceivingShadow(objectData.content.isReceivingShadow);
|
|
this.onModelChanged(objectData);
|
|
|
|
// *ALWAYS* call `this.onCreated()` at the very end of your object constructor.
|
|
this.onCreated();
|
|
}
|
|
|
|
/**
|
|
* To be called after the renderer loaded a Model resource:
|
|
* - After the renderer was instantiated
|
|
* - After reloading the model
|
|
*/
|
|
private onModelChanged(objectData: Model3DObjectData) {
|
|
this._updateModel(objectData);
|
|
if (this._animations.length > 0) {
|
|
this._renderer.playAnimation(
|
|
this._animations[0].source,
|
|
this._animations[0].loop,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
|
|
override updateOriginalDimensionsFromObjectData(
|
|
oldObjectData: Object3DData,
|
|
newObjectData: Object3DData
|
|
): void {
|
|
// Original dimensions must not be reset by `super.updateFromObjectData`.
|
|
// `_updateModel` has a different logic to evaluate them using `keepAspectRatio`.
|
|
}
|
|
|
|
updateFromObjectData(
|
|
oldObjectData: Model3DObjectData,
|
|
newObjectData: Model3DObjectData
|
|
): boolean {
|
|
super.updateFromObjectData(oldObjectData, newObjectData);
|
|
|
|
if (
|
|
oldObjectData.content.materialType !==
|
|
newObjectData.content.materialType
|
|
) {
|
|
this._materialType = this._convertMaterialType(
|
|
newObjectData.content.materialType
|
|
);
|
|
}
|
|
if (
|
|
oldObjectData.content.modelResourceName !==
|
|
newObjectData.content.modelResourceName
|
|
) {
|
|
this._reloadModel(newObjectData);
|
|
} else if (
|
|
oldObjectData.content.width !== newObjectData.content.width ||
|
|
oldObjectData.content.height !== newObjectData.content.height ||
|
|
oldObjectData.content.depth !== newObjectData.content.depth ||
|
|
oldObjectData.content.rotationX !== newObjectData.content.rotationX ||
|
|
oldObjectData.content.rotationY !== newObjectData.content.rotationY ||
|
|
oldObjectData.content.rotationZ !== newObjectData.content.rotationZ ||
|
|
oldObjectData.content.keepAspectRatio !==
|
|
newObjectData.content.keepAspectRatio ||
|
|
oldObjectData.content.materialType !==
|
|
newObjectData.content.materialType ||
|
|
oldObjectData.content.centerLocation !==
|
|
newObjectData.content.centerLocation
|
|
) {
|
|
// The center is applied to the model by `_updateModel`.
|
|
this._centerPoint = getPointForLocation(
|
|
newObjectData.content.centerLocation
|
|
);
|
|
this.onModelChanged(newObjectData);
|
|
}
|
|
if (
|
|
oldObjectData.content.originLocation !==
|
|
newObjectData.content.originLocation
|
|
) {
|
|
this._originPoint = getPointForLocation(
|
|
newObjectData.content.originLocation
|
|
);
|
|
this._renderer.updatePosition();
|
|
}
|
|
if (
|
|
oldObjectData.content.isCastingShadow !==
|
|
newObjectData.content.isCastingShadow
|
|
) {
|
|
this.setIsCastingShadow(newObjectData.content.isCastingShadow);
|
|
}
|
|
if (
|
|
oldObjectData.content.isReceivingShadow !==
|
|
newObjectData.content.isReceivingShadow
|
|
) {
|
|
this.setIsReceivingShadow(newObjectData.content.isReceivingShadow);
|
|
}
|
|
if (this.getInstanceContainer().getGame().isInGameEdition()) {
|
|
const oldDefaultAnimationSource =
|
|
this._animations.length > 0 ? this._animations[0].source : null;
|
|
this._animations = newObjectData.content.animations;
|
|
const newDefaultAnimationSource =
|
|
this._animations.length > 0 ? this._animations[0].source : null;
|
|
if (
|
|
newDefaultAnimationSource &&
|
|
oldDefaultAnimationSource !== newDefaultAnimationSource
|
|
) {
|
|
this._renderer.playAnimation(
|
|
newDefaultAnimationSource,
|
|
this._animations[0].loop,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
getNetworkSyncData(
|
|
syncOptions: GetNetworkSyncDataOptions
|
|
): Model3DObjectNetworkSyncData {
|
|
return {
|
|
...super.getNetworkSyncData(syncOptions),
|
|
mt: this._materialType,
|
|
op: this._originPoint,
|
|
cp: this._centerPoint,
|
|
anis: this._animations,
|
|
ai: this._currentAnimationIndex,
|
|
ass: this._animationSpeedScale,
|
|
aet: this.getAnimationElapsedTime(),
|
|
ap: this._animationPaused,
|
|
cfd: this._crossfadeDuration,
|
|
};
|
|
}
|
|
|
|
updateFromNetworkSyncData(
|
|
networkSyncData: Model3DObjectNetworkSyncData,
|
|
options: UpdateFromNetworkSyncDataOptions
|
|
): void {
|
|
super.updateFromNetworkSyncData(networkSyncData, options);
|
|
|
|
if (networkSyncData.mt !== undefined) {
|
|
this._materialType = networkSyncData.mt;
|
|
}
|
|
if (networkSyncData.op !== undefined) {
|
|
this._originPoint = networkSyncData.op || [null, null, null];
|
|
}
|
|
if (networkSyncData.cp !== undefined) {
|
|
this._centerPoint = networkSyncData.cp || [null, null, null];
|
|
}
|
|
if (networkSyncData.anis !== undefined) {
|
|
this._animations = networkSyncData.anis;
|
|
}
|
|
if (networkSyncData.ass !== undefined) {
|
|
this.setAnimationSpeedScale(networkSyncData.ass);
|
|
}
|
|
if (networkSyncData.ai !== undefined) {
|
|
this.setAnimationIndex(networkSyncData.ai);
|
|
}
|
|
if (networkSyncData.aet !== undefined) {
|
|
this.setAnimationElapsedTime(networkSyncData.aet);
|
|
}
|
|
if (networkSyncData.ap !== undefined) {
|
|
if (networkSyncData.ap !== this.isAnimationPaused()) {
|
|
networkSyncData.ap ? this.pauseAnimation() : this.resumeAnimation();
|
|
}
|
|
}
|
|
if (networkSyncData.cfd !== undefined) {
|
|
this._crossfadeDuration = networkSyncData.cfd;
|
|
}
|
|
}
|
|
|
|
_reloadModel(objectData: Model3DObjectData) {
|
|
this._modelResourceName = objectData.content.modelResourceName;
|
|
this._renderer._reloadModel(this, this._runtimeScene);
|
|
this.onModelChanged(objectData);
|
|
}
|
|
|
|
_updateModel(objectData: Model3DObjectData) {
|
|
const rotationX = objectData.content.rotationX || 0;
|
|
const rotationY = objectData.content.rotationY || 0;
|
|
const rotationZ = objectData.content.rotationZ || 0;
|
|
const width = objectData.content.width || 100;
|
|
const height = objectData.content.height || 100;
|
|
const depth = objectData.content.depth || 100;
|
|
const keepAspectRatio = objectData.content.keepAspectRatio;
|
|
this._renderer._updateModel(
|
|
rotationX,
|
|
rotationY,
|
|
rotationZ,
|
|
width,
|
|
height,
|
|
depth,
|
|
keepAspectRatio
|
|
);
|
|
}
|
|
|
|
getRenderer(): RuntimeObject3DRenderer {
|
|
return this._renderer;
|
|
}
|
|
|
|
_convertMaterialType(
|
|
materialTypeString: string
|
|
): gdjs.Model3DRuntimeObject.MaterialType {
|
|
if (materialTypeString === 'KeepOriginal') {
|
|
return gdjs.Model3DRuntimeObject.MaterialType.KeepOriginal;
|
|
} else if (materialTypeString === 'StandardWithoutMetalness') {
|
|
return gdjs.Model3DRuntimeObject.MaterialType.StandardWithoutMetalness;
|
|
} else {
|
|
return gdjs.Model3DRuntimeObject.MaterialType.Basic;
|
|
}
|
|
}
|
|
|
|
update(instanceContainer: gdjs.RuntimeInstanceContainer): void {
|
|
const elapsedTime = this.getElapsedTime() / 1000;
|
|
this._renderer.updateAnimation(elapsedTime);
|
|
}
|
|
|
|
/**
|
|
* Get the index of the animation being played.
|
|
* @return The index of the new animation being played
|
|
*/
|
|
getAnimationIndex(): number {
|
|
return this._currentAnimationIndex;
|
|
}
|
|
|
|
/**
|
|
* Change the animation being played.
|
|
* @param animationIndex The index of the new animation to be played
|
|
*/
|
|
setAnimationIndex(animationIndex: number): void {
|
|
animationIndex = animationIndex | 0;
|
|
if (
|
|
animationIndex < this._animations.length &&
|
|
this._currentAnimationIndex !== animationIndex &&
|
|
animationIndex >= 0
|
|
) {
|
|
const animation = this._animations[animationIndex];
|
|
this._currentAnimationIndex = animationIndex;
|
|
this._renderer.playAnimation(animation.source, animation.loop);
|
|
if (this._animationPaused) {
|
|
this._renderer.pauseAnimation();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the name of the animation being played.
|
|
* @return The name of the new animation being played
|
|
*/
|
|
getAnimationName(): string {
|
|
if (this._currentAnimationIndex >= this._animations.length) {
|
|
return '';
|
|
}
|
|
return this._animations[this._currentAnimationIndex].name;
|
|
}
|
|
|
|
/**
|
|
* Change the animation being played.
|
|
* @param newAnimationName The name of the new animation to be played
|
|
*/
|
|
setAnimationName(newAnimationName: string): void {
|
|
if (!newAnimationName) {
|
|
return;
|
|
}
|
|
const animationIndex = this._animations.findIndex(
|
|
(animation) => animation.name === newAnimationName
|
|
);
|
|
if (animationIndex >= 0) {
|
|
this.setAnimationIndex(animationIndex);
|
|
}
|
|
}
|
|
|
|
isCurrentAnimationName(name: string): boolean {
|
|
return this.getAnimationName() === name;
|
|
}
|
|
|
|
/**
|
|
* Return true if animation has ended.
|
|
* The animation had ended if:
|
|
* - it's not configured as a loop;
|
|
* - the current frame is the last frame;
|
|
* - the last frame has been displayed long enough.
|
|
*/
|
|
hasAnimationEnded(): boolean {
|
|
return this._renderer.hasAnimationEnded();
|
|
}
|
|
|
|
setIsCastingShadow(value: boolean): void {
|
|
this._isCastingShadow = value;
|
|
this._renderer._updateShadow();
|
|
}
|
|
|
|
setIsReceivingShadow(value: boolean): void {
|
|
this._isReceivingShadow = value;
|
|
this._renderer._updateShadow();
|
|
}
|
|
|
|
setCrossfadeDuration(duration: number): void {
|
|
if (this._crossfadeDuration === duration) return;
|
|
this._crossfadeDuration = duration;
|
|
}
|
|
|
|
isAnimationPaused() {
|
|
return this._animationPaused;
|
|
}
|
|
|
|
pauseAnimation() {
|
|
this._animationPaused = true;
|
|
this._renderer.pauseAnimation();
|
|
}
|
|
|
|
resumeAnimation() {
|
|
this._animationPaused = false;
|
|
this._renderer.resumeAnimation();
|
|
}
|
|
|
|
getAnimationSpeedScale() {
|
|
return this._animationSpeedScale;
|
|
}
|
|
|
|
setAnimationSpeedScale(ratio: float): void {
|
|
this._animationSpeedScale = ratio;
|
|
this._renderer.setAnimationTimeScale(ratio);
|
|
}
|
|
|
|
getAnimationElapsedTime(): float {
|
|
return this._renderer.getAnimationElapsedTime();
|
|
}
|
|
|
|
setAnimationElapsedTime(time: float): void {
|
|
this._renderer.setAnimationElapsedTime(time);
|
|
if (!this._animationPaused) {
|
|
this._renderer.resumeAnimation();
|
|
}
|
|
}
|
|
|
|
getAnimationDuration(): float {
|
|
return this._renderer.getAnimationDuration(
|
|
this._animations[this._currentAnimationIndex].source
|
|
);
|
|
}
|
|
|
|
getCenterX(): float {
|
|
const centerPoint = this._renderer.getCenterPoint();
|
|
return this.getWidth() * centerPoint[0];
|
|
}
|
|
|
|
getCenterY(): float {
|
|
const centerPoint = this._renderer.getCenterPoint();
|
|
return this.getHeight() * centerPoint[1];
|
|
}
|
|
|
|
getCenterZ(): float {
|
|
const centerPoint = this._renderer.getCenterPoint();
|
|
return this.getDepth() * centerPoint[2];
|
|
}
|
|
|
|
getDrawableX(): float {
|
|
const originPoint = this._renderer.getOriginPoint();
|
|
return this.getX() - this.getWidth() * originPoint[0];
|
|
}
|
|
|
|
getDrawableY(): float {
|
|
const originPoint = this._renderer.getOriginPoint();
|
|
return this.getY() - this.getHeight() * originPoint[1];
|
|
}
|
|
|
|
getDrawableZ(): float {
|
|
const originPoint = this._renderer.getOriginPoint();
|
|
return this.getZ() - this.getDepth() * originPoint[2];
|
|
}
|
|
|
|
/**
|
|
* Called when the object leaves the scene: give its instance slot back to
|
|
* the shared pool so another object can reuse it. A no-op for an object on
|
|
* the clone path.
|
|
*/
|
|
override onDeletedFromScene(): void {
|
|
if (this._renderer.get3DRendererObject()) {
|
|
this._renderer.releaseInstancing();
|
|
}
|
|
super.onDeletedFromScene();
|
|
}
|
|
|
|
/**
|
|
* Moving to another layer re-acquires the instance slot from that layer's
|
|
* pool (the pool key embeds the layer name).
|
|
*/
|
|
setLayer(layerName: string): void {
|
|
const wasInstanced = this._renderer.getInstanceSlot() !== null;
|
|
if (wasInstanced) this._renderer.releaseInstancing();
|
|
super.setLayer(layerName);
|
|
if (wasInstanced) this._renderer.trySetupInstancing();
|
|
}
|
|
}
|
|
|
|
/** @category Objects > 3D Model */
|
|
export namespace Model3DRuntimeObject {
|
|
export enum MaterialType {
|
|
Basic,
|
|
StandardWithoutMetalness,
|
|
KeepOriginal,
|
|
}
|
|
}
|
|
gdjs.registerObject('Scene3D::Model3DObject', gdjs.Model3DRuntimeObject);
|
|
}
|