Skip to content

Commit 2fee7a1

Browse files
committed
feat: drop items and srint
1 parent 86a8ac2 commit 2fee7a1

19 files changed

Lines changed: 1137 additions & 3 deletions

apps/client/src/app/play-controller.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { buildPlayHud } from '../ui/hud.ts'
4444
import { createDefaultClientSettings } from './client-settings.ts'
4545

4646
const FIXED_TIMESTEP = 1 / 60
47+
const DROP_STACK_THRESHOLD_SECONDS = 0.4
4748
const FIRST_PERSON_SWING_DURATION = 0.18
4849
const DEBUG_MEMORY_REFRESH_INTERVAL_SECONDS = 0.25
4950

@@ -99,6 +100,8 @@ export class PlayController {
99100
private nextDebugMemoryRefreshTime = 0
100101
private predictedInventory: InventorySnapshot | null = null
101102
private breakState: BreakState | null = null
103+
private dropHeldSeconds = 0
104+
private droppedStackThisTap = false
102105

103106
public constructor(private readonly deps: PlayControllerDeps) {}
104107

@@ -129,6 +132,8 @@ export class PlayController {
129132
this.pauseScreen = 'closed'
130133
this.firstPersonSwingRemaining = 0
131134
this.breakState = null
135+
this.dropHeldSeconds = 0
136+
this.droppedStackThisTap = false
132137
}
133138

134139
public async tick(context: PlayTickContext): Promise<PlayTickResult> {
@@ -294,6 +299,8 @@ export class PlayController {
294299
})
295300
) {
296301
this.breakState = null
302+
this.dropHeldSeconds = 0
303+
this.droppedStackThisTap = false
297304
return null
298305
}
299306

@@ -311,6 +318,33 @@ export class PlayController {
311318
adapter.eventBus.send({ type: 'selectInventorySlot', payload: { slot: next } })
312319
}
313320

321+
if (input.dropItemHeld) {
322+
this.dropHeldSeconds += deltaSeconds
323+
} else {
324+
this.dropHeldSeconds = 0
325+
this.droppedStackThisTap = false
326+
}
327+
328+
const dropSlot = worldRuntime.inventory.selectedSlot
329+
const dropSlotData = getSelectedInventorySlot(worldRuntime.inventory)
330+
331+
if (input.dropItemPressed && dropSlotData.count > 0) {
332+
adapter.eventBus.send({ type: 'dropItem', payload: { slot: dropSlot, count: 1 } })
333+
}
334+
335+
if (
336+
input.dropItemHeld &&
337+
!this.droppedStackThisTap &&
338+
this.dropHeldSeconds >= DROP_STACK_THRESHOLD_SECONDS &&
339+
dropSlotData.count > 1
340+
) {
341+
adapter.eventBus.send({
342+
type: 'dropItem',
343+
payload: { slot: dropSlot, count: dropSlotData.count - 1 },
344+
})
345+
this.droppedStackThisTap = true
346+
}
347+
314348
void worldRuntime.requestChunksAroundPosition(
315349
this.deps.player.state.position,
316350
this.deps.getClientSettings().renderDistance,

apps/client/src/game/fixed-step-input.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,37 @@ export interface PendingFixedStepInputEdges {
55
placeBlockPressed: boolean
66
hotbarSelection: number | null
77
hotbarScrollDelta: number
8+
dropItemPressed: boolean
9+
dropItemHeld: boolean
810
}
911

1012
export const createPendingFixedStepInputEdges = (): PendingFixedStepInputEdges => ({
1113
breakBlockPressed: false,
1214
placeBlockPressed: false,
1315
hotbarSelection: null,
1416
hotbarScrollDelta: 0,
17+
dropItemPressed: false,
18+
dropItemHeld: false,
1519
})
1620

1721
export const queueFixedStepInputEdges = (
1822
pending: PendingFixedStepInputEdges,
1923
input: Pick<
2024
InputState,
21-
'breakBlockPressed' | 'placeBlockPressed' | 'hotbarSelection' | 'hotbarScrollDelta'
25+
| 'breakBlockPressed'
26+
| 'placeBlockPressed'
27+
| 'hotbarSelection'
28+
| 'hotbarScrollDelta'
29+
| 'dropItemPressed'
30+
| 'dropItemHeld'
2231
>,
2332
): PendingFixedStepInputEdges => ({
2433
breakBlockPressed: pending.breakBlockPressed || input.breakBlockPressed,
2534
placeBlockPressed: pending.placeBlockPressed || input.placeBlockPressed,
2635
hotbarSelection: input.hotbarSelection ?? pending.hotbarSelection,
2736
hotbarScrollDelta: pending.hotbarScrollDelta + input.hotbarScrollDelta,
37+
dropItemPressed: pending.dropItemPressed || input.dropItemPressed,
38+
dropItemHeld: pending.dropItemHeld || input.dropItemHeld,
2839
})
2940

3041
export const applyFixedStepInputEdges = (
@@ -36,4 +47,6 @@ export const applyFixedStepInputEdges = (
3647
placeBlockPressed: pending.placeBlockPressed || input.placeBlockPressed,
3748
hotbarSelection: pending.hotbarSelection ?? input.hotbarSelection,
3849
hotbarScrollDelta: pending.hotbarScrollDelta + input.hotbarScrollDelta,
50+
dropItemPressed: pending.dropItemPressed || input.dropItemPressed,
51+
dropItemHeld: pending.dropItemHeld || input.dropItemHeld,
3952
})

apps/client/src/game/player.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ClientSettings, InputState } from '../types.ts'
2020

