minecraft-renderer 0.1.96 → 0.1.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -145,41 +145,111 @@ function shortestYawRadians(fromYawRad: number, toYawRad: number): number {
145
145
  return norm > Math.PI ? norm - TAU_YAW : norm
146
146
  }
147
147
 
148
+ /** Voice chat presence for a player, drawn as a small speaker glyph before their nametag. */
149
+ export type PlayerVoiceState = 'speaking' | 'muted' | 'idle'
150
+
151
+ /**
152
+ * Draws a small speaker glyph centered at the origin of the current transform.
153
+ * `size` is the glyph's bounding box (both width and height).
154
+ */
155
+ function drawVoiceStateIcon(ctx: OffscreenCanvasRenderingContext2D, state: PlayerVoiceState, cx: number, cy: number, size: number) {
156
+ const color = state === 'speaking' ? '#3ddc47' : state === 'muted' ? '#ff5555' : '#cfcfcf'
157
+
158
+ ctx.save()
159
+ ctx.translate(cx, cy)
160
+ ctx.fillStyle = color
161
+ ctx.strokeStyle = color
162
+ ctx.lineWidth = Math.max(1.5, size * 0.09)
163
+ ctx.lineCap = 'round'
164
+
165
+ // Speaker silhouette: a small box with a cone opening to the right
166
+ const boxWidth = size * 0.22
167
+ const boxHeight = size * 0.34
168
+ const coneWidth = size * 0.24
169
+ const left = -size * 0.44
170
+
171
+ ctx.beginPath()
172
+ ctx.moveTo(left, -boxHeight / 2)
173
+ ctx.lineTo(left + boxWidth, -boxHeight / 2)
174
+ ctx.lineTo(left + boxWidth + coneWidth, -size * 0.5)
175
+ ctx.lineTo(left + boxWidth + coneWidth, size * 0.5)
176
+ ctx.lineTo(left + boxWidth, boxHeight / 2)
177
+ ctx.lineTo(left, boxHeight / 2)
178
+ ctx.closePath()
179
+ ctx.fill()
180
+
181
+ const coneTip = left + boxWidth + coneWidth
182
+
183
+ if (state === 'speaking') {
184
+ ctx.beginPath()
185
+ ctx.arc(coneTip + size * 0.06, 0, size * 0.16, -Math.PI * 0.32, Math.PI * 0.32)
186
+ ctx.stroke()
187
+ ctx.beginPath()
188
+ ctx.arc(coneTip + size * 0.06, 0, size * 0.28, -Math.PI * 0.28, Math.PI * 0.28)
189
+ ctx.stroke()
190
+ }
191
+
192
+ if (state === 'muted') {
193
+ ctx.beginPath()
194
+ ctx.moveTo(-size * 0.46, -size * 0.46)
195
+ ctx.lineTo(size * 0.46, size * 0.46)
196
+ ctx.stroke()
197
+ }
198
+
199
+ ctx.restore()
200
+ }
201
+
148
202
  function getUsernameTexture(
149
- { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 }: any,
203
+ { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255, voiceState }: any,
150
204
  { fontFamily = 'mojangles' }: any,
151
205
  version: string
152
206
  ) {
153
- const canvas = createCanvas(64, 64)
154
-
207
+ const measureCanvas = createCanvas(64, 64)
155
208
  const PrismarineChat = PrismarineChatLoader(version)
156
209
 
157
- const ctx = canvas.getContext('2d')
158
- if (!ctx) throw new Error('Could not get 2d context')
210
+ const measureCtx = measureCanvas.getContext('2d')
211
+ if (!measureCtx) throw new Error('Could not get 2d context')
159
212
 
160
213
  const fontSize = 48
161
214
  const padding = 5
162
- ctx.font = `${fontSize}px ${fontFamily}`
215
+ measureCtx.font = `${fontSize}px ${fontFamily}`
163
216
 
164
217
  const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n')
165
218
  let textWidth = 0
166
219
  for (const line of plainLines) {
167
- const width = ctx.measureText(line).width + padding * 2
220
+ const width = measureCtx.measureText(line).width + padding * 2
168
221
  if (width > textWidth) textWidth = width
169
222
  }
170
223
 
171
- canvas.width = textWidth
172
- canvas.height = (fontSize + padding) * plainLines.length
173
-
174
- ctx.fillStyle = nameTagBackgroundColor
175
- ctx.fillRect(0, 0, canvas.width, canvas.height)
224
+ const height = (fontSize + padding) * plainLines.length
176
225
 
177
- ctx.globalAlpha = nameTagTextOpacity / 255
226
+ // Render the text onto its own transparent canvas so the existing
227
+ // centered-text layout in renderComponent() is unaffected by compositing it
228
+ // alongside the icon. The background plate is painted exactly once, on the
229
+ // final canvas below — painting it here too would double-blend the alpha
230
+ // under the text relative to the icon area, producing a visible seam.
231
+ const textCanvas = createCanvas(textWidth, height)
232
+ const textCtx = textCanvas.getContext('2d')
233
+ if (!textCtx) throw new Error('Could not get 2d context')
178
234
 
179
- const textRendered = renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize)
235
+ textCtx.globalAlpha = nameTagTextOpacity / 255
236
+ const textRendered = renderComponent(username, PrismarineChat, textCanvas, fontSize, 'white', -padding + fontSize)
180
237
  if (!textRendered) return undefined
