This commit is contained in:
Vasyl Kazakov
2026-05-29 18:01:05 +03:00
parent 2633be60a1
commit 6363396f84
20 changed files with 978 additions and 81 deletions
+96
View File
@@ -0,0 +1,96 @@
import { JoystickC, UpdateController } from "@24tools/playable_template";
import { Vector3 } from "three";
import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PropC, PropRegistry } from "./PropC";
import { Player } from "../Presets/Player";
export class GatherC {
private static activeProps = new Set<PropC>();
private static pendingAutoAttack = false;
static init() {
PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => {
const prop = PropRegistry.get(payload.lootableObject);
if (!prop || prop.isBroken) return;
this.activeProps.add(prop);
this.scheduleAutoAttackCheck();
});
PhysicsTriggerC.onTriggerExit.addDelegate((payload) => {
const prop = PropRegistry.get(payload.lootableObject);
if (prop) this.activeProps.delete(prop);
this.removeBrokenProps();
if (this.getAttackableProps().length === 0) {
Player.stopAutoAttack();
}
});
JoystickC.onJoysticEnd.addDelegate(() => {
this.scheduleAutoAttackCheck();
});
UpdateController.Instance.onUpdate.addDelegate(() => {
if (!this.pendingAutoAttack) return;
this.pendingAutoAttack = false;
this.tryStartAutoAttack();
});
}
private static scheduleAutoAttackCheck() {
this.pendingAutoAttack = true;
}
private static onAttackHit() {
for (const prop of this.getAttackableProps()) {
prop.takeDamage(1);
}
this.removeBrokenProps();
if (this.getAttackableProps().length === 0) {
Player.stopAutoAttack();
return;
}
Player.playAttack(() => this.onAttackHit());
}
private static tryStartAutoAttack() {
const props = this.getAttackableProps();
if (props.length === 0) return;
if (Player.isMoving()) return;
const targetPosition = this.getTargetPosition(props);
Player.startAutoAttack(() => this.onAttackHit(), targetPosition);
}
private static getTargetPosition(props: PropC[]): Vector3 {
const playerPosition = Player.getWorldPosition();
const target = new Vector3();
let closestDistanceSq = Infinity;
for (const prop of props) {
const propPosition = prop.object.getWorldPosition(new Vector3());
const distanceSq = playerPosition.distanceToSquared(propPosition);
if (distanceSq >= closestDistanceSq) continue;
closestDistanceSq = distanceSq;
target.copy(propPosition);
}
return target;
}
private static getAttackableProps() {
return [...this.activeProps].filter((prop) => !prop.isBroken);
}
private static removeBrokenProps() {
for (const prop of this.activeProps) {
if (prop.isBroken) this.activeProps.delete(prop);
}
}
}
+105 -9
View File
@@ -1,6 +1,12 @@
import { Object3D } from "three";
import { Box3, BoxGeometry, Mesh, Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PropC } from "./PropC";
import { resolvePropType } from "../Resources/PropDropTable";
const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"];
const GATHER_TRIGGER_PADDING = 0.2;
export class Map {
static Init() {
@@ -14,19 +20,109 @@ export class Map {
ThreeC.setShadowsStateForChildren(mapObject, true, true);
ThreeC.addToScene(mapObject);
const colliders = mapObject.getObjectByName("Colliders");
if (colliders) {
colliders.visible = false;
this.buildColliders(colliders);
PhysicsTriggerC.init();
this.buildPhysics(mapObject);
}
private static buildPhysics(mapObject: Object3D) {
for (const layerName of MAP_PHYSICS_LAYERS) {
const layer = mapObject.getObjectByName(layerName);
if (!layer) {
console.warn(`Map physics layer not found: ${layerName}`);
continue;
}
if (layerName === "Colliders") {
layer.visible = false;
this.addWallPhysics(layer);
continue;
}
layer.children.forEach((lootableObject) => {
this.setupLootableObject(lootableObject);
});
}
}
private static buildColliders(collidersRoot: Object3D) {
collidersRoot.traverse((child) => {
if (child === collidersRoot) return;
if ((child as any).isMesh) {
private static addWallPhysics(root: Object3D) {
root.traverse((child) => {
if (child === root) return;
if ((child as Mesh).isMesh) {
new PhysicsBody(child, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player);
}
});
}
private static setupLootableObject(lootableObject: Object3D) {
const propType = resolvePropType(lootableObject.name);
const prop = new PropC(lootableObject, propType);
const colliderMesh = this.findMesh(lootableObject, (name) => name.includes("collider"));
const visualMeshes = this.getVisualMeshes(lootableObject);
if (colliderMesh) {
colliderMesh.visible = false;
prop.addWallBody(
new PhysicsBody(colliderMesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player),
);
} else {
visualMeshes.forEach((mesh) => {
prop.addWallBody(
new PhysicsBody(mesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player),
);
});
}
PhysicsTriggerC.register(this.createGatherTrigger(lootableObject), lootableObject);
}
private static findMesh(
root: Object3D,
matcher: (name: string) => boolean,
): Mesh | null {
let found: Mesh | null = null;
root.traverse((child) => {
if (found) return;
if ((child as Mesh).isMesh && matcher(child.name.toLowerCase())) {
found = child as Mesh;
}
});
return found;
}
private static getVisualMeshes(root: Object3D): Mesh[] {
const visualMeshes: Mesh[] = [];
root.traverse((child) => {
if (!(child as Mesh).isMesh) return;
const name = child.name.toLowerCase();
if (name.includes("collider") || name.includes("trigger") || name.includes("gathertrigger")) return;
visualMeshes.push(child as Mesh);
});
return visualMeshes;
}
private static createGatherTrigger(lootableObject: Object3D): Mesh {
lootableObject.updateWorldMatrix(true, true);
const size = new Vector3();
new Box3().setFromObject(lootableObject).getSize(size);
const triggerMesh = new Mesh(
new BoxGeometry(
size.x + GATHER_TRIGGER_PADDING * 2,
size.y + GATHER_TRIGGER_PADDING * 2,
size.z + GATHER_TRIGGER_PADDING * 2,
),
);
triggerMesh.visible = false;
triggerMesh.name = "GatherTrigger";
lootableObject.add(triggerMesh);
return triggerMesh;
}
}
+108
View File
@@ -0,0 +1,108 @@
import {
EasyEvent,
Physics_internal,
UpdateController,
} from "@24tools/playable_template";
import { Object3D } from "three";
import { Body, Box } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
const PLAYER_RADIUS = 0.3;
export type TriggerEventPayload = {
lootableObject: Object3D;
triggerObject: Object3D;
playerBody: Body;
};
type TriggerRecord = {
lootableObject: Object3D;
triggerObject: Object3D;
radius: number;
position: Body["position"];
physicsBody: PhysicsBody;
};
export class PhysicsTriggerC {
private static inited = false;
private static triggers: TriggerRecord[] = [];
private static activeTriggers = new Set<TriggerRecord>();
static onTriggerEnter = new EasyEvent<TriggerEventPayload>();
static onTriggerExit = new EasyEvent<TriggerEventPayload>();
static init() {
if (this.inited) return;
this.inited = true;
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
}
static register(triggerObject: Object3D, lootableObject: Object3D) {
const physicsBody = new PhysicsBody(
triggerObject,
true,
0,
PhysicsLayer.Trigger,
PhysicsLayer.Player,
);
const body = physicsBody.getPhysicsBody();
body.allowSleep = false;
const shape = body.shapes[0] as Box;
const halfExtents = shape.halfExtents;
this.triggers.push({
lootableObject,
triggerObject,
radius: Math.max(halfExtents.x, halfExtents.y, halfExtents.z),
position: body.position,
physicsBody,
});
}
static unregister(lootableObject: Object3D) {
this.triggers = this.triggers.filter((record) => {
if (record.lootableObject !== lootableObject) return true;
record.physicsBody.destroy();
this.activeTriggers.delete(record);
return false;
});
}
private static update() {
const playerBody = Physics_internal.physicsWorld?.bodies.find(
(body) => body.collisionFilterGroup === PhysicsLayer.Player,
);
if (!playerBody) return;
for (const trigger of this.triggers) {
const dx = playerBody.position.x - trigger.position.x;
const dy = playerBody.position.y - trigger.position.y;
const dz = playerBody.position.z - trigger.position.z;
const distanceSq = dx * dx + dy * dy + dz * dz;
const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2;
const wasInside = this.activeTriggers.has(trigger);
if (isInside === wasInside) continue;
const payload: TriggerEventPayload = {
lootableObject: trigger.lootableObject,
triggerObject: trigger.triggerObject,
playerBody,
};
if (isInside) {
this.activeTriggers.add(trigger);
this.onTriggerEnter.Invoke(payload);
console.log("[Gather Enter]", trigger.lootableObject.name);
} else {
this.activeTriggers.delete(trigger);
this.onTriggerExit.Invoke(payload);
console.log("[Gather Exit]", trigger.lootableObject.name);
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { Object3D } from "three";
import { PhysicsBody } from "../PhysicsC";
import { PropType } from "./PropType";
import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PROP_DROPS } from "../Resources/PropDropTable";
import { createDropPlan, PropDropPlan } from "../Resources/PropDropPlanner";
import { ResourceSpawnC } from "../Resources/ResourceSpawnC";
export class PropRegistry {
private static props = new Map<Object3D, PropC>();
static register(prop: PropC) {
this.props.set(prop.object, prop);
}
static get(object: Object3D) {
return this.props.get(object);
}
}
export class PropC {
readonly object: Object3D;
readonly propType: PropType;
health: number;
readonly maxHealth: number;
isBroken = false;
private wallBodies: PhysicsBody[] = [];
private dropPlan: PropDropPlan | null = null;
constructor(object: Object3D, propType: PropType, maxHealth = 3) {
this.object = object;
this.propType = propType;
this.maxHealth = maxHealth;
this.health = maxHealth;
PropRegistry.register(this);
}
addWallBody(physicsBody: PhysicsBody) {
this.wallBodies.push(physicsBody);
}
takeDamage(amount: number): boolean {
if (this.isBroken) return false;
const hitIndex = this.maxHealth - this.health;
this.ensureDropPlan();
this.health -= amount;
this.spawnForHit(hitIndex);
if (this.health > 0) return false;
this.break();
return true;
}
break() {
if (this.isBroken) return;
this.isBroken = true;
this.object.visible = false;
this.wallBodies.forEach((body) => body.destroy());
this.wallBodies = [];
PhysicsTriggerC.unregister(this.object);
}
private ensureDropPlan() {
if (this.dropPlan) return;
const rules = PROP_DROPS[this.propType];
if (!rules.length) return;
this.dropPlan = createDropPlan(this.maxHealth, rules[0]);
}
private spawnForHit(hitIndex: number) {
if (!this.dropPlan) return;
let spawnCount = this.dropPlan.hitAmounts[hitIndex] ?? 0;
if (this.health <= 0) {
const remaining = this.dropPlan.totalDrop - this.dropPlan.spawnedDrop - spawnCount;
if (remaining > 0) {
spawnCount += remaining;
}
}
if (spawnCount <= 0) return;
ResourceSpawnC.spawnFromProp(this, this.dropPlan.resource, spawnCount);
this.dropPlan.spawnedDrop += spawnCount;
}
}
+3
View File
@@ -0,0 +1,3 @@
export enum PropType {
Box = "box",
}
@@ -51,6 +51,26 @@ export class Character {
this.curClipAction.weight = weight;
}
setObjectVisible(name: string, visible: boolean) {
this.tObj.traverse((child) => {
if (child.name === name) {
child.visible = visible;
}
});
}
setDefaultWeapons() {
this.setObjectVisible("Tool_1", false);
this.setObjectVisible("Tool_2", true);
this.setObjectVisible("Character_Pistol", false);
}
setBatEquipped(equipped: boolean) {
this.setObjectVisible("Tool_1", equipped);
this.setObjectVisible("Tool_2", !equipped);
this.setObjectVisible("Character_Pistol", false);
}
playAnimation(anim_id: number, one_time: boolean = false, fade = 0.25, randomStart = false) {
let oldClipAction: null | AnimationAction = this.curClipAction;
var clipAction = this.animMixer.clipAction(this.animationList[anim_id]);
+10 -5
View File
@@ -1,5 +1,10 @@
export enum BaseAnimation {
Nan = -1,
Idle = 0,
Run = 1,
}
export enum BaseAnimation {
Nan = -1,
Idle = 0,
Run = 1,
WeaponTakeOut = 2,
@@ -46,6 +46,7 @@ export class PlayerInput implements IMoveInput {
private static threshold = 0.25;
get CurrentDirection() { return this.currentDirection.clone(); };
get IsActive() { return this.inputaActive; };
inputaActive: boolean = false;
+8 -6
View File
@@ -22,12 +22,14 @@ export class RotationC {
private update(delta: number) {
const loockAtStep = this.input.CurrentDirection;
if (loockAtStep.length() != 0) {
const loockAtPoint = this.target.position.clone().add(loockAtStep);
this.target.lookAt(loockAtPoint);
this.targetQ.copy(this.target.quaternion);
this.target.quaternion.copy(this.currentQ);
}
if (loockAtStep.length() == 0) return;
this.currentQ.copy(this.target.quaternion);
const loockAtPoint = this.target.position.clone().add(loockAtStep);
this.target.lookAt(loockAtPoint);
this.targetQ.copy(this.target.quaternion);
this.target.quaternion.copy(this.currentQ);
this.target.quaternion.slerp(this.targetQ, delta * this.speed);
this.currentQ.copy(this.target.quaternion);
+158 -20
View File
@@ -5,22 +5,27 @@ import { MeshType } from "./Enums/MeshType";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { BaseAnimation } from "./Enums/BaseAnimation";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { Object3D } from "three";
import { Object3D, Quaternion, Vector3 } from "three";
import { ThreeC } from "../ThreeC";
import { PlayerInput } from "./Input/PlayerInput";
import { MoveC } from "./Movment/MoveC";
import { Vec3 } from "cannon-es";
import { contain } from "three/src/extras/TextureUtils";
import { Vector3CToT, Vector3TToC } from "./Helper";
import { RotationC } from "./Movment/RotationC";
import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera";
import { Vector3 } from "three";
export class Player {
private static inited: boolean = false;
private static isRunning: boolean = false;
private static isAttacking: boolean = false;
private static isAutoAttacking: boolean = false;
private static isTurningToTarget: boolean = false;
private static isBatEquipped: boolean = false;
private static attackPhase: "none" | "attacking" = "none";
private static updateDelegate: Delegate<number>;
private static onAttackComplete: (() => void) | null = null;
private static onTurnComplete: (() => void) | null = null;
private static turnTargetQ = new Quaternion();
private static container: Object3D = new Object3D;
private static input: PlayerInput;
@@ -28,6 +33,9 @@ export class Player {
private static rotation: RotationC;
private static spawnPosition: Vector3 = new Vector3(0, 0, 0);
private static readonly turnSpeed = 8;
private static readonly turnCompleteAngle = 0.05;
static character: Character;
static physics: PhysicsBody;
@@ -37,21 +45,24 @@ export class Player {
static Init() {
if (this.inited) return;
this.inited = true
const asset = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, MeshType.Character)
this.inited = true;
const asset = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, MeshType.Character);
this.character = new Character(asset);
this.container.position.copy(this.spawnPosition);
this.character.playAnimation(BaseAnimation.Idle)
this.character.setDefaultWeapons();
this.character.playAnimation(BaseAnimation.Idle);
this.container.add(this.character.tObj);
ThreeC.addToScene(this.container);
this.InitPhisic();
const input = new PlayerInput();
this.input = new PlayerInput();
const moveSpeed = 3;
const rotationSpeed = 8;
this.movement = new MoveC(input, moveSpeed);
this.rotation = new RotationC(this.container, input, rotationSpeed);
this.movement = new MoveC(this.input, moveSpeed);
this.rotation = new RotationC(this.container, this.input, rotationSpeed);
this.character.onAnimFinish.addDelegate(() => this.OnCombatAnimationFinished());
this.updateDelegate = new Delegate<number>((delta) => this.Update(delta));
UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
@@ -59,23 +70,136 @@ export class Player {
FollowCameraC.Init(this.container);
}
static getWorldPosition() {
return this.container.getWorldPosition(new Vector3());
}
static isMoving() {
return this.input.IsActive;
}
static startAutoAttack(onHit: () => void, targetWorldPosition: Vector3) {
if (this.isAutoAttacking) return;
this.isAutoAttacking = true;
this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onHit));
}
static stopAutoAttack() {
this.isAutoAttacking = false;
this.onAttackComplete = null;
this.cancelTurnToTarget();
this.resetCombatState();
if (!this.isAttacking) {
this.StopRunning();
}
}
static playAttack(onComplete: () => void) {
this.onAttackComplete = onComplete;
this.isAttacking = true;
this.isRunning = false;
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
if (!this.isBatEquipped) {
this.equipBat();
}
this.playBatAttack();
}
private static InitPhisic() {
this.physics = new PhysicsBody(
this.container,
false,
1,
PhysicsLayer.Player,
PhysicsLayer.Wall,
PhysicsLayer.Wall | PhysicsLayer.Trigger,
);
}
private static beginTurnToTarget(targetWorldPosition: Vector3, onComplete: () => void) {
const worldPosition = this.container.getWorldPosition(new Vector3());
const lookAtPoint = targetWorldPosition.clone();
lookAtPoint.y = worldPosition.y;
const turnHelper = new Object3D();
turnHelper.position.copy(worldPosition);
turnHelper.lookAt(lookAtPoint);
this.turnTargetQ.copy(turnHelper.quaternion);
this.onTurnComplete = onComplete;
this.isTurningToTarget = true;
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
}
private static cancelTurnToTarget() {
this.isTurningToTarget = false;
this.onTurnComplete = null;
}
private static updateTurnToTarget(delta: number) {
this.container.quaternion.slerp(this.turnTargetQ, delta * this.turnSpeed);
if (this.container.quaternion.angleTo(this.turnTargetQ) > this.turnCompleteAngle) {
return;
}
this.container.quaternion.copy(this.turnTargetQ);
this.isTurningToTarget = false;
const callback = this.onTurnComplete;
this.onTurnComplete = null;
callback?.();
}
private static playBatAttack() {
this.attackPhase = "attacking";
this.character.playAnimation(this.getBatAttackAnimation(), true);
}
private static getBatAttackAnimation() {
if (this.character.animationList.length > BaseAnimation.Loot) {
return BaseAnimation.Loot;
}
return BaseAnimation.Idle;
}
private static equipBat() {
this.isBatEquipped = true;
this.character.setBatEquipped(true);
}
private static resetCombatState() {
this.attackPhase = "none";
this.isAttacking = false;
this.isBatEquipped = false;
this.character.setDefaultWeapons();
this.character.playAnimation(BaseAnimation.Idle);
}
private static OnCombatAnimationFinished() {
if (this.attackPhase !== "attacking") return;
this.attackPhase = "none";
this.isAttacking = false;
const callback = this.onAttackComplete;
this.onAttackComplete = null;
callback?.();
}
private static StartRunning() {
if (this.isRunning) return;
if (this.isRunning || this.isAttacking || this.isTurningToTarget) return;
this.character.playAnimation(BaseAnimation.Run);
this.isRunning = true;
}
private static StopRunning() {
if (!this.isRunning) return;
if (!this.isRunning || this.isAttacking || this.isTurningToTarget) return;
this.character.playAnimation(BaseAnimation.Idle);
this.AnimationValue = 1;
this.isRunning = false;
@@ -87,6 +211,23 @@ export class Player {
}
private static Update(delta: number) {
if (this.input.IsActive && (this.isAutoAttacking || this.isTurningToTarget)) {
this.stopAutoAttack();
}
if (this.isTurningToTarget) {
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
this.updateTurnToTarget(delta);
this.MoveVisual(delta);
return;
}
if (this.isAttacking) {
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
this.MoveVisual(delta);
return;
}
const diraction = this.movement.Diraction;
const weight = this.movement.Weight;
@@ -98,20 +239,17 @@ export class Player {
this.StopRunning();
}
const cPos = Vector3TToC(diraction);
this.physics.getPhysicsBody().velocity.copy(cPos);
this.physics.getPhysicsBody().wakeUp();
this.MoveVisual(delta);
}
private static MoveVisual(delta: number) {
const lerpSpeed = 10;
const targetPos = (Vector3CToT(this.physics.getPhysicsBody().position));
const targetPos = Vector3CToT(this.physics.getPhysicsBody().position);
this.container.position.lerp(targetPos, delta * lerpSpeed)
this.container.position.lerp(targetPos, delta * lerpSpeed);
}
}
}
@@ -0,0 +1,65 @@
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(items: T[]) {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function splitRandom(total: number, parts: number) {
if (parts <= 0) return [];
if (parts === 1) return [total];
const result = new Array(parts).fill(1);
let remaining = total - parts;
while (remaining > 0) {
result[Math.floor(Math.random() * parts)]++;
remaining--;
}
return result;
}
import { ResourceType } from "./ResourceType";
export type PropDropPlan = {
resource: ResourceType;
totalDrop: number;
spawnedDrop: number;
hitAmounts: number[];
};
export function createDropPlan(
maxHealth: number,
rule: {
resource: ResourceType;
minTotal: number;
maxTotal: number;
minSpawnHits: number;
maxSpawnHits: number;
},
): PropDropPlan {
const totalDrop = randomInt(rule.minTotal, rule.maxTotal);
const spawnEvents = randomInt(rule.minSpawnHits, rule.maxSpawnHits);
const hitAmounts = new Array(maxHealth).fill(0);
const hitIndexes = shuffle([...Array(maxHealth).keys()]).slice(0, spawnEvents);
const amounts = splitRandom(totalDrop, spawnEvents);
hitIndexes.forEach((hitIndex, index) => {
hitAmounts[hitIndex] = amounts[index];
});
return {
resource: rule.resource,
totalDrop,
spawnedDrop: 0,
hitAmounts,
};
}
@@ -0,0 +1,31 @@
import { PropType } from "../Map/PropType";
import { ResourceType } from "./ResourceType";
export type DropRule = {
resource: ResourceType;
minTotal: number;
maxTotal: number;
minSpawnHits: number;
maxSpawnHits: number;
};
export const PROP_DROPS: Record<PropType, DropRule[]> = {
[PropType.Box]: [
{
resource: ResourceType.Wood,
minTotal: 1,
maxTotal: 3,
minSpawnHits: 1,
maxSpawnHits: 2,
},
],
};
export function resolvePropType(objectName: string): PropType {
const name = objectName.toLowerCase();
if (name.includes("box") || name.includes("crate") || name.includes("wood")) {
return PropType.Box;
}
return PropType.Box;
}
+121
View File
@@ -0,0 +1,121 @@
import { UpdateController } from "@24tools/playable_template";
import { Easing, Group, Tween } from "@tweenjs/tween.js";
import {
BoxGeometry,
Mesh,
MeshBasicMaterial,
Vector3,
} from "three";
import { CameraC } from "../CameraC";
import { ThreeC } from "../ThreeC";
import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceType } from "./ResourceType";
import { ResourceUIC } from "./ResourceUIC";
const POP_DURATION_MS = 220;
const FLY_DURATION_MS = 520;
const PICKUP_SIZE = 0.14;
const STAGGER_MS = 70;
const PLACEHOLDER_COLORS: Record<ResourceType, string> = {
[ResourceType.Wood]: "#6b4423",
};
export class ResourceFlyC {
private static inited = false;
private static tweenGroup = new Group();
static init() {
if (this.inited) return;
this.inited = true;
UpdateController.Instance.onUpdate.addDelegate(() => {
this.tweenGroup.update(performance.now());
});
}
static launch(origin: Vector3, type: ResourceType, index: number) {
const spawnPos = origin.clone().add(
new Vector3(
(Math.random() - 0.5) * 0.35,
0.25 + Math.random() * 0.2,
(Math.random() - 0.5) * 0.35,
),
);
window.setTimeout(() => {
this.startPickup(spawnPos, type);
}, index * STAGGER_MS);
}
private static startPickup(worldPos: Vector3, type: ResourceType) {
const mesh = this.createWorldMesh(type);
mesh.position.copy(worldPos);
ThreeC.addToScene(mesh);
const popTarget = worldPos.clone().add(new Vector3(0, 0.35, 0));
const popTween = new Tween(mesh.position)
.to({ x: popTarget.x, y: popTarget.y, z: popTarget.z }, POP_DURATION_MS)
.easing(Easing.Quadratic.Out)
.onComplete(() => {
ThreeC.removeFromScene(mesh);
mesh.geometry.dispose();
(mesh.material as MeshBasicMaterial).dispose();
this.flyToUI(popTarget, type);
});
this.tweenGroup.add(popTween);
popTween.start(performance.now());
}
private static flyToUI(fromWorldPos: Vector3, type: ResourceType) {
const start = this.worldToScreen(fromWorldPos);
const target = ResourceUIC.getIconCenter(type);
if (!target) return;
const element = document.createElement("div");
element.className = "resource-fly-pickup";
element.style.backgroundColor = PLACEHOLDER_COLORS[type];
element.style.left = `${start.x}px`;
element.style.top = `${start.y}px`;
document.body.appendChild(element);
const state = { x: start.x, y: start.y, scale: 1 };
const flyTween = new Tween(state)
.to({ x: target.x, y: target.y, scale: 0.35 }, FLY_DURATION_MS)
.easing(Easing.Quadratic.In)
.onUpdate(() => {
element.style.left = `${state.x}px`;
element.style.top = `${state.y}px`;
element.style.transform = `translate(-50%, -50%) scale(${state.scale})`;
})
.onComplete(() => {
element.remove();
ResourceInventoryC.add(type, 1);
ResourceUIC.refresh(type);
});
this.tweenGroup.add(flyTween);
flyTween.start(performance.now());
}
private static createWorldMesh(type: ResourceType) {
const color = PLACEHOLDER_COLORS[type];
return new Mesh(
new BoxGeometry(PICKUP_SIZE, PICKUP_SIZE, PICKUP_SIZE),
new MeshBasicMaterial({ color }),
);
}
private static worldToScreen(worldPos: Vector3) {
const camera = CameraC.camera;
const projected = worldPos.clone().project(camera);
return {
x: (projected.x * 0.5 + 0.5) * window.innerWidth,
y: (-projected.y * 0.5 + 0.5) * window.innerHeight,
};
}
}
@@ -0,0 +1,14 @@
import { ResourceType } from "./ResourceType";
export class ResourceInventoryC {
private static amounts = new Map<ResourceType, number>();
static get(type: ResourceType) {
return this.amounts.get(type) ?? 0;
}
static add(type: ResourceType, amount: number) {
if (amount <= 0) return;
this.amounts.set(type, this.get(type) + amount);
}
}
@@ -0,0 +1,16 @@
import { Vector3 } from "three";
import { PropC } from "../Map/PropC";
import { ResourceFlyC } from "./ResourceFlyC";
import { ResourceType } from "./ResourceType";
export class ResourceSpawnC {
static spawnFromProp(prop: PropC, type: ResourceType, count: number) {
if (count <= 0) return;
const origin = prop.object.getWorldPosition(new Vector3());
for (let i = 0; i < count; i++) {
ResourceFlyC.launch(origin, type, i);
}
}
}
@@ -0,0 +1,3 @@
export enum ResourceType {
Wood = "wood",
}
+70
View File
@@ -0,0 +1,70 @@
import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceType } from "./ResourceType";
type ResourceUIEntry = {
type: ResourceType;
icon: HTMLElement;
count: HTMLElement;
};
const PLACEHOLDER_COLORS: Record<ResourceType, string> = {
[ResourceType.Wood]: "#6b4423",
};
export class ResourceUIC {
private static entries = new Map<ResourceType, ResourceUIEntry>();
private static root: HTMLElement | null = null;
static init() {
const uiRoot = document.getElementById("ui");
if (!uiRoot) return;
this.root = document.createElement("div");
this.root.id = "resource-bar";
this.root.className = "resource-bar";
uiRoot.appendChild(this.root);
this.register(ResourceType.Wood);
}
static register(type: ResourceType) {
if (!this.root || this.entries.has(type)) return;
const counter = document.createElement("div");
counter.className = "resource-counter";
counter.dataset.resource = type;
const icon = document.createElement("div");
icon.className = "resource-icon";
icon.style.backgroundColor = PLACEHOLDER_COLORS[type];
const count = document.createElement("span");
count.className = "resource-count";
count.textContent = "0";
counter.appendChild(icon);
counter.appendChild(count);
this.root.appendChild(counter);
this.entries.set(type, { type, icon, count });
this.refresh(type);
}
static getIconCenter(type: ResourceType) {
const entry = this.entries.get(type);
if (!entry) return null;
const rect = entry.icon.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
static refresh(type: ResourceType) {
const entry = this.entries.get(type);
if (!entry) return;
entry.count.textContent = String(ResourceInventoryC.get(type));
}
}
+7 -37
View File
@@ -1,52 +1,22 @@
import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { ThreeC } from "./ThreeC";
import { InputC, JoystickC } from "@24tools/playable_template";
import { Vector3 } from "three";
import { Player } from "./Presets/Player";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { Map } from "./Map/Map";
import { GatherC } from "./Map/GatherC";
import { ResourceUIC } from "./Resources/ResourceUIC";
import { ResourceFlyC } from "./Resources/ResourceFlyC";
export class TestSceneC {
static init() {
// this.createPrimitive();
ResourceUIC.init();
ResourceFlyC.init();
Map.Init();
this.InitPlayer();
// example of using InputC events
// InputC.onTouchDown.addDelegate((event) => {
// console.log("onMouseDown", event);
// });
// if you have update in your controller
// UpdateController.Instance.onUpdate.addDelegate(() => {
// this.update();
// });
GatherC.init();
}
// private static createPrimitive() {
// const planeSize = new Vector3(10, 0.1, 10);
// const geometryPlane = new BoxGeometry(planeSize.x, planeSize.y, planeSize.z);
// const materialPlane = new MeshStandardMaterial({ color: 0xaaaaaa });
// const plane = new Mesh(geometryPlane, materialPlane);
// ThreeC.setShadowsStateForChildren(plane, false, true);
// plane.position.y = -planeSize.y / 2;
// new PhysicsBody(
// plane,
// false,
// 0,
// PhysicsLayer.Wall,
// PhysicsLayer.Player,
// );
// ThreeC.addToScene(plane);
// }
private static InitPlayer() {
const playerSpawnPoint = new Vector3(4, 0, 27);
Player.SetSpawnPosition(playerSpawnPoint);
Player.Init();
}
}
+45 -3
View File
@@ -1,4 +1,46 @@
#ui {
/* flex-basis: 60%; */
flex-grow: 1;
.resource-bar {
position: fixed;
top: 2vh;
left: 12vw;
display: flex;
flex-direction: column;
gap: 1vh;
pointer-events: none;
z-index: 10;
}
.resource-counter {
display: flex;
align-items: center;
gap: 1vw;
padding: 0.6vh 1.2vw;
background: rgba(0, 0, 0, 0.45);
border-radius: 0.8vh;
}
.resource-icon {
width: 4vh;
height: 4vh;
border-radius: 0.5vh;
border: 0.2vh solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.resource-count {
min-width: 2ch;
color: #ffffff;
font-size: 3vh;
font-weight: 700;
line-height: 1;
}
.resource-fly-pickup {
position: fixed;
width: 3vh;
height: 3vh;
border-radius: 0.4vh;
border: 0.15vh solid rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 20;
}
+1 -1
View File
@@ -16,7 +16,7 @@ window.setupConfig = async function (config) {
redirectOptions: {},
ticker: Template3d.ticker,
debug: {
physics: true,
physics: false,
// set true if you want to enable physics debugger
logger: false // set true if you want to enable logger
}