2121
const WORLD_UP = vec3(0, 1, 0)
2222
const MOVE_SPEED = 4.75
23+
const SPRINT_SPEED = 6.18
2324
const JUMP_VELOCITY = 7.5
2425
const GRAVITY = 24
2526
const BASE_MOUSE_SENSITIVITY = 0.0025
@@ -41,10 +42,13 @@ export class PlayerController {
4142
private grounded = false
4243
private previousJumpDown = false
4344
private timeSinceJumpPress = Number.POSITIVE_INFINITY
45+
private previousForwardDown = false
46+
private timeSinceForwardPress = Number.POSITIVE_INFINITY
4447
private fovDegrees = DEFAULT_FOV_DEGREES
4548
private mouseSensitivityPercent = 100
4649
public gamemode: PlayerGamemode = 0
4750
public flying = false
51+
public sprinting = false
4852

4953
public state: PlayerState = {
5054
position: [0, 10, 0],
@@ -120,6 +124,7 @@ export class PlayerController {
120124

121125
public update(input: InputState, deltaSeconds: number, world: VoxelWorld): void {
122126
this.timeSinceJumpPress += deltaSeconds
127+
this.timeSinceForwardPress += deltaSeconds
123128
const jumpPressed = input.moveUp && !this.previousJumpDown
124129
if (this.gamemode === 1 && jumpPressed) {
125130
if (this.timeSinceJumpPress <= DOUBLE_TAP_WINDOW_SECONDS) {
@@ -135,9 +140,23 @@ export class PlayerController {
135140
if (this.flying) {
136141
this.updateFlying(input, deltaSeconds, world)
137142
this.previousJumpDown = input.moveUp
143+
this.previousForwardDown = input.moveForward
144+
this.sprinting = false
138145
return
139146
}
140147

148+
const forwardPressed = input.moveForward && !this.previousForwardDown
149+
if (forwardPressed) {
150+
if (this.timeSinceForwardPress <= DOUBLE_TAP_WINDOW_SECONDS) {
151+
this.sprinting = true
152+
}
153+
this.timeSinceForwardPress = 0
154+
}
155+
if (!input.moveForward) {
156+
this.sprinting = false
157+
}
158+
this.previousForwardDown = input.moveForward
159+
141160
const forward = this.getWalkForwardVector()
142161
const right = normalizeVec3(crossVec3(forward, WORLD_UP))
143162
let movement = vec3()
@@ -158,8 +177,9 @@ export class PlayerController {
158177

159178
this.verticalVelocity -= GRAVITY * deltaSeconds
160179

161-
position = this.moveAxis(world, position, 'x', normalized.x * MOVE_SPEED * deltaSeconds)
162-
position = this.moveAxis(world, position, 'z', normalized.z * MOVE_SPEED * deltaSeconds)
180+
const speed = this.sprinting ? SPRINT_SPEED : MOVE_SPEED
181+
position = this.moveAxis(world, position, 'x', normalized.x * speed * deltaSeconds)
182+
position = this.moveAxis(world, position, 'z', normalized.z * speed * deltaSeconds)
163183

164184
const beforeVertical = position.y
165185
position = this.moveAxis(world, position, 'y', this.verticalVelocity * deltaSeconds)

apps/client/src/platform/native.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const library = dlopen(libraryPath, {
137137

138138
const GLFW_KEY_W = 87
139139
const GLFW_KEY_E = 69
140+
const GLFW_KEY_Q = 81
140141
const GLFW_KEY_A = 65
141142
const GLFW_KEY_S = 83
142143
const GLFW_KEY_D = 68
@@ -303,6 +304,8 @@ export class NativeBridge {
303304
enterPressed: Boolean(library.symbols.bridge_consume_key_press(GLFW_KEY_ENTER)),
304305
tabPressed: Boolean(library.symbols.bridge_consume_key_press(GLFW_KEY_TAB)),
305306
inventoryToggle: Boolean(library.symbols.bridge_consume_key_press(GLFW_KEY_E)),
307+
dropItemPressed: Boolean(library.symbols.bridge_consume_key_press(GLFW_KEY_Q)),
308+
dropItemHeld: Boolean(library.symbols.bridge_is_key_down(GLFW_KEY_Q)),
306309
hotbarSelection,
307310
hotbarScrollDelta: library.symbols.bridge_consume_scroll_y() as number,
308311
windowWidth,

apps/client/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface InputState {
2828
inventoryToggle: boolean
2929
hotbarSelection: number | null
3030
hotbarScrollDelta: number
31+
dropItemPressed: boolean
32+
dropItemHeld: boolean
3133
windowWidth: number
3234
windowHeight: number
3335
framebufferWidth: number

packages/core/src/server/authoritative-world.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
STARTUP_CHUNK_RADIUS,
2929
WORLD_SEA_LEVEL,
3030
} from '../world/constants.ts'
31+
import { getInventorySlot } from '../world/inventory.ts'
3132
import { getPlacedBlockIdForItem, isValidItemId } from '../world/items.ts'
3233
import { createGeneratedChunk, getTerrainHeight } from '../world/terrain.ts'
3334
import { worldToChunkCoord } from '../world/world.ts'
@@ -63,6 +64,12 @@ export interface WorldSimulationResult {
6364
inventoryUpdates: WorldInventoryUpdate[]
6465
}
6566

67+
export interface DropItemResult {
68+
inventory: InventorySnapshot
69+
inventoryChanged: boolean
70+
droppedItems: DroppedItemSimulationResult
71+
}
72+
6673
export interface StartupAreaProgress {
6774
completedChunks: number
6875
totalChunks: number
@@ -300,6 +307,40 @@ export class AuthoritativeWorld {
300307
return { added: result.added, remaining: result.remaining, inventory: result.inventory }
301308
}
302309

310+
public async dropItem(
311+
entityId: EntityId,
312+
slot: number,
313+
count: number,
314+
): Promise<DropItemResult> {
315+
await this.ensureInitialized()
316+
const inventory = this.playerSystem.getInventorySnapshot(entityId)
317+
const slotData = getInventorySlot(inventory, slot)
318+
if (slotData.count <= 0) {
319+
return {
320+
inventory,
321+
inventoryChanged: false,
322+
droppedItems: { spawned: [], updated: [], removed: [], inventoryUpdates: [] },
323+
}
324+
}
325+
326+
const dropCount = Math.min(Math.max(1, Math.trunc(count)), slotData.count)
327+
const mutResult = this.playerSystem.removeInventorySlot(entityId, slot, dropCount)
328+
const playerSnapshot = this.playerSystem.getPlayerSnapshot(entityId)
329+
const PLAYER_EYE_HEIGHT = 1.62
330+
const eyePosition: [number, number, number] = [
331+
playerSnapshot.state.position[0],
332+
playerSnapshot.state.position[1] + PLAYER_EYE_HEIGHT,
333+
playerSnapshot.state.position[2],
334+
]
335+
const droppedItems = await this.droppedItemSystem.spawnPlayerDrop(
336+
slotData.itemId,
337+
dropCount,
338+
eyePosition,
339+
playerSnapshot.state.yaw,
340+
)
341+
return { inventory: mutResult.inventory, inventoryChanged: mutResult.inventoryChanged, droppedItems }
342+
}
343+
303344
public getWorldTimeState(): WorldTimeState {
304345
return this.lightingSystem.getTimeState()
305346
}
@@ -486,6 +527,14 @@ export class AuthoritativeWorld {
486527
)
487528
break
488529
}
530+
case 'dropItem': {
531+
const dropped = await this.dropItem(intent.playerEntityId, intent.slot, intent.count)
532+
if (dropped.inventoryChanged) {
533+
this.mergeInventoryUpdate(result, intent.playerEntityId, dropped.inventory)
534+
}
535+
this.mergeSimulationResult(result, this.toWorldSimulationResult(dropped.droppedItems))
536+
break
537+
}
489538
}
490539
}
491540

packages/core/src/server/dropped-item-system.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const GRAVITY = 16
1717
const PICKUP_RADIUS = 1.35
1818
const PICKUP_RADIUS_SQUARED = PICKUP_RADIUS * PICKUP_RADIUS
1919
const PICKUP_COOLDOWN_MS = 250
20+
const PLAYER_DROP_PICKUP_COOLDOWN_MS = 2000
2021
const ITEM_HALF_EXTENT = 0.18
2122
const GROUND_FRICTION = 0.82
2223
const AIR_DRAG = 0.98
@@ -84,6 +85,37 @@ export class DroppedItemSystem {
8485
}
8586
}
8687

88+
public async spawnPlayerDrop(
89+
itemId: ItemId,
90+
count: number,
91+
position: readonly [number, number, number],
92+
yaw: number,
93+
): Promise<DroppedItemSimulationResult> {
94+
await this.ensureLoaded()
95+
const THROW_HORIZONTAL = 4.5
96+
const THROW_VERTICAL = 1.0
97+
const entityId = this.entities.registry.createEntity('drop')
98+
const spawnPosition: [number, number, number] = [position[0], position[1], position[2]]
99+
const velocity: [number, number, number] = [
100+
Math.cos(yaw) * THROW_HORIZONTAL,
101+
THROW_VERTICAL,
102+
Math.sin(yaw) * THROW_HORIZONTAL,
103+
]
104+
this.entities.droppedItemTransform.set(entityId, { position: spawnPosition, velocity })
105+
this.entities.droppedItemStack.set(entityId, {
106+
itemId,
107+
count: Math.max(1, Math.trunc(count)),
108+
})
109+
this.entities.droppedItemLifecycle.set(entityId, { pickupCooldownMs: PLAYER_DROP_PICKUP_COOLDOWN_MS })
110+
this.addToChunkIndex(entityId, spawnPosition)
111+
const item = this.getSnapshot(entityId)
112+
this.saveDirty = true
113+
return {
114+
...emptySimulationResult(),
115+
spawned: [item],
116+
}
117+
}
118+
87119
public async update(
88120
deltaSeconds: number,
89121
players: readonly PlayerSnapshot[],

packages/core/src/server/player-system.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
interactInventorySlot,
1717
normalizeInventorySnapshot,
1818
removeFromSelectedInventorySlot,
19+
removeInventorySlotCount,
1920
setSelectedInventorySlot,
2021
} from '../world/inventory.ts'
2122
import { type WorldEntityState } from './world-entity-state.ts'
@@ -331,6 +332,31 @@ export class PlayerSystem {
331332
}
332333
}
333334

335+
public removeInventorySlot(
336+
entityId: EntityId,
337+
slot: number,
338+
count: number,
339+
): InventoryMutationResult {
340+
const inventory = this.requireComponent(
341+
this.entities.playerInventory,
342+
entityId,
343+
'player inventory',
344+
)
345+
const persistence = this.requireComponent(
346+
this.entities.playerPersistence,
347+
entityId,
348+
'player persistence',
349+
)
350+
const next = removeInventorySlotCount(inventory.inventory, slot, count)
351+
if (inventoriesEqual(next, inventory.inventory)) {
352+
return { inventory: this.cloneInventory(inventory.inventory), inventoryChanged: false }
353+
}
354+
355+
this.entities.playerInventory.set(entityId, { inventory: next })
356+
this.entities.playerPersistence.set(entityId, { ...persistence, saveDirty: true })
357+
return { inventory: this.cloneInventory(next), inventoryChanged: true }
358+
}
359+
334360
public getSelectedInventorySlot(entityId: EntityId) {
335361
return getSelectedInventorySlot(
336362
this.requireComponent(this.entities.playerInventory, entityId, 'player inventory').inventory,

packages/core/src/server/world-session-controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type QueuedGameplayIntentInput =
1818
| Omit<Extract<QueuedGameplayIntent, { kind: 'selectInventorySlot' }>, 'sequence'>
1919
| Omit<Extract<QueuedGameplayIntent, { kind: 'interactInventorySlot' }>, 'sequence'>
2020
| Omit<Extract<QueuedGameplayIntent, { kind: 'updatePlayerState' }>, 'sequence'>
21+
| Omit<Extract<QueuedGameplayIntent, { kind: 'dropItem' }>, 'sequence'>
2122

2223
export interface WorldSessionPeer {
2324
sendEvent<K extends keyof ServerEventMap>(message: { type: K; payload: ServerEventMap[K] }): void
@@ -269,6 +270,15 @@ export class WorldSessionController implements WorldSessionPeer {
269270
flying,
270271
})
271272
}),
273+
this.adapter.eventBus.on('dropItem', ({ slot, count }) => {
274+
this.requireWorld('dropping item')
275+
this.enqueueIntent({
276+
kind: 'dropItem',
277+
playerEntityId: this.requireCurrentPlayerEntityId(),
278+
slot,
279+
count,
280+
})
281+
}),
272282
this.adapter.eventBus.on('submitChat', async ({ text }) => {
273283
const world = this.requireWorld('chatting')
274284
const playerEntityId = this.requireCurrentPlayerEntityId()

0 commit comments

Comments
 (0)