238
+ textCtx.globalAlpha = 1
239
+
240
+ const iconSize = fontSize * 0.85
241
+ const iconAreaWidth = voiceState ? iconSize + padding * 2.5 : 0
181
242
 
182
- ctx.globalAlpha = 1
243
+ const canvas = createCanvas(iconAreaWidth + textWidth, height)
244
+ const ctx = canvas.getContext('2d')
245
+ if (!ctx) throw new Error('Could not get 2d context')
246
+
247
+ ctx.fillStyle = nameTagBackgroundColor
248
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
249
+ if (voiceState) {
250
+ drawVoiceStateIcon(ctx, voiceState, iconAreaWidth / 2, canvas.height / 2, iconSize)
251
+ }
252
+ ctx.drawImage(textCanvas, iconAreaWidth, 0)
183
253
 
184
254
  return canvas
185
255
  }
@@ -234,6 +304,13 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri
234
304
  return nameTag
235
305
  }
236
306
 
307
+ /** Player nametags are placed above the skin model and scaled up; text_display/mob nametags are not. */
308
+ function applyPlayerNametagTransform(nametag: THREE.Object3D | undefined, playerObject: { position: THREE.Vector3; scale: THREE.Vector3 }) {
309
+ if (!nametag) return
310
+ nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
311
+ nametag.scale.multiplyScalar(12)
312
+ }
313
+
237
314
  // todo cleanup
238
315
  const nametags = {}
239
316
 
@@ -395,6 +472,7 @@ export class Entities {
395
472
  }
396
473
  this.entities = {}
397
474
  this.currentSkinUrls = {}
475
+ this.playerVoiceStates = {}
398
476
 
399
477
  // Clean up player entity
400
478
  if (this.playerEntity) {
@@ -737,6 +815,33 @@ export class Entities {
737
815
  return playerObject
738
816
  }
739
817
 
818
+ /** Current voice chat presence per entity id, keyed as a string. Absent = no voice indicator. */
819
+ playerVoiceStates: Record<string, PlayerVoiceState> = {}
820
+
821
+ /**
822
+ * Show or hide the mic glyph on a player's nametag. `state` undefined hides it
823
+ * (e.g. the player left voice chat, or walked out of proximity range).
824
+ * Regenerates the nametag texture, so callers should only invoke this on
825
+ * actual state transitions (speaking start/stop, mute toggle), not per-frame.
826
+ */
827
+ setPlayerVoiceState(entityId: string | number, username: string, state: PlayerVoiceState | undefined) {
828
+ const key = String(entityId)
829
+ if (state) {
830
+ this.playerVoiceStates[key] = state
831
+ } else {
832
+ delete this.playerVoiceStates[key]
833
+ }
834
+
835
+ const e = this.entities[entityId]
836
+ const playerObject = e?.playerObject
837
+ if (!e || !playerObject) return
838
+ const mesh = e.children.find(c => c.name === 'mesh')
839
+ if (!mesh) return
840
+
841
+ const nametag = addNametag({ username, voiceState: state }, this.entitiesOptions, mesh, this.worldRenderer.version)
842
+ applyPlayerNametagTransform(nametag, playerObject)
843
+ }
844
+
740
845
  uuidPerSkinUrlsCache = {} as Record<string, { skinUrl?: string; capeUrl?: string }>
741
846
  currentSkinUrls = {} as Record<string, string>
742
847
 
@@ -1087,6 +1192,7 @@ export class Entities {
1087
1192
  disposeObject(e)
1088
1193
  // todo dispose textures as well ?
1089
1194
  delete this.entities[entity.id]
1195
+ delete this.playerVoiceStates[String(entity.id)]
1090
1196
  return
1091
1197
  }
1092
1198
  if (!this.isRenderingAllowed()) return
@@ -1158,11 +1264,13 @@ export class Entities {
1158
1264
  mesh = wrapper
1159
1265
 
1160
1266
  if (entity.username) {
1161
- const nametag = addNametag(entity, { fontFamily: 'mojangles' }, wrapper, this.worldRenderer.version)
1162
- if (nametag) {
1163
- nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
1164
- nametag.scale.multiplyScalar(12)
1165
- }
1267
+ const nametag = addNametag(
1268
+ { ...entity, voiceState: this.playerVoiceStates[entity.id] },
1269
+ { fontFamily: 'mojangles' },
1270
+ wrapper,
1271
+ this.worldRenderer.version
1272
+ )
1273
+ applyPlayerNametagTransform(nametag, playerObject)
1166
1274
  }
1167
1275
  } else {
1168
1276
  mesh = getEntityMesh(this.mcData, entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] })
@@ -35,6 +35,7 @@ export const getBackendMethods = (worldRenderer: WorldRendererThree): any => {
35
35
  playEntityAnimation: worldRenderer.entities.playAnimation.bind(worldRenderer.entities),
36
36
  damageEntity: worldRenderer.entities.handleDamageEvent.bind(worldRenderer.entities),
37
37
  updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
38
+ setPlayerVoiceState: worldRenderer.entities.setPlayerVoiceState.bind(worldRenderer.entities),
38
39
  applyTemporaryPlayerSkinOverride: worldRenderer.entities.applyTemporaryPlayerSkinOverride.bind(worldRenderer.entities),
39
40
  changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
40
41
  getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),