This POC demonstrates risk-area harvesting: the wreck contains value, but extracting it creates exposure and builds attention that will eventually force you off the site. The point is not “press Space to loot.” The point is positioning + aiming + time-under-risk.
What the player does
1) Move into position
- Use WASD / Arrow keys to reposition around the wreck.
- Your position matters because the tractor has a cone, not a global vacuum.
2) Aim the tractor cone
- Your mouse sets the cone direction.
- You only pull material that is inside the cone.
3) Salvage
- Toggle START / STOP SALVAGE (button or Space).
- Salvage only produces output when scrap is actually being pulled.
That’s the mechanical heart:
No pull → no flow → no progress.
What you’re seeing on the left (field view)
Wreck
The large hull shape is the salvage source. It’s the anchor for everything.
Scrap
The small blue fragments are salvageable material around the wreck.
They drift, cluster, and become collectible only when you line up the cone.
Tractor cone
The green cone shows your active extraction direction.
If the cone is pointed away from material, your salvage rate drops to near zero.
Yellow ring
The ring is your exposure zone. It grows as you keep salvaging.
This communicates: staying longer in the salvage area increases risk.
Red arcs on the ring
These are possible approach bearings (not enemies, not dots, not random spawns).
As your Attention rises, these arcs intensify to show you’re becoming easier to locate.
What you’re seeing on the right (HUD)
Salvage Target
- Remaining Material: how much value is left in the wreck site.
- Mode: IDLE vs SALVAGING (whether you’re actively attempting extraction).
Risk
Two separate concepts are tracked:
- Exposure (time harvesting)
How long you’ve been on-task extracting in this area. - Attention (how loud you are)
How detectable your activity is. It rises with actual pulling intensity, not just because you toggled the mode. - Status
A simple state derived from Attention:- QUIET → NOTICED → COMPROMISED (or similar, depending on thresholds)
- Time Exposed
A straight timer: total time spent in harvesting mode. - Interruption In
A forward estimate based on current Attention growth. The point is to give the player a readable “how close to forced off?” gauge.
Salvage Rate + Flow visual
- Current Rate (u/s) is your live extraction rate.
- The flow box is not decorative: it scales with actual pull activity.
- Strong pull → dense/faster chunks
- Weak/no pull → sparse/empty
This is your “truth panel.” If the flow is low, you are not extracting effectively—regardless of mode.
Code for Above
<div style="max-width:1500px;margin:0 auto;">
<iframe
title="POC — Salvage Field (Flow = Pull Activity) — Rewrite"
scrolling="no"
style="width:1500px;height:1500px;border:0;display:block;background:#000;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.55);"
sandbox="allow-scripts allow-same-origin"
srcdoc='<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Salvage Field POC</title>
<style>
:root{
color-scheme:dark;
--bg:#05070c;
--panel:rgba(10,14,22,.82);
--border:rgba(255,255,255,.10);
--text:#dbe7ff;
--muted:rgba(219,231,255,.65);
--ok:#7cffb2;
--warn:#ffd166;
--danger:#ff6b6b;
--accent:#6aa9ff;
--cyan:#9fd3ff;
}
html,body{margin:0;background:var(--bg);overflow:hidden;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
#app{width:1500px;height:1500px;position:relative;margin:0 auto;background:radial-gradient(1000px 900px at 45% 25%, rgba(25,50,90,.32), rgba(5,7,12,1));}
#field{position:absolute;left:0;top:0;width:1050px;height:1500px;}
#hud{position:absolute;right:0;top:0;width:450px;height:1500px;padding:16px;box-sizing:border-box;
border-left:1px solid rgba(255,255,255,.06);
background:linear-gradient(180deg,rgba(7,10,16,.75),rgba(5,7,12,.88));
}
.card{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:12px 12px 10px;margin-bottom:12px;}
.h{font-size:12px;font-weight:800;letter-spacing:.06em;color:var(--muted);text-transform:uppercase}
.big{font-size:26px;font-weight:900;color:var(--text);line-height:1.05}
.sub{font-size:11px;color:var(--muted);line-height:1.25;margin-top:6px}
.row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:10px}
.pill{padding:6px 10px;border-radius:999px;border:1px solid var(--border);background:rgba(255,255,255,.04);font-size:12px;font-weight:800}
.pill.ok{color:var(--ok);background:rgba(124,255,178,.10);border-color:rgba(124,255,178,.20)}
.pill.warn{color:var(--warn);background:rgba(255,209,102,.10);border-color:rgba(255,209,102,.20)}
.pill.danger{color:var(--danger);background:rgba(255,107,107,.10);border-color:rgba(255,107,107,.20)}
.btnbar{display:flex;gap:10px;margin-top:10px}
button{
flex:1;cursor:pointer;border-radius:12px;padding:10px 10px;
border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);
color:var(--text);font-weight:900;font-size:12px;letter-spacing:.04em;
}
button:active{transform:translateY(1px)}
button[disabled]{opacity:.35;cursor:not-allowed}
.meter{height:12px;border-radius:8px;background:rgba(255,255,255,.08);overflow:hidden}
.fill{height:100%}
.k{font-size:11px;color:var(--muted)}
.v{font-size:14px;font-weight:900;color:var(--text);font-variant-numeric:tabular-nums}
.kv{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;margin-top:8px}
/* FLOW VISUAL (must match pull activity) */
#stream{
height:160px;border-radius:12px;overflow:hidden;position:relative;
background:linear-gradient(180deg, rgba(80,120,200,.16), rgba(80,120,200,.04));
border:1px solid rgba(255,255,255,.08);
}
.chunk{
position:absolute;width:10px;height:18px;border-radius:3px;
background:var(--cyan);
filter:drop-shadow(0 0 6px rgba(159,211,255,.35));
}
.help{margin-top:8px;display:grid;grid-template-columns:1fr;gap:6px}
.li{font-size:11px;color:var(--muted);line-height:1.25}
.li b{color:var(--text)}
</style>
</head>
<body>
<div id="app">
<canvas id="field" width="1050" height="1500"></canvas>
<div id="hud">
<div class="card">
<div class="h">Salvage Target</div>
<div class="big">WRECK-A17</div>
<div class="sub">Move into position, aim the cone, salvage what you can before you get forced off.</div>
<div class="kv">
<div class="k">Remaining Material</div>
<div class="v" id="matPct">80%</div>
</div>
<div class="meter"><div id="matFill" class="fill" style="width:80%;background:var(--accent)"></div></div>
<div class="row">
<div class="k">Mode</div>
<div class="pill ok" id="modePill">IDLE</div>
</div>
<div class="btnbar">
<button id="toggleBtn">START SALVAGE</button>
<button id="resetBtn">RESET</button>
</div>
</div>
<div class="card">
<div class="h">Risk</div>
<div class="kv"><div class="k">Exposure (time harvesting)</div><div class="v" id="expPct">0%</div></div>
<div class="meter"><div id="expFill" class="fill" style="width:0%;background:var(--warn)"></div></div>
<div class="kv"><div class="k">Attention (how loud you are)</div><div class="v" id="attPct">0%</div></div>
<div class="meter"><div id="attFill" class="fill" style="width:0%;background:var(--danger)"></div></div>
<div class="row"><div class="k">Status</div><div class="pill ok" id="statusPill">QUIET</div></div>
<div class="kv"><div class="k">Time Exposed</div><div class="v" id="timeExposed">0.0s</div></div>
<div class="kv"><div class="k">Interruption In</div><div class="v" id="eta">—</div></div>
<div class="sub" id="interruptNote" style="display:none;color:rgba(255,107,107,.95);font-weight:800;">
INTERRUPTED — cooldown running
</div>
</div>
<div class="card">
<div class="h">Salvage Rate</div>
<div class="kv"><div class="k">Current Rate</div><div class="v" id="rate">0.00 u/s</div></div>
<div id="stream"></div>
<div class="sub">This visual is proportional to <b>actual pull activity</b> (not the mode switch).</div>
</div>
<div class="card">
<div class="h">How This Works</div>
<div class="help">
<div class="li"><b>Move</b>: WASD / Arrows. <b>Aim</b>: Mouse sets cone direction.</div>
<div class="li"><b>Extract</b>: Only happens when scrap is inside the cone and moving toward you.</div>
<div class="li"><b>Yellow ring</b>: your exposure zone grows as you keep harvesting.</div>
<div class="li"><b>Red arcs</b>: possible approach bearings; they intensify as <b>Attention</b> rises.</div>
</div>
</div>
<div class="card">
<div class="h">Controls</div>
<div class="sub"><b>WASD / Arrows</b> move · <b>Mouse</b> aim · <b>Space</b> start/stop · <b>R</b> reset</div>
</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById("field");
const ctx = canvas.getContext("2d");
const FW = canvas.width, FH = canvas.height;
const stream = document.getElementById("stream");
const toggleBtn = document.getElementById("toggleBtn");
const resetBtn = document.getElementById("resetBtn");
const matPct = document.getElementById("matPct");
const matFill = document.getElementById("matFill");
const modePill = document.getElementById("modePill");
const expPct = document.getElementById("expPct");
const expFill = document.getElementById("expFill");
const attPct = document.getElementById("attPct");
const attFill = document.getElementById("attFill");
const statusPill = document.getElementById("statusPill");
const timeExposedEl = document.getElementById("timeExposed");
const etaEl = document.getElementById("eta");
const rateEl = document.getElementById("rate");
const interruptNote = document.getElementById("interruptNote");
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
const wreck = { x: 520, y: 520, r: 190 };
const ship = { x: 520, y: 1120, vx:0, vy:0 };
let aim = { x: 520, y: 900 };
const scrap = [];
function seedScrap(){
scrap.length=0;
for(let i=0;i<110;i++){
const a = Math.random()*Math.PI*2;
const rad = 60 + Math.random()*270;
scrap.push({
x: wreck.x + Math.cos(a)*rad + (Math.random()*22-11),
y: wreck.y + Math.sin(a)*rad + (Math.random()*22-11),
vx:(Math.random()*2-1)*8,
vy:(Math.random()*2-1)*8,
alive:true
});
}
}
// bearings as arcs (no dots)
const bearings = [{ang:-2.2},{ang:-0.6},{ang:1.05}];
let salvaging=false;
let exposure=0;
let attention=0;
let material=0.80;
let timeExposed=0;
let rate=0;
let last=performance.now();
let interrupted=false;
let cooldown=0;
// Rate EMA from actual collections
let rateEMA=0, pulledAccumulator=0, pulledWindow=0;
// PULL EMA (drives stream density directly)
let pullEMA=0; // 0..1
let streamAcc=0; // spawn accumulator
const MAX_CHUNKS_PER_SEC = 26; // upper bound when pull is max
// Stream chunks
const streamChunks=[];
function spawnStreamChunk(intensity){
const d=document.createElement("div");
d.className="chunk";
const w = 6 + intensity*10; // size responds to pull
const h = 12 + intensity*16;
d.style.width = w.toFixed(0)+"px";
d.style.height = h.toFixed(0)+"px";
d.style.left=(Math.random()*(stream.clientWidth-(w+4)))+"px";
d.style.top="-26px";
d.style.opacity=(0.25 + intensity*0.75).toFixed(2);
stream.appendChild(d);
streamChunks.push({el:d,y:-26,vy: 220 + intensity*780});
}
// Input
const keys={};
window.addEventListener("keydown",(e)=>{
keys[e.code]=true;
if(e.code==="Space"){
if(!interrupted && material>0){
salvaging=!salvaging;
setModeUI();
}
}
if(e.code==="KeyR"){ resetAll(); }
});
window.addEventListener("keyup",(e)=>{ keys[e.code]=false; });
canvas.addEventListener("mousemove",(e)=>{
const r = canvas.getBoundingClientRect();
aim.x = clamp((e.clientX - r.left) * (FW / r.width), 0, FW);
aim.y = clamp((e.clientY - r.top) * (FH / r.height), 0, FH);
});
toggleBtn.onclick=()=>{
if(interrupted || material<=0) return;
salvaging=!salvaging;
setModeUI();
};
resetBtn.onclick=()=> resetAll();
function setModeUI(){
const canSalvage = (material>0) && !interrupted;
if(!canSalvage) salvaging=false;
modePill.textContent = interrupted ? "LOCKED" : (salvaging ? "SALVAGING" : (material<=0 ? "DEPLETED" : "IDLE"));
modePill.className = "pill " + (interrupted ? "danger" : (salvaging ? "warn" : (material<=0 ? "danger" : "ok")));
toggleBtn.textContent = salvaging ? "STOP SALVAGE" : "START SALVAGE";
toggleBtn.disabled = (!canSalvage);
interruptNote.style.display = interrupted ? "block" : "none";
}
function setStatusUI(){
let cls="ok", text="QUIET";
if(attention>=0.80){ cls="danger"; text="COMPROMISED"; }
else if(attention>=0.40){ cls="warn"; text="NOTICED"; }
statusPill.textContent=text;
statusPill.className="pill "+cls;
}
function resetAll(){
salvaging=false;
exposure=0;
attention=0;
material=0.80;
timeExposed=0;
rate=0;
rateEMA=0;
pulledAccumulator=0;
pulledWindow=0;
pullEMA=0;
streamAcc=0;
interrupted=false;
cooldown=0;
ship.x=520; ship.y=1120; ship.vx=0; ship.vy=0;
aim.x=520; aim.y=900;
streamChunks.forEach(s=>s.el.remove());
streamChunks.length=0;
seedScrap();
setModeUI();
}
function pointInCone(px,py, ox,oy, dirx,diry, maxDist, halfAngleRad){
const vx = px-ox, vy = py-oy;
const d = Math.hypot(vx,vy);
if(d<=1e-6 || d>maxDist) return {inside:false, d};
const nx=vx/d, ny=vy/d;
const dot = nx*dirx + ny*diry;
if(dot <= 0) return {inside:false, d};
const ang = Math.acos(clamp(dot,-1,1));
return {inside: ang <= halfAngleRad, d, dot};
}
function interrupt(){
interrupted=true;
cooldown=5;
salvaging=false;
setModeUI();
}
function drawField(dt, activePull, pullIntensity){
ctx.clearRect(0,0,FW,FH);
// grid
ctx.save();
ctx.globalAlpha=0.08;
ctx.strokeStyle="rgba(180,220,255,.22)";
for(let x=0;x<FW;x+=60){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,FH); ctx.stroke(); }
for(let y=0;y<FH;y+=60){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(FW,y); ctx.stroke(); }
ctx.restore();
// exposure ring
const ringR = wreck.r + 140 + exposure*260;
ctx.save();
ctx.globalAlpha = 0.10 + exposure*0.25;
ctx.strokeStyle = "rgba(255,209,102,1)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(wreck.x,wreck.y,ringR,0,Math.PI*2);
ctx.stroke();
ctx.restore();
// approach arcs (no dots)
const arcR = ringR + 22;
for(const b of bearings){
const a = b.ang, width=0.28;
const alpha = 0.04 + attention*0.30;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = "rgba(255,107,107,1)";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(wreck.x,wreck.y,arcR, a-width, a+width);
ctx.stroke();
ctx.restore();
}
// wreck hull
ctx.save();
ctx.translate(wreck.x,wreck.y);
ctx.fillStyle="rgba(60,80,110,.55)";
ctx.beginPath();
ctx.moveTo(-170,-110);
ctx.lineTo(120,-150);
ctx.lineTo(190,30);
ctx.lineTo(40,190);
ctx.lineTo(-200,150);
ctx.closePath();
ctx.fill();
ctx.globalAlpha=0.35;
ctx.fillStyle="rgba(20,26,38,1)";
ctx.beginPath();
ctx.moveTo(-80,-30);
ctx.lineTo(40,-60);
ctx.lineTo(80,30);
ctx.lineTo(-10,80);
ctx.closePath();
ctx.fill();
ctx.restore();
// scrap drift
for(const s of scrap){
if(!s.alive) continue;
s.x += s.vx*dt;
s.y += s.vy*dt;
s.vx *= 0.995;
s.vy *= 0.995;
const dx=s.x-wreck.x, dy=s.y-wreck.y;
const d=Math.hypot(dx,dy);
if(d>520){
s.vx += (-dx/d)*18*dt;
s.vy += (-dy/d)*18*dt;
}
ctx.fillStyle="rgba(159,211,255,.80)";
ctx.fillRect(s.x-3,s.y-3,6,6);
ctx.globalAlpha=0.60;
ctx.fillRect(s.x+6,s.y-1,8,2);
ctx.globalAlpha=1;
}
// movement
const accel = 900;
const drag = 0.88;
let ax=0, ay=0;
if(keys["ArrowLeft"]||keys["KeyA"]) ax -= accel;
if(keys["ArrowRight"]||keys["KeyD"]) ax += accel;
if(keys["ArrowUp"]||keys["KeyW"]) ay -= accel;
if(keys["ArrowDown"]||keys["KeyS"]) ay += accel;
ship.vx += ax*dt;
ship.vy += ay*dt;
const sp = Math.hypot(ship.vx, ship.vy);
const spMax = 520;
if(sp>spMax){
ship.vx = ship.vx/sp*spMax;
ship.vy = ship.vy/sp*spMax;
}
const dragPow = Math.pow(drag, dt*60);
ship.vx *= dragPow;
ship.vy *= dragPow;
ship.x = clamp(ship.x + ship.vx*dt, 24, FW-24);
ship.y = clamp(ship.y + ship.vy*dt, 24, FH-24);
// aim direction
let dirx = aim.x - ship.x;
let diry = aim.y - ship.y;
const dirLen = Math.hypot(dirx,diry) || 1;
dirx/=dirLen; diry/=dirLen;
// cone visuals only when activePull
if(activePull){
const coneDist = 520;
const halfAng = 0.34;
const leftx = dirx*Math.cos(halfAng) - diry*Math.sin(halfAng);
const lefty = dirx*Math.sin(halfAng) + diry*Math.cos(halfAng);
const rightx= dirx*Math.cos(-halfAng) - diry*Math.sin(-halfAng);
const righty= dirx*Math.sin(-halfAng) + diry*Math.cos(-halfAng);
ctx.save();
ctx.globalAlpha = 0.10 + 0.25*pullIntensity; // cone intensity follows actual pull
ctx.fillStyle="rgba(124,255,178,1)";
ctx.beginPath();
ctx.moveTo(ship.x,ship.y);
ctx.lineTo(ship.x + leftx*coneDist, ship.y + lefty*coneDist);
ctx.lineTo(ship.x + rightx*coneDist, ship.y + righty*coneDist);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ship marker
const ang = Math.atan2(diry, dirx);
ctx.save();
ctx.fillStyle="rgba(124,255,178,.95)";
ctx.translate(ship.x,ship.y);
ctx.rotate(ang + Math.PI/2);
ctx.beginPath();
ctx.moveTo(0,-12);
ctx.lineTo(10,10);
ctx.lineTo(-10,10);
ctx.closePath();
ctx.fill();
ctx.restore();
// crosshair
ctx.save();
ctx.globalAlpha=0.35;
ctx.strokeStyle="rgba(219,231,255,1)";
ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(aim.x-10, aim.y); ctx.lineTo(aim.x+10, aim.y);
ctx.moveTo(aim.x, aim.y-10); ctx.lineTo(aim.x, aim.y+10);
ctx.stroke();
ctx.restore();
// interruption overlay
if(interrupted){
const a = 0.08 + 0.06*Math.sin(performance.now()*0.02);
ctx.save();
ctx.globalAlpha=a;
ctx.fillStyle="rgba(255,107,107,1)";
ctx.fillRect(0,0,FW,FH);
ctx.restore();
}
return {dirx,diry};
}
function pullAndCollect(dt){
let dirx = aim.x - ship.x;
let diry = aim.y - ship.y;
const dirLen = Math.hypot(dirx,diry) || 1;
dirx/=dirLen; diry/=dirLen;
const coneDist = 520;
const halfAng = 0.34;
let pulled=0;
let pullSum=0;
for(const s of scrap){
if(!s.alive) continue;
const res = pointInCone(s.x,s.y, ship.x,ship.y, dirx,diry, coneDist, halfAng);
if(!res.inside) continue;
const d = Math.max(6, res.d);
const pull = clamp((coneDist - d)/coneDist, 0, 1);
const fx = (ship.x - s.x) / d;
const fy = (ship.y - s.y) / d;
s.vx += fx * pull * 260 * dt;
s.vy += fy * pull * 260 * dt;
pullSum += pull;
if(pull>0.12){
ctx.save();
ctx.strokeStyle=`rgba(124,255,178,${0.12+pull*0.60})`;
ctx.lineWidth=2+pull*5;
ctx.beginPath(); ctx.moveTo(ship.x,ship.y); ctx.lineTo(s.x,s.y); ctx.stroke();
ctx.restore();
}
if(d<26){
s.alive=false;
pulled += 1;
}
}
return {pulled, pullIntensity: clamp(pullSum/20,0,1)};
}
function setHud(){
matPct.textContent = Math.round(material*100) + "%";
matFill.style.width = (material*100) + "%";
expPct.textContent = Math.round(exposure*100) + "%";
expFill.style.width = (exposure*100) + "%";
attPct.textContent = Math.round(attention*100) + "%";
attFill.style.width = (attention*100) + "%";
timeExposedEl.textContent = timeExposed.toFixed(1) + "s";
rateEl.textContent = rate.toFixed(2) + " u/s";
setStatusUI();
if(interrupted){
etaEl.textContent = cooldown.toFixed(0) + "s";
} else if(salvaging && attention < 1){
const estSlope = 0.010 + Math.max(0.0, rate)*0.028;
const seconds = (1 - attention) / Math.max(0.0001, estSlope);
etaEl.textContent = (seconds>999 ? "—" : seconds.toFixed(0) + "s");
} else {
etaEl.textContent = "—";
}
}
function loop(){
const t = performance.now();
const dt = clamp((t-last)/1000, 0, 0.05);
last = t;
if(interrupted){
cooldown = Math.max(0, cooldown - dt);
if(cooldown<=0){
interrupted=false;
setModeUI();
}
}
const canOperate = (!interrupted) && salvaging && material>0;
// Draw base (cone intensity is driven by pullEMA, set below)
drawField(dt, canOperate, pullEMA);
// Pull
let res = {pulled:0, pullIntensity:0};
if(canOperate){
res = pullAndCollect(dt);
}
// Update pull EMA (this is the single source of truth for the flow visual)
pullEMA = lerp(pullEMA, res.pullIntensity, 0.22);
if(!canOperate) pullEMA = lerp(pullEMA, 0, 0.18);
// Update rate EMA (secondary, based on collected items)
pulledAccumulator += res.pulled;
pulledWindow += dt;
if(pulledWindow >= 0.25){
const inst = pulledAccumulator / pulledWindow;
rateEMA = lerp(rateEMA, inst, 0.35);
pulledAccumulator = 0;
pulledWindow = 0;
}
// System updates (only when pulling is real)
if(canOperate){
timeExposed += dt;
const effective = (rateEMA*0.06) + (pullEMA*0.18);
rate = lerp(rate, effective, 0.25);
// drain only with real pull
material = Math.max(0, material - rate*dt*0.030);
exposure = clamp(exposure + dt*(0.006 + pullEMA*0.020), 0, 1);
attention = clamp(attention + dt*(0.010 + pullEMA*0.028), 0, 1);
} else {
exposure = Math.max(0, exposure - dt*0.030);
attention = Math.max(0, attention - dt*0.020);
rate = lerp(rate, 0, 0.15);
}
// Forced interruption
if(!interrupted && attention >= 1){
interrupted=true;
cooldown=5;
salvaging=false;
setModeUI();
}
// === FLOW VISUAL FIX ===
// Spawn rate is proportional to pullEMA (actual pull activity), not mode.
// If pullEMA ~ 0, stream spawns ~ 0. If pullEMA high, stream is dense and fast.
streamAcc += pullEMA * MAX_CHUNKS_PER_SEC * dt;
while(streamAcc >= 1){
streamAcc -= 1;
spawnStreamChunk(pullEMA);
}
// Move chunks
for(let i=streamChunks.length-1;i>=0;i--){
const s = streamChunks[i];
s.y += s.vy * dt;
s.el.style.top = s.y + "px";
// fade out if pull is low (prevents lingering flow when you stop pulling)
const fade = clamp(0.35 + pullEMA*0.65, 0, 1);
s.el.style.opacity = (parseFloat(s.el.style.opacity) * (0.985 + fade*0.01)).toFixed(2);
if(s.y > 190){
s.el.remove();
streamChunks.splice(i,1);
}
}
// Cap DOM
if(streamChunks.length > 180){
for(let i=0;i<50;i++){
const s = streamChunks.shift();
if(s) s.el.remove();
}
}
// Depleted
if(material<=0){
material=0;
setModeUI();
}
// Redraw overlay cone with final pullEMA so it visually matches the stream
drawField(0, canOperate, pullEMA);
setHud();
requestAnimationFrame(loop);
}
// Init
seedScrap();
setModeUI();
setHud();
loop();
})();
</script>
</body>
</html>'
></iframe>
</div>