Merge pull request #50 from jedmund/grid-mechanics

Implement grid mechanics
This commit is contained in:
Justin Edmund 2022-12-25 00:58:28 -08:00 committed by GitHub
commit 98710539e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 544 additions and 108 deletions

View file

@ -3,7 +3,6 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
import './index.scss'
import Button from '~components/Button'
import { ButtonType } from '~utils/enums'
// Props
interface Props {

View file

@ -1,63 +0,0 @@
.Conflict.Dialog {
& > p {
line-height: 1.2;
max-width: 400px;
}
img {
border-radius: 1rem;
}
.arrow {
color: $grey-55;
font-size: 4rem;
text-align: center;
}
.character {
display: flex;
flex-direction: column;
gap: $unit;
text-align: center;
width: 12rem;
font-weight: $medium;
}
.diagram {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
ul {
display: flex;
flex-direction: column;
gap: $unit * 2;
}
}
footer {
display: flex;
flex-direction: row;
gap: $unit;
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit * 2);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
&:not(.btn-disabled) {
background: $grey-90;
color: $grey-50;
&:hover {
background: $grey-80;
}
}
}
}
}

View file

@ -1,14 +1,10 @@
import React, { useEffect, useState } from 'react'
import { setCookie } from 'cookies-next'
import Router, { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import { AxiosResponse } from 'axios'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
import * as Dialog from '@radix-ui/react-dialog'
import api from '~utils/api'
import { appState } from '~utils/appState'
import { accountState } from '~utils/accountState'
import Button from '~components/Button'
@ -24,7 +20,11 @@ interface Props {
}
const CharacterConflictModal = (props: Props) => {
// Localization
const router = useRouter()
const { t } = useTranslation('common')
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// States
const [open, setOpen] = useState(false)
@ -60,11 +60,11 @@ const CharacterConflictModal = (props: Props) => {
function openChange(open: boolean) {
setOpen(open)
props.resetConflict()
}
function close() {
setOpen(false)
props.resetConflict()
}
return (
@ -75,33 +75,37 @@ const CharacterConflictModal = (props: Props) => {
onOpenAutoFocus={(event) => event.preventDefault()}
>
<p>
Only one version of a character can be included in each party. Do
you want to change your party members?
<Trans i18nKey="modals.conflict.character"></Trans>
</p>
<div className="diagram">
<div className="CharacterDiagram Diagram">
<ul>
{props.conflictingCharacters?.map((character, i) => (
<li className="character" key={`conflict-${i}`}>
<img
alt={character.object.name.en}
alt={character.object.name[locale]}
src={imageUrl(character.object, character.uncap_level)}
/>
<span>{character.object.name.en}</span>
<span>{character.object.name[locale]}</span>
</li>
))}
</ul>
<span className="arrow">&rarr;</span>
<div className="character">
<img
alt={props.incomingCharacter?.name.en}
src={imageUrl(props.incomingCharacter)}
/>
{props.incomingCharacter?.name.en}
<div className="wrapper">
<div className="character">
<img
alt={props.incomingCharacter?.name[locale]}
src={imageUrl(props.incomingCharacter)}
/>
<span>{props.incomingCharacter?.name[locale]}</span>
</div>
</div>
</div>
<footer>
<Button onClick={close} text="Nevermind" />
<Button onClick={props.resolveConflict} text="Confirm" />
<Button onClick={close} text={t('buttons.cancel')} />
<Button
onClick={props.resolveConflict}
text={t('modals.conflict.buttons.confirm')}
/>
</footer>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />

View file

@ -145,7 +145,8 @@ const CharacterGrid = (props: Props) => {
async function resolveConflict() {
if (incoming && conflicts.length > 0) {
await api
.resolveCharacterConflict({
.resolveConflict({
object: 'characters',
incoming: incoming.id,
conflicting: conflicts.map((c) => c.id),
position: position,

View file

@ -92,4 +92,101 @@
justify-content: flex-end;
width: 100%;
}
&.Conflict.Dialog {
$weapon-diameter: 14rem;
& > p {
line-height: 1.2;
max-width: 400px;
strong {
font-weight: $bold;
}
&:lang(ja) {
line-height: 1.4;
}
}
.weapon,
.character {
display: flex;
flex-direction: column;
gap: $unit;
text-align: center;
width: $weapon-diameter;
font-weight: $medium;
img {
border-radius: 1rem;
width: $weapon-diameter;
height: auto;
}
span {
line-height: 1.3;
}
}
.Diagram {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: flex-start;
&.CharacterDiagram {
align-items: center;
}
ul {
align-items: center;
display: flex;
flex-direction: column;
gap: $unit * 2;
}
.wrapper {
display: flex;
justify-content: center;
width: 100%;
}
.arrow {
align-items: center;
color: $grey-55;
display: flex;
font-size: 4rem;
text-align: center;
height: $weapon-diameter;
justify-content: center;
}
}
footer {
display: flex;
flex-direction: row;
gap: $unit;
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit * 2);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
&:not(.btn-disabled) {
background: $grey-90;
color: $grey-50;
&:hover {
background: $grey-80;
}
}
}
}
}
}

View file

@ -151,9 +151,22 @@ const SearchModal = (props: Props) => {
openChange()
}
const extraPositions = () => {
if (props.object === 'weapons') return [9, 10, 11]
else if (props.object === 'summons') return [4, 5]
else return []
}
function receiveFilters(filters: { [key: string]: any }) {
setCurrentPage(1)
setResults([])
// Only show extra or subaura objects if invoked from those positions
if (extraPositions().includes(props.fromPosition)) {
if (props.object === 'weapons') filters.extra = true
else if (props.object === 'summons') filters.subaura = true
}
setFilters(filters)
}
@ -175,7 +188,12 @@ const SearchModal = (props: Props) => {
: []
if (open) {
if (firstLoad && cookieObj && cookieObj.length > 0) {
if (
firstLoad &&
cookieObj &&
cookieObj.length > 0 &&
!extraPositions().includes(props.fromPosition)
) {
setResults(cookieObj)
setRecordCount(cookieObj.length)
setFirstLoad(false)

View file

@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'react-i18next'
import * as Dialog from '@radix-ui/react-dialog'
import Button from '~components/Button'
import mapWeaponSeries from '~utils/mapWeaponSeries'
import './index.scss'
interface Props {
open: boolean
incomingWeapon: Weapon
conflictingWeapons: GridWeapon[]
desiredPosition: number
resolveConflict: () => void
resetConflict: () => void
}
const WeaponConflictModal = (props: Props) => {
// Localization
const router = useRouter()
const { t } = useTranslation('common')
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// States
const [open, setOpen] = useState(false)
useEffect(() => {
setOpen(props.open)
}, [setOpen, props.open])
function imageUrl(weapon?: Weapon) {
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-square/${weapon?.granblue_id}.jpg`
}
function openChange(open: boolean) {
setOpen(open)
props.resetConflict()
}
function close() {
setOpen(false)
}
const infoString = () => {
const series = props.incomingWeapon.series
const seriesSlug = t(`series.${mapWeaponSeries(series)}`)
return [2, 3].includes(series) ? (
<Trans i18nKey="modals.conflict.weapon.opus-draconic"></Trans>
) : (
<Trans i18nKey="modals.conflict.weapon.generic">
Only one weapon from the
<strong>{{ series: seriesSlug }} Series</strong> can be included in each
party. Do you want to change your weapons?
</Trans>
)
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Portal>
<Dialog.Content
className="Conflict Dialog"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<p>{infoString()}</p>
<div className="WeaponDiagram Diagram">
<ul>
{props.conflictingWeapons?.map((weapon, i) => (
<li className="weapon" key={`conflict-${i}`}>
<img
alt={weapon.object.name[locale]}
src={imageUrl(weapon.object)}
/>
<span>{weapon.object.name[locale]}</span>
</li>
))}
</ul>
<span className="arrow">&rarr;</span>
<div className="wrapper">
<div className="weapon">
<img
alt={props.incomingWeapon?.name[locale]}
src={imageUrl(props.incomingWeapon)}
/>
{props.incomingWeapon?.name[locale]}
</div>
</div>
</div>
<footer>
<Button onClick={close} text={t('buttons.cancel')} />
<Button
onClick={props.resolveConflict}
text={t('modals.conflict.buttons.confirm')}
/>
</footer>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default WeaponConflictModal

View file

@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
@ -15,6 +16,8 @@ import { appState } from '~utils/appState'
import type { SearchableObject } from '~types'
import './index.scss'
import WeaponConflictModal from '~components/WeaponConflictModal'
import Alert from '~components/Alert'
// Props
interface Props {
@ -28,18 +31,25 @@ const WeaponGrid = (props: Props) => {
// Constants
const numWeapons: number = 9
// Localization
const { t } = useTranslation('common')
// Cookies
const cookie = getCookie('account')
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
const [modalOpen, setModalOpen] = useState(false)
const [showIncompatibleAlert, setShowIncompatibleAlert] = useState(false)
// Set up state for conflict management
const [incoming, setIncoming] = useState<Weapon>()
const [conflicts, setConflicts] = useState<GridWeapon[]>([])
const [position, setPosition] = useState(0)
// Create a temporary state to store previous weapon uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{
@ -90,9 +100,45 @@ const WeaponGrid = (props: Props) => {
)
})
} else {
saveWeapon(party.id, weapon, position).then((response) =>
storeGridWeapon(response.data)
)
if (party.editable)
saveWeapon(party.id, weapon, position)
.then((response) => handleWeaponResponse(response.data))
.catch((error) => {
const code = error.response.status
const data = error.response.data
console.log(error.response)
console.log(data, code)
if (code === 422) {
if (data.code === 'incompatible_weapon_for_position') {
console.log('Here')
setShowIncompatibleAlert(true)
}
}
})
}
}
async function handleWeaponResponse(data: any) {
if (data.hasOwnProperty('conflicts')) {
if (data.incoming) setIncoming(data.incoming)
if (data.conflicts) setConflicts(data.conflicts)
if (data.position) setPosition(data.position)
setModalOpen(true)
} else {
storeGridWeapon(data.grid_weapon)
// If we replaced an existing weapon, remove it from the grid
if (data.hasOwnProperty('meta') && data.meta['replaced'] !== undefined) {
const position = data.meta['replaced']
if (position == -1) {
appState.grid.weapons.mainWeapon = undefined
appState.party.element = 0
} else {
appState.grid.weapons.allWeapons[position] = undefined
}
}
}
}
@ -101,18 +147,15 @@ const WeaponGrid = (props: Props) => {
if (weapon.uncap.ulb) uncapLevel = 5
else if (weapon.uncap.flb) uncapLevel = 4
return await api.endpoints.weapons.create(
{
weapon: {
party_id: partyId,
weapon_id: weapon.id,
position: position,
mainhand: position == -1,
uncap_level: uncapLevel,
},
return await api.endpoints.weapons.create({
weapon: {
party_id: partyId,
weapon_id: weapon.id,
position: position,
mainhand: position == -1,
uncap_level: uncapLevel,
},
headers
)
})
}
function storeGridWeapon(gridWeapon: GridWeapon) {
@ -125,6 +168,44 @@ const WeaponGrid = (props: Props) => {
}
}
async function resolveConflict() {
if (incoming && conflicts.length > 0) {
await api
.resolveConflict({
object: 'weapons',
incoming: incoming.id,
conflicting: conflicts.map((c) => c.id),
position: position,
})
.then((response) => {
// Store new character in state
storeGridWeapon(response.data)
// Remove conflicting characters from state
conflicts.forEach((c) => {
if (appState.grid.weapons.mainWeapon?.object.id === c.id) {
appState.grid.weapons.mainWeapon = undefined
appState.party.element = 0
} else {
appState.grid.weapons.allWeapons[c.position] = undefined
}
})
// Reset conflict
resetConflict()
// Close modal
setModalOpen(false)
})
}
}
function resetConflict() {
setPosition(-1)
setConflicts([])
setIncoming(undefined)
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
@ -242,8 +323,39 @@ const WeaponGrid = (props: Props) => {
/>
)
const conflictModal = () => {
return incoming && conflicts ? (
<WeaponConflictModal
open={modalOpen}
incomingWeapon={incoming}
conflictingWeapons={conflicts}
desiredPosition={position}
resolveConflict={resolveConflict}
resetConflict={resetConflict}
/>
) : (
''
)
}
const incompatibleAlert = () => {
console.log(t('alert.incompatible_weapon'))
return showIncompatibleAlert ? (
<Alert
open={showIncompatibleAlert}
cancelAction={() => setShowIncompatibleAlert(!showIncompatibleAlert)}
cancelActionText={t('buttons.confirm')}
message={t('alert.incompatible_weapon')}
></Alert>
) : (
''
)
}
return (
<div id="WeaponGrid">
{conflicts ? conflictModal() : ''}
{incompatibleAlert()}
<div id="MainGrid">
{mainhandElement}
<ul className="grid_weapons">{weaponGridElement}</ul>

View file

@ -1,4 +1,7 @@
{
"alert": {
"incompatible_weapon": "You've selected a weapon that can't be added to the Additional Weapon slots."
},
"ax": {
"no_skill": "No AX Skill",
"errors": {
@ -20,6 +23,7 @@
"buttons": {
"cancel": "Cancel",
"copy": "Copy link",
"confirm": "Got it",
"delete": "Delete team",
"show_info": "Edit info",
"hide_info": "Hide info",
@ -86,6 +90,7 @@
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"regalia": "Regalia",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
@ -101,7 +106,10 @@
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation"
"new_world": "New World Foundation",
"revenant": "Revenant",
"proving": "Proving Grounds",
"disaster": "Disaster"
},
"recency": {
"all_time": "All time",
@ -122,6 +130,16 @@
"about": {
"title": "About"
},
"conflict": {
"character": "Only one <strong>version of a character</strong> can be included in each party. Do you want to change your party members?",
"weapon": {
"generic": "Only one weapon from the <strong>{{series}} Series</strong> can be included in each party. Do you want to change your weapons?",
"opus-draconic": "Only one <strong>Dark Opus</strong> or <strong>Draconic Weapon</strong> can be included in each party. Do you want to change your weapons?"
},
"buttons": {
"confirm": "Confirm"
}
},
"delete_team": {
"title": "Delete team",
"description": "Are you sure you want to permanently delete this team?",

View file

@ -1,4 +1,7 @@
{
"alert": {
"incompatible_weapon": "Additional Weaponsに装備できない武器を入れました。"
},
"ax": {
"no_skill": "EXスキルなし",
"errors": {
@ -18,8 +21,9 @@
}
},
"buttons": {
"cancel": "キャンセルs",
"cancel": "キャンセル",
"copy": "リンクをコピー",
"confirm": "OK",
"delete": "編成を削除",
"show_info": "詳細を編集",
"save_info": "詳細を保存",
@ -86,6 +90,7 @@
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"regalia": "レガリアシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
@ -101,7 +106,10 @@
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎"
"new_world": "新世界の礎",
"revenant": "天星器",
"proving": "ブレイブグラウンド",
"disaster": "災害シリーズ"
},
"recency": {
"all_time": "全ての期間",
@ -122,6 +130,16 @@
"about": {
"title": "このサイトについて"
},
"conflict": {
"character": "<strong>同じ名前のキャラクター</strong>がパーティに編成されています。<br />以下のキャラクターを入れ替えますか?",
"weapon": {
"generic": "<strong>{{series}}</strong>の武器に装備制限があり、武器が既に装備されています。<br /><br />以下の武器を入れ替えますか?",
"opus-draconic": "<strong>終末の神器シリーズ</strong>と<strong>ドラコニックシリーズ</strong>から1本しか装備できない制限があり、武器が既に装備されています。<br /><br />以下の武器を入れ替えますか?"
},
"buttons": {
"confirm": "入れ替える"
}
},
"delete_team": {
"title": "編成を削除しますか",
"description": "編成を削除する操作は取り消せません。",

View file

@ -77,7 +77,8 @@ class Api {
})
}
resolveCharacterConflict({ incoming, conflicting, position, params }: {
resolveConflict({ object, incoming, conflicting, position, params }: {
object: 'characters' | 'weapons'
incoming: string
conflicting: string[]
position: number,
@ -90,7 +91,7 @@ class Api {
position: position,
},
}
const resourceUrl = `${this.url}/characters/resolve`
const resourceUrl = `${this.url}/${object}/resolve`
return axios.post(resourceUrl, body, { headers: params })
}

View file

@ -0,0 +1,4 @@
import { weaponSeries } from '~utils/weaponSeries'
export default (id: number) =>
weaponSeries.find((series) => series.id === id)?.slug

119
utils/weaponSeries.tsx Normal file
View file

@ -0,0 +1,119 @@
export interface WeaponSeries {
id: number
slug: string
}
export const weaponSeries: WeaponSeries[] = [
{
id: 0,
slug: 'seraphic',
},
{
id: 1,
slug: 'grand',
},
{
id: 2,
slug: 'opus',
},
{
id: 3,
slug: 'draconic',
},
{
id: 4,
slug: 'revenant',
},
{
id: 6,
slug: 'primal',
},
{
id: 7,
slug: 'beast',
},
{
id: 8,
slug: 'regalia',
},
{
id: 9,
slug: 'omega',
},
{
id: 10,
slug: 'olden_primal',
},
{
id: 11,
slug: 'militis',
},
{
id: 12,
slug: 'hollowsky',
},
{
id: 13,
slug: 'xeno',
},
{
id: 14,
slug: 'astral',
},
{
id: 15,
slug: 'rose',
},
{
id: 16,
slug: 'bahamut',
},
{
id: 17,
slug: 'ultima',
},
{
id: 18,
slug: 'epic',
},
{
id: 19,
slug: 'ennead',
},
{
id: 20,
slug: 'cosmic',
},
{
id: 21,
slug: 'ancestral',
},
{
id: 22,
slug: 'superlative',
},
{
id: 23,
slug: 'vintage',
},
{
id: 24,
slug: 'class_champion',
},
{
id: 25,
slug: 'proving',
},
{
id: 28,
slug: 'sephira',
},
{
id: 29,
slug: 'new_world',
},
{
id: 30,
slug: 'disaster',
},
]