diff --git a/.all-contributorsrc b/.all-contributorsrc index b7ecdc05..0610aa57 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -306,6 +306,123 @@ "contributions": [ "code" ] + }, + { + "login": "lfgtwo", + "name": "lfg2", + "avatar_url": "https://avatars.githubusercontent.com/u/171595475?v=4", + "profile": "https://github.com/lfgtwo", + "contributions": [ + "code" + ] + }, + { + "login": "zintarh", + "name": "Zintarh", + "avatar_url": "https://avatars.githubusercontent.com/u/35270183?v=4", + "profile": "https://github.com/zintarh", + "contributions": [ + "code" + ] + }, + { + "login": "Mystic-Nayy", + "name": "Naomi", + "avatar_url": "https://avatars.githubusercontent.com/u/149405096?v=4", + "profile": "https://github.com/Mystic-Nayy", + "contributions": [ + "code" + ] + }, + { + "login": "supreme2580", + "name": "Supreme Labs", + "avatar_url": "https://avatars.githubusercontent.com/u/100731397?v=4", + "profile": "https://github.com/supreme2580", + "contributions": [ + "code" + ] + }, + { + "login": "AbdelStark", + "name": "A₿del ∞/21M", + "avatar_url": "https://avatars.githubusercontent.com/u/45264458?v=4", + "profile": "https://github.com/AbdelStark", + "contributions": [ + "code" + ] + }, + { + "login": "jjjike2021", + "name": "jike", + "avatar_url": "https://avatars.githubusercontent.com/u/171845810?v=4", + "profile": "https://github.com/jjjike2021", + "contributions": [ + "code" + ] + }, + { + "login": "bloomingpeach", + "name": "Rumia", + "avatar_url": "https://avatars.githubusercontent.com/u/177087057?v=4", + "profile": "https://github.com/bloomingpeach", + "contributions": [ + "code" + ] + }, + { + "login": "OkoliEvans", + "name": "Okoli Evans", + "avatar_url": "https://avatars.githubusercontent.com/u/95226065?v=4", + "profile": "https://github.com/OkoliEvans", + "contributions": [ + "code" + ] + }, + { + "login": "Iwueseiter", + "name": "Iwueseiter", + "avatar_url": "https://avatars.githubusercontent.com/u/156322726?v=4", + "profile": "https://github.com/Iwueseiter", + "contributions": [ + "code" + ] + }, + { + "login": "Shoetan", + "name": "Emmanuel Soetan", + "avatar_url": "https://avatars.githubusercontent.com/u/17912134?v=4", + "profile": "https://soetandev.netlify.app/", + "contributions": [ + "code" + ] + }, + { + "login": "EmmanuelAR", + "name": "Emmanuel Agüero ", + "avatar_url": "https://avatars.githubusercontent.com/u/54730752?v=4", + "profile": "https://github.com/EmmanuelAR", + "contributions": [ + "code" + ] + }, + { + "login": "muheebyusufbaba1", + "name": "Babs", + "avatar_url": "https://avatars.githubusercontent.com/u/134698940?v=4", + "profile": "https://github.com/muheebyusufbaba1", + "contributions": [ + "code" + ] + }, + { + "login": "adrianvrj", + "name": "ADR!AN", + "avatar_url": "https://avatars.githubusercontent.com/u/111903096?v=4", + "profile": "https://github.com/adrianvrj", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 8fd5ec2a..64b6379b 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,23 @@ Thanks goes to these wonderful people. Follow the [contributors guide](https://g BillXJ
BillXJ

💻 Jemiiah
Jemiiah

💻 Umar I Salihu
Umar I Salihu

💻 + lfg2
lfg2

💻 + Zintarh
Zintarh

💻 + + + Naomi
Naomi

💻 + Supreme Labs
Supreme Labs

💻 + A₿del ∞/21M
A₿del ∞/21M

💻 + jike
jike

💻 + Rumia
Rumia

💻 + Okoli Evans
Okoli Evans

💻 + Iwueseiter
Iwueseiter

💻 + + + Emmanuel Soetan
Emmanuel Soetan

💻 + Emmanuel Agüero
Emmanuel Agüero

💻 + Babs
Babs

💻 + ADR!AN
ADR!AN

💻 diff --git a/backend/routes/nft.go b/backend/routes/nft.go index 6c681bd6..5c857eac 100644 --- a/backend/routes/nft.go +++ b/backend/routes/nft.go @@ -1,6 +1,8 @@ package routes import ( + "encoding/json" + "fmt" "io" "net/http" "os" @@ -19,6 +21,7 @@ func InitNFTRoutes() { http.HandleFunc("/get-new-nfts", getNewNFTs) http.HandleFunc("/get-my-nfts", getMyNFTs) http.HandleFunc("/get-nft-likes", getNftLikeCount) + http.HandleFunc("/get-nft-pixel-data", getNftPixelData) // http.HandleFunc("/like-nft", LikeNFT) // http.HandleFunc("/unlike-nft", UnLikeNFT) http.HandleFunc("/get-top-nfts", getTopNFTs) @@ -220,6 +223,65 @@ func getNewNFTs(w http.ResponseWriter, r *http.Request) { routeutils.WriteDataJson(w, string(nfts)) } +func getNftPixelData(w http.ResponseWriter, r *http.Request) { + tokenId := r.URL.Query().Get("tokenId") + if tokenId == "" { + routeutils.WriteErrorJson(w, http.StatusBadRequest, "TokenId parameter is required") + return + } + + // First get the NFT data to access the imageHash + nft, err := core.PostgresQueryOneJson[NFTData]("SELECT * FROM nfts WHERE token_id = $1", tokenId) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusNotFound, "NFT not found") + return + } + + var nftData NFTData + if err := json.Unmarshal([]byte(nft), &nftData); err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to parse NFT data") + return + } + + // Try to read from file first + roundNumber := os.Getenv("ROUND_NUMBER") + if roundNumber == "" { + roundNumber = "1" // Default to round 1 if not set + } + + filename := fmt.Sprintf("nfts/round-%s/images/nft-%s.png", roundNumber, tokenId) + fileBytes, err := os.ReadFile(filename) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to read image file") + return + } + + // If we have the file, process it using imageToPixelData + pixelData, err := imageToPixelData(fileBytes, 10) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to process image") + return + } + + response := struct { + Width int `json:"width"` + Height int `json:"height"` + PixelData []int `json:"pixelData"` + }{ + Width: nftData.Width, + Height: nftData.Height, + PixelData: pixelData, + } + + jsonResponse, err := json.Marshal(response) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to create response") + return + } + + routeutils.WriteDataJson(w, string(jsonResponse)) +} + func mintNFTDevnet(w http.ResponseWriter, r *http.Request) { // Disable this in production if routeutils.NonProductionMiddleware(w, r) { diff --git a/backend/routes/stencils.go b/backend/routes/stencils.go index b29d4bfe..8d9fb835 100644 --- a/backend/routes/stencils.go +++ b/backend/routes/stencils.go @@ -386,7 +386,7 @@ func addStencilImg(w http.ResponseWriter, r *http.Request) { } bounds := img.Bounds() width, height := bounds.Max.X-bounds.Min.X, bounds.Max.Y-bounds.Min.Y - if width < 5 || width > 64 || height < 5 || height > 64 { + if width < 5 || width > 256 || height < 5 || height > 256 { routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid image dimensions") return } @@ -402,7 +402,7 @@ func addStencilImg(w http.ResponseWriter, r *http.Request) { r.Body.Close() - imageData, err := imageToPixelData(fileBytes) + imageData, err := imageToPixelData(fileBytes, 1) if err != nil { routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to convert image to pixel data") return @@ -466,7 +466,7 @@ func addStencilData(w http.ResponseWriter, r *http.Request) { return } - if width < 5 || width > 64 || height < 5 || height > 64 { + if width < 5 || width > 256 || height < 5 || height > 256 { routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid image dimensions") return } diff --git a/backend/routes/templates.go b/backend/routes/templates.go index d4bc98f7..3d3278f7 100644 --- a/backend/routes/templates.go +++ b/backend/routes/templates.go @@ -3,6 +3,7 @@ package routes import ( "bytes" "crypto/sha256" + "encoding/json" "fmt" "image" "image/color" @@ -26,6 +27,7 @@ func InitTemplateRoutes() { http.HandleFunc("/build-template-img", buildTemplateImg) http.HandleFunc("/add-template-img", addTemplateImg) http.HandleFunc("/add-template-data", addTemplateData) + http.HandleFunc("/get-template-pixel-data", getTemplatePixelData) if !core.ArtPeaceBackend.BackendConfig.Production { // http.HandleFunc("/add-template-devnet", addTemplateDevnet) http.HandleFunc("/add-faction-template-devnet", addFactionTemplateDevnet) @@ -74,7 +76,7 @@ func hexToRGBA(colorBytes string) color.RGBA { return color.RGBA{uint8(r), uint8(g), uint8(b), 255} } -func imageToPixelData(imageData []byte) ([]int, error) { +func imageToPixelData(imageData []byte, scaleFactor int) ([]int, error) { img, _, err := image.Decode(bytes.NewReader(imageData)) if err != nil { return nil, err @@ -94,16 +96,20 @@ func imageToPixelData(imageData []byte) ([]int, error) { bounds := img.Bounds() width, height := bounds.Max.X, bounds.Max.Y - pixelData := make([]int, width*height) - - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + scaledWidth := width / scaleFactor + scaledHeight := height / scaleFactor + pixelData := make([]int, scaledWidth*scaledHeight) + + for y := 0; y < height; y += scaleFactor { + for x := 0; x < width; x += scaleFactor { + newX := x / scaleFactor + newY := y / scaleFactor rgba := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) if rgba.A < 128 { // Consider pixels with less than 50% opacity as transparent - pixelData[y*width+x] = 0xFF + pixelData[newY*scaledWidth+newX] = 0xFF } else { closestIndex := findClosestColor(rgba, palette) - pixelData[y*width+x] = closestIndex + pixelData[newY*scaledWidth+newX] = closestIndex } } } @@ -227,7 +233,7 @@ func buildTemplateImg(w http.ResponseWriter, r *http.Request) { return } - imageData, err := imageToPixelData(fileBytes) + imageData, err := imageToPixelData(fileBytes, 1) if err != nil { routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to convert image to pixel data") return @@ -296,7 +302,7 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { } bounds := img.Bounds() width, height := bounds.Max.X-bounds.Min.X, bounds.Max.Y-bounds.Min.Y - if width < 5 || width > 64 || height < 5 || height > 64 { + if width < 5 || width > 256 || height < 5 || height > 256 { routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid image dimensions") return } @@ -312,7 +318,7 @@ func addTemplateImg(w http.ResponseWriter, r *http.Request) { r.Body.Close() - imageData, err := imageToPixelData(fileBytes) + imageData, err := imageToPixelData(fileBytes, 1) if err != nil { routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to convert image to pixel data") return @@ -370,7 +376,7 @@ func addTemplateData(w http.ResponseWriter, r *http.Request) { return } - if width < 5 || width > 64 || height < 5 || height > 64 { + if width < 5 || width > 256 || height < 5 || height > 256 { routeutils.WriteErrorJson(w, http.StatusBadRequest, "Invalid image dimensions") return } @@ -455,6 +461,58 @@ func addTemplateData(w http.ResponseWriter, r *http.Request) { routeutils.WriteResultJson(w, hash) } +func getTemplatePixelData(w http.ResponseWriter, r *http.Request) { + hash := r.URL.Query().Get("hash") + if hash == "" { + routeutils.WriteErrorJson(w, http.StatusBadRequest, "Hash parameter is required") + return + } + + // Read the template image file + filename := fmt.Sprintf("templates/template-%s.png", hash) + fileBytes, err := os.ReadFile(filename) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusNotFound, "Template not found") + return + } + + // Convert image to pixel data using existing function + pixelData, err := imageToPixelData(fileBytes, 1) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to process image") + return + } + + // Get image dimensions + img, _, err := image.Decode(bytes.NewReader(fileBytes)) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to decode image") + return + } + bounds := img.Bounds() + width, height := bounds.Max.X, bounds.Max.Y + + // Create response structure + response := struct { + Width int `json:"width"` + Height int `json:"height"` + PixelData []int `json:"pixelData"` + }{ + Width: width, + Height: height, + PixelData: pixelData, + } + + // Convert to JSON and send response + jsonResponse, err := json.Marshal(response) + if err != nil { + routeutils.WriteErrorJson(w, http.StatusInternalServerError, "Failed to create response") + return + } + + routeutils.WriteDataJson(w, string(jsonResponse)) +} + func addTemplateDevnet(w http.ResponseWriter, r *http.Request) { // Disable this in production if routeutils.NonProductionMiddleware(w, r) { diff --git a/docker-compose.yml b/docker-compose.yml index 63274282..b42dbe57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: - ART_PEACE_END_TIME=3000000000 - ART_PEACE_HOST=0328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 - ROUND_NUMBER=1 + volumes: + - nfts:/app/nfts consumer: build: dockerfile: backend/Dockerfile.consumer diff --git a/frontend/src/App.js b/frontend/src/App.js index 81d902af..0631f69d 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -344,6 +344,7 @@ function App() { const [availablePixels, setAvailablePixels] = useState(0); const [availablePixelsUsed, setAvailablePixelsUsed] = useState(0); const [extraPixelsData, setExtraPixelsData] = useState([]); + const [isDefending, setIsDefending] = useState(false); const [selectorMode, setSelectorMode] = useState(false); @@ -371,10 +372,12 @@ function App() { const updateInterval = 1000; // 1 second const [basePixelTimer, setBasePixelTimer] = useState('XX:XX'); + useEffect(() => { const updateBasePixelTimer = () => { let timeSinceLastPlacement = Date.now() - lastPlacedTime; let basePixelAvailable = timeSinceLastPlacement > timeBetweenPlacements; + if (basePixelAvailable) { setBasePixelUp(true); setBasePixelTimer('00:00'); @@ -383,16 +386,19 @@ function App() { let secondsTillPlacement = Math.floor( (timeBetweenPlacements - timeSinceLastPlacement) / 1000 ); - setBasePixelTimer( - `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` - ); + let minutes = Math.floor(secondsTillPlacement / 60); + let seconds = secondsTillPlacement % 60; + setBasePixelTimer(`${minutes}:${seconds.toString().padStart(2, '0')}`); setBasePixelUp(false); } }; + const interval = setInterval(() => { updateBasePixelTimer(); }, updateInterval); - updateBasePixelTimer(); + + updateBasePixelTimer(); // Call immediately + return () => clearInterval(interval); }, [lastPlacedTime, timeBetweenPlacements]); @@ -420,7 +426,9 @@ function App() { (timeBetweenPlacements - timeSinceLastPlacement) / 1000 ); newChainFactionPixelTimers.push( - `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` + `${Math.floor(secondsTillPlacement / 60)}:${ + secondsTillPlacement % 60 < 10 ? '0' : '' + }${secondsTillPlacement % 60}` ); newChainFactionPixels.push(0); } @@ -459,7 +467,9 @@ function App() { (timeBetweenPlacements - timeSinceLastPlacement) / 1000 ); newFactionPixelTimers.push( - `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` + `${Math.floor(secondsTillPlacement / 60)}:${ + secondsTillPlacement % 60 < 10 ? '0' : '' + }${secondsTillPlacement % 60}` ); newFactionPixels.push(0); } @@ -546,9 +556,11 @@ function App() { const clearExtraPixels = useCallback(() => { setAvailablePixelsUsed(0); + setTotalPixelsUsed(0); setExtraPixelsData([]); const canvas = extraPixelsCanvasRef.current; + if (!canvas) return; const context = canvas.getContext('2d'); context.clearRect(0, 0, width, height); }, [width, height]); @@ -588,6 +600,239 @@ function App() { [extraPixelsData, availablePixelsUsed, selectedColorId] ); + const addExtraPixels = async (pixels) => { + const available = availablePixels - availablePixelsUsed; + if (available < pixels.length) { + setNotificationMessage('Not enough available pixels'); + return; + } + setAvailablePixelsUsed(availablePixelsUsed + pixels.length); + setExtraPixelsData([...extraPixelsData, ...pixels]); + }; + + const extraPixelPlaceCall = async (positions, colors, now) => { + if (devnetMode) return; + if (!address || !artPeaceContract || !account) return; + // TODO: Validate inputs + const placeExtraPixelsCallData = artPeaceContract.populate( + 'place_extra_pixels', + { + positions: positions, + colors: colors, + now: now + } + ); + const { suggestedMaxFee } = await estimateInvokeFee({ + contractAddress: artPeaceContract.address, + entrypoint: 'place_extra_pixels', + calldata: placeExtraPixelsCallData.calldata + }); + /* global BigInt */ + const maxFee = (suggestedMaxFee * BigInt(15)) / BigInt(10); + const result = await artPeaceContract.place_extra_pixels( + placeExtraPixelsCallData.calldata, + { + maxFee + } + ); + console.log(result); + }; + + const [basePixelUsed, setBasePixelUsed] = React.useState(false); + const [totalChainFactionPixels, setTotalChainFactionPixels] = + React.useState(0); + const [totalFactionPixels, setTotalFactionPixels] = React.useState(0); + const [chainFactionPixelsUsed, setChainFactionPixelsUsed] = React.useState(0); + const [factionPixelsUsed, setFactionPixelsUsed] = React.useState(0); + const [extraPixelsUsed, setExtraPixelsUsed] = React.useState(0); + const [totalPixelsUsed, setTotalPixelsUsed] = React.useState(0); + React.useEffect(() => { + let pixelsUsed = availablePixelsUsed; + if (basePixelUp) { + if (pixelsUsed > 0) { + setBasePixelUsed(true); + pixelsUsed--; + } else { + setBasePixelUsed(false); + } + } + let allChainFactionPixels = 0; + for (let i = 0; i < chainFactionPixels.length; i++) { + allChainFactionPixels += chainFactionPixels[i]; + } + setTotalChainFactionPixels(allChainFactionPixels); + let allFactionPixels = 0; + for (let i = 0; i < factionPixels.length; i++) { + allFactionPixels += factionPixels[i]; + } + setTotalFactionPixels(allFactionPixels); + if (allChainFactionPixels > 0) { + let chainFactionsPixelsUsed = Math.min( + pixelsUsed, + totalChainFactionPixels + ); + setChainFactionPixelsUsed(chainFactionsPixelsUsed); + pixelsUsed -= chainFactionsPixelsUsed; + } + if (allFactionPixels > 0) { + let factionsPixelsUsed = Math.min(pixelsUsed, totalFactionPixels); + setFactionPixelsUsed(factionsPixelsUsed); + pixelsUsed -= factionsPixelsUsed; + } + if (extraPixels > 0) { + let extraPixelsUsed = Math.min(pixelsUsed, extraPixels); + setExtraPixelsUsed(extraPixelsUsed); + pixelsUsed -= extraPixelsUsed; + } + setTotalPixelsUsed(availablePixelsUsed - pixelsUsed); + }, [availablePixels, availablePixelsUsed]); + + const clearAll = () => { + clearExtraPixels(); + setSelectedColorId(-1); + }; + + // TODO: Is rounding down the time always okay? + const submit = async () => { + let timestamp = Math.floor(Date.now() / 1000); + if (!devnetMode) { + await extraPixelPlaceCall( + extraPixelsData.map( + (pixel) => pixel.x + pixel.y * canvasConfig.canvas.width + ), + extraPixelsData.map((pixel) => pixel.colorId), + timestamp + ); + } else { + let placeExtraPixelsEndpoint = 'place-extra-pixels-devnet'; + const response = await fetchWrapper(placeExtraPixelsEndpoint, { + mode: 'cors', + method: 'POST', + body: JSON.stringify({ + extraPixels: extraPixelsData.map((pixel) => ({ + position: pixel.x + pixel.y * canvasConfig.canvas.width, + colorId: pixel.colorId + })), + timestamp: timestamp + }) + }); + if (response.result) { + console.log(response.result); + } + } + for (let i = 0; i < extraPixelsData.length; i++) { + let position = + extraPixelsData[i].x + extraPixelsData[i].y * canvasConfig.canvas.width; + colorPixel(position, extraPixelsData[i].colorId); + } + if (basePixelUsed) { + setLastPlacedTime(timestamp * 1000); + } + if (chainFactionPixelsUsed > 0) { + let chainFactionIndex = 0; + let chainFactionUsedCounter = 0; + let newChainFactionPixels = []; + let newChainFactionPixelsData = []; + while (chainFactionIndex < chainFactionPixels.length) { + if (chainFactionUsedCounter >= chainFactionPixelsUsed) { + newChainFactionPixels.push(chainFactionPixels[chainFactionIndex]); + newChainFactionPixelsData.push( + chainFactionPixelsData[chainFactionIndex] + ); + chainFactionIndex++; + continue; + } + let currChainFactionPixelsUsed = Math.min( + chainFactionPixelsUsed - chainFactionUsedCounter, + chainFactionPixels[chainFactionIndex] + ); + if (currChainFactionPixelsUsed <= 0) { + newChainFactionPixels.push(chainFactionPixels[chainFactionIndex]); + newChainFactionPixelsData.push( + chainFactionPixelsData[chainFactionIndex] + ); + chainFactionIndex++; + continue; + } + if ( + currChainFactionPixelsUsed === chainFactionPixels[chainFactionIndex] + ) { + newChainFactionPixels.push(0); + let newChainFactionData = chainFactionPixelsData[chainFactionIndex]; + newChainFactionData.lastPlacedTime = timestamp * 1000; + newChainFactionData.memberPixels = 0; + newChainFactionPixelsData.push(newChainFactionData); + } else { + newChainFactionPixels.push( + chainFactionPixels[chainFactionIndex] - currChainFactionPixelsUsed + ); + let newChainFactionData = chainFactionPixelsData[chainFactionIndex]; + newChainFactionData.memberPixels = + chainFactionPixels[chainFactionIndex] - currChainFactionPixelsUsed; + newChainFactionPixelsData.push(newChainFactionData); + } + chainFactionUsedCounter += currChainFactionPixelsUsed; + chainFactionIndex++; + } + setChainFactionPixels(newChainFactionPixels); + setChainFactionPixelsData(newChainFactionPixelsData); + } + + // TODO: Click faction pixels button to expand out info here + if (factionPixelsUsed > 0) { + // TODO: Will order always be the same? + let factionIndex = 0; + let factionUsedCounter = 0; + let newFactionPixels = []; + let newFactionPixelsData = []; + while (factionIndex < factionPixels.length) { + if (factionUsedCounter >= factionPixelsUsed) { + newFactionPixels.push(factionPixels[factionIndex]); + newFactionPixelsData.push(factionPixelsData[factionIndex]); + factionIndex++; + continue; + } + let currFactionPixelsUsed = Math.min( + factionPixelsUsed - factionUsedCounter, + factionPixels[factionIndex] + ); + if (currFactionPixelsUsed <= 0) { + newFactionPixels.push(factionPixels[factionIndex]); + newFactionPixelsData.push(factionPixelsData[factionIndex]); + factionIndex++; + continue; + } + if (currFactionPixelsUsed === factionPixels[factionIndex]) { + newFactionPixels.push(0); + let newFactionData = factionPixelsData[factionIndex]; + newFactionData.lastPlacedTime = timestamp * 1000; + newFactionData.memberPixels = 0; + newFactionPixelsData.push(newFactionData); + } else { + newFactionPixels.push( + factionPixels[factionIndex] - currFactionPixelsUsed + ); + let newFactionData = factionPixelsData[factionIndex]; + newFactionData.memberPixels = + factionPixels[factionIndex] - currFactionPixelsUsed; + newFactionPixelsData.push(newFactionData); + } + factionUsedCounter += currFactionPixelsUsed; + factionIndex++; + } + setFactionPixels(newFactionPixels); + setFactionPixelsData(newFactionPixelsData); + } + if (extraPixelsUsed > 0) { + let newExtraPixels = extraPixels - extraPixelsUsed; + setExtraPixels(newExtraPixels); + } + clearAll(); + setIsEraserMode(false); + setSelectorMode(false); + clearPixelSelection(); + }; + // Factions const [chainFaction, setChainFaction] = useState(null); const [userFactions, setUserFactions] = useState([]); @@ -683,7 +928,6 @@ function App() { return { suggestedMaxFee }; } catch (error) { console.error(error); - /* global BigInt */ return { suggestedMaxFee: BigInt(1000000000000000) }; } }; @@ -854,6 +1098,64 @@ function App() { basePixelUp ]); + const [templatePixels, setTemplatePixels] = useState([]); + + useEffect(() => { + const getTemplatePixelData = async (hash) => { + if (hash !== null) { + const response = await fetchWrapper( + `get-template-pixel-data?hash=${hash}` + ); + return response.data; + } + return []; + }; + + const getNftPixelData = async (tokenId) => { + if (tokenId !== null) { + const response = await fetchWrapper( + `get-nft-pixel-data?tokenId=${tokenId}` + ); + if (!response.data) { + console.error('NFT pixel data not found'); + return []; + } + return response.data; + } + return []; + }; + + const fetchPixelData = async () => { + try { + if (!overlayTemplate) { + setTemplatePixels([]); + return; + } + + // Handle NFT overlay case + if (overlayTemplate.isNft && overlayTemplate.tokenId !== undefined) { + const data = await getNftPixelData(overlayTemplate.tokenId); + setTemplatePixels(data); + return; + } + + // Handle template overlay case + if (overlayTemplate.hash) { + const data = await getTemplatePixelData(overlayTemplate.hash); + setTemplatePixels(data); + return; + } + + setTemplatePixels([]); + } catch (error) { + console.error('Error fetching pixel data:', error); + setTemplatePixels([]); + } + }; + + fetchPixelData(); + }, [overlayTemplate]); + return (
@@ -953,6 +1255,8 @@ function App() { queryAddress={queryAddress} account={account} usingSessionKeys={usingSessionKeys} + submit={submit} + clearAll={clearAll} // chain={chain} setConnected={setConnected} artPeaceContract={artPeaceContract} @@ -1057,6 +1361,7 @@ function App() { host={host} width={width} height={height} + isDefending={isDefending} />
@@ -1064,13 +1369,21 @@ function App() { style={{ width: '100%', display: 'flex', - justifyContent: `${footerExpanded && isFooterSplit ? 'space-between' : 'center'}`, - alignItems: `${footerExpanded && isFooterSplit ? 'flex-end' : 'center'}` + justifyContent: `${ + footerExpanded && isFooterSplit ? 'space-between' : 'center' + }`, + alignItems: `${ + footerExpanded && isFooterSplit ? 'flex-end' : 'center' + }` }} > {!gameEnded && ( )} {isFooterSplit && !footerExpanded && ( diff --git a/frontend/src/canvas/NFTSelector.js b/frontend/src/canvas/NFTSelector.js index 646b3cba..e3974cbd 100644 --- a/frontend/src/canvas/NFTSelector.js +++ b/frontend/src/canvas/NFTSelector.js @@ -76,14 +76,14 @@ const NFTSelector = (props) => { let width = endX - startX; let height = endY - startY; // Max NFT sizes - if (width > 64) { - width = 64; + if (width > 256) { + width = 256; if (x < initX) { startX = endX - width; } } - if (height > 64) { - height = 64; + if (height > 256) { + height = 256; if (y < initY) { startY = endY - height; } @@ -190,14 +190,14 @@ const NFTSelector = (props) => { let width = endX - startX; let height = endY - startY; // Max NFT sizes - if (width > 64) { - width = 64; + if (width > 256) { + width = 256; if (x < initX) { startX = endX - width; } } - if (height > 64) { - height = 64; + if (height > 256) { + height = 256; if (y < initY) { startY = endY - height; } diff --git a/frontend/src/configs/backend.config.json b/frontend/src/configs/backend.config.json index e9f57c37..9636245b 100644 --- a/frontend/src/configs/backend.config.json +++ b/frontend/src/configs/backend.config.json @@ -1,6 +1,7 @@ { "host": "api.art-peace.net", "port": 8080, + "consumer_port": 8081, "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", diff --git a/frontend/src/footer/PixelSelector.css b/frontend/src/footer/PixelSelector.css index 3743e417..c970746f 100644 --- a/frontend/src/footer/PixelSelector.css +++ b/frontend/src/footer/PixelSelector.css @@ -1,6 +1,10 @@ .PixelSelector { margin: 1rem; padding: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; } .PixelSelector__button--valid:active { diff --git a/frontend/src/footer/PixelSelector.js b/frontend/src/footer/PixelSelector.js index 83df3fd1..5f58e8f4 100644 --- a/frontend/src/footer/PixelSelector.js +++ b/frontend/src/footer/PixelSelector.js @@ -30,6 +30,7 @@ const PixelSelector = (props) => { } else { // TODO: Use lowest timer out of base, chain, faction, ... setPlacementTimer(props.basePixelTimer); + props.clearAll(); } if ( placementTimer === '0:00' && @@ -50,6 +51,93 @@ const PixelSelector = (props) => { placementMode ]); + const defendTemplate = async () => { + if (!props.templatePixels || !props.templatePixels.pixelData) return; + const availableCount = props.availablePixels - props.availablePixelsUsed; + if (props.availablePixels == 0) return; + + const templateX = props.overlayTemplate.position % props.width; + const templateY = Math.floor(props.overlayTemplate.position / props.width); + + const canvas = props.canvasRef.current; + const context = canvas.getContext('2d'); + + const pixelsToPlace = []; + for (let i = 0; i < props.templatePixels.pixelData.length; i++) { + const colorId = props.templatePixels.pixelData[i]; + if (colorId === 0xff) continue; // Skip transparent pixels + + const pixelX = i % props.templatePixels.width; + const pixelY = Math.floor(i / props.templatePixels.width); + const canvasX = templateX + pixelX; + const canvasY = templateY + pixelY; + + // Get current pixel color + const imageData = context.getImageData(canvasX, canvasY, 1, 1).data; + const currentColor = `${imageData[0] + .toString(16) + .padStart(2, '0')}${imageData[1] + .toString(16) + .padStart(2, '0')}${imageData[2].toString(16).padStart(2, '0')}`; + + // Only add if different from template + if (currentColor.toLowerCase() !== props.colors[colorId].toLowerCase()) { + pixelsToPlace.push({ + x: canvasX, + y: canvasY, + colorId: colorId + }); + } + } + + while (pixelsToPlace.length < availableCount) { + // Place random pixel in template + let idx = Math.floor( + Math.random() * props.templatePixels.pixelData.length + ); + const randomX = idx % props.templatePixels.width; + const randomY = Math.floor(idx / props.templatePixels.width); + const colorId = props.templatePixels.pixelData[idx]; + if (colorId === 0xff) continue; // Skip transparent pixels + const canvasX = templateX + randomX; + const canvasY = templateY + randomY; + pixelsToPlace.push({ + x: canvasX, + y: canvasY, + colorId: colorId + }); + } + + // Randomly select pixels up to available amount + const shuffledPixels = pixelsToPlace.sort(() => Math.random() - 0.5); + const selectedPixels = shuffledPixels.slice(0, availableCount); + + await props.addExtraPixels(selectedPixels); + }; + + useEffect(() => { + if ( + props.isDefending && + (placementTimer === 'Place Pixels' || placementTimer === 'Place Pixel') + ) { + defendTemplate(); + } + }, [placementTimer, props.isDefending]); + + useEffect(() => { + const submit = async () => { + if ( + props.templatePixels && + props.isDefending && + props.availablePixels > 0 && + props.totalPixelsUsed === props.availablePixels + ) { + await props.submit(); + } + }; + submit(); + }, [props.totalPixelsUsed]); + const toSelectorMode = (event) => { event.preventDefault(); // Only works if not hitting the close button @@ -191,6 +279,20 @@ const PixelSelector = (props) => { )}
)} + {props.queryAddress !== '0' && + props.templatePixels.pixelData && + props.templatePixels.pixelData.length > 0 && ( +
+
props.setIsDefending(!props.isDefending)} + > +

+ {props.isDefending ? 'Stop' : 'Defend'} +

+
+
+ )}
); }; diff --git a/frontend/src/tabs/TabPanel.js b/frontend/src/tabs/TabPanel.js index 4a5ac52b..e54e55cf 100644 --- a/frontend/src/tabs/TabPanel.js +++ b/frontend/src/tabs/TabPanel.js @@ -46,6 +46,8 @@ const TabPanel = (props) => { appear > { setTemplateImage={props.setTemplateImage} setTemplateColorIds={props.setTemplateColorIds} setActiveTab={props.setActiveTab} + setTemplateOverlayMode={props.setTemplateOverlayMode} + setOverlayTemplate={props.setOverlayTemplate} /> )} @@ -314,6 +318,8 @@ const TabPanel = (props) => { queryAddress={props.queryAddress} isMobile={props.isMobile} gameEnded={props.gameEnded} + setTemplateOverlayMode={props.setTemplateOverlayMode} + setOverlayTemplate={props.setOverlayTemplate} /> )} diff --git a/frontend/src/tabs/account/Account.js b/frontend/src/tabs/account/Account.js index c9eeddbb..f5235ebd 100644 --- a/frontend/src/tabs/account/Account.js +++ b/frontend/src/tabs/account/Account.js @@ -334,7 +334,9 @@ const Account = (props) => { const interval = setInterval(() => { setAnimatedRankColor((animatedRankColor + 3) % 360); setRankBackground({ - background: `linear-gradient(45deg, hsl(${animatedRankColor}, 100%, 50%), hsl(${(animatedRankColor + btrColorOffset) % 360}, 100%, 50%))` + background: `linear-gradient(45deg, hsl(${animatedRankColor}, 100%, 50%), hsl(${ + (animatedRankColor + btrColorOffset) % 360 + }, 100%, 50%))` }); }, 50); return () => clearInterval(interval); @@ -501,7 +503,11 @@ const Account = (props) => { />