hytopia 0.1.77 → 0.1.78
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/docs/server.gameserver.md +2 -2
- package/docs/{server.gameserver.modelmanager.md → server.gameserver.modelregistry.md} +3 -3
- package/docs/server.md +1 -1
- package/docs/{server.modelmanager.getboundingbox.md → server.modelregistry.getboundingbox.md} +2 -2
- package/docs/server.modelregistry.instance.md +13 -0
- package/docs/{server.modelmanager.md → server.modelregistry.md} +12 -12
- package/examples/ai-agents/README.md +47 -0
- package/examples/ai-agents/assets/map.json +25828 -0
- package/examples/ai-agents/assets/ui/index.html +215 -0
- package/examples/ai-agents/index.ts +350 -0
- package/examples/ai-agents/package.json +16 -0
- package/examples/ai-agents/src/BaseAgent.ts +482 -0
- package/examples/ai-agents/src/behaviors/FishingBehavior.ts +181 -0
- package/examples/ai-agents/src/behaviors/FollowBehavior.ts +171 -0
- package/examples/ai-agents/src/behaviors/MiningBehavior.ts +226 -0
- package/examples/ai-agents/src/behaviors/PathfindingBehavior.ts +435 -0
- package/examples/ai-agents/src/behaviors/SpeakBehavior.ts +50 -0
- package/examples/ai-agents/src/behaviors/TradeBehavior.ts +254 -0
- package/examples/entity-controller/MyEntityController.ts +1 -1
- package/package.json +1 -1
- package/server.api.json +15 -15
- package/server.d.ts +11 -19
- package/server.js +1 -1
- package/docs/server.modelmanager.instance.md +0 -13
@@ -0,0 +1,435 @@
|
|
1
|
+
import { Vector3, World, SimpleEntityController, PlayerEntity } from "hytopia";
|
2
|
+
import { BaseAgent } from "../BaseAgent";
|
3
|
+
import type { AgentBehavior } from "../BaseAgent";
|
4
|
+
import { Player } from "hytopia";
|
5
|
+
|
6
|
+
interface Node {
|
7
|
+
x: number;
|
8
|
+
z: number;
|
9
|
+
g: number; // Cost from start
|
10
|
+
h: number; // Heuristic (estimated cost to end)
|
11
|
+
f: number; // Total cost (g + h)
|
12
|
+
parent?: Node;
|
13
|
+
}
|
14
|
+
|
15
|
+
/**
|
16
|
+
* This is a simple implementation of A* pathfinding for Agents.
|
17
|
+
* There are many simplifications here, like no diagonal movement, sketchy jump code, and no path smoothing.
|
18
|
+
* It is good enough for a simple demo like this, but in a more polished game you would want to use a more robust pathfinding library or implementation.
|
19
|
+
*/
|
20
|
+
export class PathfindingBehavior implements AgentBehavior {
|
21
|
+
private path: Vector3[] = [];
|
22
|
+
private currentPathIndex: number = 0;
|
23
|
+
private targetEntity?: BaseAgent;
|
24
|
+
private moveSpeed = 4;
|
25
|
+
private isJumping = false; // Track if we're currently in a jump
|
26
|
+
private jumpCooldown = 0; // Cooldown timer for jumps
|
27
|
+
|
28
|
+
onUpdate(agent: BaseAgent, world: World): void {
|
29
|
+
if (!(agent.controller instanceof SimpleEntityController)) return;
|
30
|
+
|
31
|
+
// Decrease jump cooldown
|
32
|
+
if (this.jumpCooldown > 0) {
|
33
|
+
this.jumpCooldown--;
|
34
|
+
}
|
35
|
+
|
36
|
+
if (this.path.length > 0 && this.currentPathIndex < this.path.length) {
|
37
|
+
const nextPoint = this.path[this.currentPathIndex];
|
38
|
+
const distance = Vector3.fromVector3Like(agent.position).distance(
|
39
|
+
nextPoint
|
40
|
+
);
|
41
|
+
|
42
|
+
// Check if we're close enough to final destination
|
43
|
+
const isNearEnd = this.currentPathIndex >= this.path.length - 3;
|
44
|
+
if (isNearEnd) {
|
45
|
+
const finalPoint = this.path[this.path.length - 1];
|
46
|
+
const distanceToFinal = Vector3.fromVector3Like(
|
47
|
+
agent.position
|
48
|
+
).distance(finalPoint);
|
49
|
+
|
50
|
+
if (distanceToFinal < 3) {
|
51
|
+
agent.stopModelAnimations(["walk"]);
|
52
|
+
agent.startModelLoopedAnimations(["idle"]);
|
53
|
+
if (this.targetEntity) {
|
54
|
+
agent.controller.face(
|
55
|
+
this.targetEntity.position,
|
56
|
+
this.moveSpeed * 2
|
57
|
+
);
|
58
|
+
}
|
59
|
+
agent.handleEnvironmentTrigger(
|
60
|
+
`You have arrived at your destination.`
|
61
|
+
);
|
62
|
+
this.targetEntity = undefined;
|
63
|
+
this.path = [];
|
64
|
+
this.currentPathIndex = 0;
|
65
|
+
return;
|
66
|
+
}
|
67
|
+
}
|
68
|
+
|
69
|
+
if (distance < 0.5) {
|
70
|
+
this.currentPathIndex++;
|
71
|
+
this.isJumping = false; // Reset jump state when reaching waypoint
|
72
|
+
if (this.currentPathIndex >= this.path.length) {
|
73
|
+
if (this.targetEntity) {
|
74
|
+
agent.controller.face(
|
75
|
+
this.targetEntity.position,
|
76
|
+
this.moveSpeed * 2
|
77
|
+
);
|
78
|
+
agent.stopModelAnimations(["walk"]);
|
79
|
+
agent.startModelLoopedAnimations(["idle"]);
|
80
|
+
|
81
|
+
this.targetEntity = undefined;
|
82
|
+
}
|
83
|
+
agent.handleEnvironmentTrigger(
|
84
|
+
`You have arrived at your destination.`
|
85
|
+
);
|
86
|
+
return;
|
87
|
+
}
|
88
|
+
} else {
|
89
|
+
const yDiff = nextPoint.y - agent.position.y;
|
90
|
+
const horizontalDistance = Math.sqrt(
|
91
|
+
Math.pow(nextPoint.x - agent.position.x, 2) +
|
92
|
+
Math.pow(nextPoint.z - agent.position.z, 2)
|
93
|
+
);
|
94
|
+
|
95
|
+
if (
|
96
|
+
yDiff > 0.5 &&
|
97
|
+
horizontalDistance < 1.5 &&
|
98
|
+
!this.isJumping &&
|
99
|
+
this.jumpCooldown === 0
|
100
|
+
) {
|
101
|
+
const direction = Vector3.fromVector3Like(nextPoint)
|
102
|
+
.subtract(Vector3.fromVector3Like(agent.position))
|
103
|
+
.normalize();
|
104
|
+
direction.y = 10 * agent.mass;
|
105
|
+
agent.applyImpulse(direction);
|
106
|
+
this.isJumping = true;
|
107
|
+
this.jumpCooldown = 30;
|
108
|
+
}
|
109
|
+
|
110
|
+
agent.controller.move(nextPoint, this.moveSpeed, {
|
111
|
+
moveIgnoreAxes: yDiff >= 0 ? { y: true } : undefined,
|
112
|
+
});
|
113
|
+
agent.controller.face(nextPoint, this.moveSpeed * 2);
|
114
|
+
agent.startModelLoopedAnimations(["walk"]);
|
115
|
+
}
|
116
|
+
} else if (this.path.length > 0) {
|
117
|
+
this.path = [];
|
118
|
+
this.currentPathIndex = 0;
|
119
|
+
this.isJumping = false;
|
120
|
+
this.jumpCooldown = 0;
|
121
|
+
agent.stopModelAnimations(["walk"]);
|
122
|
+
agent.startModelLoopedAnimations(["idle"]);
|
123
|
+
}
|
124
|
+
}
|
125
|
+
|
126
|
+
private isWalkable(
|
127
|
+
world: World,
|
128
|
+
x: number,
|
129
|
+
z: number,
|
130
|
+
y: number,
|
131
|
+
startY?: number
|
132
|
+
): { walkable: boolean; y?: number; canJumpFrom?: boolean } {
|
133
|
+
const maxDropHeight = 3;
|
134
|
+
const maxStepUp = startY !== undefined ? 1 : 1.5;
|
135
|
+
const legY = Math.floor(y);
|
136
|
+
|
137
|
+
if (startY !== undefined && legY > startY + 2) {
|
138
|
+
return { walkable: false };
|
139
|
+
}
|
140
|
+
|
141
|
+
for (
|
142
|
+
let floorY = Math.min(
|
143
|
+
legY + maxStepUp,
|
144
|
+
startY ? startY + 2 : Infinity
|
145
|
+
);
|
146
|
+
floorY >= legY - maxDropHeight && floorY >= 0;
|
147
|
+
floorY--
|
148
|
+
) {
|
149
|
+
// Check floor block
|
150
|
+
const blockBelow = world.chunkLattice.hasBlock({
|
151
|
+
x: Math.floor(x),
|
152
|
+
y: floorY - 1,
|
153
|
+
z: Math.floor(z),
|
154
|
+
});
|
155
|
+
|
156
|
+
if (!blockBelow) continue;
|
157
|
+
|
158
|
+
// Check leg and head space
|
159
|
+
const blockLeg = world.chunkLattice.hasBlock({
|
160
|
+
x: Math.floor(x),
|
161
|
+
y: floorY,
|
162
|
+
z: Math.floor(z),
|
163
|
+
});
|
164
|
+
const blockHead = world.chunkLattice.hasBlock({
|
165
|
+
x: Math.floor(x),
|
166
|
+
y: floorY + 1,
|
167
|
+
z: Math.floor(z),
|
168
|
+
});
|
169
|
+
|
170
|
+
// Check extra block above for jump clearance
|
171
|
+
const blockJump = world.chunkLattice.hasBlock({
|
172
|
+
x: Math.floor(x),
|
173
|
+
y: floorY + 2,
|
174
|
+
z: Math.floor(z),
|
175
|
+
});
|
176
|
+
|
177
|
+
// If both leg and head space are clear
|
178
|
+
if (!blockLeg && !blockHead) {
|
179
|
+
return {
|
180
|
+
walkable: true,
|
181
|
+
y: floorY,
|
182
|
+
// Can only jump from here if we have the extra block of clearance
|
183
|
+
canJumpFrom: !blockJump,
|
184
|
+
};
|
185
|
+
}
|
186
|
+
}
|
187
|
+
return { walkable: false };
|
188
|
+
}
|
189
|
+
|
190
|
+
private heuristic(x1: number, z1: number, x2: number, z2: number): number {
|
191
|
+
return Math.abs(x1 - x2) + Math.abs(z1 - z2); // Manhattan distance
|
192
|
+
}
|
193
|
+
|
194
|
+
getState(): string {
|
195
|
+
// We want to return a message depending on whether or not we are currently pathfinding
|
196
|
+
if (this.path.length > 0) {
|
197
|
+
const distance = Vector3.fromVector3Like(
|
198
|
+
this.path[this.path.length - 1]
|
199
|
+
).distance(this.path[0]);
|
200
|
+
return `Pathfinding (${distance.toFixed(1)}m remaining)`;
|
201
|
+
} else {
|
202
|
+
return "Not currently pathfinding";
|
203
|
+
}
|
204
|
+
}
|
205
|
+
|
206
|
+
private findPath(
|
207
|
+
agent: BaseAgent,
|
208
|
+
world: World,
|
209
|
+
start: Vector3,
|
210
|
+
end: Vector3
|
211
|
+
): Vector3[] {
|
212
|
+
const openSet: Node[] = [];
|
213
|
+
const closedSet: Set<string> = new Set();
|
214
|
+
const startY = Math.floor(start.y);
|
215
|
+
|
216
|
+
interface NodeWithY extends Node {
|
217
|
+
y: number;
|
218
|
+
}
|
219
|
+
|
220
|
+
const startNode: NodeWithY = {
|
221
|
+
x: Math.floor(start.x),
|
222
|
+
z: Math.floor(start.z),
|
223
|
+
y: Math.floor(start.y),
|
224
|
+
g: 0,
|
225
|
+
h: this.heuristic(start.x, start.z, end.x, end.z),
|
226
|
+
f: 0,
|
227
|
+
};
|
228
|
+
|
229
|
+
openSet.push(startNode);
|
230
|
+
|
231
|
+
while (openSet.length > 0) {
|
232
|
+
let current = openSet.reduce((min, node) =>
|
233
|
+
node.f < min.f ? node : min
|
234
|
+
) as NodeWithY;
|
235
|
+
|
236
|
+
if (
|
237
|
+
Math.abs(current.x - end.x) < 1 &&
|
238
|
+
Math.abs(current.z - end.z) < 1
|
239
|
+
) {
|
240
|
+
// Path found, reconstruct it
|
241
|
+
const path: Vector3[] = [];
|
242
|
+
while (current) {
|
243
|
+
const walkableCheck = this.isWalkable(
|
244
|
+
world,
|
245
|
+
current.x,
|
246
|
+
current.z,
|
247
|
+
current.y,
|
248
|
+
startY
|
249
|
+
);
|
250
|
+
path.unshift(
|
251
|
+
new Vector3(
|
252
|
+
current.x + 0.5,
|
253
|
+
walkableCheck.y! + 1,
|
254
|
+
current.z + 0.5
|
255
|
+
)
|
256
|
+
);
|
257
|
+
current = current.parent! as NodeWithY;
|
258
|
+
}
|
259
|
+
return path;
|
260
|
+
}
|
261
|
+
|
262
|
+
openSet.splice(openSet.indexOf(current), 1);
|
263
|
+
closedSet.add(`${current.x},${current.z}`);
|
264
|
+
|
265
|
+
// Only cardinal directions - no diagonals
|
266
|
+
const neighbors = [
|
267
|
+
{ x: current.x + 1, z: current.z }, // East
|
268
|
+
{ x: current.x - 1, z: current.z }, // West
|
269
|
+
{ x: current.x, z: current.z + 1 }, // North
|
270
|
+
{ x: current.x, z: current.z - 1 }, // South
|
271
|
+
];
|
272
|
+
|
273
|
+
for (const neighbor of neighbors) {
|
274
|
+
if (closedSet.has(`${neighbor.x},${neighbor.z}`)) continue;
|
275
|
+
|
276
|
+
const walkableCheck = this.isWalkable(
|
277
|
+
world,
|
278
|
+
neighbor.x,
|
279
|
+
neighbor.z,
|
280
|
+
current.y,
|
281
|
+
startY
|
282
|
+
);
|
283
|
+
|
284
|
+
if (!walkableCheck.walkable) continue;
|
285
|
+
if (walkableCheck.y! > startY + 2) continue;
|
286
|
+
|
287
|
+
// Calculate movement cost
|
288
|
+
let movementCost = 1;
|
289
|
+
|
290
|
+
// Add significant cost for moving up (jumping)
|
291
|
+
const heightDiff = walkableCheck.y! - current.y;
|
292
|
+
if (heightDiff > 0) {
|
293
|
+
// Check if we can actually jump from the current position
|
294
|
+
const currentPos = this.isWalkable(
|
295
|
+
world,
|
296
|
+
current.x,
|
297
|
+
current.z,
|
298
|
+
current.y,
|
299
|
+
startY
|
300
|
+
);
|
301
|
+
|
302
|
+
if (!currentPos.canJumpFrom) {
|
303
|
+
movementCost += 1000;
|
304
|
+
} else {
|
305
|
+
movementCost += heightDiff * 5;
|
306
|
+
}
|
307
|
+
}
|
308
|
+
|
309
|
+
const g = current.g + movementCost;
|
310
|
+
const h = this.heuristic(neighbor.x, neighbor.z, end.x, end.z);
|
311
|
+
const f = g + h;
|
312
|
+
|
313
|
+
const existingNode = openSet.find(
|
314
|
+
(n) => n.x === neighbor.x && n.z === neighbor.z
|
315
|
+
);
|
316
|
+
|
317
|
+
if (!existingNode || g < existingNode.g) {
|
318
|
+
const newNode: NodeWithY = {
|
319
|
+
x: neighbor.x,
|
320
|
+
z: neighbor.z,
|
321
|
+
y: walkableCheck.y!,
|
322
|
+
g,
|
323
|
+
h,
|
324
|
+
f,
|
325
|
+
parent: current,
|
326
|
+
};
|
327
|
+
|
328
|
+
if (!existingNode) {
|
329
|
+
openSet.push(newNode);
|
330
|
+
} else {
|
331
|
+
Object.assign(existingNode, newNode);
|
332
|
+
}
|
333
|
+
}
|
334
|
+
}
|
335
|
+
}
|
336
|
+
return [];
|
337
|
+
}
|
338
|
+
|
339
|
+
onToolCall(
|
340
|
+
agent: BaseAgent,
|
341
|
+
world: World,
|
342
|
+
toolName: string,
|
343
|
+
args: any,
|
344
|
+
player?: Player
|
345
|
+
): string | void {
|
346
|
+
if (toolName === "pathfindTo") {
|
347
|
+
let targetPos: Vector3;
|
348
|
+
let targetName: string;
|
349
|
+
|
350
|
+
if (args.coordinates) {
|
351
|
+
// Use provided coordinates
|
352
|
+
targetPos = new Vector3(
|
353
|
+
args.coordinates.x,
|
354
|
+
args.coordinates.y,
|
355
|
+
args.coordinates.z
|
356
|
+
);
|
357
|
+
} else {
|
358
|
+
// Find target entity
|
359
|
+
console.log("Pathfinding to", args.targetName);
|
360
|
+
const target = world.entityManager
|
361
|
+
.getAllEntities()
|
362
|
+
.find(
|
363
|
+
(e) =>
|
364
|
+
(e instanceof BaseAgent &&
|
365
|
+
e.name === args.targetName) ||
|
366
|
+
(e instanceof PlayerEntity &&
|
367
|
+
e.player.username === args.targetName)
|
368
|
+
);
|
369
|
+
|
370
|
+
if (!target) {
|
371
|
+
return;
|
372
|
+
}
|
373
|
+
|
374
|
+
if (target instanceof BaseAgent) {
|
375
|
+
this.targetEntity = target;
|
376
|
+
}
|
377
|
+
targetPos = Vector3.fromVector3Like(target.position);
|
378
|
+
targetName =
|
379
|
+
target instanceof PlayerEntity
|
380
|
+
? target.player.username
|
381
|
+
: target.name;
|
382
|
+
}
|
383
|
+
|
384
|
+
const startPos = Vector3.fromVector3Like(agent.position);
|
385
|
+
|
386
|
+
// Check if start and end are walkable
|
387
|
+
const startWalkable = this.isWalkable(
|
388
|
+
world,
|
389
|
+
Math.floor(startPos.x),
|
390
|
+
Math.floor(startPos.z),
|
391
|
+
Math.floor(startPos.y)
|
392
|
+
);
|
393
|
+
|
394
|
+
const endWalkable = this.isWalkable(
|
395
|
+
world,
|
396
|
+
Math.floor(targetPos.x),
|
397
|
+
Math.floor(targetPos.z),
|
398
|
+
Math.floor(targetPos.y)
|
399
|
+
);
|
400
|
+
|
401
|
+
this.path = this.findPath(agent, world, startPos, targetPos);
|
402
|
+
|
403
|
+
if (this.path.length === 0) {
|
404
|
+
return "No valid path found to target.";
|
405
|
+
}
|
406
|
+
|
407
|
+
this.currentPathIndex = 0;
|
408
|
+
|
409
|
+
return "Started pathfinding. The system will notify you when you arrive.";
|
410
|
+
}
|
411
|
+
}
|
412
|
+
|
413
|
+
getPromptInstructions(): string {
|
414
|
+
return `
|
415
|
+
To navigate to a target, use:
|
416
|
+
<action type="pathfindTo">
|
417
|
+
{
|
418
|
+
"targetName": "Name of character or player to pathfind to", // Optional
|
419
|
+
"coordinates": { // Optional
|
420
|
+
"x": number,
|
421
|
+
"y": number,
|
422
|
+
"z": number
|
423
|
+
}
|
424
|
+
}
|
425
|
+
</action>
|
426
|
+
|
427
|
+
Returns:
|
428
|
+
- Success message if pathfinding is successfully started
|
429
|
+
- Error message if no path can be found.
|
430
|
+
|
431
|
+
The Pathfinding procedure will result in a later message when you arrive at your destination.
|
432
|
+
|
433
|
+
You must provide either targetName OR coordinates.`;
|
434
|
+
}
|
435
|
+
}
|
@@ -0,0 +1,50 @@
|
|
1
|
+
import { World, Player } from "hytopia";
|
2
|
+
import type { AgentBehavior, BaseAgent } from "../BaseAgent";
|
3
|
+
|
4
|
+
/**
|
5
|
+
* Very simple implementation of speak behavior for Agents.
|
6
|
+
* You can imagine how this could be extended to include more complex behaviors like Text-to-Speech.
|
7
|
+
* It has no state, and no callbacks.
|
8
|
+
*/
|
9
|
+
export class SpeakBehavior implements AgentBehavior {
|
10
|
+
onUpdate(agent: BaseAgent, world: World): void {}
|
11
|
+
|
12
|
+
getPromptInstructions(): string {
|
13
|
+
return `
|
14
|
+
To speak out loud, use:
|
15
|
+
<action type="speak">
|
16
|
+
{
|
17
|
+
"message": "What to say"
|
18
|
+
}
|
19
|
+
</action>`;
|
20
|
+
}
|
21
|
+
|
22
|
+
getState(): string {
|
23
|
+
return "";
|
24
|
+
}
|
25
|
+
|
26
|
+
onToolCall(
|
27
|
+
agent: BaseAgent,
|
28
|
+
world: World,
|
29
|
+
toolName: string,
|
30
|
+
args: { message: string }
|
31
|
+
): string | void {
|
32
|
+
if (toolName === "speak") {
|
33
|
+
agent.setChatUIState({ message: args.message });
|
34
|
+
|
35
|
+
if (world) {
|
36
|
+
world.chatManager.sendBroadcastMessage(
|
37
|
+
`[${agent.name}]: ${args.message}`,
|
38
|
+
"FF69B4"
|
39
|
+
);
|
40
|
+
}
|
41
|
+
|
42
|
+
// Clear message after delay
|
43
|
+
setTimeout(() => {
|
44
|
+
agent.setChatUIState({ message: "" });
|
45
|
+
}, 5300);
|
46
|
+
|
47
|
+
return "You said: " + args.message;
|
48
|
+
}
|
49
|
+
}
|
50
|
+
}
|