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:
+86
-52
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user