minecraft-renderer 0.1.96 → 0.1.97

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.97",
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,116 @@ 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 mic glyph before their nametag. */
149
+ export type PlayerVoiceState = 'speaking' | 'muted' | 'idle'
150
+
151
+ /**
152
+ * Draws a small mic 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
+
161
+ if (state === 'speaking') {
162
+ ctx.beginPath()
163
+ ctx.arc(0, 0, size * 0.62, 0, Math.PI * 2)
164
+ ctx.strokeStyle = 'rgba(61, 220, 71, 0.55)'
165
+ ctx.lineWidth = Math.max(1, size * 0.08)
166
+ ctx.stroke()
167
+ }
168
+
169
+ ctx.fillStyle = color
170
+ ctx.strokeStyle = color
171
+ ctx.lineWidth = Math.max(1, size * 0.12)
172
+
173
+ // Mic body: a rounded capsule
174
+ const bodyWidth = size * 0.34
175
+ const bodyHeight = size * 0.55
176
+ const bodyRadius = bodyWidth / 2
177
+ ctx.beginPath()
178
+ ctx.moveTo(-bodyRadius, -bodyHeight / 2 + bodyRadius)
179
+ ctx.arcTo(-bodyRadius, -bodyHeight / 2, 0, -bodyHeight / 2, bodyRadius)
180
+ ctx.arcTo(bodyRadius, -bodyHeight / 2, bodyRadius, -bodyHeight / 2 + bodyRadius, bodyRadius)
181
+ ctx.lineTo(bodyRadius, bodyHeight / 2 - bodyRadius)
182
+ ctx.arcTo(bodyRadius, bodyHeight / 2, 0, bodyHeight / 2, bodyRadius)
183
+ ctx.arcTo(-bodyRadius, bodyHeight / 2, -bodyRadius, bodyHeight / 2 - bodyRadius, bodyRadius)
184
+ ctx.closePath()
185
+ ctx.fill()
186
+
187
+ // Stand: arc + base line
188
+ ctx.beginPath()
189
+ ctx.arc(0, bodyHeight / 2 - bodyRadius / 2, size * 0.32, Math.PI * 0.15, Math.PI * 0.85, false)
190
+ ctx.stroke()
191
+ ctx.beginPath()
192
+ ctx.moveTo(0, size * 0.5)
193
+ ctx.lineTo(0, size * 0.62)
194
+ ctx.stroke()
195
+
196
+ if (state === 'muted') {
197
+ ctx.beginPath()
198
+ ctx.moveTo(-size * 0.42, -size * 0.42)
199
+ ctx.lineTo(size * 0.42, size * 0.42)
200
+ ctx.stroke()
201
+ }
202
+
203
+ ctx.restore()
204
+ }
205
+
148
206
  function getUsernameTexture(
149
- { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 }: any,
207
+ { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255, voiceState }: any,
150
208
  { fontFamily = 'mojangles' }: any,
151
209
  version: string
152
210
  ) {
153
- const canvas = createCanvas(64, 64)
154
-
211
+ const measureCanvas = createCanvas(64, 64)
155
212
  const PrismarineChat = PrismarineChatLoader(version)
156
213
 
157
- const ctx = canvas.getContext('2d')
158
- if (!ctx) throw new Error('Could not get 2d context')
214
+ const measureCtx = measureCanvas.getContext('2d')
215
+ if (!measureCtx) throw new Error('Could not get 2d context')
159
216
 
160
217
  const fontSize = 48
161
218
  const padding = 5
162
- ctx.font = `${fontSize}px ${fontFamily}`
219
+ measureCtx.font = `${fontSize}px ${fontFamily}`
163
220
 
164
221
  const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n')
165
222
  let textWidth = 0
166
223
  for (const line of plainLines) {
167
- const width = ctx.measureText(line).width + padding * 2
224
+ const width = measureCtx.measureText(line).width + padding * 2
168
225
  if (width > textWidth) textWidth = width
169
226
  }
170
227
 
171
- canvas.width = textWidth
172
- canvas.height = (fontSize + padding) * plainLines.length
228
+ const height = (fontSize + padding) * plainLines.length
173
229
 
174
- ctx.fillStyle = nameTagBackgroundColor
175
- ctx.fillRect(0, 0, canvas.width, canvas.height)
230
+ // Render the text on its own canvas first so the existing centered-text
231
+ // layout in renderComponent() is unaffected by the icon we add alongside it.
232
+ const textCanvas = createCanvas(textWidth, height)
233
+ const textCtx = textCanvas.getContext('2d')
234
+ if (!textCtx) throw new Error('Could not get 2d context')
176
235
 
177
- ctx.globalAlpha = nameTagTextOpacity / 255
236
+ textCtx.fillStyle = nameTagBackgroundColor
237
+ textCtx.fillRect(0, 0, textCanvas.width, textCanvas.height)
238
+ textCtx.globalAlpha = nameTagTextOpacity / 255
178
239
 
179
- const textRendered = renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize)
240
+ const textRendered = renderComponent(username, PrismarineChat, textCanvas, fontSize, 'white', -padding + fontSize)
180
241
  if (!textRendered) return undefined
181
242
 
182
- ctx.globalAlpha = 1
243
+ textCtx.globalAlpha = 1
244
+
245
+ if (!voiceState) return textCanvas
246
+
247
+ const iconSize = fontSize * 0.55
248
+ const iconAreaWidth = iconSize + padding * 2
249
+
250
+ const canvas = createCanvas(iconAreaWidth + textWidth, height)
251
+ const ctx = canvas.getContext('2d')
252
+ if (!ctx) throw new Error('Could not get 2d context')
253
+
254
+ ctx.fillStyle = nameTagBackgroundColor
255
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
256
+ drawVoiceStateIcon(ctx, voiceState, iconAreaWidth / 2, canvas.height / 2, iconSize)
257
+ ctx.drawImage(textCanvas, iconAreaWidth, 0)
183
258
 
184
259
  return canvas
185
260
  }
@@ -234,6 +309,13 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri
234
309
  return nameTag
235
310
  }
236
311
 
312
+ /** Player nametags are placed above the skin model and scaled up; text_display/mob nametags are not. */
313
+ function applyPlayerNametagTransform(nametag: THREE.Object3D | undefined, playerObject: { position: THREE.Vector3; scale: THREE.Vector3 }) {
314
+ if (!nametag) return
315
+ nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
316
+ nametag.scale.multiplyScalar(12)
317
+ }
318
+
237
319
  // todo cleanup
