flow: inventory and item usage!

This commit is contained in:
cha0s 2019-05-26 08:07:45 -05:00
parent 238eff70b4
commit a0249b66b3
18 changed files with 582 additions and 34 deletions

View File

@ -251,9 +251,20 @@ export class App extends decorate(class {}) {
const slotIndex = (parseInt(key) + 9) % 10; const slotIndex = (parseInt(key) + 9) % 10;
this.setActiveSlotIndex(slotIndex); this.setActiveSlotIndex(slotIndex);
break; break;
case 'ArrowLeft':
if (this.selfEntity) {
this.actionRegistry.state = this.actionRegistry.state.set(
'UseItem',
this.selfEntity.activeSlotIndex
);
}
break;
} }
} }
onKeyUp(key) {
}
onPacket(packet) { onPacket(packet) {
if (!this.hasReceivedState) { if (!this.hasReceivedState) {
this.renderIntoDom(document.querySelector('.app')).then(() => { this.renderIntoDom(document.querySelector('.app')).then(() => {
@ -468,8 +479,10 @@ export class App extends decorate(class {}) {
startProcessingInput() { startProcessingInput() {
const config = this.readConfig(); const config = this.readConfig();
// Pointer input. // Key input.
this.stage.on('keyDown', this.onKeyDown, this); this.stage.on('keyDown', this.onKeyDown, this);
this.stage.on('keyUp', this.onKeyUp, this);
// Pointer input.
this.stage.on('pointerDown', this.onPointerDown, this); this.stage.on('pointerDown', this.onPointerDown, this);
this.stage.on('pointerMove', this.onPointerMove, this); this.stage.on('pointerMove', this.onPointerMove, this);
this.stage.on('pointerUp', this.onPointerUp, this); this.stage.on('pointerUp', this.onPointerUp, this);
@ -486,6 +499,9 @@ export class App extends decorate(class {}) {
if (this.actionState !== this.actionRegistry.state) { if (this.actionState !== this.actionRegistry.state) {
this.actionState = this.actionRegistry.state; this.actionState = this.actionRegistry.state;
this.socket.send(InputPacket.fromState(this.actionState)); this.socket.send(InputPacket.fromState(this.actionState));
this.actionRegistry.state = this.actionRegistry.state.delete(
'UseItem'
);
} }
}, 1000 * config.inputFrequency); }, 1000 * config.inputFrequency);
// Mouse/touch movement. // Mouse/touch movement.

View File

@ -0,0 +1,18 @@
// 3rd party.
import React, {useEffect} from 'react';
export function useEvent(object, eventName, fn) {
useEffect(() => {
if (!object) {
return;
}
const onEvent = (...args) => {
fn(...args);
};
onEvent();
object.on(eventName, onEvent);
return () => {
object.off(eventName, onEvent);
};
}, [object]);
}

View File

@ -1,10 +1,12 @@
// 3rd party. // 3rd party.
import classnames from 'classnames'; import classnames from 'classnames';
import * as I from 'immutable';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
// 2nd party. // 2nd party.
import {compose} from '@avocado/core'; import {compose} from '@avocado/core';
import contempo from 'contempo'; import contempo from 'contempo';
// 1st party. // 1st party.
import {useEvent} from '../hooks/use-event';
import {usePropertyChange} from '../hooks/use-property-change'; import {usePropertyChange} from '../hooks/use-property-change';
import ItemSlot from './item-slot'; import ItemSlot from './item-slot';
@ -43,49 +45,34 @@ const decorate = compose(
const HotbarComponent = ({app}) => { const HotbarComponent = ({app}) => {
const selfEntity = usePropertyChange(app, 'selfEntity'); const selfEntity = usePropertyChange(app, 'selfEntity');
const activeSlotIndex = usePropertyChange(selfEntity, 'activeSlotIndex'); const activeSlotIndex = usePropertyChange(selfEntity, 'activeSlotIndex');
const [slotImageUris, setSlotImageUris] = useState({}); const [items, setItems] = useState(I.List());
useEffect(() => { useEvent(selfEntity, 'inventoryChanged', () => {
if (!selfEntity) { setItems(items.withMutations((items) => {
return; for (let i = 0; i < 10; ++i) {
}
const onInventoryChanged = () => {
const imageUris = {};
const itemPromises = [];
for (let i = 0; i < 10; i++) {
const item = selfEntity.itemInSlot(i); const item = selfEntity.itemInSlot(i);
if (!item) { if (!item) {
continue; continue;
} }
itemPromises.push(item.then((item) => { if (!items.has(i)) {
return item.hydrate().then(() => { items.set(i, I.Map());
const image = item.imageForSlot(0); }
if (image) { items.set(i, items.get(i).withMutations((listItem) => {
imageUris[i] = image.uri; listItem.set('backgroundImage', item.imageForSlot());
} listItem.set('qty', item.qty);
});
})); }));
} }
Promise.all(itemPromises).then(() => { }));
setSlotImageUris(imageUris); });
})
};
selfEntity.on('inventoryChanged', onInventoryChanged);
onInventoryChanged();
return () => {
selfEntity.off('inventoryChanged', onInventoryChanged);
};
}, [selfEntity]);
const hotkeyForSlot = (index) => { const hotkeyForSlot = (index) => {
return (index + 1) % 10; return (index + 1) % 10;
} }
const slots = []; const slots = [];
for (let i = 0; i < 10; ++i) { for (let i = 0; i < 10; ++i) {
const slot = <ItemSlot const slot = <ItemSlot
backgroundImage={slotImageUris[i]}
className={classnames( className={classnames(
activeSlotIndex === i ? 'active' : '', activeSlotIndex === i ? 'active' : '',
)} )}
item={items.get(i)}
key={i} key={i}
onClick={(event) => { onClick={(event) => {
if (selfEntity) { if (selfEntity) {

View File

@ -1,6 +1,6 @@
// 3rd party. // 3rd party.
import classnames from 'classnames'; import classnames from 'classnames';
import React from 'react'; import React, {useEffect, useState} from 'react';
// 2nd party. // 2nd party.
import {compose} from '@avocado/core'; import {compose} from '@avocado/core';
import contempo from 'contempo'; import contempo from 'contempo';
@ -30,12 +30,62 @@ const decorate = compose(
background-size: contain; background-size: contain;
width: 100%; width: 100%;
height: 100%; height: 100%;
image-rendering: optimizeSpeed; /* Older versions of FF */
image-rendering: -moz-crisp-edges; /* FF 6.0+ */
image-rendering: -webkit-optimize-contrast; /* Safari */
image-rendering: -o-crisp-edges; /* OS X & Windows Opera (12.02+) */
image-rendering: pixelated; /* Awesome future-browsers */
-ms-interpolation-mode: nearest-neighbor; /* IE */
} }
.item-slot-inner .qty {
color: white;
text-shadow: 0.25px 0.25px 0 black;
font-family: joystix;
position: absolute;
bottom: 0px;
right: 0.5px;
font-size: 5px;
line-height: 5px;
}
.qty.e3 {
font-size: 4px;
}
.qty.e4 {
font-size: 3px;
bottom: -1px;
}
`), `),
); );
const ItemSlotComponent = (props) => { const ItemSlotComponent = (props) => {
const {backgroundImage, children, className, ...rest} = props; const {children, className, item, ...rest} = props;
let backgroundImageUri;
let qty;
let qtyClass;
if (item) {
const backgroundImage = item.get('backgroundImage');
if (backgroundImage) {
backgroundImageUri = backgroundImage.uri;
}
qty = item.get('qty');
if (qty > 9999) {
qtyClass = 'e4';
}
else if (qty > 999) {
qtyClass = 'e3';
}
else if (qty > 99) {
qtyClass = 'e2';
}
else if (qty > 9) {
qtyClass = 'e1';
}
else {
qtyClass = 'e0';
}
}
return <div return <div
{...rest} {...rest}
className={classnames( className={classnames(
@ -46,8 +96,16 @@ const ItemSlotComponent = (props) => {
> >
<div <div
className="item-slot-inner" className="item-slot-inner"
style={{backgroundImage: `url(${backgroundImage})`}} style={{
>{children}</div> backgroundImage: backgroundImageUri && `url(${backgroundImageUri})`,
}}
>
{children}
{qty && <div className={classnames(
'qty',
qtyClass,
)}>{qty}</div>}
</div>
</div>; </div>;
} }

View File

@ -16,6 +16,12 @@ export class Controllable extends Trait {
set inputState(inputState) { set inputState(inputState) {
this._inputState = I.Map(inputState); this._inputState = I.Map(inputState);
if (AVOCADO_SERVER) {
if (inputState.has('UseItem')) {
const slotIndex = inputState.get('UseItem');
this.entity.useItemInSlot(slotIndex);
}
}
} }
tick(elapsed) { tick(elapsed) {

View File

@ -0,0 +1,76 @@
import {behaviorItemFromJSON} from '@avocado/behavior';
import {compose, TickingPromise} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/entity';
import {Image} from '@avocado/graphics';
const decorate = compose(
StateProperty('qty', {
track: true,
}),
);
export class Item extends decorate(Trait) {
static defaultParams() {
return {
itemActions: {
type: 'actions',
traversals: [],
},
slotImages: {
default: '/question-mark.png',
},
};
}
static defaultState() {
return {
qty: 1,
};
}
static type() {
return 'item';
}
constructor(entity, params, state) {
super(entity, params, state);
this._itemActions = behaviorItemFromJSON(this.params.itemActions);
this._slotImages = {};
}
hydrate() {
const promises = [];
for (const index in this.params.slotImages) {
promises.push(Image.load(this.params.slotImages[index]).then((image) => {
this._slotImages[index] = image;
}));
}
return Promise.all(promises);
}
get itemActions() {
return this._itemActions;
}
methods() {
return {
decrementQuantity: (amount = 1) => {
this.entity.qty -= amount;
},
imageForSlot: () => {
const qty = this.entity.qty;
if (this._slotImages[qty]) {
return this._slotImages[qty];
}
else {
return this._slotImages['default'];
}
},
};
}
}

View File

@ -0,0 +1,208 @@
import {compose, TickingPromise} from '@avocado/core';
import {Entity, StateProperty, Trait} from '@avocado/entity';
import {TraitItemQtyPacket} from './trait-item-qty.packet';
import {TraitItemSwapPacket} from './trait-item-swap.packet';
const decorate = compose(
StateProperty('slotCount', {
track: true,
}),
);
const NULL_SLOT = 65535;
const AUTO_SLOT = 65535;
// TODO more localized events; inventoryChanged is too noisy
export class Receptacle extends decorate(Trait) {
static defaultParams() {
return {
slots: {},
};
}
static defaultState() {
return {
slotCount: 10,
};
}
static type() {
return 'receptacle';
}
constructor(entity, params, state) {
super(entity, params, state);
this.packetUpdates = [];
this.qtyListeners = new Map();
this.slotItems = {};
// Load items.
for (const index in this.params.slots) {
const slotSpec = this.params.slots[index];
this.slotItems[index] = Entity.load(slotSpec.uri).then((item) => {
// Set quantity.
item.qty = slotSpec.qty;
// On the client, hydrate the item before adding it to the inventory.
if (AVOCADO_CLIENT) {
item.hydrate().then(() => {
this.entity.addItemToSlot(item, index);
});
}
// Server just adds it.
else {
this.entity.addItemToSlot(item, index);
}
});
}
}
destroy() {
for (let i = 0; i < this.entity.slotCount; ++i) {
const item = this.entity.removeItemFromSlot(i);
if (item) {
this.removeListenersForItem(item);
item.destroy();
}
}
}
acceptPacket(packet) {
// Quantity update.
if (packet instanceof TraitItemQtyPacket) {
const item = this.entity.itemInSlot(packet.data.slotIndex);
if (item) {
item.qty = packet.data.qty;
}
}
// Slot swap.
if (packet instanceof TraitItemSwapPacket) {
// NULL destination slot === remove.
const {firstSlotIndex, secondSlotIndex} = packet.data;
if (NULL_SLOT === secondSlotIndex) {
const item = this.entity.removeItemFromSlot(firstSlotIndex);
if (item) {
item.destroy();
}
}
// Normal slot swap.
else {
this.entity.swapSlots(firstSlotIndex, secondSlotIndex);
}
}
}
addListenersForItem(item) {
const listener = (oldQty, newQty) => {
if (AVOCADO_SERVER) {
const slotIndex = this.itemSlotIndex(item);
// Valid quantity; update client.
if (newQty > 0) {
this.packetUpdates.push(new TraitItemQtyPacket({
slotIndex,
qty: item.qty,
}, this.entity));
}
// Item was used up.
else {
this.entity.removeItemFromSlot(slotIndex);
item.destroy();
}
}
this.entity.emit('inventoryChanged');
};
this.qtyListeners.set(item, listener);
item.on('qtyChanged', listener);
}
isSlotIndexInRange(slotIndex) {
return slotIndex >= 0 && slotIndex < this.entity.slotCount;
}
itemSlotIndex(item) {
for (let i = 0; i < this.entity.slotCount; ++i) {
if (item === this.slotItems[i]) {
return i;
}
}
return -1;
}
// TODO
mergeItem(item) {
}
packetsForUpdate() {
const packetsForUpdate = this.packetUpdates;
this.packetUpdates = [];
return packetsForUpdate;
}
removeListenersForItem(item) {
item.off('qtyChanged', this.qtyListeners.get(item));
this.qtyListeners.delete(item);
}
methods() {
return {
addItemToSlot: (item, slotIndex = AUTO_SLOT) => {
if (AUTO_SLOT === slotIndex) {
return this.mergeItem(item);
}
else {
this.addListenersForItem(item);
this.slotItems[slotIndex] = item;
}
this.entity.emit('inventoryChanged');
},
removeItemFromSlot: (slotIndex) => {
const item = this.entity.itemInSlot(slotIndex);
if (!item) {
return;
}
this.removeListenersForItem(item);
delete this.slotItems[slotIndex];
if (AVOCADO_SERVER) {
this.packetUpdates.push(new TraitItemSwapPacket({
firstSlotIndex: slotIndex,
secondSlotIndex: NULL_SLOT,
}, this.entity));
}
this.entity.emit('inventoryChanged');
return item;
},
itemInSlot: (slotIndex) => {
if (this.isSlotIndexInRange(slotIndex)) {
// While it's loading, it'll be a promise.
if (this.slotItems[slotIndex] instanceof Entity) {
return this.slotItems[slotIndex];
}
}
},
swapSlots: (leftIndex, rightIndex) => {
if (
!this.isSlotIndexInRange(leftIndex)
|| !this.isSlotIndexInRange(rightIndex)
) {
return;
}
// Swap items.
const leftItem = this.entity.itemInSlot(leftIndex);
const rightItem = this.entity.itemInSlot(rightIndex);
this.removeListenersForItem(leftItem);
this.removeListenersForItem(rightItem);
this.slotItems[leftIndex] = rightItem;
this.slotItems[rightIndex] = leftItem;
this.addListenersForItem(leftItem);
this.addListenersForItem(rightItem);
},
};
}
}

View File

@ -0,0 +1,12 @@
import {EntityPacket} from '@avocado/entity';
export class TraitItemQtyPacket extends EntityPacket {
static get schema() {
const schema = super.schema;
schema.data.slotIndex = 'uint16';
schema.data.qty = 'uint16';
return schema;
}
}

View File

@ -0,0 +1,12 @@
import {EntityPacket} from '@avocado/entity';
export class TraitItemSwapPacket extends EntityPacket {
static get schema() {
const schema = super.schema;
schema.data.firstSlotIndex = 'uint16';
schema.data.secondSlotIndex = 'uint16';
return schema;
}
}

View File

@ -0,0 +1,84 @@
import {createContext} from '@avocado/behavior';
import {compose} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/entity';
const decorate = compose(
StateProperty('activeSlotIndex', {
track: true,
}),
);
export class Wielder extends decorate(Trait) {
static dependencies() {
return [
'receptacle',
];
}
static defaultState() {
return {
activeSlotIndex: 0,
};
}
static type() {
return 'wielder';
}
constructor(entity, params, state) {
super(entity, params, state);
this.itemActions = undefined;
this.itemActionsContext = createContext();
this.itemInUse = undefined;
}
methods() {
return {
useItemInActiveSlot: () => {
if (-1 === this.entity.activeSlotIndex) {
return;
}
this.entity.useItemInSlot(this.entity.activeSlotIndex)
},
useItemInSlot: (slotIndex) => {
// Already using an item? TODO: cancel on active slot change?
if (this.itemInUse) {
return;
}
const item = this.entity.itemInSlot(slotIndex);
if (!item) {
return;
}
this.itemInUse = item;
// Set up context.
this.itemActionsContext.add('user', this.entity);
this.itemActionsContext.add('item', item);
this.itemActionsContext.add('slotIndex', slotIndex);
// Keep a reference to the item.
this.itemActions = item.itemActions;
// Defer until the item actions are finished.
return new Promise((resolve) => {
this.itemActions.once('actionsFinished', () => {
// Check if the item was used up.
this.itemActions = undefined;
this.itemActionsContext.clear();
this.itemInUse = undefined;
resolve();
});
});
},
};
}
tick(elapsed) {
if (this.itemActions) {
this.itemActions.tick(this.itemActionsContext, elapsed);
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"traits":{"damaging":{"params":{"damageSpecs":[{"affinity":0,"lock":0,"power":-50,"variance":0.1}]}},"existent":{},"item":{"params":{"itemActions":{"type":"actions","traversals":[{"type":"traversal","steps":[{"type":"key","key":"user"},{"type":"key","key":"takeDamageFrom"},{"type":"invoke","args":[{"type":"traversal","steps":[{"type":"key","key":"item"}]}]}]},{"type":"traversal","steps":[{"type":"key","key":"item"},{"type":"key","key":"decrementQuantity"},{"type":"invoke","args":[]}]},{"type":"traversal","steps":[{"type":"key","key":"global"},{"type":"key","key":"wait"},{"type":"invoke","args":[{"type":"literal","value":0.5}]}]}]},"slotImages":{"default":"/potion.png"}}}}}

BIN
resource/potion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
resource/question-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

View File

@ -59,6 +59,16 @@ export function createEntityForConnection(socket) {
y: y * 4, y: y * 4,
}, },
}, },
receptacle: {
params: {
slots: {
0: {
qty: 10,
uri: '/potion.entity.json',
},
},
},
},
roomed: {}, roomed: {},
shaped: { shaped: {
params: { params: {
@ -70,6 +80,11 @@ export function createEntityForConnection(socket) {
}, },
visible: {}, visible: {},
vulnerable: {}, vulnerable: {},
wielder: {
state: {
activeSlotIndex: 0,
},
},
}, },
}); });
// Embed socket. // Embed socket.

View File

@ -25,6 +25,9 @@ import {mamaKittySpawnerJSON} from './fixtures/mama-kitty-spawner.entity';
writeFixture('mama-kitty-spawner.entity.json', mamaKittySpawnerJSON()); writeFixture('mama-kitty-spawner.entity.json', mamaKittySpawnerJSON());
import {mamaKittyJSON} from './fixtures/mama-kitty.entity'; import {mamaKittyJSON} from './fixtures/mama-kitty.entity';
writeFixture('mama-kitty.entity.json', mamaKittyJSON()); writeFixture('mama-kitty.entity.json', mamaKittyJSON());
// Write items.
import {potionJSON} from './fixtures/potion.entity';
writeFixture('potion.entity.json', potionJSON());
// Write rooms. // Write rooms.
import {kittyFireJSON} from './fixtures/kitty-fire.room'; import {kittyFireJSON} from './fixtures/kitty-fire.room';
writeFixture('kitty-fire.room.json', kittyFireJSON()); writeFixture('kitty-fire.room.json', kittyFireJSON());

View File

@ -0,0 +1,49 @@
import {buildInvoke, buildTraversal} from '@avocado/behavior';
import {AFFINITY_NONE} from '../../common/combat/constants';
// Healing potion.
export function potionJSON() {
const causeHealing = buildInvoke(['user', 'takeDamageFrom'], [
buildTraversal(['item']),
]);
const decrement = buildInvoke(
['item', 'decrementQuantity'],
);
const cooldown = buildInvoke(
['global', 'wait'],
[0.5],
);
return {
traits: {
damaging: {
params: {
damageSpecs: [
{
affinity: AFFINITY_NONE,
lock: 0,
power: -50,
variance: 0.1,
},
],
},
},
existent: {},
item: {
params: {
itemActions: {
type: 'actions',
traversals: [
causeHealing,
decrement,
cooldown,
],
},
slotImages: {
default: '/potion.png',
},
},
},
},
};
}

View File

@ -247,6 +247,9 @@ export class Informed extends decorate(Trait) {
if (0 === packets.length) { if (0 === packets.length) {
return; return;
} }
// TODO: filter packets that are only delivered to self entity.
// Filter invisible entities. // Filter invisible entities.
packets = this.filterInvisibleEntityPackets(packets); packets = this.filterInvisibleEntityPackets(packets);
// Reduce entities by range. // Reduce entities by range.