feat: shop

This commit is contained in:
cha0s 2024-09-27 07:45:13 -05:00
parent 512c5a470a
commit f673833a1d
27 changed files with 476 additions and 58 deletions

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
import Component from '@/ecs/component.js';
export default class Shop extends Component {}

View File

@ -0,0 +1,7 @@
import Component from '@/ecs/component.js';
export default class Wallet extends Component {
static properties = {
gold: {type: 'uint32'},
};
}

View File

@ -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'}}
>
<style>{`
.${styles.bag} .${gridStyles.highlighted} {
background-color: rgba(255, 0, 0, 0.4);
}
.${styles.bag} .${gridStyles.highlighted} button {
border: 2.5px dashed rgba(255, 255, 255, 0.4);
}
`}</style>
<Grid
color="rgba(02, 02, 28, 0.6)"
columns={10}
highlighted={highlighted}
label="Bag"
onActivate={onActivate}
onSlotMouseDown={onSlotMouseDown}
slots={slots}
/>
</div>

View File

@ -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'}}
>
<style>{`
.${styles.external} .${gridStyles.highlighted} {
background-color: rgba(0, 255, 0, 0.4);
}
.${styles.external} .${gridStyles.highlighted} button {
border: 2.5px dashed rgba(0, 0, 0, 0.4);
}
`}</style>
<Grid
color="rgba(57, 02, 02, 0.6)"
columns={10}
highlighted={highlighted}
label="Chest"
onActivate={onActivate}
onSlotMouseDown={onSlotMouseDown}
slots={slots}
/>
</div>

View File

@ -1,7 +1,3 @@
.external {
left: 20px;
opacity: 1;
position: absolute;
top: 274px;
transition: top 150ms, opacity 200ms;
--nothing: 0;
}

View File

