File size: 6,500 Bytes
af99a3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// ═══════════════════════════════════════════════════════
//  ITEMS β€” decorative floating "?" bonus boxes
//  Purely ambient trackside dressing (no gameplay effect):
//  they hover just beyond the road edge, spin and bob, and
//  glow warmly in the sunset light. The original build gave
//  every box its own real PointLight (10 extra lights!); this
//  version uses emissive materials + one additive glow sprite
//  per box instead β€” zero additional lights.
// ═══════════════════════════════════════════════════════
import * as THREE from 'three';
import { TRACK_WIDTH, CURB_W } from './config.js';
import { frame } from './track.js';

// ── Shared assets (built once) ──

let _shared = null;
function getShared() {
  if (_shared) return _shared;

  // "?" decal texture β€” warm amber tile with a bold question mark
  const qCanvas = document.createElement('canvas');
  qCanvas.width = qCanvas.height = 128;
  const qctx = qCanvas.getContext('2d');
  const grad = qctx.createLinearGradient(0, 0, 0, 128);
  grad.addColorStop(0, '#ffc85e');
  grad.addColorStop(1, '#f09120');
  qctx.fillStyle = grad;
  qctx.fillRect(0, 0, 128, 128);
  qctx.strokeStyle = 'rgba(60,30,5,0.9)';
  qctx.lineWidth = 8;
  qctx.strokeRect(4, 4, 120, 120);
  // Corner rivets
  qctx.fillStyle = 'rgba(60,30,5,0.8)';
  for (const [rx, ry] of [[16, 16], [112, 16], [16, 112], [112, 112]]) {
    qctx.beginPath(); qctx.arc(rx, ry, 5, 0, Math.PI * 2); qctx.fill();
  }
  qctx.fillStyle = '#fff7e8';
  qctx.strokeStyle = 'rgba(60,30,5,0.85)';
  qctx.lineWidth = 6;
  qctx.font = '900 84px Arial';
  qctx.textAlign = 'center';
  qctx.textBaseline = 'middle';
  qctx.strokeText('?', 64, 70);
  qctx.fillText('?', 64, 70);
  const decalTex = new THREE.CanvasTexture(qCanvas);
  decalTex.anisotropy = 4;

  // Soft radial glow sprite (additive, replaces PointLights)
  const gCanvas = document.createElement('canvas');
  gCanvas.width = gCanvas.height = 128;
  const gctx = gCanvas.getContext('2d');
  const rg = gctx.createRadialGradient(64, 64, 4, 64, 64, 64);
  rg.addColorStop(0, 'rgba(255,190,90,0.55)');
  rg.addColorStop(0.4, 'rgba(255,150,50,0.22)');
  rg.addColorStop(1, 'rgba(255,120,30,0)');
  gctx.fillStyle = rg;
  gctx.fillRect(0, 0, 128, 128);
  const glowTex = new THREE.CanvasTexture(gCanvas);

  _shared = {
    decalTex,
    glowTex,
    boxGeo: new THREE.BoxGeometry(1.3, 1.3, 1.3),
    lidGeo: new THREE.BoxGeometry(1.36, 0.18, 1.36),
    decalGeo: new THREE.PlaneGeometry(1.05, 1.05),
    glowGeo: new THREE.PlaneGeometry(3.6, 3.6),
    boxMat: new THREE.MeshPhongMaterial({
      color: 0xffa832, emissive: 0x7a4a00, emissiveIntensity: 0.55,
      shininess: 90, specular: 0xffeecc,
    }),
    lidMat: new THREE.MeshPhongMaterial({
      color: 0x8a5a18, emissive: 0x3a2404, emissiveIntensity: 0.4,
      shininess: 60,
    }),
    glowMat: new THREE.MeshBasicMaterial({
      map: glowTex, transparent: true, depthWrite: false,
      blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
    }),
  };
  return _shared;
}

// ═══════════════════════════════════════════════════════
//  createItemBoxes(scene) β€” place decorative boxes along the
//  track, alternating sides, just beyond the curb so they
//  never interfere with racing. Returns an array of boxes
//  whose userData carries animation state.
// ═══════════════════════════════════════════════════════
export function createItemBoxes(scene) {
  const S = getShared();
  const boxes = [];
  const COUNT = 10;

  for (let i = 0; i < COUNT; i++) {
    const t = (i + 0.5) / COUNT; // offset from start line & sector marks
    const { point, side } = frame(t);
    const sideSign = i % 2 === 0 ? 1 : -1;
    const lateral = sideSign * (TRACK_WIDTH / 2 + CURB_W + 2.2);

    const box = new THREE.Group();

    const body = new THREE.Mesh(S.boxGeo, S.boxMat);
    body.castShadow = true;
    box.add(body);

    // Lid seams top & bottom give the cube a "crate" read
    for (const y of [0.62, -0.62]) {
      const lid = new THREE.Mesh(S.lidGeo, S.lidMat);
      lid.position.y = y;
      box.add(lid);
    }

    // "?" decals on the four vertical faces
    const decalMat = new THREE.MeshBasicMaterial({ map: S.decalTex, transparent: true });
    for (const [pos, rotY] of [
      [[0, 0, 0.66], 0], [[0, 0, -0.66], Math.PI],
      [[0.66, 0, 0], Math.PI / 2], [[-0.66, 0, 0], -Math.PI / 2],
    ]) {
      const q = new THREE.Mesh(S.decalGeo, decalMat);
      q.position.set(...pos);
      q.rotation.y = rotY;
      box.add(q);
    }

    // Additive glow halo (billboarded cheaply by facing up-track)
    const glow = new THREE.Mesh(S.glowGeo, S.glowMat);
    glow.renderOrder = 5;
    box.add(glow);
    box.userData.glow = glow;

    box.position.set(point.x + side.x * lateral, 1.7, point.z + side.z * lateral);
    box.userData.baseY = 1.7;
    box.userData.phase = i * 1.37;      // desync bobbing
    box.userData.spinRate = 0.6 + (i % 3) * 0.25;
    scene.add(box);
    boxes.push(box);
  }
  return boxes;
}

// ═══════════════════════════════════════════════════════
//  updateItemBoxes(boxes, elapsed, camera) β€” bob, spin, pulse.
//  Call once per frame from the game loop. Cheap: no allocs.
// ═══════════════════════════════════════════════════════
export function updateItemBoxes(boxes, elapsed, camera) {
  if (!boxes.length) return;
  // Pulse the shared box material once (all boxes pulse together)
  const S = getShared();
  S.boxMat.emissiveIntensity = 0.45 + 0.25 * Math.sin(elapsed * 2.2);
  for (const box of boxes) {
    const ph = box.userData.phase;
    box.position.y = box.userData.baseY + Math.sin(elapsed * 1.6 + ph) * 0.25;
    box.rotation.y = elapsed * box.userData.spinRate + ph;
    // Billboard the glow halo toward the camera when available
    if (camera && box.userData.glow) box.userData.glow.lookAt(camera.position);
  }
}