@rpgjs/common 3.3.2 → 4.0.0-beta.10
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/LICENSE +19 -0
- package/lib/AbstractObject.d.ts +3 -2
- package/lib/AbstractObject.js +296 -323
- package/lib/AbstractObject.js.map +1 -1
- package/lib/Color.js +1 -5
- package/lib/Color.js.map +1 -1
- package/lib/Event.js +2 -6
- package/lib/Event.js.map +1 -1
- package/lib/EventEmitter.js +3 -7
- package/lib/EventEmitter.js.map +1 -1
- package/lib/Game.js +73 -85
- package/lib/Game.js.map +1 -1
- package/lib/Hit.js +21 -28
- package/lib/Hit.js.map +1 -1
- package/lib/Logger.js +2 -7
- package/lib/Logger.js.map +1 -1
- package/lib/Map.d.ts +1 -0
- package/lib/Map.js +29 -53
- package/lib/Map.js.map +1 -1
- package/lib/Module.d.ts +2 -2
- package/lib/Module.js +84 -97
- package/lib/Module.js.map +1 -1
- package/lib/Player.d.ts +1 -0
- package/lib/Player.js +4 -7
- package/lib/Player.js.map +1 -1
- package/lib/Plugin.d.ts +4 -3
- package/lib/Plugin.js +14 -14
- package/lib/Plugin.js.map +1 -1
- package/lib/Scheduler.js +14 -21
- package/lib/Scheduler.js.map +1 -1
- package/lib/Shape.d.ts +1 -1
- package/lib/Shape.js +39 -52
- package/lib/Shape.js.map +1 -1
- package/lib/Utils.d.ts +4 -1
- package/lib/Utils.js +38 -53
- package/lib/Utils.js.map +1 -1
- package/lib/Vector2d.js +3 -8
- package/lib/Vector2d.js.map +1 -1
- package/lib/VirtualGrid.d.ts +1 -1
- package/lib/VirtualGrid.js +5 -12
- package/lib/VirtualGrid.js.map +1 -1
- package/lib/Worker.js +2 -10
- package/lib/Worker.js.map +1 -1
- package/lib/WorldMaps.d.ts +3 -1
- package/lib/WorldMaps.js +29 -15
- package/lib/WorldMaps.js.map +1 -1
- package/lib/gui/PrebuiltGui.js +2 -5
- package/lib/gui/PrebuiltGui.js.map +1 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.js +24 -69
- package/lib/index.js.map +1 -1
- package/lib/transports/io.js +5 -9
- package/lib/transports/io.js.map +1 -1
- package/lib/workers/move.js +26 -42
- package/lib/workers/move.js.map +1 -1
- package/package.json +9 -11
- package/src/AbstractObject.ts +915 -0
- package/src/Color.ts +29 -0
- package/src/DefaultInput.ts +26 -0
- package/src/Event.ts +3 -0
- package/src/EventEmitter.ts +52 -0
- package/src/Game.ts +150 -0
- package/src/Hit.ts +70 -0
- package/src/Logger.ts +7 -0
- package/src/Map.ts +335 -0
- package/src/Module.ts +108 -0
- package/src/Player.ts +30 -0
- package/src/Plugin.ts +91 -0
- package/src/Scheduler.ts +88 -0
- package/src/Shape.ts +300 -0
- package/src/Utils.ts +168 -0
- package/src/Vector2d.ts +70 -0
- package/src/VirtualGrid.ts +78 -0
- package/src/Worker.ts +17 -0
- package/src/WorldMaps.ts +204 -0
- package/src/gui/PrebuiltGui.ts +27 -0
- package/src/index.ts +24 -0
- package/src/transports/io.ts +91 -0
- package/src/workers/move.ts +61 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { intersection, generateUID, toRadians, isInstanceOf } from './Utils'
|
|
2
|
+
import { Hit, HitType } from './Hit'
|
|
3
|
+
import { RpgShape } from './Shape'
|
|
4
|
+
import SAT from 'sat'
|
|
5
|
+
import { TileInfo, RpgCommonMap } from './Map'
|
|
6
|
+
import { RpgPlugin, HookServer } from './Plugin'
|
|
7
|
+
import { GameSide, RpgCommonGame } from './Game'
|
|
8
|
+
import { Vector2d, Vector2dZero } from './Vector2d'
|
|
9
|
+
import { Box } from './VirtualGrid'
|
|
10
|
+
import { Behavior, ClientMode, Direction, MoveClientMode, MoveTo, PlayerType, Position, PositionXY, Tick } from '@rpgjs/types'
|
|
11
|
+
import { from, map, mergeMap, Observable, Subject, tap, takeUntil } from 'rxjs'
|
|
12
|
+
|
|
13
|
+
const ACTIONS = { IDLE: 0, RUN: 1, ACTION: 2 }
|
|
14
|
+
|
|
15
|
+
type CollisionOptions = {
|
|
16
|
+
collision?: (event: AbstractObject) => void
|
|
17
|
+
near?: (event: AbstractObject) => void,
|
|
18
|
+
allSearch?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class AbstractObject {
|
|
22
|
+
map: string = ''
|
|
23
|
+
height: number = 0
|
|
24
|
+
width: number = 0
|
|
25
|
+
speed: number
|
|
26
|
+
direction: number = 3
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Properties for move mode
|
|
30
|
+
*/
|
|
31
|
+
checkCollision: boolean = true
|
|
32
|
+
clientModeMove: ClientMode = MoveClientMode.ByDirection
|
|
33
|
+
behavior: Behavior = Behavior.Direction
|
|
34
|
+
|
|
35
|
+
hitbox: SAT.Box
|
|
36
|
+
|
|
37
|
+
inShapes: {
|
|
38
|
+
[shapeId: string]: RpgShape
|
|
39
|
+
} = {}
|
|
40
|
+
|
|
41
|
+
disableVirtualGrid: boolean = false
|
|
42
|
+
|
|
43
|
+
private shapes: RpgShape[] = []
|
|
44
|
+
private _position: Vector2d
|
|
45
|
+
private _hitboxPos: SAT.Vector
|
|
46
|
+
private collisionWith: AbstractObject[] = []
|
|
47
|
+
private _collisionWithTiles: TileInfo[] = []
|
|
48
|
+
private _collisionWithShapes: RpgShape[] = []
|
|
49
|
+
|
|
50
|
+
private destroyMove$: Subject<boolean> = new Subject<boolean>()
|
|
51
|
+
// notifier for destroy
|
|
52
|
+
_destroy$: Subject<void> = new Subject()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
static get ACTIONS() {
|
|
56
|
+
return ACTIONS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
constructor(private gameEngine: RpgCommonGame, public playerId: string) {
|
|
60
|
+
this._hitboxPos = new SAT.Vector(0, 0)
|
|
61
|
+
this.setHitbox(this.width, this.height)
|
|
62
|
+
this.position = { x: 0, y: 0, z: 0 }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get id() {
|
|
66
|
+
return this.playerId
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set id(str: string) {
|
|
70
|
+
this.playerId = str
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
updateInVirtualGrid() {
|
|
74
|
+
const map = this.mapInstance
|
|
75
|
+
if (map && !this.disableVirtualGrid /*&& this.gameEngine.isWorker TODO */) {
|
|
76
|
+
map.grid.insertInCells(this.id, this.getSizeMaxShape())
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get canMove(): boolean {
|
|
81
|
+
return this.clientModeMove == MoveClientMode.ByDirection
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set canMove(val: boolean) {
|
|
85
|
+
this.clientModeMove = val ? MoveClientMode.ByDirection : MoveClientMode.Disabled
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get/Set position x, y and z of player
|
|
90
|
+
*
|
|
91
|
+
* z is the depth layer. By default, its value is 0. Collisions and overlays will be performed with other objects on the same z-position.
|
|
92
|
+
*
|
|
93
|
+
* @title Get/Set position
|
|
94
|
+
* @prop { { x: number, y: number, z: number } } position
|
|
95
|
+
* @memberof Player
|
|
96
|
+
*/
|
|
97
|
+
set position(val: Position | Vector2d) {
|
|
98
|
+
const { x, y, z } = val
|
|
99
|
+
if (!isInstanceOf(val, Vector2d)) {
|
|
100
|
+
val = new Vector2d(x, y, z)
|
|
101
|
+
}
|
|
102
|
+
this._hitboxPos.x = x
|
|
103
|
+
this._hitboxPos.y = y
|
|
104
|
+
this._hitboxPos.z = z
|
|
105
|
+
this.updateInVirtualGrid()
|
|
106
|
+
this._position = new Proxy<Vector2d>(val as Vector2d, {
|
|
107
|
+
get: (target, prop: string) => target[prop],
|
|
108
|
+
set: (target, prop, value) => {
|
|
109
|
+
this._hitboxPos[prop] = value
|
|
110
|
+
target[prop] = value
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get position(): Vector2d {
|
|
117
|
+
return this._position
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get worldPositionX(): number {
|
|
121
|
+
let x = this.position.x
|
|
122
|
+
if (this.mapInstance) {
|
|
123
|
+
x += this.mapInstance.worldX
|
|
124
|
+
}
|
|
125
|
+
return x
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get worldPositionY(): number {
|
|
129
|
+
let y = this.position.y
|
|
130
|
+
if (this.mapInstance) {
|
|
131
|
+
y += this.mapInstance.worldY
|
|
132
|
+
}
|
|
133
|
+
return y
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
set posX(val) {
|
|
137
|
+
this.position.x = val
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
set posY(val) {
|
|
141
|
+
this.position.y = val
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
set posZ(val) {
|
|
145
|
+
this.position.z = val
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @internal */
|
|
149
|
+
get mapInstance(): RpgCommonMap {
|
|
150
|
+
if (this.gameEngine.side == GameSide.Client) {
|
|
151
|
+
return RpgCommonMap.bufferClient.get(this.map)
|
|
152
|
+
}
|
|
153
|
+
return RpgCommonMap.buffer.get(this.map)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
*
|
|
158
|
+
* Recovers all the colliding shapes of the current player
|
|
159
|
+
*
|
|
160
|
+
* @title Get Collision of shapes
|
|
161
|
+
* @since 3.2.0
|
|
162
|
+
* @readonly
|
|
163
|
+
* @prop { RpgShape[] } shapes
|
|
164
|
+
* @memberof Player
|
|
165
|
+
* @memberof RpgSpriteLogic
|
|
166
|
+
*/
|
|
167
|
+
get shapesCollision(): RpgShape[] {
|
|
168
|
+
return this._collisionWithShapes
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
*
|
|
173
|
+
* Recovers all the colliding tiles of the current player
|
|
174
|
+
*
|
|
175
|
+
* @title Get Collision of tiles
|
|
176
|
+
* @since 3.0.0-beta.4
|
|
177
|
+
* @readonly
|
|
178
|
+
* @prop { TileInfo[] } tiles
|
|
179
|
+
* @memberof Player
|
|
180
|
+
* @memberof RpgSpriteLogic
|
|
181
|
+
*/
|
|
182
|
+
get tilesCollision(): TileInfo[] {
|
|
183
|
+
return this._collisionWithTiles
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
*
|
|
188
|
+
* Recovers all other players and events colliding with the current player's hitbox
|
|
189
|
+
*
|
|
190
|
+
* @title Get Collision of other players/events
|
|
191
|
+
* @since 3.0.0-beta.4
|
|
192
|
+
* @readonly
|
|
193
|
+
* @prop { (RpgPlayer | RpgEvent)[] } otherPlayersCollision
|
|
194
|
+
* @memberof Player
|
|
195
|
+
* @memberof RpgSpriteLogic
|
|
196
|
+
*/
|
|
197
|
+
get otherPlayersCollision(): AbstractObject[] {
|
|
198
|
+
return this.collisionWith
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Define the size of the player. You can set the hitbox for collisions
|
|
203
|
+
*
|
|
204
|
+
* ```ts
|
|
205
|
+
* player.setSizes({
|
|
206
|
+
* width: 32,
|
|
207
|
+
* height: 32
|
|
208
|
+
* })
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* and with hitbox:
|
|
212
|
+
*
|
|
213
|
+
* ```ts
|
|
214
|
+
* player.setSizes({
|
|
215
|
+
* width: 32,
|
|
216
|
+
* height: 32,
|
|
217
|
+
* hitbox: {
|
|
218
|
+
* width: 20,
|
|
219
|
+
* height: 20
|
|
220
|
+
* }
|
|
221
|
+
* })
|
|
222
|
+
* ```
|
|
223
|
+
*
|
|
224
|
+
* @title Set Sizes
|
|
225
|
+
* @method player.setSizes(key,value)
|
|
226
|
+
* @param { { width: number, height: number, hitbox?: { width: number, height: number } } } obj
|
|
227
|
+
* @deprecated
|
|
228
|
+
* @returns {void}
|
|
229
|
+
* @memberof Player
|
|
230
|
+
*/
|
|
231
|
+
setSizes(obj: { width: number, height: number, hitbox?: { width: number, height: number } }): void {
|
|
232
|
+
this.width = obj.width
|
|
233
|
+
this.height = obj.height
|
|
234
|
+
if (obj.hitbox) {
|
|
235
|
+
this.hitbox = new SAT.Box(this._hitboxPos, obj.hitbox.width, obj.hitbox.height)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Define the hitbox of the player.
|
|
241
|
+
*
|
|
242
|
+
* ```ts
|
|
243
|
+
* player.setHitbox(20, 20)
|
|
244
|
+
* ```
|
|
245
|
+
*
|
|
246
|
+
* @title Set Hitbox
|
|
247
|
+
* @method player.setHitbox(width,height)
|
|
248
|
+
* @param {number} width
|
|
249
|
+
* @param {number} height
|
|
250
|
+
* @returns {void}
|
|
251
|
+
* @memberof Player
|
|
252
|
+
*/
|
|
253
|
+
setHitbox(width: number, height: number): void {
|
|
254
|
+
const map = this.mapInstance
|
|
255
|
+
if (map) {
|
|
256
|
+
this.width = map.tileWidth
|
|
257
|
+
this.height = map.tileHeight
|
|
258
|
+
}
|
|
259
|
+
this.hitbox = new SAT.Box(this._hitboxPos, width, height)
|
|
260
|
+
this.wHitbox = width
|
|
261
|
+
this.hHitbox = height
|
|
262
|
+
this.updateInVirtualGrid()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
set wHitbox(val) {
|
|
266
|
+
this.hitbox.w = val
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
set hHitbox(val) {
|
|
270
|
+
this.hitbox.h = val
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get wHitbox() {
|
|
274
|
+
return this.hitbox.w
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get hHitbox() {
|
|
278
|
+
return this.hitbox.h
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private directionToAngle(direction: number): number {
|
|
282
|
+
const angle = (direction < 2 ? +direction + 2 : direction - 2) * 90
|
|
283
|
+
return toRadians(angle)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @internal */
|
|
287
|
+
defineNextPosition(direction: number, deltaTimeInt: number): Vector2d {
|
|
288
|
+
const angle = this.directionToAngle(direction)
|
|
289
|
+
const computePosition = (prop: string) => {
|
|
290
|
+
return this.position[prop] + this.speed * deltaTimeInt
|
|
291
|
+
* (Math.round(Math[prop == 'x' ? 'cos' : 'sin'](angle) * 100) / 100)
|
|
292
|
+
}
|
|
293
|
+
return new Vector2d(~~computePosition('x'), ~~computePosition('y'), ~~this.position.z)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** @internal */
|
|
297
|
+
setPosition({ x, y, tileX, tileY }, move = true) {
|
|
298
|
+
const { tileWidth, tileHeight } = this.mapInstance
|
|
299
|
+
if (x !== undefined) this.posX = x
|
|
300
|
+
if (y !== undefined) this.posY = y
|
|
301
|
+
if (tileX !== undefined) this.posX = tileX * tileWidth
|
|
302
|
+
if (tileY !== undefined) this.posY = tileY * tileHeight
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @internal */
|
|
306
|
+
async triggerCollisionWith(type?: number) {
|
|
307
|
+
for (let collisionWith of this.collisionWith) {
|
|
308
|
+
if (collisionWith instanceof RpgShape) {
|
|
309
|
+
const goMap = collisionWith.getProperty<string>('go-map')
|
|
310
|
+
if (goMap && 'changeMap' in this) await this.changeMap(goMap)
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
if (type == AbstractObject.ACTIONS.ACTION) {
|
|
314
|
+
if ('onAction' in collisionWith) await collisionWith.execMethod('onAction', [this])
|
|
315
|
+
}
|
|
316
|
+
else if ('onPlayerTouch' in collisionWith) await collisionWith.execMethod('onPlayerTouch', [this])
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** @internal */
|
|
322
|
+
zCollision(other: Pick<AbstractObject, 'height'> & { position: Pick<AbstractObject['position'], 'z'> }): boolean {
|
|
323
|
+
const z = this.position.z
|
|
324
|
+
const otherZ = other.position.z
|
|
325
|
+
return intersection([z, z + this.height], [otherZ, otherZ + other.height])
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @internal */
|
|
329
|
+
moveByDirection(direction: Direction, deltaTimeInt: number): Promise<boolean> {
|
|
330
|
+
const nextPosition = this.defineNextPosition(direction, deltaTimeInt)
|
|
331
|
+
return this.move(nextPosition)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Retrieves a tile and checks if the player has a collision
|
|
336
|
+
*
|
|
337
|
+
* ```ts
|
|
338
|
+
* const tileInfo = player.getTile(20, 30)
|
|
339
|
+
* console.log(tileInfo)
|
|
340
|
+
* ```
|
|
341
|
+
*
|
|
342
|
+
* Example of returns:
|
|
343
|
+
*
|
|
344
|
+
```ts
|
|
345
|
+
{
|
|
346
|
+
tiles: [
|
|
347
|
+
{
|
|
348
|
+
id: 0,
|
|
349
|
+
terrain: [],
|
|
350
|
+
probability: null,
|
|
351
|
+
properties: [Object],
|
|
352
|
+
animations: [],
|
|
353
|
+
objectGroups: [],
|
|
354
|
+
image: null,
|
|
355
|
+
gid: 1
|
|
356
|
+
}
|
|
357
|
+
],
|
|
358
|
+
hasCollision: false,
|
|
359
|
+
isOverlay: undefined,
|
|
360
|
+
objectGroups: [],
|
|
361
|
+
isClimbable: undefined,
|
|
362
|
+
tileIndex: 93
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
*
|
|
366
|
+
* @title Get Tile
|
|
367
|
+
* @since 3.0.0-beta.4
|
|
368
|
+
* @method player.getTile(x,y,z?)
|
|
369
|
+
* @param {number} x
|
|
370
|
+
* @param {number} y
|
|
371
|
+
* @param {number} [z]
|
|
372
|
+
* @returns {object}
|
|
373
|
+
* @memberof Player
|
|
374
|
+
* @memberof RpgSpriteLogic
|
|
375
|
+
*/
|
|
376
|
+
getTile(x: number, y: number, z: number = 0, hitbox?: SAT.Box): TileInfo {
|
|
377
|
+
const map = this.mapInstance
|
|
378
|
+
return map.getTile(hitbox || this.hitbox, x, y, [z, this.height])
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async collisionObjects(
|
|
382
|
+
playerSizeBox: Box,
|
|
383
|
+
hitbox: SAT.Box,
|
|
384
|
+
triggers?: CollisionOptions
|
|
385
|
+
): Promise<boolean> {
|
|
386
|
+
const map = this.mapInstance
|
|
387
|
+
|
|
388
|
+
if (!map) return true
|
|
389
|
+
|
|
390
|
+
const events: { [id: string]: AbstractObject } = this.gameEngine.world.getObjectsOfGroup(this.map, this)
|
|
391
|
+
const objects = map.grid.getObjectsByBox(playerSizeBox)
|
|
392
|
+
let boolFound = false
|
|
393
|
+
|
|
394
|
+
for (let objectId of objects) {
|
|
395
|
+
// client side: read "object" propertie
|
|
396
|
+
if (!events[objectId]) continue
|
|
397
|
+
const event = events[objectId]['object'] || events[objectId]
|
|
398
|
+
|
|
399
|
+
if (event.id == this.id) continue
|
|
400
|
+
if (!this.zCollision(event)) continue
|
|
401
|
+
|
|
402
|
+
const collided = Hit.testPolyCollision(HitType.Box, hitbox, event.hitbox)
|
|
403
|
+
|
|
404
|
+
for (let shape of this.shapes) {
|
|
405
|
+
await this.collisionWithShape(shape, event)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (let shape of event.shapes) {
|
|
409
|
+
await event.collisionWithShape(shape, this)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (triggers?.near) triggers.near(event)
|
|
413
|
+
|
|
414
|
+
if (collided) {
|
|
415
|
+
this.collisionWith.push(event)
|
|
416
|
+
this.triggerCollisionWith()
|
|
417
|
+
let throughOtherPlayer = false
|
|
418
|
+
if (event.type == PlayerType.Player && this.type == PlayerType.Player) {
|
|
419
|
+
if (!(event.throughOtherPlayer || this.throughOtherPlayer)) {
|
|
420
|
+
boolFound = true
|
|
421
|
+
if (!triggers?.allSearch) return true
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
throughOtherPlayer = true
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!throughOtherPlayer && (!(event.through || this.through))) {
|
|
428
|
+
boolFound = true
|
|
429
|
+
if (!triggers?.allSearch) return true
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (boolFound) {
|
|
434
|
+
if (triggers?.collision) triggers.collision(event)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return boolFound
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** @internal */
|
|
442
|
+
private async collisionWithShape(shape: RpgShape, player: AbstractObject, nextPosition?: Vector2d): Promise<boolean> {
|
|
443
|
+
const collision = shape.hasCollision
|
|
444
|
+
const z = shape.z
|
|
445
|
+
if (shape.isShapePosition()) return false
|
|
446
|
+
if (z !== undefined && !this.zCollision({
|
|
447
|
+
position: { z },
|
|
448
|
+
height: this.mapInstance.zTileHeight
|
|
449
|
+
})) {
|
|
450
|
+
return false
|
|
451
|
+
}
|
|
452
|
+
let position: Vector2d
|
|
453
|
+
let { hitbox } = player
|
|
454
|
+
if (nextPosition) {
|
|
455
|
+
position = nextPosition.copy()
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
position = player.position.copy()
|
|
459
|
+
}
|
|
460
|
+
const hitboxObj = Hit.createObjectHitbox(
|
|
461
|
+
position.x,
|
|
462
|
+
position.y,
|
|
463
|
+
position.z,
|
|
464
|
+
hitbox.w,
|
|
465
|
+
hitbox.h
|
|
466
|
+
)
|
|
467
|
+
let collided = Hit.testPolyCollision(shape.type, hitboxObj, shape.hitbox)
|
|
468
|
+
const playerPositionSaved = player.position.copy()
|
|
469
|
+
|
|
470
|
+
// Position can changed after enter or exit shape. So, we need to verify if position changed and update if z is changed
|
|
471
|
+
// If X or Y changed, we need to return true, it means that stop the current movement, and apply the new position
|
|
472
|
+
const verifyIfPositionChanged = (): boolean | undefined => {
|
|
473
|
+
if (this.position.z != playerPositionSaved.z && nextPosition) {
|
|
474
|
+
nextPosition.z = this.position.z
|
|
475
|
+
}
|
|
476
|
+
if (this.position.x != playerPositionSaved.x || this.position.y != playerPositionSaved.y) {
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (collided) {
|
|
482
|
+
this._collisionWithShapes.push(shape)
|
|
483
|
+
// TODO: in shape after map load
|
|
484
|
+
if (!collision) await shape.in(this)
|
|
485
|
+
if (verifyIfPositionChanged() === true) return true
|
|
486
|
+
this.triggerCollisionWith()
|
|
487
|
+
if (collision) return true
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
await shape.out(this)
|
|
491
|
+
if (verifyIfPositionChanged() === true) return true
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return false
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async collisionShapes(playerSizeBox: Box, nextPosition?: Vector2d, triggers?: CollisionOptions): Promise<boolean> {
|
|
498
|
+
const map = this.mapInstance
|
|
499
|
+
if (!map) return false
|
|
500
|
+
const shapes: { [id: string]: RpgShape } = this.gameEngine.world.getShapesOfGroup(this.map)
|
|
501
|
+
const shapesInGrid = this.gameEngine.side == GameSide.Client
|
|
502
|
+
? new Set(Object.keys(shapes))
|
|
503
|
+
: map.gridShapes.getObjectsByBox(playerSizeBox)
|
|
504
|
+
let boolFound = false
|
|
505
|
+
|
|
506
|
+
for (let shapeId of shapesInGrid) {
|
|
507
|
+
const shape = shapes[shapeId]['object'] || shapes[shapeId]
|
|
508
|
+
if (triggers?.near) triggers.near(shape)
|
|
509
|
+
const bool = await this.collisionWithShape(shape, this, nextPosition)
|
|
510
|
+
if (bool) {
|
|
511
|
+
if (triggers?.collision) triggers.collision(shape)
|
|
512
|
+
boolFound = true
|
|
513
|
+
if (!triggers?.allSearch) return true
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return boolFound
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async computeNextPositionByTarget(nextPosition: Vector2d, target: Vector2d): Promise<Vector2d> {
|
|
520
|
+
const pullDistance = target.distanceWith(nextPosition)
|
|
521
|
+
if (pullDistance <= this.speed) {
|
|
522
|
+
return nextPosition.set(target)
|
|
523
|
+
}
|
|
524
|
+
const pull = (target.copy().subtract(nextPosition)).multiply((1 / pullDistance))
|
|
525
|
+
const totalPush = new Vector2dZero()
|
|
526
|
+
let contenders = 0
|
|
527
|
+
const hitbox = Hit.createObjectHitbox(nextPosition.x, nextPosition.y, nextPosition.z, this.hitbox.w, this.hitbox.h)
|
|
528
|
+
|
|
529
|
+
const createObstacle = function (x: number, y: number, radius: number): Vector2d {
|
|
530
|
+
const obstacle = new Vector2d(x, y)
|
|
531
|
+
let push = nextPosition.copy().subtract(obstacle)
|
|
532
|
+
let distance = (nextPosition.distanceWith(obstacle) - radius) - radius;
|
|
533
|
+
if (distance < radius * 2 * 10) {
|
|
534
|
+
++contenders
|
|
535
|
+
if (distance < 0.0001) distance = 0.0001 // avoid div by 0
|
|
536
|
+
let weight = 1 / distance;
|
|
537
|
+
totalPush.add(push.multiply(weight))
|
|
538
|
+
}
|
|
539
|
+
return obstacle
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const area = this.mapInstance.tileheight * 2
|
|
543
|
+
this.mapInstance.gridTiles.getCells({
|
|
544
|
+
minX: nextPosition.x - area,
|
|
545
|
+
maxX: nextPosition.x + area,
|
|
546
|
+
minY: nextPosition.y - area,
|
|
547
|
+
maxY: nextPosition.y + area
|
|
548
|
+
}, (index) => {
|
|
549
|
+
if (index < 0) return
|
|
550
|
+
const pos = this.mapInstance.getTilePosition(index)
|
|
551
|
+
const hitbox = Hit.createObjectHitbox(pos.x, pos.y, nextPosition.z, this.hitbox.w, this.hitbox.h)
|
|
552
|
+
const radius = this.mapInstance.tilewidth / 2
|
|
553
|
+
const tile = this.getTile(pos.x, pos.y, nextPosition.z, hitbox)
|
|
554
|
+
if (tile.hasCollision) {
|
|
555
|
+
createObstacle(pos.x, pos.y, radius)
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
const playerSizeBox = this.getSizeMaxShape(nextPosition.x, nextPosition.y)
|
|
561
|
+
|
|
562
|
+
await this.collisionObjects(playerSizeBox, hitbox, {
|
|
563
|
+
collision: (event: AbstractObject) => {
|
|
564
|
+
const { x, y } = event.position
|
|
565
|
+
createObstacle(x, y, event.hitbox.w)
|
|
566
|
+
},
|
|
567
|
+
allSearch: true
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
await this.collisionShapes(playerSizeBox, nextPosition, {
|
|
571
|
+
collision: (shape) => {
|
|
572
|
+
const { x, y } = shape.position
|
|
573
|
+
createObstacle(x, y, shape.hitbox.w)
|
|
574
|
+
},
|
|
575
|
+
allSearch: true
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
pull
|
|
579
|
+
.multiply(Math.max(1, 4 * contenders))
|
|
580
|
+
.add(totalPush)
|
|
581
|
+
.normalize()
|
|
582
|
+
|
|
583
|
+
return nextPosition.add(pull.multiply(this.speed))
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async isCollided(nextPosition: Vector2d, options: CollisionOptions = {}): Promise<boolean> {
|
|
587
|
+
this.collisionWith = []
|
|
588
|
+
this._collisionWithTiles = []
|
|
589
|
+
const prevMapId = this.map
|
|
590
|
+
const hitbox = Hit.createObjectHitbox(nextPosition.x, nextPosition.y, 0, this.hitbox.w, this.hitbox.h)
|
|
591
|
+
const boundingMap = this.mapInstance.boundingMap(nextPosition, this.hitbox)
|
|
592
|
+
let collided = false
|
|
593
|
+
|
|
594
|
+
if (boundingMap?.bounding) {
|
|
595
|
+
this.position.set(nextPosition)
|
|
596
|
+
if (!options.allSearch) return true
|
|
597
|
+
else collided = true
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const tileCollision = (x: number, y: number): boolean => {
|
|
601
|
+
const tile = this.getTile(x, y, nextPosition.z, hitbox)
|
|
602
|
+
if (tile.hasCollision) {
|
|
603
|
+
this._collisionWithTiles.push(tile)
|
|
604
|
+
return true
|
|
605
|
+
}
|
|
606
|
+
return false
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (
|
|
610
|
+
tileCollision(nextPosition.x, nextPosition.y) ||
|
|
611
|
+
tileCollision(nextPosition.x + this.hitbox.w, nextPosition.y) ||
|
|
612
|
+
tileCollision(nextPosition.x, nextPosition.y + this.hitbox.h) ||
|
|
613
|
+
tileCollision(nextPosition.x + this.hitbox.w, nextPosition.y + this.hitbox.h)
|
|
614
|
+
) {
|
|
615
|
+
if (!options.allSearch) return true
|
|
616
|
+
else collided = true
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (this.autoChangeMap) {
|
|
620
|
+
const changeMap = await this.autoChangeMap(nextPosition)
|
|
621
|
+
if (changeMap) {
|
|
622
|
+
return true
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const playerSizeBox = this.getSizeMaxShape(nextPosition.x, nextPosition.y)
|
|
627
|
+
|
|
628
|
+
if (await this.collisionObjects(playerSizeBox, hitbox, options)) {
|
|
629
|
+
if (!options.allSearch) return true
|
|
630
|
+
else collided = true
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (await this.collisionShapes(playerSizeBox, nextPosition, options)) {
|
|
634
|
+
if (!options.allSearch) return true
|
|
635
|
+
else collided = true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// if there is a change of map after a move, the moves are not changed
|
|
639
|
+
if (prevMapId != this.map) {
|
|
640
|
+
return true
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return collided
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Attach a shape to the player (and allow interaction with it)
|
|
648
|
+
*
|
|
649
|
+
* ```ts
|
|
650
|
+
* import { ShapePositioning } from '@rpgjs/server'
|
|
651
|
+
*
|
|
652
|
+
* player.attachShape({
|
|
653
|
+
* width: 100,
|
|
654
|
+
* height: 100,
|
|
655
|
+
* positioning: ShapePositioning.Center
|
|
656
|
+
* })
|
|
657
|
+
* ```
|
|
658
|
+
*
|
|
659
|
+
* @title Attach Shape
|
|
660
|
+
* @method player.attachShape(parameters)
|
|
661
|
+
* @param { { width: number, height: number, positioning?, name?, properties?: object } } obj
|
|
662
|
+
* - positioning: Indicate where the shape is placed.
|
|
663
|
+
* - properties: An object in order to retrieve information when interacting with the shape
|
|
664
|
+
* - name: The name of the shape
|
|
665
|
+
* @since 3.0.0-beta.3
|
|
666
|
+
* @returns {RpgShape}
|
|
667
|
+
* @memberof Player
|
|
668
|
+
*/
|
|
669
|
+
attachShape(obj: {
|
|
670
|
+
width: number,
|
|
671
|
+
height: number
|
|
672
|
+
positioning?: string
|
|
673
|
+
name?: string
|
|
674
|
+
properties?: object
|
|
675
|
+
}): RpgShape {
|
|
676
|
+
obj.name = (obj.name || generateUID()) as string
|
|
677
|
+
const shape = new RpgShape({
|
|
678
|
+
...obj,
|
|
679
|
+
fixEvent: this
|
|
680
|
+
} as any)
|
|
681
|
+
this.shapes.push(shape)
|
|
682
|
+
return shape
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Returns all shapes assigned to this player
|
|
687
|
+
*
|
|
688
|
+
* @title Get Shapes
|
|
689
|
+
* @method player.getShapes()
|
|
690
|
+
* @returns {RpgShape[]}
|
|
691
|
+
* @since 3.0.0-beta.3
|
|
692
|
+
* @memberof Player
|
|
693
|
+
* @memberof RpgSpriteLogic
|
|
694
|
+
*/
|
|
695
|
+
getShapes(): RpgShape[] {
|
|
696
|
+
return this.shapes
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private autoChangeDirection(nextPosition: Vector2d) {
|
|
700
|
+
const { x, y } = this.position
|
|
701
|
+
const { x: nx, y: ny } = nextPosition
|
|
702
|
+
const diff = Math.abs(x - nx) > Math.abs(y - ny)
|
|
703
|
+
if (diff) {
|
|
704
|
+
if (nx > x) {
|
|
705
|
+
this.changeDirection(Direction.Right)
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
this.changeDirection(Direction.Left)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
if (ny > y) {
|
|
713
|
+
this.changeDirection(Direction.Down)
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
this.changeDirection(Direction.Up)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Stops the movement of the player who moves towards his target
|
|
723
|
+
*
|
|
724
|
+
* @title Stop Move To
|
|
725
|
+
* @method player.stopMoveTo()
|
|
726
|
+
* @returns {void}
|
|
727
|
+
* @since 3.2.0
|
|
728
|
+
* @memberof MoveManager
|
|
729
|
+
*/
|
|
730
|
+
stopMoveTo() {
|
|
731
|
+
if (this.destroyMove$.closed) return
|
|
732
|
+
this.destroyMove$.next(true)
|
|
733
|
+
this.destroyMove$.unsubscribe()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
_moveTo(tick$: Observable<Tick>, positionTarget: AbstractObject | RpgShape | PositionXY, options: MoveTo = {}): Observable<Vector2d> {
|
|
737
|
+
let i = 0
|
|
738
|
+
let count = 0
|
|
739
|
+
const lastPositions: Vector2d[] = []
|
|
740
|
+
this.stopMoveTo()
|
|
741
|
+
this.destroyMove$ = new Subject()
|
|
742
|
+
const { infinite, onStuck, onComplete } = options
|
|
743
|
+
const getPosition = (): Vector2d => {
|
|
744
|
+
let pos
|
|
745
|
+
if ('x' in positionTarget) {
|
|
746
|
+
pos = new Vector2d(positionTarget.x, positionTarget.y)
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
pos = positionTarget.position
|
|
750
|
+
}
|
|
751
|
+
return pos
|
|
752
|
+
}
|
|
753
|
+
return tick$
|
|
754
|
+
.pipe(
|
|
755
|
+
takeUntil(this.destroyMove$),
|
|
756
|
+
takeUntil(this._destroy$),
|
|
757
|
+
mergeMap(() => from(this.computeNextPositionByTarget(this.position.copy(), getPosition()))),
|
|
758
|
+
map((position) => {
|
|
759
|
+
this.autoChangeDirection(position)
|
|
760
|
+
return this.position.set(position)
|
|
761
|
+
}),
|
|
762
|
+
tap((position: Vector2d) => {
|
|
763
|
+
lastPositions[i] = position.copy()
|
|
764
|
+
i++
|
|
765
|
+
count++
|
|
766
|
+
if (i >= 3) {
|
|
767
|
+
i = 0
|
|
768
|
+
}
|
|
769
|
+
if (
|
|
770
|
+
lastPositions[2] && lastPositions[0].isEqual(lastPositions[2])
|
|
771
|
+
) {
|
|
772
|
+
onStuck?.(count)
|
|
773
|
+
}
|
|
774
|
+
else if (this.position.isEqual(getPosition())) {
|
|
775
|
+
onComplete?.()
|
|
776
|
+
if (!infinite) {
|
|
777
|
+
this.stopMoveTo()
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
count = 0
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** @internal */
|
|
788
|
+
async move(nextPosition: Vector2d): Promise<boolean> {
|
|
789
|
+
this.autoChangeDirection(nextPosition)
|
|
790
|
+
|
|
791
|
+
const notCollided = !(await this.isCollided(nextPosition))
|
|
792
|
+
|
|
793
|
+
if (notCollided || !this.checkCollision) {
|
|
794
|
+
this.position = nextPosition.copy()
|
|
795
|
+
await RpgPlugin.emit(HookServer.PlayerMove, this)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return true
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Retrieves all shapes where the player is located
|
|
803
|
+
*
|
|
804
|
+
* @title Get In-Shapes
|
|
805
|
+
* @method player.getInShapes()
|
|
806
|
+
* @returns {RpgShape[]}
|
|
807
|
+
* @since 3.0.0-beta.3
|
|
808
|
+
* @memberof Player
|
|
809
|
+
*/
|
|
810
|
+
getInShapes(): RpgShape[] {
|
|
811
|
+
return Object.values(this.inShapes)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Get the current direction.
|
|
816
|
+
*
|
|
817
|
+
* ```ts
|
|
818
|
+
* player.getDirection()
|
|
819
|
+
* ```
|
|
820
|
+
*
|
|
821
|
+
* @title Get Direction
|
|
822
|
+
* @method player.getDirection()
|
|
823
|
+
* @returns {Direction | number} direction
|
|
824
|
+
* @memberof Player
|
|
825
|
+
*/
|
|
826
|
+
getDirection(direction?: Direction | number): string | number {
|
|
827
|
+
return direction || this.direction
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Changes the player's direction
|
|
832
|
+
*
|
|
833
|
+
* ```ts
|
|
834
|
+
* import { Direction } from '@rpgjs/server'
|
|
835
|
+
*
|
|
836
|
+
* player.changeDirection(Direction.Left)
|
|
837
|
+
* ```
|
|
838
|
+
*
|
|
839
|
+
* @title Change direction
|
|
840
|
+
* @method player.changeDirection(direction)
|
|
841
|
+
* @param {Direction} direction
|
|
842
|
+
* @enum {string}
|
|
843
|
+
*
|
|
844
|
+
* Direction.Left | left
|
|
845
|
+
* Direction.Right | right
|
|
846
|
+
* Direction.Up | up
|
|
847
|
+
* Direction.Down | down
|
|
848
|
+
* @returns {boolean} the direction has changed
|
|
849
|
+
* @memberof Player
|
|
850
|
+
*/
|
|
851
|
+
changeDirection(direction: Direction): boolean {
|
|
852
|
+
const dir = +this.getDirection(direction)
|
|
853
|
+
if (dir === undefined) return false
|
|
854
|
+
this.direction = dir
|
|
855
|
+
return true
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Gets the necessary number of pixels to allow the player to cross a tile.
|
|
860
|
+
* This is the ratio between the height or width of the tile and the speed of the player.
|
|
861
|
+
*/
|
|
862
|
+
get nbPixelInTile(): any {
|
|
863
|
+
const direction = this.getDirection()
|
|
864
|
+
switch (direction) {
|
|
865
|
+
case Direction.Down:
|
|
866
|
+
case Direction.Up:
|
|
867
|
+
return Math.floor(this.mapInstance.tileHeight / this.speed)
|
|
868
|
+
case Direction.Left:
|
|
869
|
+
case Direction.Right:
|
|
870
|
+
return Math.floor(this.mapInstance.tileWidth / this.speed)
|
|
871
|
+
default:
|
|
872
|
+
return NaN
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
getSizeMaxShape(x?: number, y?: number): Box {
|
|
877
|
+
const _x = x || this.position.x
|
|
878
|
+
const _y = y || this.position.y
|
|
879
|
+
let minX = _x
|
|
880
|
+
let minY = _y
|
|
881
|
+
let maxX = _x + this.wHitbox
|
|
882
|
+
let maxY = _y + this.hHitbox
|
|
883
|
+
const shapes = this.getShapes()
|
|
884
|
+
for (let shape of shapes) {
|
|
885
|
+
if (shape.x < minX) minX = shape.x
|
|
886
|
+
if (shape.y < minY) minY = shape.y
|
|
887
|
+
const shapeMaxX = shape.x + shape.width
|
|
888
|
+
const shapeMaxY = shape.y + shape.height
|
|
889
|
+
if (shapeMaxX > maxX) maxX = shapeMaxX
|
|
890
|
+
if (shapeMaxY > maxY) maxY = shapeMaxY
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
minX,
|
|
894
|
+
minY,
|
|
895
|
+
maxX,
|
|
896
|
+
maxY
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** @internal */
|
|
901
|
+
async execMethod(methodName: string, methodData?, instance?) { }
|
|
902
|
+
/** @internal */
|
|
903
|
+
onAction() { }
|
|
904
|
+
/** @internal */
|
|
905
|
+
onPlayerTouch() { }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export interface AbstractObject {
|
|
909
|
+
readonly type: string
|
|
910
|
+
through: boolean
|
|
911
|
+
throughOtherPlayer: boolean
|
|
912
|
+
autoChangeMap?(nextPosition: Position): Promise<boolean>
|
|
913
|
+
execMethod(methodName: string, methodData?, instance?)
|
|
914
|
+
changeMap(mapName: string)
|
|
915
|
+
}
|