diff --git a/app/ecs/components/inventory.js b/app/ecs/components/inventory.js index ab3ba54..b9bad8a 100644 --- a/app/ecs/components/inventory.js +++ b/app/ecs/components/inventory.js @@ -97,21 +97,25 @@ class ItemProxy { get projection() { return this.json.projection; } + get price() { + return this.json.price; + } get qty() { return this.instance.slots[this.slot].qty; } set qty(qty) { const {instance} = this; if (qty <= 0) { - this.Component.markChange(instance.entity, 'cleared', {[this.slot]: true}); - delete instance.slots[this.slot]; - delete instance.$$items[this.slot]; + instance.clear(this.slot); } else { instance.slots[this.slot].qty = qty; this.Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: qty}); } } + get source() { + return this.instance.slots[this.slot].source; + } } export default class Inventory extends Component { @@ -169,6 +173,11 @@ export default class Inventory extends Component { const Component = this; return class InventoryInstance extends Instance { $$items = {}; + clear(slot) { + Component.markChange(this.entity, 'cleared', {[slot]: true}); + delete this.slots[slot]; + delete this.$$items[slot]; + } item(slot) { return this.$$items[slot]; } @@ -185,7 +194,7 @@ export default class Inventory extends Component { if (!slots[slot]) { slots[slot] = stack; this.$$items[slot] = new ItemProxy(Component, this, slot); - await this.$$items[slot].load(); + await this.$$items[slot].load(stack.source); Component.markChange(this.entity, 'given', {[slot]: slots[slot]}); return; } diff --git a/app/ecs/components/shop.js b/app/ecs/components/shop.js new file mode 100644 index 0000000..3d4d8d0 --- /dev/null +++ b/app/ecs/components/shop.js @@ -0,0 +1,3 @@ +import Component from '@/ecs/component.js'; + +export default class Shop extends Component {} diff --git a/app/ecs/components/wallet.js b/app/ecs/components/wallet.js new file mode 100644 index 0000000..d52e000 --- /dev/null +++ b/app/ecs/components/wallet.js @@ -0,0 +1,7 @@ +import Component from '@/ecs/component.js'; + +export default class Wallet extends Component { + static properties = { + gold: {type: 'uint32'}, + }; +} diff --git a/app/react/components/dom/bag.jsx b/app/react/components/dom/bag.jsx index 193e05f..34ad4ca 100644 --- a/app/react/components/dom/bag.jsx +++ b/app/react/components/dom/bag.jsx @@ -1,6 +1,7 @@ import {memo} from 'react'; import styles from './bag.module.css'; +import gridStyles from './grid.module.css'; import Grid from './grid.jsx'; @@ -8,8 +9,9 @@ import Grid from './grid.jsx'; * Inventory bag. 10-40 slots of inventory. */ function Bag({ + highlighted, isInventoryOpen, - onActivate, + onSlotMouseDown, slots, }) { return ( @@ -17,11 +19,20 @@ function Bag({ className={styles.bag} style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, left: '-440px'}} > + diff --git a/app/react/components/dom/external.jsx b/app/react/components/dom/external.jsx index 3755f7e..7af68a9 100644 --- a/app/react/components/dom/external.jsx +++ b/app/react/components/dom/external.jsx @@ -1,6 +1,7 @@ import {memo} from 'react'; import styles from './external.module.css'; +import gridStyles from './grid.module.css'; import Grid from './grid.jsx'; @@ -8,8 +9,9 @@ import Grid from './grid.jsx'; * External inventory. */ function External({ + highlighted, isInventoryOpen, - onActivate, + onSlotMouseDown, slots, }) { return ( @@ -17,11 +19,20 @@ function External({ className={styles.external} style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, top: '450px'}} > + diff --git a/app/react/components/dom/external.module.css b/app/react/components/dom/external.module.css index 1b717b0..49e57a9 100644 --- a/app/react/components/dom/external.module.css +++ b/app/react/components/dom/external.module.css @@ -1,7 +1,3 @@ .external { - left: 20px; - opacity: 1; - position: absolute; - top: 274px; - transition: top 150ms, opacity 200ms; + --nothing: 0; } diff --git a/app/react/components/dom/grid.jsx b/app/react/components/dom/grid.jsx index 1a33b49..c40413b 100644 --- a/app/react/components/dom/grid.jsx +++ b/app/react/components/dom/grid.jsx @@ -8,14 +8,19 @@ export default function Grid({ active = -1, color, columns, + highlighted, label, - onActivate, + onSlotMouseDown, slots, }) { const Slots = slots.map((slot, i) => (
{ - onActivate(i) + onSlotMouseDown(i) event.stopPropagation(); }} onMouseUp={(event) => { event.stopPropagation(); }} - onDragOver={(event) => { - event.preventDefault(); - }} onDragStart={(event) => { - if (!slot) { - event.preventDefault(); - } - event.dataTransfer.setData('silphius/item', i); - onActivate(i); - }} - onDrop={(event) => { event.preventDefault(); - onActivate(i); }} qty={slot?.qty} /> diff --git a/app/react/components/dom/grid.module.css b/app/react/components/dom/grid.module.css index b4b569d..883f8fa 100644 --- a/app/react/components/dom/grid.module.css +++ b/app/react/components/dom/grid.module.css @@ -43,3 +43,7 @@ z-index: 1; } } + +.highlighted { + --nothing: 0; +} diff --git a/app/react/components/dom/hotbar.jsx b/app/react/components/dom/hotbar.jsx index c0a1772..e219a3f 100644 --- a/app/react/components/dom/hotbar.jsx +++ b/app/react/components/dom/hotbar.jsx @@ -10,8 +10,9 @@ import Grid from './grid.jsx'; */ function Hotbar({ active, + highlighted, hotbarIsHidden, - onActivate, + onSlotMouseDown, slots, }) { return ( @@ -23,13 +24,20 @@ function Hotbar({ .${styles.hotbar} .${gridStyles.label} { text-align: center; } + .${styles.hotbar} .${gridStyles.highlighted} { + background-color: rgba(255, 0, 0, 0.4); + } + .${styles.hotbar} .${gridStyles.highlighted} button { + border: 2.5px dashed rgba(255, 255, 255, 0.4); + } `}
diff --git a/app/react/components/dom/trade.jsx b/app/react/components/dom/trade.jsx new file mode 100644 index 0000000..f9bb658 --- /dev/null +++ b/app/react/components/dom/trade.jsx @@ -0,0 +1,86 @@ +import styles from './trade.module.css'; + +function reducePrice(r, item) { + return r + item.price * item.qty; +} + +function Trade({ + isInventoryOpen, + losing: losingUnfiltered, + onTradeAccepted, + gaining: gainingUnfiltered, + wallet, +}) { + const losing = losingUnfiltered.filter(Boolean); + const gaining = gainingUnfiltered.filter(Boolean); + const nop = 0 === losing.length && 0 === gaining.length; + let earn = losing.reduce(reducePrice, 0) - gaining.reduce(reducePrice, 0); + const canAfford = -earn <= wallet; + return ( +
+
+ { + nop + ? ( +
Let's trade!
+ ) + : ( +
    + {losing.length > 0 && ( +
  • + Shop receives: + {' '} +
      + {losing.map(({label, qty}, i) => ( +
    • {label} x{qty}
    • + ))} +
    +
  • + )} + {gaining.length > 0 && ( +
  • + You receive: + {' '} +
      + {gaining.map(({label, qty}, i) => ( +
    • {label} x{qty}
    • + ))} +
    +
  • + )} +
  • + { + earn > 0 + ? ( + You earn {earn}🄶 + ) + : ( + earn < 0 + ? You pay {-earn}🄶 + : false + ) + } + +
  • +
+ ) + } +
+
+ ); +} + +export default Trade; diff --git a/app/react/components/dom/trade.module.css b/app/react/components/dom/trade.module.css new file mode 100644 index 0000000..b78303b --- /dev/null +++ b/app/react/components/dom/trade.module.css @@ -0,0 +1,129 @@ +.trade { + border: 2.5px solid #444444; + flex-grow: 1; + font-size: 0.8em; + margin: 16px 40px 0 8px; + text-shadow: + 1px 0 0 black, + 0 1px 0 black, + -1px 0 0 black, + 0 -1px 0 black + ; +} + +.tradeInner { + background-color: rgba(0, 0, 0, 0.7); + border: 2.5px solid #999999; + display: flex; + font-family: Cookbook, Georgia, 'Times New Roman', Times, serif; + height: 100%; + padding: 2.5px; + position: relative; + width: 100%; + ul { + list-style: none; + margin: 0; + padding-left: 0; + } +} + +.agree { + background-color: rgba(255, 90, 0, 1); + border: 2.5px solid #cccccc; + box-shadow: inset 0 0 10px #666666; + color: white; + font-family: Cookbook, Georgia, 'Times New Roman', Times, serif; + font-size: 1em; + padding: 5px; + position: absolute; + bottom: 2.5px; + right: 2.5px; + text-shadow: + 1px 0 0 black, + 0 1px 0 black, + -1px 0 0 black, + 0 -1px 0 black + ; + transition: box-shadow 200ms; + &:disabled { + background-color: #333333; + border-color: #777777; + color: #777777; + } + &:not(:disabled):hover { + box-shadow: inset 0 0 10px #222222; + cursor: pointer; + text-decoration: underline; + } +} + +.summary { + display: flex; + flex-direction: column; + gap: 2.5px; + width: 100%; + > li { + padding: 2.5px; + } +} + +.gain { + background-color: rgba(0, 255, 0, 0.5); + border: 2.5px solid rgba(0, 255, 0, 0.5); + max-height: 40.5px; + overflow-y: auto; +} + +.lose { + background-color: rgba(255, 0, 0, 0.5); + border: 2.5px solid rgba(255, 0, 0, 0.5); + max-height: 40.5px; + overflow-y: auto; +} + +.items { + display: inline; + li { + display: inline; + &:not(:last-child):after { + content: ', '; + } + } +} + +.make { + color: rgb(0, 255, 0); +} + +.pay { + color: red; +} + +.gold { + color: gold; +} + +.qty { + font-family: Joystix; + font-size: 0.75em; +} + +.cost { + position: absolute; + bottom: 2.5px; + left: 5px; +} + +.amount { + font-size: 1.5em; + position: relative; + top: 2px; +} + +.nop { + align-self: center; + font-size: 2em; + padding: 1em; + text-align: center; + width: 100%; +} diff --git a/app/react/components/dom/wallet.jsx b/app/react/components/dom/wallet.jsx new file mode 100644 index 0000000..34275b6 --- /dev/null +++ b/app/react/components/dom/wallet.jsx @@ -0,0 +1,17 @@ +import styles from './wallet.module.css'; + +function Wallet({gold}) { + const goldString = gold.toString(); + const pad = ''.padStart(7 - goldString.length, '0'); + return ( + + + {pad} + {goldString} + + 🄶 + + ) +} + +export default Wallet; diff --git a/app/react/components/dom/wallet.module.css b/app/react/components/dom/wallet.module.css new file mode 100644 index 0000000..7b77e9b --- /dev/null +++ b/app/react/components/dom/wallet.module.css @@ -0,0 +1,19 @@ +.wallet { + position: absolute; + right: 10px; + text-shadow: 1px 0 0 black, 0 1px 0 black, -1px 0 0 black, 0 -1px 0 black; + top: 20px; +} + +.gold { + color: gold; + font-family: Cookbook, Georgia, 'Times New Roman', Times, serif; +} + +.number { + font-family: Joystix; +} + +.pad { + color: #555555; +} diff --git a/app/react/components/ui.jsx b/app/react/components/ui.jsx index 41222b9..82b277d 100644 --- a/app/react/components/ui.jsx +++ b/app/react/components/ui.jsx @@ -15,6 +15,8 @@ import DateTime from './dom/datetime.jsx'; import Dom from './dom/dom.jsx'; import Entities from './dom/entities.jsx'; import External from './dom/external.jsx'; +import Trade from './dom/trade.jsx'; +import Wallet from './dom/wallet.jsx'; import HotBar from './dom/hotbar.jsx'; import Pixi from './pixi/pixi.jsx'; import Devtools from './devtools.jsx'; @@ -62,7 +64,11 @@ function Ui({disconnected}) { const [isInventoryOpen, setIsInventoryOpen] = useState(false); const [externalInventory, setExternalInventory] = useState(); const [externalInventorySlots, setExternalInventorySlots] = useState(); + const [gaining, setGaining] = useState([]); + const [losing, setLosing] = useState([]); + const [wallet, setWallet] = useState(0); const [particleWorker, setParticleWorker] = useState(); + const [trading, setTrading] = useState(false); useEffect(() => { let handle; if (disconnected) { @@ -245,6 +251,9 @@ function Ui({disconnected}) { if (update.MainEntity) { mainEntityRef.current = id; } + if (update.Wallet && mainEntityRef.current === id) { + setWallet(update.Wallet.gold); + } if (update.Inventory) { if (mainEntityRef.current === id) { setBufferSlot(entity.Inventory.item(0)); @@ -267,6 +276,7 @@ function Ui({disconnected}) { newInventorySlots[i] = entity.Inventory.item(i); } setExternalInventory(entity.id) + setTrading(!!entity.Shop); setExternalInventorySlots(newInventorySlots); setIsInventoryOpen(true); setHotbarIsHidden(false); @@ -277,6 +287,9 @@ function Ui({disconnected}) { else if (update.Inventory.closed) { setExternalInventory(); setExternalInventorySlots(); + setGaining([]); + setLosing([]); + setTrading(false); } } if (mainEntityRef.current === id) { @@ -371,25 +384,67 @@ function Ui({disconnected}) { mainEntityRef, scale, ]); - const hotbarOnActivate = useCallback((i) => { + const hotbarOnSlotMouseDown = useCallback((i) => { keepHotbarOpen(); + if (trading) { + const index = losing.indexOf(i + 1); + if (-1 === index) { + losing.push(i + 1); + } + else { + losing.splice(index, 1); + } + setLosing([...losing]); + } + else { + client.send({ + type: 'Action', + payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 1]}, + }); + } + }, [client, keepHotbarOpen, losing, mainEntityRef, trading]); + const bagOnSlotMouseDown = useCallback((i) => { + if (trading) { + const index = losing.indexOf(i + 11); + if (-1 === index) { + losing.push(i + 11); + } + else { + losing.splice(index, 1); + } + setLosing([...losing]); + } + else { + client.send({ + type: 'Action', + payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 11]}, + }); + } + }, [client, losing, mainEntityRef, trading]); + const externalInventoryOnSlotMouseDown = useCallback((i) => { + if (trading) { + const index = gaining.indexOf(i); + if (-1 === index) { + gaining.push(i); + } + else { + gaining.splice(index, 1); + } + setGaining([...gaining]); + } + else { + client.send({ + type: 'Action', + payload: {type: 'swapSlots', value: [0, externalInventory, i]}, + }); + } + }, [client, externalInventory, gaining, trading]); + const onTradeAccepted = useCallback(() => { client.send({ type: 'Action', - payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 1]}, + payload: {type: 'acceptTrade', value: {gaining, losing}}, }); - }, [client, keepHotbarOpen, mainEntityRef]); - const bagOnActivate = useCallback((i) => { - client.send({ - type: 'Action', - payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 11]}, - }); - }, [client, mainEntityRef]); - const externalInventoryOnActivate = useCallback((i) => { - client.send({ - type: 'Action', - payload: {type: 'swapSlots', value: [0, externalInventory, i]}, - }); - }, [client, externalInventory]); + }, [client, gaining, losing]); useEffect(() => { if (!pixiRef.current) { return; @@ -510,21 +565,37 @@ function Ui({disconnected}) { i < 11).map((i) => i - 1)} hotbarIsHidden={hotbarIsHidden} - onActivate={hotbarOnActivate} + onSlotMouseDown={hotbarOnSlotMouseDown} slots={hotbarSlots} /> i >= 11).map((i) => i - 11)} isInventoryOpen={isInventoryOpen} - onActivate={bagOnActivate} + onSlotMouseDown={bagOnSlotMouseDown} slots={inventorySlots} /> {externalInventory && ( - +
+ + {trading && ( + externalInventorySlots[slot])} + losing={losing.map((slot) => { + return slot < 11 ? hotbarSlots[slot - 1] : inventorySlots[slot - 11]; + })} + wallet={wallet} + /> + )} +
)} )} +
{devtoolsIsOpen && ( diff --git a/app/react/components/ui.module.css b/app/react/components/ui.module.css index 583c77f..2299940 100644 --- a/app/react/components/ui.module.css +++ b/app/react/components/ui.module.css @@ -22,3 +22,13 @@ position: relative; user-select: none; } + +.external { + display: flex; + left: 20px; + opacity: 1; + position: absolute; + top: 274px; + transition: top 150ms, opacity 200ms; + width: 100%; +} diff --git a/app/server/create/homestead.js b/app/server/create/homestead.js index e8073b7..28befba 100644 --- a/app/server/create/homestead.js +++ b/app/server/create/homestead.js @@ -152,6 +152,7 @@ export default async function createHomestead(id) { }, }, Position: {x: 200, y: 200}, + Shop: {}, Sprite: { anchorX: 0.5, anchorY: 0.7, diff --git a/app/server/create/player.js b/app/server/create/player.js index f5c1080..536d3f7 100644 --- a/app/server/create/player.js +++ b/app/server/create/player.js @@ -49,6 +49,9 @@ export default async function createPlayer(id) { }, Ticking: {}, VisibleAabb: {}, + Wallet: { + gold: 1000, + }, Wielder: { activeSlot: 1, }, diff --git a/app/server/engine.js b/app/server/engine.js index 328e7b1..c19a6c5 100644 --- a/app/server/engine.js +++ b/app/server/engine.js @@ -166,6 +166,8 @@ export default class Engine { Interacts, Interlocutor, Inventory, + Player, + Wallet, Wielder, } = entity; const ecs = this.ecses[Ecs.path]; @@ -231,6 +233,33 @@ export default class Engine { Controlled[payload.type] = payload.value; break; } + case 'acceptTrade': { + const {losing, gaining} = payload.value; + const gainingSlots = gaining.filter((slot) => Player.openInventory.item(slot)); + const losingSlots = losing.filter((slot) => Inventory.item(slot)); + const nop = 0 === gainingSlots.length && 0 === losingSlots.length; + const gainingItems = gainingSlots.map((slot) => Player.openInventory.item(slot)); + const losingItems = losingSlots.map((slot) => Inventory.item(slot)); + if (nop) { + break; + } + const reducePrice = (r, item) => { + return r + item.price * item.qty; + }; + const earn = losingItems.reduce(reducePrice, 0) - gainingItems.reduce(reducePrice, 0); + const canAfford = -earn <= Wallet.gold; + if (!canAfford) { + break; + } + for (const slot of losingSlots) { + Inventory.clear(slot); + } + for (const item of gainingItems) { + Inventory.give({qty: item.qty, source: item.source}); + } + Wallet.gold += earn; + break; + } case 'swapSlots': { if (!Controlled.locked) { const [l, other, r] = payload.value; diff --git a/resources/brush/brush.json b/resources/brush/brush.json index fa2d886..3acb94d 100644 --- a/resources/brush/brush.json +++ b/resources/brush/brush.json @@ -1,5 +1,6 @@ { "icon": "/resources/brush/brush.png", "label": "Brush", + "price": 100, "start": "/resources/brush/start.js" } \ No newline at end of file diff --git a/resources/furball/furball.json b/resources/furball/furball.json index 7ccc3ee..3041d32 100644 --- a/resources/furball/furball.json +++ b/resources/furball/furball.json @@ -1,4 +1,5 @@ { "icon": "/resources/furball/furball.png", - "label": "Fur Ball" + "label": "Fur Ball", + "price": 5 } diff --git a/resources/hoe/hoe.json b/resources/hoe/hoe.json index 0ae663f..8ba90b2 100644 --- a/resources/hoe/hoe.json +++ b/resources/hoe/hoe.json @@ -1,6 +1,7 @@ { "icon": "/resources/hoe/icon.png", "label": "Hoe", + "price": 100, "projectionCheck": "/resources/hoe/projection-check.js", "projection": { "distance": [3, -1], diff --git a/resources/magic-swords/magic-swords.json b/resources/magic-swords/magic-swords.json index 65573d0..12f9377 100644 --- a/resources/magic-swords/magic-swords.json +++ b/resources/magic-swords/magic-swords.json @@ -1,5 +1,6 @@ { "icon": "/resources/magic-swords/icon.png", "label": "Magic swords", + "price": 2000, "start": "/resources/magic-swords/start.js" } \ No newline at end of file diff --git a/resources/potion/potion.json b/resources/potion/potion.json index 9fb2299..46d23d0 100644 --- a/resources/potion/potion.json +++ b/resources/potion/potion.json @@ -1,5 +1,6 @@ { "icon": "/resources/potion/icon.png", "label": "Potion", + "price": 50, "start": "/resources/potion/start.js" } \ No newline at end of file diff --git a/resources/tomato-seeds/tomato-seeds.json b/resources/tomato-seeds/tomato-seeds.json index dcafb37..fa3f5c5 100644 --- a/resources/tomato-seeds/tomato-seeds.json +++ b/resources/tomato-seeds/tomato-seeds.json @@ -1,6 +1,7 @@ { "icon": "/resources/tomato-seeds/icon.png", "label": "Tomato Seeds", + "price": 20, "projection": { "distance": [1, -1], "grid": [ diff --git a/resources/tomato/tomato.json b/resources/tomato/tomato.json index c413f76..6f07453 100644 --- a/resources/tomato/tomato.json +++ b/resources/tomato/tomato.json @@ -1,3 +1,5 @@ { - "icon": "/resources/tomato/tomato.png" + "icon": "/resources/tomato/tomato.png", + "label": "Tomato", + "price": 20 } diff --git a/resources/watering-can/watering-can.json b/resources/watering-can/watering-can.json index 85661c4..f696826 100644 --- a/resources/watering-can/watering-can.json +++ b/resources/watering-can/watering-can.json @@ -1,6 +1,7 @@ { "icon": "/resources/watering-can/icon.png", "label": "Watering Can", + "price": 100, "projectionCheck": "/resources/watering-can/projection-check.js", "projection": { "distance": [3, -1], diff --git a/stories/hotbar.stories.js b/stories/hotbar.stories.js index a92bc66..d90db8a 100644 --- a/stories/hotbar.stories.js +++ b/stories/hotbar.stories.js @@ -14,11 +14,11 @@ export default { decorators: [ (Hotbar, ctx) => { const [, updateArgs] = useArgs(); - const {onActivate} = ctx.args; - ctx.args.onActivate = (i) => { + const {onSlotMouseDown} = ctx.args; + ctx.args.onSlotMouseDown = (i) => { updateArgs({active: i}); - if (onActivate) { - onActivate(i); + if (onSlotMouseDown) { + onSlotMouseDown(i); } }; return Hotbar(); @@ -33,7 +33,7 @@ export default { tags: ['autodocs'], args: { active: 0, - onActivate: fn(), + onSlotMouseDown: fn(), slots, }, argTypes: {