238
320
  const nametags = {}
239
321
 
@@ -395,6 +477,7 @@ export class Entities {
395
477
  }
396
478
  this.entities = {}
397
479
  this.currentSkinUrls = {}
480
+ this.playerVoiceStates = {}
398
481
 
399
482
  // Clean up player entity
400
483
  if (this.playerEntity) {
@@ -737,6 +820,33 @@ export class Entities {
737
820
  return playerObject
738
821
  }
739
822
 
823
+ /** Current voice chat presence per entity id, keyed as a string. Absent = no voice indicator. */
824
+ playerVoiceStates: Record<string, PlayerVoiceState> = {}
825
+
826
+ /**
827
+ * Show or hide the mic glyph on a player's nametag. `state` undefined hides it
828
+ * (e.g. the player left voice chat, or walked out of proximity range).
829
+ * Regenerates the nametag texture, so callers should only invoke this on
830
+ * actual state transitions (speaking start/stop, mute toggle), not per-frame.
831
+ */
832
+ setPlayerVoiceState(entityId: string | number, username: string, state: PlayerVoiceState | undefined) {
833
+ const key = String(entityId)
834
+ if (state) {
835
+ this.playerVoiceStates[key] = state
836
+ } else {
837
+ delete this.playerVoiceStates[key]
838
+ }
839
+
840
+ const e = this.entities[entityId]
841
+ const playerObject = e?.playerObject
842
+ if (!e || !playerObject) return
843
+ const mesh = e.children.find(c => c.name === 'mesh')
844
+ if (!mesh) return
845
+
846
+ const nametag = addNametag({ username, voiceState: state }, this.entitiesOptions, mesh, this.worldRenderer.version)
847
+ applyPlayerNametagTransform(nametag, playerObject)
848
+ }
849
+
740
850
  uuidPerSkinUrlsCache = {} as Record<string, { skinUrl?: string; capeUrl?: string }>
741
851
  currentSkinUrls = {} as Record<string, string>
742
852
 
@@ -1087,6 +1197,7 @@ export class Entities {
1087
1197
  disposeObject(e)
1088
1198
  // todo dispose textures as well ?
1089
1199
  delete this.entities[entity.id]
1200
+ delete this.playerVoiceStates[String(entity.id)]
1090
1201
  return
1091
1202
  }
1092
1203
  if (!this.isRenderingAllowed()) return
@@ -1158,11 +1269,13 @@ export class Entities {
1158
1269
  mesh = wrapper
1159
1270
 
1160
1271
  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
- }
1272
+ const nametag = addNametag(
1273
+ { ...entity, voiceState: this.playerVoiceStates[entity.id] },
1274
+ { fontFamily: 'mojangles' },
1275
+ wrapper,
1276
+ this.worldRenderer.version
1277
+ )
1278
+ applyPlayerNametagTransform(nametag, playerObject)
1166
1279
  }
1167
1280
  } else {
1168
1281
  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),