feat: Add VFX JSON files and loader implementation

- Created a new JSON file for VFX configuration (`test.json`) containing geometries, materials, textures, and particle emitters.
- Implemented a TypeScript module (`vfx_json.ts`) to define VFX resources and a loader function using QuarksLoader for parsing the JSON data.
- Included the new test VFX in the resource list for loading.
This commit is contained in:
24Play-Mykyta-Slobodianiuk
2026-06-05 18:03:54 +03:00
parent 6d4e4e2800
commit d6fc6be717
16 changed files with 555 additions and 173 deletions
+30 -52
View File
@@ -4,38 +4,25 @@ import { PlayerC } from "./PlayerC";
import { Crate, LootableC } from "./LootableC";
import { Trigger } from "./TriggerC";
// Health removed from a crate per strike (the bat touches it during a swing).
const ATTACK_DAMAGE = 10;
// Bat-tip → crate horizontal distance under which the bat is "touching" it.
const HIT_DIST = 0.8;
// The swing only reaches crates within this asymmetric arc of the facing dir
// (negative = player's left, positive = right): left cut at 90°, right 130°.
const ARC_LEFT = (90 * Math.PI) / 180;
const ARC_RIGHT = (130 * Math.PI) / 180;
const ATTACK_DAMAGE = 10; // damage per bat-tip touch
const CONTACT_DIST = 0.8; // bat tip → crate distance that counts as a touch
const _tmp = new Vector3();
const _center = new Vector3();
const _fwd = new Vector3();
const _pos = new Vector3();
const _tip = new Vector3();
/**
* "Stand near crates → auto-attack" loop.
*
* Each crate has a Trigger zone (= which crates are in reach). While the player
* is stopped, the Loot swing plays and the player faces the crates' centre.
* The Loot clip has TWO strikes (left swing, then right). Per STRIKE, every
* crate the bat tip actually reaches takes one hit (front arc only) — so one
* swing damages all the crates it sweeps over, and a crate caught by both
* swings takes two sequential hits, rather than being hit at random times.
* Auto-attack: while the player stands near crates, swing the bat and damage a
* crate when the bat tip touches it. Hits are gated by PlayerC's swing window
* (no damage during the wind-up) and limited to once per crate per swing.
*/
export class CombatC {
private static inRange = new Set<Crate>();
private static hitThisStrike = new Set<Crate>(); // crates already hit in the current strike
private static lastStrike = -1;
private static inRange = new Set<Crate>(); // crates currently in reach
private static hitThisSwing = new Set<Crate>(); // crates already hit this swing
private static lastSwingId = 0;
static init() {
// A proximity trigger around every crate. Its size = the attack reach.
// Proximity trigger around every crate → decides which crates are in reach.
for (const crate of LootableC.crates) {
crate.root.getWorldPosition(_tmp);
crate.trigger = new Trigger(
@@ -54,16 +41,16 @@ export class CombatC {
private static update() {
this.pruneBroken();
// Moving → stop at once.
// Moving → stop attacking immediately.
if (PlayerC.isMoving()) {
PlayerC.setAttacking(false);
this.resetCycle();
this.endSwing();
return;
}
// Nothing left in reach → let the current swing finish, then idle.
// No crates left → let the current swing finish, then idle.
if (this.inRange.size === 0) {
PlayerC.finishAttack();
this.resetCycle();
this.endSwing();
return;
}
@@ -76,51 +63,42 @@ export class CombatC {
_center.divideScalar(this.inRange.size);
PlayerC.setAttacking(true, _center);
this.applyBatContact();
this.applyContact();
}
// Per strike: damage every in-reach crate the bat tip reaches (front arc).
private static applyBatContact() {
// New strike → all crates can be hit once again.
const strike = PlayerC.getSwingCycle();
if (strike !== this.lastStrike) {
this.hitThisStrike.clear();
this.lastStrike = strike;
// Damage each touched crate once per swing. Nothing happens during the wind-up.
private static applyContact() {
const swingId = PlayerC.getActiveSwingId();
if (swingId === 0) return; // bat not live (wind-up / between swings)
// New swing → every crate can be hit again.
if (swingId !== this.lastSwingId) {
this.hitThisSwing.clear();
this.lastSwingId = swingId;
}
const tip = PlayerC.getBatTip(_tip);
if (!tip) return;
PlayerC.getForward(_fwd);
PlayerC.getPosition(_pos);
const rx = _fwd.z, rz = -_fwd.x; // player's right
for (const c of [...this.inRange]) {
if (c.broken || this.hitThisStrike.has(c)) continue;
if (c.broken || this.hitThisSwing.has(c)) continue;
c.root.getWorldPosition(_tmp);
// Front-arc gate (the bat can't reach behind the player).
const dx = _tmp.x - _pos.x, dz = _tmp.z - _pos.z;
const len = Math.hypot(dx, dz) || 1;
const angle = Math.atan2((rx * dx + rz * dz) / len, (_fwd.x * dx + _fwd.z * dz) / len);
if (angle < -ARC_LEFT || angle > ARC_RIGHT) continue;
// Bat tip actually reached this crate → hit it once this strike.
if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= HIT_DIST) {
this.hitThisStrike.add(c);
if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= CONTACT_DIST) {
this.hitThisSwing.add(c);
LootableC.damageCrate(c, ATTACK_DAMAGE);
}
}
this.pruneBroken();
}
private static resetCycle() {
this.hitThisStrike.clear();
this.lastStrike = -1;
private static endSwing() {
this.hitThisSwing.clear();
this.lastSwingId = 0;
}
private static pruneBroken() {
for (const c of this.inRange) {
if (c.broken) { this.inRange.delete(c); this.hitThisStrike.delete(c); }
if (c.broken) { this.inRange.delete(c); this.hitThisSwing.delete(c); }
}
}
}
+86 -52
View File
@@ -24,7 +24,7 @@ const LAND_SQUASH = 0.6; // squash on the final landing
const LAND_POP_MS = 160; // duration of the final "pop"
// Collect (#8): delay after landing before flying to the corner, UI icon size, etc.
const COLLECT_DELAY_MS = 100; // almost immediately after the bounces
const COLLECT_DELAY_MS = 40; // almost immediately after the bounces (flows into the collect)
const UI_ICON_SIZE = 28; // wood UI icon size (px) — smaller than loot on the ground, but not tiny
const UI_RIGHT = 16; // offset from the right edge (px)
const UI_TOP = 110; // offset from the top (px) — lower, like in the REF
@@ -37,10 +37,11 @@ const _topV = new Vector3();
export class LootC {
static pieces: Sprite[] = [];
static balance = 0; // collected wood the player can spend
private static texture: Texture | null = null;
private static uiIcon: HTMLImageElement | null = null;
// Our own tween group. In tween.js v25 `new Tween(obj)` does NOT join the
// default group automatically, so we keep and update our own (else tweens freeze).
private static countEl: HTMLElement | null = null; // the "×N" label next to the icon
// Our tween group, pumped each frame (tween.js v25 needs an explicit group).
private static tweens = new TWEEN.Group();
static init() {
@@ -58,10 +59,45 @@ export class LootC {
document.body.appendChild(icon);
this.uiIcon = icon;
// The numeric balance, just left of the icon.
const count = document.createElement("div");
count.id = "wood-count";
count.style.cssText =
`position:fixed; top:${UI_TOP}px; right:${UI_RIGHT + UI_ICON_SIZE + 6}px;` +
`height:${UI_ICON_SIZE}px; line-height:${UI_ICON_SIZE}px;` +
`font-family:sans-serif; font-weight:700; font-size:18px; color:#fff;` +
`text-shadow:0 1px 2px rgba(0,0,0,0.6); z-index:1001; pointer-events:none;` +
`transition:transform 0.12s ease-out;`;
document.body.appendChild(count);
this.countEl = count;
this.renderCount();
// ⚠️ Key: pump our group every frame, otherwise the tweens don't advance.
UpdateController.Instance.onUpdate.addDelegate(() => this.tweens.update());
}
/** Current spendable wood. */
static getBalance(): number {
return this.balance;
}
/** Spend up to `n` wood; returns how much was actually taken (clamped to balance). */
static spend(n: number): number {
const taken = Math.min(n, this.balance);
this.balance -= taken;
this.renderCount();
return taken;
}
/** Screen-pixel center of the UI wood icon (used as a fly source/target). */
static uiIconScreenCenter(): { x: number; y: number } {
return this.uiIconCenter();
}
private static renderCount() {
if (this.countEl) this.countEl.textContent = `×${this.balance}`;
}
/** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */
static spawn(origin: Vector3, count?: number) {
const n = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1)));
@@ -94,14 +130,11 @@ export class LootC {
return piece;
}
/**
* Scatter: a tall first arc (stretched along its motion), then a few decaying
* bounces off the ground, and a final "pop" (squash → springs back to normal).
*/
/** A piece flies in an arc, bounces a couple times, then pops on landing. */
private static animatePiece(piece: Sprite, from: Vector3, to: Vector3) {
const restY = to.y; // sprite center at rest (= groundY + PIECE_SIZE/2)
const restY = to.y; // sprite center at rest
// One "hop": parabola fx,fz→tx,tz peaking at peak; stretched by speed.
// One hop: an arc from (fx,fz) to (tx,tz), stretched while moving fast.
const hop = (fx: number, fz: number, tx: number, tz: number, peak: number, ms: number) =>
new TWEEN.Tween({ t: 0 }, this.tweens)
.to({ t: 1 }, ms)
@@ -121,8 +154,8 @@ export class LootC {
const totalDist = Math.hypot(dx, dz) || 1e-4;
const dirX = dx / totalDist, dirZ = dz / totalDist;
// Split the horizontal distance between the flight and the bounces (geometric
// decay) so the plank also moves forward on bounces, not just up; sum = totalDist.
// Share the horizontal distance across the flight + bounces (so it also
// moves forward on each bounce, not just up).
const hops = BOUNCES + 1;
const series = (1 - Math.pow(BOUNCE_FORWARD, hops)) / (1 - BOUNCE_FORWARD);
let step = totalDist / series;
@@ -140,7 +173,7 @@ export class LootC {
step *= BOUNCE_FORWARD; peak *= BOUNCE_HEIGHT; ms *= BOUNCE_TIME;
}
// 3) final impact: sharp squash (bottom on the ground) → springs back to normal
// Landing pop: squash on the ground, then spring back to normal.
const groundY = restY - PIECE_SIZE / 2;
const pop = new TWEEN.Tween({ k: 0 }, this.tweens)
.to({ k: 1 }, LAND_POP_MS)
@@ -151,8 +184,7 @@ export class LootC {
piece.position.y = groundY + (PIECE_SIZE * s) / 2; // bottom stays on the ground
})
.onComplete(() => {
// After resting briefly → flies into the UI icon.
setTimeout(() => this.collect(piece), COLLECT_DELAY_MS);
setTimeout(() => this.collect(piece), COLLECT_DELAY_MS); // then fly to the UI
});
prev!.chain(pop);
@@ -160,9 +192,8 @@ export class LootC {
}
/**
* Collect (#8): project the piece into screen pixels, swap the 3D sprite for an
* HTML image of the same size, shrink it to the UI icon size and fly it to the
* top-right corner — sizes match there, so the "arrival" is seamless.
* Collect: swap the 3D sprite for an HTML image at the same screen spot, then
* shrink it and fly it into the top-right UI counter.
*/
private static collect(piece: Sprite) {
if (!this.pieces.includes(piece)) return; // already collected/removed
@@ -171,12 +202,12 @@ export class LootC {
if (!cam || !canvas) return;
const rect = canvas.getBoundingClientRect();
// sprite center and top → screen pixels (for on-screen position and size)
// Sprite center + size in screen pixels.
const center = this.toScreen(piece.position, cam, rect);
_topV.copy(piece.position); _topV.y += piece.scale.y / 2;
const sizePx = Math.max(8, Math.abs(center.y - this.toScreen(_topV, cam, rect).y) * 2);
// drop the 3D sprite, replace it with an HTML image at the same point/size
// Drop the 3D sprite; the HTML image takes over from the same spot.
this.remove(piece);
const flier = document.createElement("img");
@@ -186,50 +217,53 @@ export class LootC {
`z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`;
document.body.appendChild(flier);
const st = { x: center.x, y: center.y, size: sizePx };
const apply = () => {
flier.style.left = `${st.x}px`;
flier.style.top = `${st.y}px`;
flier.style.width = `${st.size}px`;
flier.style.height = `${st.size}px`;
};
apply();
// White "glint" copy that rides on top of the flier and fades out as it moves.
const flash = document.createElement("img");
flash.src = woodIconUrl;
flash.style.cssText = flier.style.cssText;
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
flash.style.zIndex = "1002";
document.body.appendChild(flash);
const target = this.uiIconCenter();
// 1) shrink to UI size in place → 2) fly to the corner
const shrink = new TWEEN.Tween(st, this.tweens)
.to({ size: UI_ICON_SIZE }, SHRINK_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(apply);
// Shared state. The flight, shrink and blink below all run at once, so they
// blend into one smooth motion instead of separate steps.
const st = { x: center.x, y: center.y, size: sizePx, o: 0 };
const place = (el: HTMLElement) => {
el.style.left = `${st.x}px`;
el.style.top = `${st.y}px`;
el.style.width = `${st.size}px`;
el.style.height = `${st.size}px`;
};
const apply = () => { place(flier); place(flash); flash.style.opacity = `${st.o}`; };
apply();
// Flight (position) — the longest tween, so it owns the cleanup.
const fly = new TWEEN.Tween(st, this.tweens)
.to({ x: target.x, y: target.y }, FLY_MS)
.easing(TWEEN.Easing.Quadratic.In)
.onUpdate(apply)
.onComplete(() => { flier.remove(); this.pulseUiIcon(); });
shrink.chain(fly);
// Smooth white blink before the flight: a white copy of the plank fades in
// and out on top ("collected" feedback), then the shrink + flight.
const flash = document.createElement("img");
flash.src = woodIconUrl;
flash.style.cssText = flier.style.cssText; // same position/size
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
flash.style.opacity = "0";
flash.style.zIndex = "1002";
document.body.appendChild(flash);
const fl = { o: 0 };
const setO = () => { flash.style.opacity = `${fl.o}`; };
const flashIn = new TWEEN.Tween(fl, this.tweens)
.onComplete(() => { flier.remove(); flash.remove(); this.balance++; this.renderCount(); this.pulseUiIcon(); });
// Shrink (size) — runs alongside the flight, eases out so it shrinks early.
const shrink = new TWEEN.Tween(st, this.tweens)
.to({ size: UI_ICON_SIZE }, SHRINK_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(apply);
// Blink (opacity) — a quick glint that overlaps the start of the motion.
const flashIn = new TWEEN.Tween(st, this.tweens)
.to({ o: 1 }, BLINK_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(setO);
const flashOut = new TWEEN.Tween(fl, this.tweens)
.onUpdate(apply);
const flashOut = new TWEEN.Tween(st, this.tweens)
.to({ o: 0 }, BLINK_MS * 1.6)
.easing(TWEEN.Easing.Quadratic.In)
.onUpdate(setO)
.onComplete(() => { flash.remove(); shrink.start(); });
.onUpdate(apply);
flashIn.chain(flashOut);
// Kick them all off together → blended, fluid collect.
fly.start();
shrink.start();
flashIn.start();
}
+117 -18
View File
@@ -1,37 +1,47 @@
import { Object3D, Vector3 } from "three";
import { Mesh, Object3D, Vector3 } from "three";
import * as TWEEN from "@tweenjs/tween.js";
import { UpdateController } from "@24tools/playable_template";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { Trigger } from "./TriggerC";
import { LootC } from "./LootC";
import { VfxManager } from "../resources/vfx/VfxManager";
// A full crate's health. There are 3 damage levels (S1/S2/S3) splitting it
// evenly: S1 = 10067%, S2 = 6634%, S3 = 331%, broken at 0. A crate that
// ships already damaged (no S1 node, etc.) starts at the matching lower health.
// Crate health, split evenly across 3 damage states (S1/S2/S3), broken at 0.
const CRATE_MAX_HEALTH = 100;
const LEVELS = 3;
// --- Hit/break animation tunables ---
const HIT_PUNCH = 0.30; // how hard a hit squashes the crate
const HIT_PUNCH_MS = 280; // squash → spring-back time
const FLASH_MS = 200; // white blink time
const BREAK_MS = 300; // shrink-to-nothing time on break
const BREAK_SPIN = Math.PI * 0.66; // how far it spins while vanishing
const VFX_CENTER_Y = 0.5; // VFX height (crate middle, not its base)
/** One breakable crate. Damage states are indexed by level: 0=S1, 1=S2, 2=S3. */
export interface Crate {
root: Object3D;
statesByLevel: (Object3D | null)[]; // length 3; null where that state isn't authored
startLevel: number; // lowest authored state = how damaged it starts
statesByLevel: (Object3D | null)[]; // the 3 damage meshes; null where not authored
startLevel: number; // how damaged it starts
level: number; // currently shown level
collider: PhysicsBody;
trigger: Trigger | null;
health: number;
maxHealth: number;
broken: boolean;
baseScale: Vector3; // resting scale (tweens multiply this)
baseRotY: number; // resting Y rotation
hitTween: TWEEN.Tween<{ k: number }> | null; // live hit-punch tween (killed before re-firing)
}
/**
* Sets up the crates baked into the map's "Lootable" group.
*
* Each Wooden_Box ships its damage states (S1/S2/S3) all visible at once, but
* NOT every crate has all three — some start at S2 or S3, i.e. pre-damaged. We
* read the lowest authored state, show only it, set health to the matching
* percentage, and turn the collider proxy into a STATIC cannon box.
* Builds the breakable crates from the map's "Lootable" group: shows one damage
* state per crate, sets its health, and gives it a static collider.
*/
export class LootableC {
static crates: Crate[] = [];
private static tweens = new TWEEN.Group(); // our tween group, pumped each frame
private static flashedStates = new WeakSet<Object3D>(); // states whose materials we've cloned for flashing
static init(lootableGroup: Object3D | null) {
if (!lootableGroup) {
@@ -39,6 +49,9 @@ export class LootableC {
return;
}
// Drive our juice tweens (hit punch, flash, break) every frame.
UpdateController.Instance.onUpdate.addDelegate(() => this.tweens.update());
lootableGroup.visible = true;
lootableGroup.updateWorldMatrix(true, true); // collider world positions must be current
@@ -80,6 +93,7 @@ export class LootableC {
this.crates.push({
root: crate, statesByLevel, startLevel, level: startLevel,
collider, trigger: null, health, maxHealth: CRATE_MAX_HEALTH, broken: false,
baseScale: crate.scale.clone(), baseRotY: crate.rotation.y, hitTween: null,
});
}
@@ -96,6 +110,10 @@ export class LootableC {
return;
}
// Per-hit juice: a quick squash-punch + a white blink.
this.punchCrate(crate);
this.flashCrate(crate);
// Map remaining health to a level, never below where this crate started.
let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS);
level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level));
@@ -107,20 +125,101 @@ export class LootableC {
}
}
/** Crate destroyed: hide it and remove its physics + trigger from the world. */
/** Crate destroyed: drop loot, then shrink-and-spin away. */
static breakCrate(crate: Crate) {
if (crate.broken) return;
crate.broken = true;
crate.statesByLevel.forEach(s => { if (s) s.visible = false; });
// Stop the hit-punch so it doesn't fight the break animation.
crate.hitTween?.stop();
crate.hitTween = null;
// Remove physics + trigger now so the player walks through right away.
crate.collider.destroy();
crate.trigger?.destroy();
crate.trigger = null;
// TODO #7/#8: spawn wood loot at crate.root world position, scatter with a
// bounce, then tween it into the resource counter.
console.log("[Lootable] crate broken");
// Drop loot at the crate's spot.
LootC.spawn(crate.root.getWorldPosition(new Vector3()));
// Shrink to nothing while spinning, then hide the meshes.
const s = { k: 0 };
new TWEEN.Tween(s, this.tweens)
.to({ k: 1 }, BREAK_MS)
.easing(TWEEN.Easing.Back.In)
.onUpdate(({ k }) => {
const sc = Math.max(0, 1 - k);
crate.root.scale.set(crate.baseScale.x * sc, crate.baseScale.y * sc, crate.baseScale.z * sc);
crate.root.rotation.y = crate.baseRotY + k * BREAK_SPIN;
})
.onComplete(() => {
crate.statesByLevel.forEach(st => { if (st) st.visible = false; });
})
.start();
const breakFxPos = crate.root.getWorldPosition(new Vector3());
breakFxPos.y += VFX_CENTER_Y;
VfxManager.Play("DestroyEffect", null, breakFxPos);
}
// Squash the crate, then spring back (with a small overshoot pop).
private static punchCrate(crate: Crate) {
crate.hitTween?.stop(); // drop the previous punch so rapid hits don't stack
const s = { k: 0 };
crate.hitTween = new TWEEN.Tween(s, this.tweens)
.to({ k: 1 }, HIT_PUNCH_MS)
.easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => {
const q = (1 - k) * HIT_PUNCH; // HIT_PUNCH (squashed) → 0 (rest)
crate.root.scale.set(
crate.baseScale.x * (1 + q),
crate.baseScale.y * (1 - q),
crate.baseScale.z * (1 + q),
);
})
.onComplete(() => {
crate.root.scale.copy(crate.baseScale); // snap back exactly
crate.hitTween = null;
})
.start();
// Hit spark at the crate's middle (world position, no parent).
const hitPos = crate.root.getWorldPosition(new Vector3());
hitPos.y += VFX_CENTER_Y;
VfxManager.Play("HitEffect", null, hitPos);
}
// Flash the crate white, then fade out. Materials are cloned per state so the
// flash doesn't bleed onto other crates sharing the same material.
private static flashCrate(crate: Crate) {
const state = crate.statesByLevel[crate.level];
if (!state) return;
if (!this.flashedStates.has(state)) {
state.traverse(o => {
const mesh = o as Mesh;
if (!mesh.isMesh) return;
mesh.material = Array.isArray(mesh.material)
? mesh.material.map(m => m.clone())
: (mesh.material as any).clone();
});
this.flashedStates.add(state);
}
const mats: any[] = [];
state.traverse(o => {
const mesh = o as Mesh;
if (!mesh.isMesh) return;
(Array.isArray(mesh.material) ? mesh.material : [mesh.material]).forEach(m => mats.push(m));
});
mats.forEach(m => { if (m.emissive) m.emissive.setRGB(1, 1, 1); });
const s = { k: 1 };
new TWEEN.Tween(s, this.tweens)
.to({ k: 0 }, FLASH_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ k }) => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = k; }); })
.onComplete(() => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = 0; }); })
.start();
}
}
+201
View File
@@ -0,0 +1,201 @@
import { Object3D, Raycaster, Vector3 } from "three";
import * as TWEEN from "@tweenjs/tween.js";
import { UpdateController, CameraC_internal } from "@24tools/playable_template";
import { Trigger } from "./TriggerC";
import { TestSceneC } from "./TestSceneC";
import { LootC } from "./LootC";
import { woodIconUrl } from "../resources/images/woodIcon";
// --- Tunables ---
const COST = 15; // wood needed to fully pay the zone
const ZONE_FALLBACK = new Vector3(0, 0, 0); // zone spot if UI_Interactive_Zone_02 is missing
const ZONE_OFFSET = new Vector3(0, 0, -4); // shift the zone toward the player (off the crates)
const ZONE_SCALE = 1.6; // scale the pad up for presence
const ICON_LIFT = 0.04; // wood icon height above the pad (local)
const TRIGGER_HALF = 1.0; // enter-trigger half size (x/z)
const PAY_INTERVAL = 0.09; // seconds between flying planks
const FLY_MS = 420; // UI → zone flight time
const FLY_ARC_PX = 90; // height of the flight arc (px)
const PLANK_PX = 26; // flying plank size (px)
const FILL_GROW = 0.15; // how much the pad grows when full
const PULSE_MS = 160; // per-plank pulse time
const _v = new Vector3();
const _ndc = new Vector3();
/**
* Pay zone: the player stands on the UI_Tool_Zone pad and the wood collected in
* the top-right counter flies into it, plank by plank. When COST is reached the
* pad pops and disappears.
*/
export class PayZoneC {
private static zone: Object3D | null = null;
private static trigger: Trigger | null = null;
private static tweens = new TWEEN.Group(); // our tween group, pumped each frame
private static inside = false; // is the player standing on the pad
private static done = false; // already paid in full
private static paid = 0; // wood delivered so far
private static payCd = 0; // cooldown until the next plank
private static baseScale = new Vector3(1, 1, 1); // pad resting scale (tweens multiply it)
private static pulseTween: TWEEN.Tween<{ k: number }> | null = null;
static init() {
const zone = TestSceneC.payZone;
if (!zone) { console.warn("[PayZone] UI_Tool_Zone not found"); return; }
this.zone = zone;
// Where to put the pad: UI_Interactive_Zone_02's spot, shifted toward the
// player, sitting on the real surface (raycast — the road is above the sand).
const target = _v.copy(ZONE_FALLBACK);
if (TestSceneC.interactiveZone) TestSceneC.interactiveZone.getWorldPosition(target);
target.x += ZONE_OFFSET.x;
target.z += ZONE_OFFSET.z;
target.y = this.sampleSurfaceY(target.x, target.z) + 0.02;
// Make the wood icon a child of the pad so it moves/vanishes with it.
const icon = TestSceneC.payZoneIcon;
if (icon) {
zone.attach(icon);
icon.position.set(0, ICON_LIFT, 0);
icon.visible = true;
}
// Place, scale, remember the resting scale, show.
zone.position.copy(target);
zone.scale.multiplyScalar(ZONE_SCALE);
this.baseScale.copy(zone.scale);
zone.visible = true;
// Trigger that tells us when the player is on the pad.
this.trigger = new Trigger(
{ x: target.x, y: target.y + 0.5, z: target.z },
{ x: TRIGGER_HALF, y: 1.0, z: TRIGGER_HALF },
{ onEnter: () => { this.inside = true; }, onExit: () => { this.inside = false; } },
);
UpdateController.Instance.onUpdate.addDelegate((d) => this.update(d));
}
private static update(delta: number) {
this.tweens.update();
if (this.done || !this.inside) return;
// One plank at a time: wait out the cooldown, stop if paid or out of wood.
this.payCd -= delta;
if (this.payCd > 0) return;
if (this.paid >= COST || LootC.getBalance() <= 0) return;
// Spend one wood and fly it into the zone.
LootC.spend(1);
this.paid++;
this.payCd = PAY_INTERVAL;
this.flyOnePlank();
if (this.paid >= COST) this.complete();
}
// Fly one HTML plank from the UI counter to the zone along an arc.
private static flyOnePlank() {
const src = LootC.uiIconScreenCenter();
const tgt = this.zoneScreenCenter();
if (!tgt) return;
const img = document.createElement("img");
img.src = woodIconUrl;
img.style.cssText =
`position:fixed; left:0; top:0; width:${PLANK_PX}px; height:${PLANK_PX}px;` +
`z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top;`;
document.body.appendChild(img);
const st = { t: 0 };
new TWEEN.Tween(st, this.tweens)
.to({ t: 1 }, FLY_MS)
.easing(TWEEN.Easing.Quadratic.In)
.onUpdate(({ t }) => {
const x = src.x + (tgt.x - src.x) * t;
const y = src.y + (tgt.y - src.y) * t - Math.sin(Math.PI * t) * FLY_ARC_PX; // arc
img.style.left = `${x}px`;
img.style.top = `${y}px`;
})
.onComplete(() => { img.remove(); this.onPlankArrived(); })
.start();
}
// A plank reached the zone → pulse the pad (bigger the fuller it is).
private static onPlankArrived() {
const zone = this.zone;
if (!zone || this.done) return;
const fill = FILL_GROW * (this.paid / COST);
this.pulseTween?.stop(); // drop the previous pulse so they don't fight
const s = { k: 0 };
this.pulseTween = new TWEEN.Tween(s, this.tweens)
.to({ k: 1 }, PULSE_MS)
.easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => {
const g = 1 + fill + (1 - k) * 0.12; // settle at fill size with a small punch
zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g);
})
.onComplete(() => { this.pulseTween = null; })
.start();
}
// Paid in full: grow, then shrink to nothing.
private static complete() {
this.done = true;
this.trigger?.destroy();
this.trigger = null;
this.pulseTween?.stop();
this.pulseTween = null;
const zone = this.zone;
if (!zone) return;
const setScale = (g: number) => zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g);
const grow = new TWEEN.Tween({ k: 0 }, this.tweens)
.to({ k: 1 }, 180)
.easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => setScale(1 + 0.45 * k));
const vanish = new TWEEN.Tween({ k: 0 }, this.tweens)
.to({ k: 1 }, 260)
.easing(TWEEN.Easing.Back.In)
.onUpdate(({ k }) => setScale(1.45 * Math.max(0, 1 - k)))
.onComplete(() => {
zone.visible = false;
if (TestSceneC.payZoneIcon) TestSceneC.payZoneIcon.visible = false; // hide the icon too
});
grow.chain(vanish);
grow.start();
console.log("[PayZone] paid in full");
}
// Ground height (road/sand) at an x/z via a downward ray.
private static sampleSurfaceY(x: number, z: number): number {
const env = TestSceneC.environment;
if (env) {
const ray = new Raycaster(new Vector3(x, 5, z), new Vector3(0, -1, 0));
const hits = ray.intersectObject(env, true);
if (hits.length) return hits[0].point.y;
}
return TestSceneC.groundY;
}
// Zone world center → screen pixels (target for the flying planks).
private static zoneScreenCenter(): { x: number; y: number } | null {
const cam = CameraC_internal.camera;
const canvas = document.querySelector("canvas");
const zone = this.zone;
if (!cam || !canvas || !zone) return null;
const rect = canvas.getBoundingClientRect();
_v.copy(zone.position); _v.y += 0.3;
_ndc.copy(_v).project(cam);
return {
x: rect.left + (_ndc.x * 0.5 + 0.5) * rect.width,
y: rect.top + (-_ndc.y * 0.5 + 0.5) * rect.height,
};
}
}
+32 -31
View File
@@ -13,15 +13,18 @@ enum MoveState { Idle = "idle", Walk = "walk", Run = "run" }
const CHAR_RADIUS = 0.35;
// Extra time the bat keeps swinging after the hit that breaks the last crate,
// so the strike completes visually before returning to idle.
// How long the bat keeps swinging after the killing hit, before going idle.
const ATTACK_FOLLOW_THROUGH = 0.25;
// How fast the feet ease toward the sampled ground height (per second).
// Higher = snappier / hugs the surface tighter; lower = smoother but lags more
// on slopes and curbs. This is what kills the jolt when crossing a curb.
// How fast the feet ease toward the ground height (per second). Higher = snappier.
const GROUND_SMOOTH = 12;
// The two moments in the Loot clip where each swing connects (0..1 of the clip).
const IMPACT_PHASES = [0.38, 0.68];
// The bat can hit only within ±this of an impact phase — outside it (wind-up /
// follow-through) it deals no damage. Bigger = longer hit window.
const CONTACT_WINDOW = 0.14;
const _inputTarget = new Vector3();
export class PlayerC {
@@ -44,21 +47,17 @@ export class PlayerC {
private static hasAttackTarget = false;
private static attackAction: AnimationAction | null = null; // the "Loot" clip used as the swing
// Cached local ends of the bat mesh (computed once) → its swinging tip in
// world space. CombatC reads getBatTip() to damage a crate only when the bat
// actually reaches it (geometric contact, no animation-time markers).
// Swing hit-window tracking. _activeWindow = which IMPACT_PHASES window we're in
// (-1 = none). _swingId bumps on entering a window, so CombatC knows it's a new swing.
private static _activeWindow = -1;
private static _swingId = 0;
// Cached bat-mesh ends → its swinging tip in world space (used by getBatTip).
private static _batEndA: Vector3 | null = null;
private static _batEndB: Vector3 | null = null;
private static _tipA = new Vector3();
private static _tipB = new Vector3();
// Swing counter: the Loot clip contains TWO strikes (a left swing then a
// right swing), so this bumps TWICE per loop — at the half-way point and at
// the wrap. CombatC uses it to damage every touched crate once per strike,
// so a crate the bat sweeps over in both swings takes two sequential hits.
private static swingCycle = 0;
private static _prevStrike = 0;
// When the killing blow lands we don't cut the swing at the impact frame —
// we let the bat follow through for this long, then return to idle.
private static finishing = false;
@@ -117,8 +116,8 @@ export class PlayerC {
this.currentAction?.fadeOut(0.15);
this.attackAction.reset().fadeIn(0.15).play();
this.currentAction = this.attackAction;
this._prevStrike = 0;
this.swingCycle++; // new attack = fresh strike
this._activeWindow = -1; // not in a live window yet this attack
this._swingId = 0;
} else if (!active) {
this.hasAttackTarget = false;
this.attackAction?.fadeOut(0.15);
@@ -293,15 +292,19 @@ export class PlayerC {
// locomotion state machine below is suspended so it can't override it.
if (this.attacking) {
if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta);
// Split the Loot clip into its two strikes (first half = left swing,
// second half = right swing). Bump the counter on each → two hits/loop.
// Which hit-window the Loot clip is in now (-1 = none → bat can't hit).
const a = this.attackAction;
if (a) {
const dur = a.getClip().duration;
const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the swing
const strike = phase < 0.5 ? 0 : 1;
if (strike !== this._prevStrike) this.swingCycle++;
this._prevStrike = strike;
const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the clip
let wi = -1;
for (let i = 0; i < IMPACT_PHASES.length; i++) {
if (Math.abs(phase - IMPACT_PHASES[i]) <= CONTACT_WINDOW) { wi = i; break; }
}
if (wi !== this._activeWindow) {
if (wi !== -1) this._swingId++; // entered a new window → new swing
this._activeWindow = wi;
}
}
if (this.finishing) {
this.finishTimer -= delta;
@@ -331,9 +334,12 @@ export class PlayerC {
}
}
// World position of the bat's swinging tip (the end farther from the body),
// or null when the bat isn't in hand. CombatC uses this to damage a crate the
// instant the bat actually reaches it.
/** Current swing id while the bat is in a hit window, or 0 when it can't hit. */
static getActiveSwingId(): number {
return this._activeWindow === -1 ? 0 : this._swingId;
}
/** World position of the bat tip, or null if the bat isn't in hand. */
static getBatTip(out: Vector3): Vector3 | null {
const bat = this.batInHand as Mesh | null;
if (!bat || !bat.visible || !bat.geometry) return null;
@@ -356,11 +362,6 @@ export class PlayerC {
return out.copy(farther);
}
/** Index of the current strike (bumps twice per Loot loop: left then right swing). */
static getSwingCycle(): number {
return this.swingCycle;
}
// Smoothly rotate the mesh's Y so it faces the given world x/z point.
private static faceTowards(x: number, z: number, delta: number) {
const dx = x - this.mesh.position.x;
+14 -2
View File
@@ -13,6 +13,9 @@ export class TestSceneC {
static environment: Object3D | null = null; // Ground_ — visual ground/road/borders, the only thing rendered
static colliderGroup: Object3D | null = null; // Colliders — invisible BoxCollider proxies → cannon bodies
static lootableGroup: Object3D | null = null; // Lootable — interactive crates, hidden until activated per-crate
static payZone: Object3D | null = null; // UI_Tool_Zone — the pay-zone pad
static payZoneIcon: Object3D | null = null; // UI_Wood — wood icon shown on the pad
static interactiveZone: Object3D | null = null; // UI_Interactive_Zone_02 — marks where the pad goes
// World Y of the walkable sand surface (the floor proxy is aligned to it).
static groundY = 0;
@@ -38,8 +41,10 @@ export class TestSceneC {
this.environment = this.mapObject.getObjectByName("Ground_") ?? null;
this.colliderGroup = this.mapObject.getObjectByName("Colliders") ?? null;
this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null;
this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null;
this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null;
this.interactiveZone = this.mapObject.getObjectByName("UI_Interactive_Zone_02") ?? null;
const uiGroup = this.mapObject.getObjectByName("UI") ?? null;
const uiWood = this.mapObject.getObjectByName("UI_Wood") ?? null;
// Add the whole graph so every world transform stays intact (the collider
// proxies' world positions depend on the full parent chain), then hide
@@ -56,7 +61,14 @@ export class TestSceneC {
if (this.colliderGroup) this.colliderGroup.visible = false; // physics-only, never rendered
// Lootable crates are set up by LootableC (one state shown + colliders).
if (uiGroup) uiGroup.visible = false; // playable UI is HTML/CSS, not in-world
if (uiWood) uiWood.visible = false;
if (this.interactiveZone) this.interactiveZone.visible = false; // only its position is used
// payZone + its icon are placed and shown by PayZoneC.
if (this.payZone) this.payZone.visible = false;
if (this.payZoneIcon) this.payZoneIcon.visible = false;
// Hide the unused duplicate wood icon (three.js drops the dot → "UI_Wood001").
const strayWoodIcon = this.mapObject.getObjectByName("UI_Wood001")
?? this.mapObject.getObjectByName("UI_Wood.001");
if (strayWoodIcon) strayWoodIcon.visible = false;
// Only the environment is rendered with shadows.
if (this.environment) {
View File
+4
View File
@@ -0,0 +1,4 @@
export enum VFXType {
HitEffect,
DestroyEffect
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
// URL дерев'яної іконки лута. У релізі інлайниться в base64 (як меші/звуки).
// Шлях відносно цього файлу.
// URL of the wood loot icon. In a release build it's inlined as base64 (like
// meshes/sounds). Path is relative to this file.
export const woodIconUrl = ConvertToBase64WhenRelease("./Icon_Wood.webp");
+2 -1
View File
@@ -1,5 +1,6 @@
import { ConvertResourcesType } from "@24tools/playable_template";
import { meshes } from "./meshes/meshes";
import { sounds } from "./sounds/sounds";
import { vfx_json } from "./vfx/vfx_json";
export const resources: ConvertResourcesType = [meshes, sounds];
export const resources: ConvertResourcesType = [meshes, sounds, vfx_json];
+19 -14
View File
@@ -1,17 +1,16 @@
import { BatchedRenderer, QuarksLoader, QuarksUtil } from "three.quarks";
import { Object3D, Euler, Vector3 } from "three";
import { ResourcesC, UpdateController } from "@24tools/playable_template";
import { ThreeC } from "../../ThreeC";
import { TimeC } from "../Timers/TimeC";
import { VFXType } from "../Enums/VFXType";
import { ResourcesType } from "../Enums/ResourcesType";
import { vfxTest } from "./RunTimeTest";
import { ThreeC } from "../../controllers/ThreeC";
// Resource "type" under which Quark VFX JSONs are registered (see resources.ts).
const VFX_RESOURCE_TYPE = "vfx_json";
export class VfxManager {
static batchRenderer: BatchedRenderer;
static loader: QuarksLoader;
static Init() {
static init() {
this.batchRenderer = new BatchedRenderer();
this.loader = new QuarksLoader();
ThreeC.addToScene(this.batchRenderer);
@@ -26,24 +25,30 @@ export class VfxManager {
}
static update(delta: number) {
delta *= TimeC.TimeScale;
this.batchRenderer.update(delta);
}
static Play(type: VFXType|string, parent: Object3D | null = null, position: Vector3 | null = null, rotation: Euler | null = null, scale: Vector3 | null = null, odred: number | null = null) {
let loaded = (ResourcesC.getResource(ResourcesType.VFX, type.toString()) as { obj: any }).obj;
if (!loaded) return new Object3D();
// console.error("Type non loaded " + loaded);
const effect = loaded.clone(true) as Object3D;
static Play(
name: string,
parent: Object3D | null = null,
position: Vector3 | null = null,
rotation: Euler | null = null,
scale: Vector3 | null = null,
renderOrder: number | null = null,
): Object3D {
const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>(VFX_RESOURCE_TYPE, name);
if (!resource?.obj) return new Object3D();
const effect = resource.obj.clone(true);
QuarksUtil.setAutoDestroy(effect, true);
QuarksUtil.addToBatchRenderer(effect, this.batchRenderer);
if (parent) parent.add(effect)
if (parent) parent.add(effect);
else ThreeC.addToScene(effect);
if (position) effect.position.copy(position);
if (rotation) effect.rotation.copy(rotation);
if (scale) effect.scale.copy(scale);
if (odred) effect.renderOrder = odred;
if (renderOrder != null) effect.renderOrder = renderOrder;
return effect;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
import { ConvertResourceType, Template3d } from "@24tools/playable_template";
import { QuarksLoader } from "three.quarks";
export const vfx_json: ConvertResourceType = {
type: "vfx_json",
resources: [
{
name: "HitEffect",
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Hit.json"),
},
{
name: "DestroyEffect",
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Destroy.json"),
},
{
name: "Test",
value: ConvertToBase64WhenRelease("resources/vfx/files/test.json"),
},
],
loader: quarksLoader,
}
export function quarksLoader(base64String: string) {
return new Promise((resolve, reject) => {
try {
new QuarksLoader(Template3d.manager).parse(
JSON.parse(atob(base64String.split(",")[1])),
(obj) => {
resolve({ obj });
}
);
} catch (error) {
reject("Error loading vfx: " + error);
}
});
}
@@ -6,6 +6,8 @@ import { TriggerC } from "../controllers/TriggerC";
import { CombatC } from "../controllers/CombatC";
import { JoystickC, SoundC, Template } from "@24tools/playable_template";
import { LootC } from "../controllers/LootC";
import { PayZoneC } from "../controllers/PayZoneC";
import { VfxManager } from "../resources/vfx/VfxManager";
export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
TestSceneC.init();
@@ -49,6 +51,12 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
// Loot: spawn loot pieces when a crate breaks.
LootC.init();
// Pay zone: walk onto UI_Tool_Zone → spend collected wood into it, then it vanishes.
PayZoneC.init();
// VFX: set up the quark particle renderer.
VfxManager.init();
// if (import.meta.env.DEV) {
// const { CameraDebugUI } = await import("../controllers/CameraDebugUI");
// CameraDebugUI.init();