@ -8,14 +8,19 @@ export default function Grid({
active = -1,
color,
columns,
highlighted,
label,
onActivate,
onSlotMouseDown,
slots,
}) {
const Slots = slots.map((slot, i) => (
<div
className={
[styles.slot, active === i && styles.active]
[
styles.slot,
active === i && styles.active,
highlighted && highlighted.includes(i) && styles.highlighted,
]
.filter(Boolean).join(' ')
}
key={i}
@ -23,25 +28,14 @@ export default function Grid({
<Slot
icon={slot?.icon}
onMouseDown={(event) => {
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}
/>

View File

@ -43,3 +43,7 @@
z-index: 1;
}
}
.highlighted {
--nothing: 0;
}

View File

@ -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);
}
`}</style>
<Grid
active={active}
color="rgba(02, 02, 57, 0.6)"
columns={10}
highlighted={highlighted}
label={slots[active] && slots[active].label}
onActivate={onActivate}
onSlotMouseDown={onSlotMouseDown}
slots={slots}
/>
</div>

View File

@ -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 (
<div
className={styles.trade}
style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, top: '450px'}}
>
<div className={styles.tradeInner}>
{
nop
? (
<div className={styles.nop}>Let&apos;s trade!</div>
)
: (
<ul className={styles.summary}>
{losing.length > 0 && (
<li className={styles.lose}>
Shop receives:
{' '}
<ul className={styles.items}>
{losing.map(({label, qty}, i) => (
<li key={i}>{label} x<span className={styles.qty}>{qty}</span></li>
))}
</ul>
</li>
)}
{gaining.length > 0 && (
<li className={styles.gain}>
You receive:
{' '}
<ul className={styles.items}>
{gaining.map(({label, qty}, i) => (
<li key={i}>{label} x<span className={styles.qty}>{qty}</span></li>
))}
</ul>
</li>
)}
<li className={styles.subtotal}>
{
earn > 0
? (
<span className={styles.cost}>You <span className={styles.make}>earn <span className={styles.amount}>{earn}</span></span><span className={styles.gold}>🄶</span></span>
)
: (
earn < 0
? <span className={styles.cost}>You <span className={styles.pay}>pay <span className={styles.amount}>{-earn}</span></span><span className={styles.gold}>🄶</span></span>
: false
)
}
<button
className={styles.agree}
disabled={!canAfford}
onClick={(event) => {
event.stopPropagation();
onTradeAccepted(event);
}}
title={canAfford ? "Finish the transaction" : `Transaction requires ${-earn}g but you only have ${wallet}g`}
>
{canAfford ? "Agree" : "Can't afford"}
</button>
</li>
</ul>
)
}
</div>
</div>
);
}
export default Trade;

View File

@ -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%;
}

View File

@ -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 (
<span className={styles.wallet}>
<span className={styles.number}>
<span className={styles.pad}>{pad}</span>
<span className={styles.amount}>{goldString}</span>
</span>
<span className={styles.gold}>🄶</span>
</span>
)
}
export default Wallet;

View File

@ -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;
}

View File

@ -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, mainEntityRef]);
const bagOnActivate = useCallback((i) => {
}
}, [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, mainEntityRef]);
const externalInventoryOnActivate = useCallback((i) => {
}
}, [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]);
}
}, [client, externalInventory, gaining, trading]);
const onTradeAccepted = useCallback(() => {
client.send({
type: 'Action',
payload: {type: 'acceptTrade', value: {gaining, losing}},
});
}, [client, gaining, losing]);
useEffect(() => {
if (!pixiRef.current) {
return;
@ -510,21 +565,37 @@ function Ui({disconnected}) {
<Dom>
<HotBar
active={activeSlot}
highlighted={trading && losing.filter((i) => i < 11).map((i) => i - 1)}
hotbarIsHidden={hotbarIsHidden}
onActivate={hotbarOnActivate}
onSlotMouseDown={hotbarOnSlotMouseDown}
slots={hotbarSlots}
/>
<Bag
highlighted={trading && losing.filter((i) => i >= 11).map((i) => i - 11)}
isInventoryOpen={isInventoryOpen}
onActivate={bagOnActivate}
onSlotMouseDown={bagOnSlotMouseDown}
slots={inventorySlots}
/>
{externalInventory && (
<div className={styles.external}>
<External
highlighted={trading && gaining}
isInventoryOpen={isInventoryOpen}
onActivate={externalInventoryOnActivate}
onSlotMouseDown={externalInventoryOnSlotMouseDown}
slots={externalInventorySlots}
/>
{trading && (
<Trade
isInventoryOpen={isInventoryOpen}
onTradeAccepted={onTradeAccepted}
gaining={gaining.map((slot) => externalInventorySlots[slot])}
losing={losing.map((slot) => {
return slot < 11 ? hotbarSlots[slot - 1] : inventorySlots[slot - 11];
})}
wallet={wallet}
/>
)}
</div>
)}
<Entities
camera={camera}
@ -550,6 +621,7 @@ function Ui({disconnected}) {
<Disconnected />
)}
<DateTime />
<Wallet gold={wallet} />
</Dom>
</div>
{devtoolsIsOpen && (

View File

@ -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%;
}

View File

@ -152,6 +152,7 @@ export default async function createHomestead(id) {
},
},
Position: {x: 200, y: 200},
Shop: {},
Sprite: {
anchorX: 0.5,
anchorY: 0.7,

View File

@ -49,6 +49,9 @@ export default async function createPlayer(id) {
},
Ticking: {},
VisibleAabb: {},
Wallet: {
gold: 1000,
},
Wielder: {
activeSlot: 1,
},

View File

@ -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;

View File

@ -1,5 +1,6 @@
{
"icon": "/resources/brush/brush.png",
"label": "Brush",
"price": 100,
"start": "/resources/brush/start.js"
}

View File

@ -1,4 +1,5 @@
{
"icon": "/resources/furball/furball.png",
"label": "Fur Ball"
"label": "Fur Ball",
"price": 5
}

View File

@ -1,6 +1,7 @@
{
"icon": "/resources/hoe/icon.png",
"label": "Hoe",
"price": 100,
"projectionCheck": "/resources/hoe/projection-check.js",
"projection": {
"distance": [3, -1],

View File

@ -1,5 +1,6 @@
{
"icon": "/resources/magic-swords/icon.png",
"label": "Magic swords",
"price": 2000,
"start": "/resources/magic-swords/start.js"
}

View File

@ -1,5 +1,6 @@
{
"icon": "/resources/potion/icon.png",
"label": "Potion",
"price": 50,
"start": "/resources/potion/start.js"
}

View File

@ -1,6 +1,7 @@
{
"icon": "/resources/tomato-seeds/icon.png",
"label": "Tomato Seeds",
"price": 20,
"projection": {
"distance": [1, -1],
"grid": [

View File

@ -1,3 +1,5 @@
{
"icon": "/resources/tomato/tomato.png"
"icon": "/resources/tomato/tomato.png",
"label": "Tomato",
"price": 20
}

View File

@ -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],

View File

@ -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: {